diff --git a/deploy/tksea-portal/admin/logical-groups.html b/deploy/tksea-portal/admin/logical-groups.html index 878c395a..cc94a450 100644 --- a/deploy/tksea-portal/admin/logical-groups.html +++ b/deploy/tksea-portal/admin/logical-groups.html @@ -545,6 +545,36 @@ +
+ + +
+
+ + +
@@ -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); diff --git a/deploy/tksea-portal/index.html b/deploy/tksea-portal/index.html index d91783ea..ad1c7c56 100644 --- a/deploy/tksea-portal/index.html +++ b/deploy/tksea-portal/index.html @@ -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 ( '
' + '

' + escapeHTML(group.display_name || group.logicalGroupID || "未命名逻辑分组") + '

' + '
' + '' + escapeHTML(row.stateText) + '' + + '' + escapeHTML(packageTier) + '' + + '' + escapeHTML(visibilityScope) + '' + '兼容线路 ' + String(row.candidates.length) + '' + '活跃订阅 ' + String(row.matchingActiveSubscriptions.length) + '' + '已有 Key ' + String(row.matchingKeys.length) + '' + @@ -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 @@ '
' + '
' + '' + escapeHTML(guide.stateText) + '' + + '' + escapeHTML(guide.packageTier) + '' + + '' + escapeHTML(guide.visibilityScope) + '' + '' + escapeHTML(guide.logicalGroupID) + '' + '
' + '

' + escapeHTML(guide.title) + '

' + @@ -1315,6 +1374,9 @@ '
兼容线路:' + escapeHTML(guide.compatibility) + '
' + '
路由策略:' + escapeHTML(guide.routePolicy) + ' / ' + escapeHTML(guide.stickyMode) + '
' + '
' + + ((guide.cta.label && guide.cta.url && guide.stateKey !== "active") + ? ('' + escapeHTML(guide.cta.label) + '') + : '') + '
' ); }).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 ? "" : "
当前尚未登记公开模型。
"; @@ -1409,6 +1473,8 @@ '
' + 'logical group' + '' + escapeHTML(group.logical_group_id || "--") + '' + + '' + escapeHTML(packageTier) + '' + + '' + escapeHTML(visibilityScope) + '' + '' + escapeHTML(group.route_policy || "priority") + '' + '' + escapeHTML(group.sticky_mode || "conversation_preferred") + '' + '' + escapeHTML(presentation.text) + '' + @@ -1456,11 +1522,14 @@ ? "你的账号暂未开通兼容宿主线路,当前只能浏览目录,不能直接申请测试 Key。" : "当前逻辑分组尚未建立自动申请测试 Key 的兼容线路。"; $("create-key-btn").disabled = !canCreate; + const cta = logicalGroupPurchaseCTA(row.logicalGroup); $("selection-summary").innerHTML = [ '
' + escapeHTML(row.logicalGroup.display_name || row.logicalGroup.logical_group_id || "未命名逻辑分组") + ' / ' + escapeHTML(logicalGroupID) + '
', '
公开模型: ' + escapeHTML(models.join(", ") || "--") + '
', + '
package_tier = ' + escapeHTML(logicalGroupPackageTier(row.logicalGroup)) + ' / visibility_scope = ' + escapeHTML(logicalGroupVisibilityScope(row.logicalGroup)) + '
', '
route_policy = ' + escapeHTML(row.logicalGroup.route_policy || "priority") + ' / sticky_mode = ' + escapeHTML(row.logicalGroup.sticky_mode || "conversation_preferred") + '
', - '
' + escapeHTML(compatibility) + '
' + '
' + escapeHTML(compatibility) + '
', + (cta.label && cta.url ? ('
' + escapeHTML(cta.label) + '
') : '') ].join(""); } diff --git a/internal/app/logical_groups_api.go b/internal/app/logical_groups_api.go index a7d22571..ec60715e 100644 --- a/internal/app/logical_groups_api.go +++ b/internal/app/logical_groups_api.go @@ -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, diff --git a/internal/app/logical_groups_api_test.go b/internal/app/logical_groups_api_test.go index 13063cff..ef435db7 100644 --- a/internal/app/logical_groups_api_test.go +++ b/internal/app/logical_groups_api_test.go @@ -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", diff --git a/internal/app/portal_api.go b/internal/app/portal_api.go index 886eb69a..741b7163 100644 --- a/internal/app/portal_api.go +++ b/internal/app/portal_api.go @@ -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, diff --git a/internal/app/portal_api_test.go b/internal/app/portal_api_test.go index a5886d40..fa7a2b22 100644 --- a/internal/app/portal_api_test.go +++ b/internal/app/portal_api_test.go @@ -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"]) + } } diff --git a/internal/store/migrations/0014_logical_group_packaging.sql b/internal/store/migrations/0014_logical_group_packaging.sql new file mode 100644 index 00000000..784388dc --- /dev/null +++ b/internal/store/migrations/0014_logical_group_packaging.sql @@ -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 ''; diff --git a/internal/store/sqlite/logical_groups_repo.go b/internal/store/sqlite/logical_groups_repo.go index f30f950f..e38b98d5 100644 --- a/internal/store/sqlite/logical_groups_repo.go +++ b/internal/store/sqlite/logical_groups_repo.go @@ -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 } diff --git a/internal/store/sqlite/logical_groups_repo_test.go b/internal/store/sqlite/logical_groups_repo_test.go index 8ca8a848..9ea6557d 100644 --- a/internal/store/sqlite/logical_groups_repo_test.go +++ b/internal/store/sqlite/logical_groups_repo_test.go @@ -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) diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh index 3e70f8d4..5dc72b82 100755 --- a/scripts/test/test_tksea_portal_assets.sh +++ b/scripts/test/test_tksea_portal_assets.sh @@ -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"