txagent-y/docs/superpowers/plans/2026-04-13-subscription-tier-mutual-exclusion.md

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

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 free enabled

  • 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.go existing 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"