13 KiB
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.goResponsibility: shared tier normalization, validation, and transaction behavior - Modify:
apps/client/internal/logic/jwt_ratelimit/subscriptionlogic.goResponsibility: creator-side tier setting error mapping and tier list behavior assumptions - Modify:
apps/admin/internal/logic/jwt_rbac/postsubscriptionadmin.goResponsibility: admin-side tier setting request validation and shared upsert usage - Modify:
apps/admin/internal/logic/jwt_rbac/admincreatorcreate.goResponsibility: initialize full tier set for admin-created creators - Modify:
apps/admin/internal/logic/jwt_rbac/admincontentsubscription_test.goResponsibility: admin tier-setting regression tests - Modify:
apps/admin/internal/logic/jwt_rbac/admincreatorcreate_test.goResponsibility: creator initialization regression tests - Modify:
docs/dev/api/API-05-订阅系统.mdResponsibility: client API contract update - Modify:
docs/dev/admin-api/ADMIN-API-11-内容管理.mdResponsibility: admin tier-setting contract update - Modify:
docs/dev/admin-api/ADMIN-API-12-创作者代运营.mdResponsibility: 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
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:
/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
freeenabled -
FAIL because downgrade path does not yet reject inactive target tier
-
Step 3: Write minimal implementation
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:
if newTier.IsActive != 1 {
return ErrTierInactive
}
inside DowngradeMark(...).
- Step 4: Run test to verify it passes
Run:
/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
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.goexisting tier-setting tests or new test file in same package -
Step 1: Write the failing test
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:
/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
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:
/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
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
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:
/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
if level == enum.TierLevelFree {
hasFree = true
if input.Price != 0 {
return nil, errcode.New(42201, "free 档价格必须为 0")
}
}
And in creator initialization:
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:
/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
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
- free 必须启用
- 免费关注层不可删除/关闭
+ free 与付费档互斥
+ 任一付费档启用时 free 自动关闭
+ 全部付费档关闭时 free 自动开启
+ 关闭付费档不影响已订阅用户当前周期
- Step 2: Run verification grep to confirm old wording still exists
Run:
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
- `free` 记录必须存在,价格恒为 0
- 任一付费档启用时,后台自动关闭 `free`
- 全部付费档关闭时,后台自动开启 `free`
- 关闭付费档后,新用户无法购买/续费/降级至该档
- 已订阅用户保留至到期
- Step 4: Run verification and targeted tests
Run:
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
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"