docs: align subscription tier mutual exclusion rules

This commit is contained in:
qingfeng 2026-04-13 20:59:49 +08:00
parent 33b2f2d417
commit 880dea1616
5 changed files with 623 additions and 15 deletions

View File

@ -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免费档未传 / 价格不合法 / 付费档未连续启用 / 权益枚举非法
---

View File

@ -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`
### 请求参数

View File

@ -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_levelvisibility=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=freetier_level=0且 price=0
- 付费档tier_level 1-4数量 ≤ 4且启用的档位 tier_level 必须从 1 开始连续(不能跳级)
- 至少启用 1 个档位(免费层 enabled=true 即满足)
- 每个档位 permissions 仅允许枚举值
- 必须包含 tier=freetier_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=0ORDER BY tier_level ASC
3. 若免费层tier_level=0不存在默认补一个空记录返回不落库方便前端首次进入即可渲染
3. 若免费层tier_level=0不存在默认补一个空记录返回不落库方便前端首次进入即可渲染;其 enabled 状态应遵循当前互斥规则
4. 对每个档位调用 `TierSubscriberCount` 统计当前订阅人数status=1
5. 返回完整档位列表,每项包含 permissions/permissionLabels/subscriberCount

View File

@ -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"
```

View File

@ -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.