@@ -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 (
'
'
);
}).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
? "
' +
'
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 ? ('
') : '')
].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"