feat(portal): add logical group packaging config

This commit is contained in:
phamnazage-jpg
2026-05-30 10:54:32 +08:00
parent aac18e0df6
commit ef33762db5
10 changed files with 300 additions and 41 deletions

View File

@@ -545,6 +545,36 @@
<textarea id="group-next-step-hint" placeholder="例如:先创建测试 Key再按推荐模型发起第一次请求。"></textarea>
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<label>
visibility_scope
<select id="group-visibility-scope">
<option value="public">public</option>
<option value="login_required">login_required</option>
<option value="entitled_only">entitled_only</option>
<option value="hidden">hidden</option>
</select>
</label>
<label>
package_tier
<select id="group-package-tier">
<option value="free">free</option>
<option value="standard">standard</option>
<option value="pro">pro</option>
<option value="enterprise">enterprise</option>
</select>
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<label>
purchase_cta_label
<input id="group-purchase-cta-label" type="text" placeholder="例如:升级到 Pro">
</label>
<label>
purchase_cta_url
<input id="group-purchase-cta-url" type="text" placeholder="例如https://sub.tksea.top/portal/upgrade/pro">
</label>
</div>
<div class="actions">
<button class="primary" id="create-group-btn" type="button">创建分组</button>
<button class="secondary" id="update-group-btn" type="button">更新分组</button>
@@ -708,6 +738,10 @@
const groupUsageScenarioInput = document.getElementById("group-usage-scenario");
const groupRecommendationInput = document.getElementById("group-recommendation");
const groupNextStepHintInput = document.getElementById("group-next-step-hint");
const groupVisibilityScopeInput = document.getElementById("group-visibility-scope");
const groupPackageTierInput = document.getElementById("group-package-tier");
const groupPurchaseCTALabelInput = document.getElementById("group-purchase-cta-label");
const groupPurchaseCTAURLInput = document.getElementById("group-purchase-cta-url");
const groupRoutePolicyInput = document.getElementById("group-route-policy");
const groupStickyModeInput = document.getElementById("group-sticky-mode");
const groupConversationTTLInput = document.getElementById("group-conversation-ttl");
@@ -888,6 +922,10 @@
usage_scenario: groupUsageScenarioInput.value.trim(),
recommendation: groupRecommendationInput.value.trim(),
next_step_hint: groupNextStepHintInput.value.trim(),
visibility_scope: groupVisibilityScopeInput.value,
package_tier: groupPackageTierInput.value,
purchase_cta_label: groupPurchaseCTALabelInput.value.trim(),
purchase_cta_url: groupPurchaseCTAURLInput.value.trim(),
route_policy: groupRoutePolicyInput.value,
sticky_mode: groupStickyModeInput.value,
conversation_ttl_seconds: Number(groupConversationTTLInput.value || "0"),
@@ -919,6 +957,10 @@
groupUsageScenarioInput.value = group?.usage_scenario || "";
groupRecommendationInput.value = group?.recommendation || "";
groupNextStepHintInput.value = group?.next_step_hint || "";
groupVisibilityScopeInput.value = group?.visibility_scope || "public";
groupPackageTierInput.value = group?.package_tier || "standard";
groupPurchaseCTALabelInput.value = group?.purchase_cta_label || "";
groupPurchaseCTAURLInput.value = group?.purchase_cta_url || "";
groupRoutePolicyInput.value = group?.route_policy || "priority";
groupStickyModeInput.value = group?.sticky_mode || "conversation_preferred";
groupConversationTTLInput.value = String(group?.conversation_ttl_seconds || 7200);

View File

@@ -528,6 +528,23 @@
font-size: 12px;
line-height: 1.6;
}
.cta-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(15, 118, 110, 0.24);
background: rgba(15, 118, 110, 0.08);
color: var(--teal);
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.cta-link:hover {
background: rgba(15, 118, 110, 0.14);
}
.toast {
position: fixed;
right: 20px;
@@ -1106,6 +1123,38 @@
: [];
}
function logicalGroupVisibilityScope(group) {
return String(group && group.visibility_scope ? group.visibility_scope : "public").trim() || "public";
}
function logicalGroupPackageTier(group) {
return String(group && group.package_tier ? group.package_tier : "standard").trim() || "standard";
}
function logicalGroupPurchaseCTA(group) {
return {
label: String(group && group.purchase_cta_label ? group.purchase_cta_label : "").trim(),
url: String(group && group.purchase_cta_url ? group.purchase_cta_url : "").trim()
};
}
function logicalGroupVisibleForViewer(row) {
const scope = logicalGroupVisibilityScope(row.logicalGroup);
if (scope === "hidden") {
return false;
}
if (scope === "public") {
return true;
}
if (scope === "login_required") {
return !!state.user;
}
if (scope === "entitled_only") {
return !!state.user && (row.stateKey === "active" || row.stateKey === "granted" || row.stateKey === "ambiguous");
}
return true;
}
function legacyCompatibilityCandidates(group) {
const portalModels = new Set(portalLogicalGroupModels(group));
if (!portalModels.size) {
@@ -1125,7 +1174,8 @@
enabledCandidates
};
});
rows.sort((left, right) => {
const filtered = rows.filter((row) => logicalGroupVisibleForViewer(row));
filtered.sort((left, right) => {
const leftActive = Number(left.logicalGroup.active_route_count || 0);
const rightActive = Number(right.logicalGroup.active_route_count || 0);
if (leftActive !== rightActive) {
@@ -1136,7 +1186,7 @@
"zh-CN"
);
});
return rows;
return filtered;
}
function activeSubscriptionItems() {
@@ -1221,11 +1271,15 @@
: "pending";
const compatibilityNames = (row.candidates || []).map((candidate) => candidate.title).join(" / ") || "无兼容线路";
const models = portalLogicalGroupModels(group).join(", ") || "--";
const packageTier = logicalGroupPackageTier(group);
const visibilityScope = logicalGroupVisibilityScope(group);
return (
'<article class="group-card ' + (row.stateKey === "active" ? "active" : (row.stateKey === "catalog_only" ? "neutral" : "pending")) + '">' +
'<h4>' + escapeHTML(group.display_name || group.logicalGroupID || "未命名逻辑分组") + '</h4>' +
'<div class="group-meta">' +
'<span class="badge strong ' + stateBadgeClass + '">' + escapeHTML(row.stateText) + '</span>' +
'<span class="badge">' + escapeHTML(packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(visibilityScope) + '</span>' +
'<span class="badge">兼容线路 ' + String(row.candidates.length) + '</span>' +
'<span class="badge">活跃订阅 ' + String(row.matchingActiveSubscriptions.length) + '</span>' +
'<span class="badge">已有 Key ' + String(row.matchingKeys.length) + '</span>' +
@@ -1277,10 +1331,13 @@
models,
stateText: row.stateText,
stateKey: row.stateKey,
packageTier: logicalGroupPackageTier(group),
visibilityScope: logicalGroupVisibilityScope(group),
scenario: configuredScenario || guidance.scenario,
recommendation: configuredRecommendation || guidance.recommendation,
nextStep: configuredNextStep || defaultNextStep,
compatibility,
cta: logicalGroupPurchaseCTA(group),
stickyMode: group.sticky_mode || "conversation_preferred",
routePolicy: group.route_policy || "priority"
};
@@ -1304,6 +1361,8 @@
'<article class="guide-card">' +
'<div class="group-meta">' +
'<span class="badge strong ' + badgeClass + '">' + escapeHTML(guide.stateText) + '</span>' +
'<span class="badge">' + escapeHTML(guide.packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(guide.visibilityScope) + '</span>' +
'<span class="badge mono">' + escapeHTML(guide.logicalGroupID) + '</span>' +
'</div>' +
'<h4>' + escapeHTML(guide.title) + '</h4>' +
@@ -1315,6 +1374,9 @@
'<div class="guide-line"><strong>兼容线路:</strong>' + escapeHTML(guide.compatibility) + '</div>' +
'<div class="guide-line"><strong>路由策略:</strong><span class="mono">' + escapeHTML(guide.routePolicy) + '</span> / <span class="mono">' + escapeHTML(guide.stickyMode) + '</span></div>' +
'</div>' +
((guide.cta.label && guide.cta.url && guide.stateKey !== "active")
? ('<a class="cta-link" href="' + escapeHTML(guide.cta.url) + '" target="_blank" rel="noreferrer">' + escapeHTML(guide.cta.label) + '</a>')
: '') +
'</article>'
);
}).join("");
@@ -1392,6 +1454,8 @@
const group = row.logicalGroup;
const presentation = getPresentationStatus(row);
const models = portalLogicalGroupModels(group);
const packageTier = logicalGroupPackageTier(group);
const visibilityScope = logicalGroupVisibilityScope(group);
const modelsHTML = models.length
? "<ul class=\"group-models\">" + models.map((model) => "<li><span class=\"mono\">" + escapeHTML(model) + "</span></li>").join("") + "</ul>"
: "<div class=\"group-note\">当前尚未登记公开模型。</div>";
@@ -1409,6 +1473,8 @@
'<div class="group-meta">' +
'<span class="badge">logical group</span>' +
'<span class="badge mono">' + escapeHTML(group.logical_group_id || "--") + '</span>' +
'<span class="badge">' + escapeHTML(packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(visibilityScope) + '</span>' +
'<span class="badge">' + escapeHTML(group.route_policy || "priority") + '</span>' +
'<span class="badge">' + escapeHTML(group.sticky_mode || "conversation_preferred") + '</span>' +
'<span class="badge strong ' + presentation.cls + '">' + escapeHTML(presentation.text) + '</span>' +
@@ -1456,11 +1522,14 @@
? "你的账号暂未开通兼容宿主线路,当前只能浏览目录,不能直接申请测试 Key。"
: "当前逻辑分组尚未建立自动申请测试 Key 的兼容线路。";
$("create-key-btn").disabled = !canCreate;
const cta = logicalGroupPurchaseCTA(row.logicalGroup);
$("selection-summary").innerHTML = [
'<div><strong>' + escapeHTML(row.logicalGroup.display_name || row.logicalGroup.logical_group_id || "未命名逻辑分组") + '</strong> / <span class="mono">' + escapeHTML(logicalGroupID) + '</span></div>',
'<div class="mono">公开模型: ' + escapeHTML(models.join(", ") || "--") + '</div>',
'<div class="mono">package_tier = ' + escapeHTML(logicalGroupPackageTier(row.logicalGroup)) + ' / visibility_scope = ' + escapeHTML(logicalGroupVisibilityScope(row.logicalGroup)) + '</div>',
'<div class="mono">route_policy = ' + escapeHTML(row.logicalGroup.route_policy || "priority") + ' / sticky_mode = ' + escapeHTML(row.logicalGroup.sticky_mode || "conversation_preferred") + '</div>',
'<div>' + escapeHTML(compatibility) + '</div>'
'<div>' + escapeHTML(compatibility) + '</div>',
(cta.label && cta.url ? ('<div><a class="cta-link" href="' + escapeHTML(cta.url) + '" target="_blank" rel="noreferrer">' + escapeHTML(cta.label) + '</a></div>') : '')
].join("");
}

View File

@@ -19,6 +19,10 @@ type CreateLogicalGroupRequest struct {
UsageScenario string `json:"usage_scenario,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
NextStepHint string `json:"next_step_hint,omitempty"`
VisibilityScope string `json:"visibility_scope,omitempty"`
PackageTier string `json:"package_tier,omitempty"`
PurchaseCTALabel string `json:"purchase_cta_label,omitempty"`
PurchaseCTAURL string `json:"purchase_cta_url,omitempty"`
RoutePolicy string `json:"route_policy,omitempty"`
StickyMode string `json:"sticky_mode,omitempty"`
ConversationTTLSeconds int `json:"conversation_ttl_seconds,omitempty"`
@@ -35,6 +39,10 @@ type UpdateLogicalGroupRequest struct {
UsageScenario string `json:"usage_scenario,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
NextStepHint string `json:"next_step_hint,omitempty"`
VisibilityScope string `json:"visibility_scope,omitempty"`
PackageTier string `json:"package_tier,omitempty"`
PurchaseCTALabel string `json:"purchase_cta_label,omitempty"`
PurchaseCTAURL string `json:"purchase_cta_url,omitempty"`
RoutePolicy string `json:"route_policy,omitempty"`
StickyMode string `json:"sticky_mode,omitempty"`
ConversationTTLSeconds int `json:"conversation_ttl_seconds,omitempty"`
@@ -51,6 +59,10 @@ type LogicalGroupInfo struct {
UsageScenario string `json:"usage_scenario,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
NextStepHint string `json:"next_step_hint,omitempty"`
VisibilityScope string `json:"visibility_scope,omitempty"`
PackageTier string `json:"package_tier,omitempty"`
PurchaseCTALabel string `json:"purchase_cta_label,omitempty"`
PurchaseCTAURL string `json:"purchase_cta_url,omitempty"`
RoutePolicy string `json:"route_policy,omitempty"`
StickyMode string `json:"sticky_mode,omitempty"`
ConversationTTLSeconds int `json:"conversation_ttl_seconds,omitempty"`
@@ -445,6 +457,10 @@ func buildUpdateLogicalGroupAction(sqliteDSN string) func(context.Context, Updat
UsageScenario: req.UsageScenario,
Recommendation: req.Recommendation,
NextStepHint: req.NextStepHint,
VisibilityScope: req.VisibilityScope,
PackageTier: req.PackageTier,
PurchaseCTALabel: req.PurchaseCTALabel,
PurchaseCTAURL: req.PurchaseCTAURL,
RoutePolicy: req.RoutePolicy,
StickyMode: req.StickyMode,
ConversationTTLSeconds: req.ConversationTTLSeconds,
@@ -698,6 +714,10 @@ func logicalGroupRequestToRow(req CreateLogicalGroupRequest) sqlite.LogicalGroup
UsageScenario: strings.TrimSpace(req.UsageScenario),
Recommendation: strings.TrimSpace(req.Recommendation),
NextStepHint: strings.TrimSpace(req.NextStepHint),
VisibilityScope: strings.TrimSpace(req.VisibilityScope),
PackageTier: strings.TrimSpace(req.PackageTier),
PurchaseCTALabel: strings.TrimSpace(req.PurchaseCTALabel),
PurchaseCTAURL: strings.TrimSpace(req.PurchaseCTAURL),
RoutePolicy: strings.TrimSpace(req.RoutePolicy),
StickyMode: strings.TrimSpace(req.StickyMode),
ConversationTTLSeconds: req.ConversationTTLSeconds,
@@ -763,6 +783,10 @@ func logicalGroupRowToInfo(group sqlite.LogicalGroup, models []LogicalGroupModel
UsageScenario: group.UsageScenario,
Recommendation: group.Recommendation,
NextStepHint: group.NextStepHint,
VisibilityScope: group.VisibilityScope,
PackageTier: group.PackageTier,
PurchaseCTALabel: group.PurchaseCTALabel,
PurchaseCTAURL: group.PurchaseCTAURL,
RoutePolicy: group.RoutePolicy,
StickyMode: group.StickyMode,
ConversationTTLSeconds: group.ConversationTTLSeconds,

View File

@@ -19,20 +19,31 @@ func TestAPICreateLogicalGroupReturnsCreated(t *testing.T) {
if req.UsageScenario != "适合统一 GPT 产品入口" {
t.Fatalf("UsageScenario = %q, want configured guidance", req.UsageScenario)
}
if req.VisibilityScope != "login_required" || req.PackageTier != "pro" || req.PurchaseCTALabel != "升级到 Pro" || req.PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("packaging fields = %+v, want configured packaging", req)
}
return LogicalGroupInfo{
LogicalGroupID: req.LogicalGroupID,
DisplayName: req.DisplayName,
Status: req.Status,
UsageScenario: req.UsageScenario,
LogicalGroupID: req.LogicalGroupID,
DisplayName: req.DisplayName,
Status: req.Status,
UsageScenario: req.UsageScenario,
VisibilityScope: req.VisibilityScope,
PackageTier: req.PackageTier,
PurchaseCTALabel: req.PurchaseCTALabel,
PurchaseCTAURL: req.PurchaseCTAURL,
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/logical-groups", map[string]any{
"logical_group_id": "gpt-shared",
"display_name": "GPT Shared",
"status": "active",
"usage_scenario": "适合统一 GPT 产品入口",
"logical_group_id": "gpt-shared",
"display_name": "GPT Shared",
"status": "active",
"usage_scenario": "适合统一 GPT 产品入口",
"visibility_scope": "login_required",
"package_tier": "pro",
"purchase_cta_label": "升级到 Pro",
"purchase_cta_url": "https://sub.tksea.top/portal/upgrade/pro",
}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusCreated)
@@ -46,13 +57,17 @@ func TestAPIGetLogicalGroupReturnsAggregatedItem(t *testing.T) {
t.Fatalf("groupID = %q, want gpt-shared", groupID)
}
return LogicalGroupInfo{
LogicalGroupID: groupID,
DisplayName: "GPT Shared",
Status: "active",
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
Models: []LogicalGroupModelInfo{{PublicModel: "gpt-5.4", Status: "active"}},
LogicalGroupID: groupID,
DisplayName: "GPT Shared",
Status: "active",
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
VisibilityScope: "login_required",
PackageTier: "pro",
PurchaseCTALabel: "升级到 Pro",
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
Models: []LogicalGroupModelInfo{{PublicModel: "gpt-5.4", Status: "active"}},
Routes: []LogicalGroupRouteInfo{{
RouteID: "asxs",
LogicalGroupID: groupID,
@@ -94,6 +109,9 @@ func TestAPIGetLogicalGroupReturnsAggregatedItem(t *testing.T) {
if group["usage_scenario"] != "适合统一 GPT 产品入口" || group["recommendation"] != "优先使用 gpt-5.4" || group["next_step_hint"] != "先创建测试 Key" {
t.Fatalf("group guidance = %#v, want configured guidance fields", group)
}
if group["visibility_scope"] != "login_required" || group["package_tier"] != "pro" || group["purchase_cta_label"] != "升级到 Pro" || group["purchase_cta_url"] != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("group packaging = %#v, want configured packaging fields", group)
}
}
func TestAPICreateLogicalGroupRouteUsesPathGroupID(t *testing.T) {
@@ -159,12 +177,16 @@ func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) {
ctx := context.Background()
createdGroup, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
VisibilityScope: "login_required",
PackageTier: "pro",
PurchaseCTALabel: "升级到 Pro",
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
})
if err != nil {
t.Fatalf("CreateLogicalGroup() error = %v", err)
@@ -175,6 +197,9 @@ func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) {
if createdGroup.UsageScenario != "适合统一 GPT 产品入口" || createdGroup.Recommendation != "优先使用 gpt-5.4" || createdGroup.NextStepHint != "先创建测试 Key" {
t.Fatalf("CreateLogicalGroup() guidance = %+v, want configured guidance fields", createdGroup)
}
if createdGroup.VisibilityScope != "login_required" || createdGroup.PackageTier != "pro" || createdGroup.PurchaseCTALabel != "升级到 Pro" || createdGroup.PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("CreateLogicalGroup() packaging = %+v, want configured packaging fields", createdGroup)
}
if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{
LogicalGroupID: "gpt-shared",
@@ -219,14 +244,21 @@ func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) {
if group.UsageScenario != "适合统一 GPT 产品入口" || group.Recommendation != "优先使用 gpt-5.4" || group.NextStepHint != "先创建测试 Key" {
t.Fatalf("GetLogicalGroup() guidance = %+v, want configured guidance fields", group)
}
if group.VisibilityScope != "login_required" || group.PackageTier != "pro" || group.PurchaseCTALabel != "升级到 Pro" || group.PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("GetLogicalGroup() packaging = %+v, want configured packaging fields", group)
}
if _, err := actions.UpdateLogicalGroup(ctx, UpdateLogicalGroupRequest{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared Updated",
Status: "paused",
UsageScenario: "适合升级后的 GPT 产品入口",
Recommendation: "先验证高质量推理链路",
NextStepHint: "升级后重新申请测试 Key",
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared Updated",
Status: "paused",
UsageScenario: "适合升级后的 GPT 产品入口",
Recommendation: "先验证高质量推理链路",
NextStepHint: "升级后重新申请测试 Key",
VisibilityScope: "entitled_only",
PackageTier: "enterprise",
PurchaseCTALabel: "联系销售升级",
PurchaseCTAURL: "https://sub.tksea.top/portal/contact-sales",
}); err != nil {
t.Fatalf("UpdateLogicalGroup() error = %v", err)
}
@@ -254,6 +286,9 @@ func TestNewActionSetLogicalGroupCRUDFlow(t *testing.T) {
if groups[0].UsageScenario != "适合升级后的 GPT 产品入口" || groups[0].Recommendation != "先验证高质量推理链路" || groups[0].NextStepHint != "升级后重新申请测试 Key" {
t.Fatalf("ListLogicalGroups() guidance = %+v, want updated guidance fields", groups[0])
}
if groups[0].VisibilityScope != "entitled_only" || groups[0].PackageTier != "enterprise" || groups[0].PurchaseCTALabel != "联系销售升级" || groups[0].PurchaseCTAURL != "https://sub.tksea.top/portal/contact-sales" {
t.Fatalf("ListLogicalGroups() packaging = %+v, want updated packaging fields", groups[0])
}
routeModels, err := actions.ListLogicalGroupRouteModels(ctx, ListLogicalGroupRouteModelsRequest{
LogicalGroupID: "gpt-shared",

View File

@@ -16,6 +16,10 @@ type PortalLogicalGroupInfo struct {
UsageScenario string `json:"usage_scenario,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
NextStepHint string `json:"next_step_hint,omitempty"`
VisibilityScope string `json:"visibility_scope,omitempty"`
PackageTier string `json:"package_tier,omitempty"`
PurchaseCTALabel string `json:"purchase_cta_label,omitempty"`
PurchaseCTAURL string `json:"purchase_cta_url,omitempty"`
Status string `json:"status"`
StickyMode string `json:"sticky_mode,omitempty"`
RoutePolicy string `json:"route_policy,omitempty"`
@@ -165,6 +169,10 @@ func buildPortalLogicalGroupInfo(ctx context.Context, store *sqlite.DB, group sq
UsageScenario: group.UsageScenario,
Recommendation: group.Recommendation,
NextStepHint: group.NextStepHint,
VisibilityScope: group.VisibilityScope,
PackageTier: group.PackageTier,
PurchaseCTALabel: group.PurchaseCTALabel,
PurchaseCTAURL: group.PurchaseCTAURL,
Status: group.Status,
StickyMode: group.StickyMode,
RoutePolicy: group.RoutePolicy,

View File

@@ -18,6 +18,10 @@ func TestAPIListPortalLogicalGroups(t *testing.T) {
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
VisibilityScope: "login_required",
PackageTier: "pro",
PurchaseCTALabel: "升级到 Pro",
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
Status: "active",
RouteCount: 2,
ActiveRouteCount: 1,
@@ -43,6 +47,9 @@ func TestAPIListPortalLogicalGroups(t *testing.T) {
if listPayload.LogicalGroups[0].UsageScenario != "适合统一 GPT 产品入口" || listPayload.LogicalGroups[0].Recommendation != "优先使用 gpt-5.4" || listPayload.LogicalGroups[0].NextStepHint != "先创建测试 Key" {
t.Fatalf("portal logical groups guidance = %+v", listPayload.LogicalGroups[0])
}
if listPayload.LogicalGroups[0].VisibilityScope != "login_required" || listPayload.LogicalGroups[0].PackageTier != "pro" || listPayload.LogicalGroups[0].PurchaseCTALabel != "升级到 Pro" || listPayload.LogicalGroups[0].PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("portal logical groups packaging = %+v", listPayload.LogicalGroups[0])
}
}
func TestAPIGetPortalLogicalGroupModels(t *testing.T) {
@@ -89,6 +96,10 @@ func TestNewActionSetPortalLogicalGroups(t *testing.T) {
UsageScenario: "适合统一 GPT 产品入口",
Recommendation: "优先使用 gpt-5.4",
NextStepHint: "先创建测试 Key",
VisibilityScope: "login_required",
PackageTier: "pro",
PurchaseCTALabel: "升级到 Pro",
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
RoutePolicy: "priority",
StickyMode: "conversation_preferred",
ConversationTTLSeconds: 7200,
@@ -170,6 +181,9 @@ func TestNewActionSetPortalLogicalGroups(t *testing.T) {
if group.UsageScenario != "适合统一 GPT 产品入口" || group.Recommendation != "优先使用 gpt-5.4" || group.NextStepHint != "先创建测试 Key" {
t.Fatalf("GetPortalLogicalGroup() guidance = %+v", group)
}
if group.VisibilityScope != "login_required" || group.PackageTier != "pro" || group.PurchaseCTALabel != "升级到 Pro" || group.PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("GetPortalLogicalGroup() packaging = %+v", group)
}
models, err := actions.ListPortalLogicalGroupModels(ctx, "gpt-shared")
if err != nil {
@@ -192,4 +206,7 @@ func TestNewActionSetPortalLogicalGroups(t *testing.T) {
if payload["logical_group"].Recommendation != "优先使用 gpt-5.4" {
t.Fatalf("portal logical group recommendation = %+v, want configured recommendation", payload["logical_group"])
}
if payload["logical_group"].PackageTier != "pro" {
t.Fatalf("portal logical group package_tier = %+v, want configured package tier", payload["logical_group"])
}
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE logical_groups ADD COLUMN visibility_scope TEXT NOT NULL DEFAULT 'public';
ALTER TABLE logical_groups ADD COLUMN package_tier TEXT NOT NULL DEFAULT 'standard';
ALTER TABLE logical_groups ADD COLUMN purchase_cta_label TEXT NOT NULL DEFAULT '';
ALTER TABLE logical_groups ADD COLUMN purchase_cta_url TEXT NOT NULL DEFAULT '';

View File

@@ -13,6 +13,8 @@ const (
defaultUserModelTTLSeconds = 1800
defaultFailoverThreshold = 2
defaultCooldownSeconds = 600
defaultLogicalGroupVisibility = "public"
defaultLogicalGroupPackageTier = "standard"
)
type LogicalGroup struct {
@@ -24,6 +26,10 @@ type LogicalGroup struct {
UsageScenario string
Recommendation string
NextStepHint string
VisibilityScope string
PackageTier string
PurchaseCTALabel string
PurchaseCTAURL string
RoutePolicy string
StickyMode string
ConversationTTLSeconds int
@@ -58,13 +64,17 @@ func (r *LogicalGroupsRepo) Create(ctx context.Context, group LogicalGroup) (int
usage_scenario,
recommendation,
next_step_hint,
visibility_scope,
package_tier,
purchase_cta_label,
purchase_cta_url,
route_policy,
sticky_mode,
conversation_ttl_seconds,
user_model_ttl_seconds,
failover_threshold,
cooldown_seconds
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
group.LogicalGroupID,
group.DisplayName,
group.Status,
@@ -72,6 +82,10 @@ func (r *LogicalGroupsRepo) Create(ctx context.Context, group LogicalGroup) (int
group.UsageScenario,
group.Recommendation,
group.NextStepHint,
group.VisibilityScope,
group.PackageTier,
group.PurchaseCTALabel,
group.PurchaseCTAURL,
group.RoutePolicy,
group.StickyMode,
group.ConversationTTLSeconds,
@@ -99,7 +113,7 @@ func (r *LogicalGroupsRepo) GetByLogicalGroupID(ctx context.Context, logicalGrou
var group LogicalGroup
if err := r.db.QueryRowContext(
ctx,
`SELECT id, logical_group_id, display_name, status, description, usage_scenario, recommendation, next_step_hint, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
`SELECT id, logical_group_id, display_name, status, description, usage_scenario, recommendation, next_step_hint, visibility_scope, package_tier, purchase_cta_label, purchase_cta_url, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
FROM logical_groups
WHERE logical_group_id = ?`,
logicalGroupID,
@@ -112,6 +126,10 @@ func (r *LogicalGroupsRepo) GetByLogicalGroupID(ctx context.Context, logicalGrou
&group.UsageScenario,
&group.Recommendation,
&group.NextStepHint,
&group.VisibilityScope,
&group.PackageTier,
&group.PurchaseCTALabel,
&group.PurchaseCTAURL,
&group.RoutePolicy,
&group.StickyMode,
&group.ConversationTTLSeconds,
@@ -129,7 +147,7 @@ func (r *LogicalGroupsRepo) GetByLogicalGroupID(ctx context.Context, logicalGrou
func (r *LogicalGroupsRepo) List(ctx context.Context) ([]LogicalGroup, error) {
rows, err := r.db.QueryContext(
ctx,
`SELECT id, logical_group_id, display_name, status, description, usage_scenario, recommendation, next_step_hint, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
`SELECT id, logical_group_id, display_name, status, description, usage_scenario, recommendation, next_step_hint, visibility_scope, package_tier, purchase_cta_label, purchase_cta_url, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
FROM logical_groups
ORDER BY id ASC`,
)
@@ -150,6 +168,10 @@ func (r *LogicalGroupsRepo) List(ctx context.Context) ([]LogicalGroup, error) {
&group.UsageScenario,
&group.Recommendation,
&group.NextStepHint,
&group.VisibilityScope,
&group.PackageTier,
&group.PurchaseCTALabel,
&group.PurchaseCTAURL,
&group.RoutePolicy,
&group.StickyMode,
&group.ConversationTTLSeconds,
@@ -178,7 +200,7 @@ func (r *LogicalGroupsRepo) UpdateByLogicalGroupID(ctx context.Context, group Lo
result, err := r.db.ExecContext(
ctx,
`UPDATE logical_groups
SET display_name = ?, status = ?, description = ?, usage_scenario = ?, recommendation = ?, next_step_hint = ?, route_policy = ?, sticky_mode = ?, conversation_ttl_seconds = ?, user_model_ttl_seconds = ?, failover_threshold = ?, cooldown_seconds = ?, updated_at = CURRENT_TIMESTAMP
SET display_name = ?, status = ?, description = ?, usage_scenario = ?, recommendation = ?, next_step_hint = ?, visibility_scope = ?, package_tier = ?, purchase_cta_label = ?, purchase_cta_url = ?, route_policy = ?, sticky_mode = ?, conversation_ttl_seconds = ?, user_model_ttl_seconds = ?, failover_threshold = ?, cooldown_seconds = ?, updated_at = CURRENT_TIMESTAMP
WHERE logical_group_id = ?`,
group.DisplayName,
group.Status,
@@ -186,6 +208,10 @@ func (r *LogicalGroupsRepo) UpdateByLogicalGroupID(ctx context.Context, group Lo
group.UsageScenario,
group.Recommendation,
group.NextStepHint,
group.VisibilityScope,
group.PackageTier,
group.PurchaseCTALabel,
group.PurchaseCTAURL,
group.RoutePolicy,
group.StickyMode,
group.ConversationTTLSeconds,
@@ -235,6 +261,10 @@ func normalizeLogicalGroup(group LogicalGroup) (LogicalGroup, error) {
group.UsageScenario = strings.TrimSpace(group.UsageScenario)
group.Recommendation = strings.TrimSpace(group.Recommendation)
group.NextStepHint = strings.TrimSpace(group.NextStepHint)
group.VisibilityScope = strings.TrimSpace(group.VisibilityScope)
group.PackageTier = strings.TrimSpace(group.PackageTier)
group.PurchaseCTALabel = strings.TrimSpace(group.PurchaseCTALabel)
group.PurchaseCTAURL = strings.TrimSpace(group.PurchaseCTAURL)
group.RoutePolicy = strings.TrimSpace(group.RoutePolicy)
group.StickyMode = strings.TrimSpace(group.StickyMode)
@@ -250,6 +280,12 @@ func normalizeLogicalGroup(group LogicalGroup) (LogicalGroup, error) {
if group.RoutePolicy == "" {
group.RoutePolicy = defaultLogicalGroupRoutePolicy
}
if group.VisibilityScope == "" {
group.VisibilityScope = defaultLogicalGroupVisibility
}
if group.PackageTier == "" {
group.PackageTier = defaultLogicalGroupPackageTier
}
if group.StickyMode == "" {
group.StickyMode = defaultLogicalGroupStickyMode
}

View File

@@ -12,13 +12,17 @@ func TestLogicalGroupsRepoCreateGetUpdateDelete(t *testing.T) {
ctx := context.Background()
id, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
Description: "shared group",
UsageScenario: "适合统一 GPT 产品入口。",
Recommendation: "优先使用 gpt-5.4。",
NextStepHint: "先创建测试 Key。",
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
Description: "shared group",
UsageScenario: "适合统一 GPT 产品入口。",
Recommendation: "优先使用 gpt-5.4。",
NextStepHint: "先创建测试 Key。",
VisibilityScope: "login_required",
PackageTier: "pro",
PurchaseCTALabel: "升级到 Pro",
PurchaseCTAURL: "https://sub.tksea.top/portal/upgrade/pro",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
@@ -40,6 +44,9 @@ func TestLogicalGroupsRepoCreateGetUpdateDelete(t *testing.T) {
if group.UsageScenario != "适合统一 GPT 产品入口。" || group.Recommendation != "优先使用 gpt-5.4。" || group.NextStepHint != "先创建测试 Key。" {
t.Fatalf("guidance fields = %+v, want persisted guidance", group)
}
if group.VisibilityScope != "login_required" || group.PackageTier != "pro" || group.PurchaseCTALabel != "升级到 Pro" || group.PurchaseCTAURL != "https://sub.tksea.top/portal/upgrade/pro" {
t.Fatalf("packaging fields = %+v, want persisted packaging", group)
}
if err := store.LogicalGroups().UpdateByLogicalGroupID(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
@@ -49,6 +56,10 @@ func TestLogicalGroupsRepoCreateGetUpdateDelete(t *testing.T) {
UsageScenario: "适合更新后的产品入口。",
Recommendation: "优先做连通性验证。",
NextStepHint: "先确认订阅再调用。",
VisibilityScope: "entitled_only",
PackageTier: "enterprise",
PurchaseCTALabel: "联系销售升级",
PurchaseCTAURL: "https://sub.tksea.top/portal/contact-sales",
RoutePolicy: "priority",
StickyMode: "user_preferred",
ConversationTTLSeconds: 3600,
@@ -69,6 +80,9 @@ func TestLogicalGroupsRepoCreateGetUpdateDelete(t *testing.T) {
if updated.UsageScenario != "适合更新后的产品入口。" || updated.Recommendation != "优先做连通性验证。" || updated.NextStepHint != "先确认订阅再调用。" {
t.Fatalf("updated guidance = %+v, want updated guidance fields", updated)
}
if updated.VisibilityScope != "entitled_only" || updated.PackageTier != "enterprise" || updated.PurchaseCTALabel != "联系销售升级" || updated.PurchaseCTAURL != "https://sub.tksea.top/portal/contact-sales" {
t.Fatalf("updated packaging = %+v, want updated packaging fields", updated)
}
if err := store.LogicalGroups().DeleteByLogicalGroupID(ctx, "gpt-shared"); err != nil {
t.Fatalf("DeleteByLogicalGroupID() error = %v", err)

View File

@@ -73,6 +73,12 @@ assert_contains_file "$HTML_FILE" "路由策略"
assert_contains_file "$HTML_FILE" "usage_scenario"
assert_contains_file "$HTML_FILE" "recommendation"
assert_contains_file "$HTML_FILE" "next_step_hint"
assert_contains_file "$HTML_FILE" "visibility_scope"
assert_contains_file "$HTML_FILE" "package_tier"
assert_contains_file "$HTML_FILE" "purchase_cta_label"
assert_contains_file "$HTML_FILE" "purchase_cta_url"
assert_contains_file "$HTML_FILE" "logicalGroupVisibleForViewer"
assert_contains_file "$HTML_FILE" "cta-link"
assert_contains_file "$HTML_FILE" "已开通订阅"
assert_contains_file "$HTML_FILE" "已授予权限"
assert_contains_file "$HTML_FILE" "归属待整理"
@@ -128,6 +134,10 @@ assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "shadow_host_id"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "usage_scenario"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "recommendation"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "next_step_hint"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "visibility_scope"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "package_tier"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_label"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_url"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "首版页面只覆盖新增与查看"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal-admin-api"