docs: align subscription tier mutual exclusion rules
This commit is contained in:
parent
33b2f2d417
commit
880dea1616
@ -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:免费档未传 / 价格不合法 / 付费档未连续启用 / 权益枚举非法
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
### 请求参数
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
```
|
||||
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user