From 880dea1616ae0a82f4591b498e7266ccf5ce9f4d Mon Sep 17 00:00:00 2001 From: qingfeng Date: Mon, 13 Apr 2026 20:59:49 +0800 Subject: [PATCH] docs: align subscription tier mutual exclusion rules --- docs/dev/admin-api/ADMIN-API-11-内容管理.md | 9 +- .../admin-api/ADMIN-API-12-创作者代运营.md | 2 +- docs/dev/api/API-05-订阅系统.md | 20 +- ...4-13-subscription-tier-mutual-exclusion.md | 353 ++++++++++++++++++ ...bscription-tier-mutual-exclusion-design.md | 254 +++++++++++++ 5 files changed, 623 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md create mode 100644 docs/superpowers/specs/2026-04-13-subscription-tier-mutual-exclusion-design.md diff --git a/docs/dev/admin-api/ADMIN-API-11-内容管理.md b/docs/dev/admin-api/ADMIN-API-11-内容管理.md index f766ee0..fe5ebd8 100644 --- a/docs/dev/admin-api/ADMIN-API-11-内容管理.md +++ b/docs/dev/admin-api/ADMIN-API-11-内容管理.md @@ -337,7 +337,7 @@ | 项目 | 说明 | |------|------| | 接口路径 | PUT /admin/api/v1/creators/:creatorId/subscription/tiers | -| 接口用途 | 后台为创作者配置订阅档位,支持强制启用、补齐缺失档位,并服务于内容收费运营 | +| 接口用途 | 后台为创作者配置订阅档位,并服务于内容收费运营 | | 权限要求 | 超级管理员 / 运营专员(permission_key: `creator:subscription:manage`;当前实现建议额外限制为超级管理员) | | 涉及表 | user, creator_profile, subscription_tier, user_subscription, admin_operation_log | @@ -345,10 +345,11 @@ 1. 校验权限(permission_key: `creator:subscription:manage`) 2. 校验 `creatorId` 存在且角色为创作者,同时 `creator_profile` 处于有效状态 3. 校验请求中的档位配置: - - 必须包含 `free` 档,且 `enabled=true`、`price=0` + - 必须包含 `free` 档,且 `price=0` - 付费档位可配置 `junior/basic/senior/supreme` - 启用的付费档必须从 1 开始连续,不能跳级 - 已启用付费档价格必须 `> 0` + - 免费档与付费档互斥:任一付费档启用时,后台自动关闭 `free`;全部付费档关闭时,后台自动开启 `free` 4. 对 `tiers[]` 逐项 upsert 到 `subscription_tier` - 已存在则更新 `is_active/price/name/description/permissions` - 不存在则创建(支持补齐旧创作者缺失的 free / supreme 档位) @@ -368,7 +369,7 @@ |------|------|------|------| | tiers | array | 是 | 档位配置数组(含 free 与 1~4 个付费档) | | tiers[].tier | string | 是 | 档位 key:`free/junior/basic/senior/supreme` | -| tiers[].enabled | boolean | 是 | 是否启用 | +| tiers[].enabled | boolean | 是 | 是否启用;付费档按传值生效,`free` 的最终状态由后台按互斥规则归一 | | tiers[].price | number | 是 | 月价格(分);`free` 固定为 0 | | tiers[].name | string | 否 | 档位名称,不传则使用默认名称 | | tiers[].description | string | 否 | 档位说明 | @@ -396,7 +397,7 @@ - 403:权限不足 - 404:创作者不存在 - 409:目标用户不是创作者 / 创作者资料不可用 -- 422:免费档未传 / 免费档未启用 / 价格不合法 / 付费档未连续启用 / 权益枚举非法 +- 422:免费档未传 / 价格不合法 / 付费档未连续启用 / 权益枚举非法 --- diff --git a/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md b/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md index c682efe..8f2f8a8 100644 --- a/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md +++ b/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md @@ -94,7 +94,7 @@ 3. 创建 `user`,角色直接写创作者 4. 创建或初始化 `user_profile`,写入昵称、头像等基础资料 5. 创建 `creator_profile`,写入简介、banner 等创作者资料 -6. 初始化 3 条默认订阅档位(默认 `is_active=0`) +6. 初始化 5 条默认订阅档位:`free` 默认 `is_active=1`、price=0;`junior/basic/senior/supreme` 默认 `is_active=0` 7. 写入 `admin_operation_log` ### 请求参数 diff --git a/docs/dev/api/API-05-订阅系统.md b/docs/dev/api/API-05-订阅系统.md index 5afa3a2..1d1248a 100644 --- a/docs/dev/api/API-05-订阅系统.md +++ b/docs/dev/api/API-05-订阅系统.md @@ -12,7 +12,7 @@ | 3 | senior | 高级会员 | | 4 | supreme | 至尊会员 | -> 创作者可按需启用 1-4 档付费档(必须连续);免费关注层(tier_level=0)始终存在且 price 必须为 0。同一创作者订阅档位(含免费层)最多 5 行,付费档最多 4 个。 +> 创作者可按需启用 1-4 档付费档(必须连续);免费关注层(tier_level=0)始终存在且 price 必须为 0。免费档与付费档互斥:任一付费档启用时,免费档自动关闭;全部付费档关闭时,免费档自动开启。同一创作者订阅档位(含免费层)最多 5 行,付费档最多 4 个。 > 内容可见性 visibility=0(免费)/1(初级)/2(基础)/3(高级)/4(至尊) 对应订阅 tier_level(visibility=0 无需付费订阅,但仍需关注/订阅免费层)。 ### 内容权限枚举(subscription_tier.permissions) @@ -90,9 +90,9 @@ API 响应中的时间戳字段统一使用 `At` 后缀: | 涉及表 | subscription_tier, user_subscription | **接口逻辑:** -1. 查询 `subscription_tier` 表 WHERE creator_id=creatorId AND is_active=1 ORDER BY tier_level ASC(读,包含 tier_level=0 免费关注层) +1. 查询 `subscription_tier` 表 WHERE creator_id=creatorId AND is_active=1 ORDER BY tier_level ASC(读,仅返回当前启用档位;若开启任意付费档,则不会返回 free) 2. 如果用户已登录,查询 `user_subscription` 表 WHERE user_id=me AND creator_id=creatorId(读) -3. 组装当前订阅信息(currentSubscription),未订阅时为 null +3. 组装当前订阅信息(currentSubscription),未订阅时为 null;若用户仍处于一个已关闭档位的有效周期内,仍需回填该档位名称 4. 返回档位列表 + 当前订阅状态 **路径参数:** @@ -361,10 +361,10 @@ API 响应中的时间戳字段统一使用 `At` 后缀: **接口逻辑:** 1. 校验当前用户为创作者 2. 校验: - - 必须包含 tier=free(tier_level=0)且 price=0 - - 付费档(tier_level 1-4)数量 ≤ 4,且启用的档位 tier_level 必须从 1 开始连续(不能跳级) - - 至少启用 1 个档位(免费层 enabled=true 即满足) - - 每个档位 permissions 仅允许枚举值 + - 必须包含 tier=free(tier_level=0)且 price=0 + - 付费档(tier_level 1-4)数量 ≤ 4,且启用的档位 tier_level 必须从 1 开始连续(不能跳级) + - 免费档与付费档互斥:任一付费档启用时,后端自动关闭 free;全部付费档关闭时,后端自动开启 free + - 每个档位 permissions 仅允许枚举值 3. 遍历请求中的 tiers 数组: - 查询/更新 `subscription_tier` 表(WHERE creator_id=me AND tier_level=对应等级)(读+更新) - 如果记录不存在则创建(首次设置时写入对应记录)(写) @@ -372,7 +372,7 @@ API 响应中的时间戳字段统一使用 `At` 后缀: 5. 统计各档位订阅人数:查询 `user_subscription` 表 COUNT(WHERE creator_id=me AND tier_level=X AND status=1)(读) 6. 返回更新后的档位列表 -> **删除档位语义:** 客户端「删除」按钮等同于将该档位 `enabled=false`(软关闭,is_active=0)。已订阅该档位的用户保留至到期;关闭后用户无法续费该档位(SUB-05 校验)。免费关注层(tier=free)不可删除/关闭。 +> **删除档位语义:** 客户端「删除」按钮等同于将该档位 `enabled=false`(软关闭,is_active=0)。已订阅该档位的用户保留至到期;关闭后用户无法再购买、续费或降级到该档位。免费档不单独手工开关,而是由后端按互斥规则自动开关。 **请求参数(Body):** @@ -380,7 +380,7 @@ API 响应中的时间戳字段统一使用 `At` 后缀: |------|------|------|------| | tiers | array | 是 | 档位设置数组(含 free 与 1~4 个付费档) | | tiers[].tier | string | 是 | 档位标识:free/junior/basic/senior/supreme | -| tiers[].enabled | boolean | 是 | 是否开启(free 必须为 true) | +| tiers[].enabled | boolean | 是 | 是否开启;付费档按传值生效,free 的最终开关状态由后端按互斥规则自动归一 | | tiers[].price | number | 是 | 月价格(糖心币,free=0;付费档 >0) | | tiers[].name | string | 否 | 自定义档位名称(默认使用枚举名) | | tiers[].description | string | 否 | 档位权益说明 | @@ -414,7 +414,7 @@ API 响应中的时间戳字段统一使用 `At` 后缀: **接口逻辑:** 1. 校验当前用户为创作者(role=2) 2. 查询 `subscription_tier` 表 WHERE creator_id=me(含 is_active=0)ORDER BY tier_level ASC(读) -3. 若免费层(tier_level=0)不存在,默认补一个空记录返回(不落库),方便前端首次进入即可渲染 +3. 若免费层(tier_level=0)不存在,默认补一个空记录返回(不落库),方便前端首次进入即可渲染;其 enabled 状态应遵循当前互斥规则 4. 对每个档位调用 `TierSubscriberCount` 统计当前订阅人数(status=1) 5. 返回完整档位列表,每项包含 permissions/permissionLabels/subscriberCount diff --git a/docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md b/docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md new file mode 100644 index 0000000..9c7c4da --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md @@ -0,0 +1,353 @@ +# Subscription Tier Mutual Exclusion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Update subscription-tier behavior so `free` and paid tiers are mutually exclusive, paid tiers stay continuous from `junior`, and closed paid tiers block new purchase / renew / downgrade flows without immediately affecting existing subscribers. + +**Architecture:** Keep a single shared normalization and validation rule in `common/model/subscriptionmodel.go`, then let both client and admin tier-setting flows reuse that rule. Adjust creator initialization and documentation so the persisted tier shape matches the runtime contract everywhere. + +**Tech Stack:** Go, go-zero, sqlx, sqlmock, Markdown API docs + +--- + +## File Map + +- Modify: `common/model/subscriptionmodel.go` + Responsibility: shared tier normalization, validation, and transaction behavior +- Modify: `apps/client/internal/logic/jwt_ratelimit/subscriptionlogic.go` + Responsibility: creator-side tier setting error mapping and tier list behavior assumptions +- Modify: `apps/admin/internal/logic/jwt_rbac/postsubscriptionadmin.go` + Responsibility: admin-side tier setting request validation and shared upsert usage +- Modify: `apps/admin/internal/logic/jwt_rbac/admincreatorcreate.go` + Responsibility: initialize full tier set for admin-created creators +- Modify: `apps/admin/internal/logic/jwt_rbac/admincontentsubscription_test.go` + Responsibility: admin tier-setting regression tests +- Modify: `apps/admin/internal/logic/jwt_rbac/admincreatorcreate_test.go` + Responsibility: creator initialization regression tests +- Modify: `docs/dev/api/API-05-订阅系统.md` + Responsibility: client API contract update +- Modify: `docs/dev/admin-api/ADMIN-API-11-内容管理.md` + Responsibility: admin tier-setting contract update +- Modify: `docs/dev/admin-api/ADMIN-API-12-创作者代运营.md` + Responsibility: direct-create-creator contract update + +### Task 1: Shared Tier Normalization + +**Files:** +- Modify: `/Users/zc/Desktop/golang-ps/common/model/subscriptionmodel.go` +- Test: `/Users/zc/Desktop/golang-ps/common/model/subscriptionmodel.go` (table-driven tests to be added in same package or existing test file if present) + +- [ ] **Step 1: Write the failing test** + +```go +func TestTierUpsertBatchNormalizesFreeAgainstPaidTiers(t *testing.T) { + items := []TierUpsertItem{ + {TierKey: "free", Enabled: true, Price: 0, Name: "免费关注"}, + {TierKey: "junior", Enabled: true, Price: 9900, Name: "初级会员"}, + } + + got, err := normalizeTierUpsertItems(items) + if err != nil { + t.Fatalf("normalizeTierUpsertItems: %v", err) + } + if got[0].TierKey != "free" || got[0].Enabled { + t.Fatalf("free should be auto-disabled when any paid tier is enabled: %+v", got[0]) + } +} + +func TestTierUpsertBatchRejectsInactiveDowngradeTarget(t *testing.T) { + // add a downgrade-path regression once helper / tx seam is available +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./common/model -run 'TestTierUpsertBatchNormalizesFreeAgainstPaidTiers|TestTierUpsertBatchRejectsInactiveDowngradeTarget'" +``` + +Expected: + +- FAIL because current model still requires `free` enabled +- FAIL because downgrade path does not yet reject inactive target tier + +- [ ] **Step 3: Write minimal implementation** + +```go +func normalizeTierUpsertItems(items []TierUpsertItem) ([]TierUpsertItem, error) { + normalized := make([]TierUpsertItem, 0, len(items)) + hasFree := false + enabledPaid := make([]enum.TierLevel, 0, 4) + + for _, it := range items { + level, ok := tierKeyToLevel(it.TierKey) + if !ok { + return nil, fmt.Errorf("invalid tier_key: %s", it.TierKey) + } + if level == enum.TierLevelFree { + hasFree = true + it.Price = 0 + } else if it.Enabled { + enabledPaid = append(enabledPaid, level) + } + normalized = append(normalized, it) + } + if !hasFree { + return nil, fmt.Errorf("free tier is required") + } + sortInt16Asc(enabledPaid) + for i, lv := range enabledPaid { + if enum.TierLevel(i+1) != lv { + return nil, fmt.Errorf("paid tiers must be continuous starting from level 1") + } + } + hasEnabledPaid := len(enabledPaid) > 0 + for i := range normalized { + if level, _ := tierKeyToLevel(normalized[i].TierKey); level == enum.TierLevelFree { + normalized[i].Enabled = !hasEnabledPaid + normalized[i].Price = 0 + } + } + return normalized, nil +} +``` + +Also update: + +```go +if newTier.IsActive != 1 { + return ErrTierInactive +} +``` + +inside `DowngradeMark(...)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./common/model -run 'TestTierUpsertBatchNormalizesFreeAgainstPaidTiers|TestTierUpsertBatchRejectsInactiveDowngradeTarget'" +``` + +Expected: + +- PASS for the new normalization and inactive downgrade assertions + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/zc/Desktop/golang-ps add common/model/subscriptionmodel.go +git -C /Users/zc/Desktop/golang-ps commit -m "feat: normalize free and paid subscription tiers" +``` + +### Task 2: Client Tier Setting and Read Flows + +**Files:** +- Modify: `/Users/zc/Desktop/golang-ps/apps/client/internal/logic/jwt_ratelimit/subscriptionlogic.go` +- Test: `/Users/zc/Desktop/golang-ps/apps/client/internal/logic/jwt_ratelimit/subscriptionlogic.go` existing tier-setting tests or new test file in same package + +- [ ] **Step 1: Write the failing test** + +```go +func TestSubTierSettingsUpdateReturnsNormalizedFreeDisabledWhenPaidEnabled(t *testing.T) { + // mock TierUpsertBatch returning free inactive + junior active + // assert response tiers reflect free=false +} + +func TestSubTierSettingsUpdateDoesNotReturnFreeMustBeEnabledError(t *testing.T) { + err := errors.New("free tier must be enabled") + if got := mapTierSettingError(err); got == nil { + t.Fatal("expected mapped error") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./apps/client/internal/logic/jwt_ratelimit -run 'TestSubTierSettingsUpdateReturnsNormalizedFreeDisabledWhenPaidEnabled|TestSubTierSettingsUpdateDoesNotReturnFreeMustBeEnabledError'" +``` + +Expected: + +- FAIL because client-side error mapping and response assumptions still reflect the old contract + +- [ ] **Step 3: Write minimal implementation** + +```go +switch { +case strings.Contains(msg, "free tier is required"): + return nil, errcode.New(40001, "tiers 必须包含 free") +case strings.Contains(msg, "paid tiers must be continuous starting from level 1"): + return nil, errcode.New(40001, "启用的付费档位必须从 junior 开始连续") +} +``` + +Keep `SUB-01` and `SUB-09` response assembly unchanged except for relying on persisted `is_active` values instead of the old "free must be enabled" assumption. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./apps/client/internal/logic/jwt_ratelimit -run 'TestSubTierSettingsUpdateReturnsNormalizedFreeDisabledWhenPaidEnabled|TestSubTierSettingsUpdateDoesNotReturnFreeMustBeEnabledError'" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/zc/Desktop/golang-ps add apps/client/internal/logic/jwt_ratelimit/subscriptionlogic.go +git -C /Users/zc/Desktop/golang-ps commit -m "feat: align client subscription tier rules" +``` + +### Task 3: Admin Tier Setting and Creator Initialization + +**Files:** +- Modify: `/Users/zc/Desktop/golang-ps/apps/admin/internal/logic/jwt_rbac/postsubscriptionadmin.go` +- Modify: `/Users/zc/Desktop/golang-ps/apps/admin/internal/logic/jwt_rbac/admincreatorcreate.go` +- Test: `/Users/zc/Desktop/golang-ps/apps/admin/internal/logic/jwt_rbac/admincontentsubscription_test.go` +- Test: `/Users/zc/Desktop/golang-ps/apps/admin/internal/logic/jwt_rbac/admincreatorcreate_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestAdminCreatorSubscriptionTiersPutAllowsFreeInactiveWhenPaidEnabled(t *testing.T) { + // request includes free=false + junior=true + // expect success and persisted free inactive +} + +func TestAdminCreateCreatorInitializesFreeAndFourPaidTiers(t *testing.T) { + // expect inserts: free active, junior/basic/senior/supreme inactive +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./apps/admin/internal/logic/jwt_rbac -run 'TestAdminCreatorSubscriptionTiersPutAllowsFreeInactiveWhenPaidEnabled|TestAdminCreateCreatorInitializesFreeAndFourPaidTiers'" +``` + +Expected: + +- FAIL because admin validation still requires `free.enabled=true` +- FAIL because creator initialization still only inserts paid tiers + +- [ ] **Step 3: Write minimal implementation** + +```go +if level == enum.TierLevelFree { + hasFree = true + if input.Price != 0 { + return nil, errcode.New(42201, "free 档价格必须为 0") + } +} +``` + +And in creator initialization: + +```go +tiers := []struct { + Level enum.TierLevel + Key string + Name string + Price int64 + Enabled int16 +}{ + {enum.TierLevelFree, "free", "免费关注", 0, 1}, + {enum.TierLevelJunior, "junior", "初级", 1, 0}, + {enum.TierLevelBasic, "basic", "基础", 1, 0}, + {enum.TierLevelSenior, "senior", "高级", 1, 0}, + {enum.TierLevelSupreme, "supreme", "至尊", 1, 0}, +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./apps/admin/internal/logic/jwt_rbac -run 'TestAdminCreatorSubscriptionTiersPutAllowsFreeInactiveWhenPaidEnabled|TestAdminCreateCreatorInitializesFreeAndFourPaidTiers'" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/zc/Desktop/golang-ps add apps/admin/internal/logic/jwt_rbac/postsubscriptionadmin.go apps/admin/internal/logic/jwt_rbac/admincreatorcreate.go apps/admin/internal/logic/jwt_rbac/admincontentsubscription_test.go apps/admin/internal/logic/jwt_rbac/admincreatorcreate_test.go +git -C /Users/zc/Desktop/golang-ps commit -m "feat: align admin subscription tier behavior" +``` + +### Task 4: Docs and Regression + +**Files:** +- Modify: `/Users/zc/Desktop/txagent-y-main/docs/dev/api/API-05-订阅系统.md` +- Modify: `/Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-11-内容管理.md` +- Modify: `/Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md` + +- [ ] **Step 1: Write the failing documentation diff** + +```md +- free 必须启用 +- 免费关注层不可删除/关闭 ++ free 与付费档互斥 ++ 任一付费档启用时 free 自动关闭 ++ 全部付费档关闭时 free 自动开启 ++ 关闭付费档不影响已订阅用户当前周期 +``` + +- [ ] **Step 2: Run verification grep to confirm old wording still exists** + +Run: + +```bash +rg -n "free 必须启用|免费关注层不可删除/关闭|始终存在且 price 必须为 0" /Users/zc/Desktop/txagent-y-main/docs/dev/api/API-05-订阅系统.md /Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-11-内容管理.md /Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md +``` + +Expected: + +- matches found for old wording + +- [ ] **Step 3: Write minimal documentation updates** + +```md +- `free` 记录必须存在,价格恒为 0 +- 任一付费档启用时,后台自动关闭 `free` +- 全部付费档关闭时,后台自动开启 `free` +- 关闭付费档后,新用户无法购买/续费/降级至该档 +- 已订阅用户保留至到期 +``` + +- [ ] **Step 4: Run verification and targeted tests** + +Run: + +```bash +rg -n "互斥|自动关闭|自动开启|保留至到期" /Users/zc/Desktop/txagent-y-main/docs/dev/api/API-05-订阅系统.md /Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-11-内容管理.md /Users/zc/Desktop/txagent-y-main/docs/dev/admin-api/ADMIN-API-12-创作者代运营.md +/bin/zsh -lc "GOCACHE=/tmp/gocache GOMODCACHE=/tmp/gomodcache GOPROXY=https://goproxy.cn,direct GOSUMDB=off go test ./common/model ./apps/client/internal/logic/jwt_ratelimit ./apps/admin/internal/logic/jwt_rbac" +``` + +Expected: + +- grep shows new wording +- go tests pass, or only known unrelated sandbox/network tests remain + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/zc/Desktop/txagent-y-main add docs/dev/api/API-05-订阅系统.md docs/dev/admin-api/ADMIN-API-11-内容管理.md docs/dev/admin-api/ADMIN-API-12-创作者代运营.md docs/superpowers/specs/2026-04-13-subscription-tier-mutual-exclusion-design.md docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md +git -C /Users/zc/Desktop/txagent-y-main commit -m "docs: define subscription tier mutual exclusion rules" +``` diff --git a/docs/superpowers/specs/2026-04-13-subscription-tier-mutual-exclusion-design.md b/docs/superpowers/specs/2026-04-13-subscription-tier-mutual-exclusion-design.md new file mode 100644 index 0000000..b461e7f --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-subscription-tier-mutual-exclusion-design.md @@ -0,0 +1,254 @@ +# Subscription Tier Mutual Exclusion Design + +**Date:** 2026-04-13 + +**Goal:** Align subscription-tier behavior with the new product rule: free and paid tiers are mutually exclusive, paid tiers must start at `junior` and remain continuous, and closing a tier must not immediately revoke existing subscribers. + +## Context + +The current implementation assumes: + +- `free` must always exist and be enabled. +- `free.price` must always be `0`. +- Creator and admin tier-setting flows both reuse the same `TierUpsertBatch(...)` validation. +- Fan-side tier list (`SUB-01`) only returns active tiers, while creator-side tier list (`SUB-09`) returns all tiers. + +The new requirement changes the core rule: + +- `free` and paid tiers are mutually exclusive. +- Once any paid tier is enabled, `free` must be automatically closed. +- If all paid tiers are closed, `free` must automatically reopen. +- Paid tiers can enable at most four levels and must remain continuous from `junior`. +- Closing a paid tier only blocks new purchase / renew / downgrade-into flows; existing subscribers keep their entitlement until expiration. + +## Scope + +This change covers: + +- Shared tier persistence and validation in `common/model/subscriptionmodel.go` +- Client creator tier setting and tier query flows +- Admin creator tier setting flow +- Admin direct-create-creator initialization logic +- Subscription docs in client and admin API documents + +This change does not cover: + +- New database schema +- Immediate forced downgrade / refund for active subscribers +- New UI widgets or new API endpoints + +## Design Decisions + +### 1. Keep the `free` record, but allow it to be inactive + +`free` remains a first-class row in `subscription_tier`: + +- `tier_level=0` +- `tier_key=free` +- `price=0` + +We do not physically delete it when paid tiers are enabled. We only toggle `is_active`. + +Reason: + +- Existing code frequently identifies tiers by `tier_level`. +- Keeping the row avoids null-state branching across admin, creator, and fan flows. +- It makes editing and auditing easier than representing "free disabled" as "free missing". + +### 2. Normalize tier state on the backend + +Backend persistence becomes the source of truth for mutual-exclusion rules. + +Normalization rules before upsert: + +1. Request must still contain `free`. +2. `free.price` is always normalized to `0`. +3. Enabled paid tiers must be continuous from `junior`. +4. If any paid tier is enabled, `free.enabled` is forced to `false`. +5. If no paid tier is enabled, `free.enabled` is forced to `true`. + +This is a deliberate rule change, not the previous temporary compatibility idea of forcing `free=true`. + +### 3. Existing subscribers are preserved + +Closing a paid tier affects only future operations: + +- New purchase into the closed tier: rejected +- Upgrade into the closed tier: rejected +- Renew on the closed tier: rejected +- Downgrade targeting the closed tier: rejected + +Existing active subscriptions remain valid until `expired_at`. + +This matches the product statement: "关闭某档后新用户无法订阅该档,已订阅用户保留至到期。" + +### 4. Default creator tier initialization becomes full 5-tier initialization + +Admin direct-create-creator currently inserts only three paid tiers and omits `free`. + +After this change, creator initialization should insert: + +- `free` with `is_active=1`, `price=0` +- `junior/basic/senior/supreme` with `is_active=0` + +Reason: + +- Avoid creator-side `SUB-09` needing to synthesize too much placeholder state +- Keep admin-created creators and organically-created creators under the same tier contract + +## Detailed Behavior + +### Shared persistence rule: `TierUpsertBatch(...)` + +Current rule: + +- `free` must exist +- `free` must be enabled +- `free.price` must be `0` + +New rule: + +- `free` must exist +- `free.price` must be `0` +- `free.enabled` is derived by backend normalization: + - any enabled paid tier => `free=false` + - no enabled paid tier => `free=true` +- enabled paid tiers count must be `<= 4` +- enabled paid tiers must be continuous starting from `junior` + +The shared model layer remains the single rule engine for both client and admin tier setting. + +### Client creator tier setting: `SUB-07` + +Request contract remains a full snapshot of tier settings including `free`. + +Behavior changes: + +- Frontend may still send `free.enabled`, but backend will normalize it. +- Error messages should focus on: + - missing `free` + - illegal / duplicate tier + - paid tier continuity + - paid tier positive price + - invalid permission enum + +Errors about "`free` must be enabled" should be removed, because that rule no longer exists. + +### Creator self tier list: `SUB-09` + +Creator-side list still returns all tiers including inactive ones. + +Behavior changes: + +- If initialization is corrected to full 5-tier insertion, `SUB-09` should normally no longer need to synthesize a missing `free` placeholder. +- The fallback placeholder logic can remain defensive, but the normal path should read complete rows from DB. + +### Fan-side tier list: `SUB-01` + +No endpoint shape change is needed. + +Behavior changes only by data result: + +- If any paid tier is active, fan-side list will only show active paid tiers. +- If all paid tiers are inactive, fan-side list will show `free`. + +### Purchase / Upgrade / Renew / Downgrade + +Current code already blocks purchase / upgrade / renew against inactive target tiers. + +Required clarification and completion: + +- `PurchaseTx`: unchanged logically, because it already rejects `tier.IsActive != 1` +- `UpgradeTx`: unchanged logically, because it already rejects `newTier.IsActive != 1` +- `RenewTx`: unchanged logically, because it already rejects target renew tier `is_active != 1` +- `DowngradeMark`: must also reject downgrading into an inactive target tier + +`DowngradeMark` is the only transaction path in this group that currently does not enforce active target tier. + +## API and Doc Changes + +### Client API-05 + +Update the following wording: + +- `free` no longer "must be enabled" +- `free` and paid tiers are mutually exclusive +- enabling any paid tier auto-closes `free` +- when all paid tiers are closed, `free` auto-reopens +- closing a paid tier preserves existing subscribers until expiration + +### Admin API-11 + +Update admin tier-setting documentation to match the same normalization rule. Admin and creator settings must not diverge. + +### Admin API-12 + +Update direct-create-creator documentation: + +- default tier initialization becomes full 5-tier initialization +- initial state is `free` active + paid tiers inactive + +## Data Migration / Backfill + +No schema migration is required. + +One data backfill is recommended for test / existing environments: + +- find creators missing `free` row in `subscription_tier` +- insert `free` with `price=0` +- set `is_active` based on whether they currently have any active paid tier + +This can be handled by: + +- a one-off script, or +- lazy self-healing in tier-setting writes plus a manual environment patch + +Recommendation: + +- implement code first +- run a one-off backfill for test environment separately + +## Risks + +### 1. Legacy doc and frontend assumption mismatch + +Current docs and some frontend behavior still assume "`free` must be enabled". If docs are not updated together, the frontend will keep sending or validating against obsolete assumptions. + +### 2. Missing `free` rows in existing data + +Some creators already exist without a `free` row due to older initialization logic. Shared persistence now requires `free` to exist, so missing rows must be corrected before relying on the new contract everywhere. + +### 3. Downgrade target loophole + +If `DowngradeMark` is not updated, users could still mark a downgrade into a closed tier, which would violate the new rule and only fail later during renew. + +## Verification Plan + +Minimum regression coverage: + +1. Shared model tests + - paid tier enabled => `free` becomes inactive + - no paid tier enabled => `free` becomes active + - paid tiers must remain continuous from `junior` + - inactive paid tier cannot be downgrade target + +2. Client logic tests + - `SUB-07` returns normalized `free=false` when paid tiers are enabled + - `SUB-07` returns normalized `free=true` when all paid tiers are disabled + - `SUB-01` only returns active tiers + - `SUB-09` returns complete editable tier list + +3. Admin logic tests + - admin tier setting applies the same normalization as client tier setting + - admin direct-create-creator initializes all five tiers with correct active states + +4. Real HTTP regression + - creator / admin set tiers + - verify tier lists from fan-side and creator-side + - verify purchase / renew failure on closed paid tier + +## Final Recommendation + +Implement the new rule in the shared model layer and keep `free` as a persistent but optionally inactive row. + +This keeps the system internally consistent, minimizes schema risk, and correctly matches the new product semantics without introducing endpoint sprawl or one-off admin/client behavior differences.