feat(ECR-014): MembershipPlan 套餐配置并 Closed

membership_plans 表、admin 套餐页、Grant/CreateOrder 读表;
Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:03:38 +08:00
co-authored by Cursor
parent e25cd94b0c
commit 882c01d81a
31 changed files with 887 additions and 27 deletions
+1 -1
View File
@@ -235,7 +235,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`ECR-006 Closed |
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md` |
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md` |
| Phase D+ | **Contract-First****ECR-013A Closed** → **ECR-013B Implementedreview**。详见 `docs/WAVE0/`。 |
| Phase D+ | **Contract-First****ECR-013A/013B Closed** → **ECR-014** MembershipPlanLoop continuous)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 |
| 排除 | **UGC / 社区广场**M10.2)仍 `[No]`;真支付最后 |
不计入 P1 Complete;不进入五 Tab。
@@ -0,0 +1,77 @@
# Feature Spec: 会员套餐 MembershipPlanOps · ECR-014
> Status: `Active`Loop continuous · Approved · coding)· Map: `§7` · Phase: `Ops-D`
> Parent: **WAVE0-FROZEN** · Predecessor: **ECR-013B Closed**
> ESS: `docs/ECR/ECR-014-membership-plan.md`
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
---
## ESS 门禁
1. L2 · Loop continuous:契约齐 → 自动 Approve + coding
2. **不含** 真支付 · 兑换码 · Entitlement 矩阵 · ask_pack 价目表
---
## L0 Capability
| 字段 | 内容 |
|------|------|
| Capability ID | `CommerceEntitlement` |
| Purpose | 运营可配置成长会员套餐时长与标价(mock 履约仍用既有支付) |
| Why now | planDays/金额硬编码;013B 完成后进入 Commerce 配置面最小切片 |
| Non-goals | 真支付网关 · RedemptionCode · Entitlement 细权 · ask_pack |
---
## L1 Bounded Context
| Primary BC | `Membership_Orders` |
| owns | `MembershipPlan` |
| does_not_own | `UserStatus` · `Payment` 适配器 · `AdminRole` |
| allowed | `Admin_Auth_Audit.write_audit` |
| forbidden | 真支付 · UGC |
---
## L2 Domain
| Entity | 不变式 |
|--------|--------|
| `MembershipPlan` | `code` ∈ {month,quarter,year} 本切片冻结;`duration_days`>0`amount_cents`≥0`active` 布尔 |
---
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/membership-plans` | `admin.membership.plans.read` | 列表 |
| GET | `/admin/membership-plans/:code` | `admin.membership.plans.read` | 详情 |
| PUT | `/admin/membership-plans/:code` | `admin.membership.plans.write` | 更新 title/days/amount/active + AuditLog |
履约:`GrantMembership` / membership `CreateOrder` 读表(缺行回退旧硬编码)。
`contract_diff``docs/CONTRACT_DIFF/ECR-014.yaml`
---
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | GET plans 含 month/quarter/year |
| AC-F-02 | PUT month days/amount → GET 一致 |
| AC-F-03 | GrantMembership 使用表内 duration_days |
| AC-S-01 | 无 write 权限 PUT → 403 |
| AC-S-02 | 无 Admin → 401 |
| AC-P-01 | GET list P95 &lt; 500ms 本机 |
| AC-O-01 | PUT 成功 → AuditLog `membership.plans.update` |
---
## Implementation Notes
Migration `membership_plans` + RBAC additive permissions · admin-h5 最小列表编辑页
+22
View File
@@ -136,6 +136,28 @@ export const adminApi = {
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
membershipPlans: () =>
request<{
items: Array<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
updated_at: string
}>
}>('GET', '/membership-plans'),
updateMembershipPlan: (
code: string,
body: { title: string; duration_days: number; amount_cents: number; active: boolean },
) =>
request<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -28,6 +28,7 @@ async function onLogout() {
<RouterLink to="/analytics">数据</RouterLink>
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Plan = {
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const items = ref<Plan[]>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.membershipPlans()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function save(p: Plan) {
msg.value = ''
try {
await adminApi.updateMembershipPlan(p.code, {
title: p.title,
duration_days: Number(p.duration_days),
amount_cents: Number(p.amount_cents),
active: p.active,
})
msg.value = `${p.code} 已保存`
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '保存失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>会员套餐</h1>
<p class="muted">配置成长会员时长与标价mock 履约读表</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-for="p in items" :key="p.code" class="card block">
<h2>{{ p.code }}</h2>
<div class="row">
<label>标题 <input v-model="p.title" /></label>
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
<label>标价 <input v-model.number="p.amount_cents" type="number" min="0" /></label>
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
<button
v-if="auth.can('admin.membership.plans.write')"
class="btn"
type="button"
@click="save(p)"
>
保存
</button>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; text-transform: uppercase; }
.block { margin-bottom: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
input { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
</style>
+1
View File
@@ -15,6 +15,7 @@ const router = createRouter({
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
+1
View File
@@ -44,6 +44,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerContent(authed)
h.registerRBAC(authed)
h.registerLifecycle(authed)
h.registerMembershipPlans(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -0,0 +1,78 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerMembershipPlans(authed *gin.RouterGroup) {
authed.GET("/membership-plans", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListMembershipPlans)
authed.GET("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.GetMembershipPlan)
authed.PUT("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutMembershipPlan)
}
func (h *AdminHandler) ListMembershipPlans(c *gin.Context) {
items, err := h.Svc.ListMembershipPlans(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) GetMembershipPlan(c *gin.Context) {
plan, err := h.Svc.GetMembershipPlan(c.Request.Context(), c.Param("code"))
if errors.Is(err, admin.ErrPlanNotFound) {
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, plan)
}
func (h *AdminHandler) PutMembershipPlan(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
var body struct {
Title string `json:"title"`
DurationDays int `json:"duration_days"`
AmountCents int `json:"amount_cents"`
Active *bool `json:"active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
return
}
active := true
if body.Active != nil {
active = *body.Active
}
plan, err := h.Svc.UpdateMembershipPlan(
c.Request.Context(), adminID, c.Param("code"), body.Title, body.DurationDays, body.AmountCents, active,
)
if errors.Is(err, admin.ErrPlanNotFound) {
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
return
}
if errors.Is(err, admin.ErrInvalidPlanU) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, plan)
}
@@ -0,0 +1,97 @@
package integration_test
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestMembershipPlans(t *testing.T) {
r, _ := setupAPIPool(t)
tok := adminLogin(t, r, "admin", "change-me")
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, "")
if code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", code)
}
start := time.Now()
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, tok)
if code != 200 || time.Since(start) > 500*time.Millisecond {
t.Fatalf("list plans http=%d dur=%v msg=%s", code, time.Since(start), env.Message)
}
var list struct {
Items []struct {
Code string `json:"code"`
DurationDays int `json:"duration_days"`
AmountCents int `json:"amount_cents"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &list)
if len(list.Items) < 3 {
t.Fatalf("expected 3 plans, got %#v", list.Items)
}
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership-plans/month", map[string]any{
"title": "月卡测", "duration_days": 30, "amount_cents": 2600, "active": true,
}, tok)
if code != 200 {
t.Fatalf("put failed %d %s", code, env.Message)
}
var plan struct {
DurationDays int `json:"duration_days"`
AmountCents int `json:"amount_cents"`
}
_ = json.Unmarshal(env.Data, &plan)
if plan.DurationDays != 30 || plan.AmountCents != 2600 {
t.Fatalf("unexpected plan %#v", plan)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans/month", nil, tok)
if code != 200 {
t.Fatalf("get %d", code)
}
_ = json.Unmarshal(env.Data, &plan)
if plan.DurationDays != 30 {
t.Fatalf("get mismatch %#v", plan)
}
_ = mustRegister(t, r)
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, tok)
var users struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &users)
if len(users.Items) == 0 {
t.Fatal("need user")
}
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+users.Items[0].ID+"/membership/grant",
map[string]string{"plan": "month"}, tok)
if code != 200 {
t.Fatalf("grant %d %s", code, env.Message)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
if code != 200 {
t.Fatalf("audit %d", code)
}
var audit struct {
Items []struct {
Action string `json:"action"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &audit)
found := false
for _, it := range audit.Items {
if it.Action == "membership.plans.update" {
found = true
break
}
}
if !found {
t.Fatal("missing membership.plans.update audit")
}
}
@@ -0,0 +1,94 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// MembershipPlanRow is a configurable growth membership SKU.
type MembershipPlanRow struct {
Code string `json:"code"`
Title string `json:"title"`
DurationDays int `json:"duration_days"`
AmountCents int `json:"amount_cents"`
Active bool `json:"active"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListMembershipPlans returns all plans ordered by code.
func (r *AdminRepo) ListMembershipPlans(ctx context.Context) ([]MembershipPlanRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT code, title, duration_days, amount_cents, active, updated_at
FROM membership_plans ORDER BY code`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MembershipPlanRow
for rows.Next() {
var p MembershipPlanRow
if err := rows.Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetMembershipPlan loads one plan by code.
func (r *AdminRepo) GetMembershipPlan(ctx context.Context, code string) (*MembershipPlanRow, error) {
var p MembershipPlanRow
err := r.Pool.QueryRow(ctx, `
SELECT code, title, duration_days, amount_cents, active, updated_at
FROM membership_plans WHERE code=$1`, code,
).Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// UpdateMembershipPlanWithAudit updates mutable fields and audits.
func (r *AdminRepo) UpdateMembershipPlanWithAudit(
ctx context.Context,
adminID uuid.UUID,
code, title string,
days, amountCents int,
active bool,
meta json.RawMessage,
) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE membership_plans
SET title=$2, duration_days=$3, amount_cents=$4, active=$5, updated_at=now()
WHERE code=$1`, code, title, days, amountCents, active)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errString("plan not found")
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'membership.plans.update','membership_plan',$2,$3)`,
adminID, code, meta,
); err != nil {
return err
}
return tx.Commit(ctx)
}
@@ -316,6 +316,38 @@ func AskPackAmountCents(plan string) int {
}
}
// MembershipPlanAmountCents returns catalog price or fallback for membership plans.
func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) {
var amount int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&amount, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipAmountFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active {
return 0, errString("plan inactive")
}
return amount, nil
}
func membershipAmountFallback(plan string) int {
switch plan {
case "month":
return 2500
case "quarter":
return 6800
case "year":
return 19800
default:
return 2500
}
}
var errMissingReport = errString("report_id required for deep_access")
type errString string
@@ -0,0 +1,86 @@
package admin
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrPlanNotFound = errString("plan not found")
ErrInvalidPlanU = errString("invalid plan update")
)
// ListMembershipPlans returns catalog.
func (s *Service) ListMembershipPlans(ctx context.Context) ([]repository.MembershipPlanRow, error) {
items, err := s.Repo.ListMembershipPlans(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.MembershipPlanRow{}
}
return items, nil
}
// GetMembershipPlan returns one plan.
func (s *Service) GetMembershipPlan(ctx context.Context, code string) (*repository.MembershipPlanRow, error) {
p, err := s.Repo.GetMembershipPlan(ctx, code)
if err != nil {
return nil, err
}
if p == nil {
return nil, ErrPlanNotFound
}
return p, nil
}
// UpdateMembershipPlan updates mutable fields.
func (s *Service) UpdateMembershipPlan(
ctx context.Context, adminID uuid.UUID, code, title string, days, amount int, active bool,
) (*repository.MembershipPlanRow, error) {
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if title == "" || days <= 0 || amount < 0 {
return nil, ErrInvalidPlanU
}
if _, err := s.GetMembershipPlan(ctx, code); err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{
"title": title, "duration_days": days, "amount_cents": amount, "active": active,
})
if err := s.Repo.UpdateMembershipPlanWithAudit(ctx, adminID, code, title, days, amount, active, meta); err != nil {
return nil, err
}
return s.GetMembershipPlan(ctx, code)
}
// PlanDurationDays resolves grant length from catalog with hardcoded fallback.
func (s *Service) PlanDurationDays(ctx context.Context, plan string) (int, error) {
p, err := s.Repo.GetMembershipPlan(ctx, plan)
if err != nil {
return 0, err
}
if p != nil && p.Active && p.DurationDays > 0 {
return p.DurationDays, nil
}
return planDaysFallback(plan)
}
func planDaysFallback(plan string) (int, error) {
switch plan {
case "month":
return 31, nil
case "quarter":
return 92, nil
case "year":
return 366, nil
default:
return 0, ErrInvalidPlan
}
}
+3 -1
View File
@@ -20,13 +20,15 @@ const (
PermRolesRead = "admin.roles.read"
PermRolesWrite = "admin.roles.write"
PermUsersStatusWrite = "admin.users.status.write"
PermMembershipPlansRead = "admin.membership.plans.read"
PermMembershipPlansWrite = "admin.membership.plans.write"
)
var knownPermissions = map[string]struct{}{
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
}
var (
+2 -11
View File
@@ -181,7 +181,7 @@ type GrantInput struct {
// GrantMembership extends membership and writes audit.
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
days, err := planDays(plan)
days, err := s.PlanDurationDays(ctx, plan)
if err != nil {
return err
}
@@ -230,16 +230,7 @@ func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repos
}
func planDays(plan string) (int, error) {
switch plan {
case "month":
return 31, nil
case "quarter":
return 92, nil
case "year":
return 366, nil
default:
return 0, ErrInvalidPlan
}
return planDaysFallback(plan)
}
func newToken() (string, error) {
@@ -34,7 +34,17 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
amount := 990
plan := in.Plan
if in.Kind == "membership" {
amount = 2500
if plan == "" {
plan = "month"
}
a, err := s.Reports.MembershipPlanAmountCents(ctx, plan)
if err != nil {
return uuid.Nil, err
}
if a <= 0 {
return uuid.Nil, errors.New("invalid membership plan")
}
amount = a
}
if in.Kind == "ask_pack" {
if plan == "" {
@@ -0,0 +1,3 @@
DELETE FROM admin_role_permissions
WHERE code IN ('admin.membership.plans.read', 'admin.membership.plans.write');
DROP TABLE IF EXISTS membership_plans;
@@ -0,0 +1,27 @@
-- ECR-014 MembershipPlan
CREATE TABLE IF NOT EXISTS membership_plans (
code varchar(32) PRIMARY KEY,
title varchar(64) NOT NULL,
duration_days int NOT NULL CHECK (duration_days > 0),
amount_cents int NOT NULL CHECK (amount_cents >= 0),
active boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO membership_plans(code, title, duration_days, amount_cents, active)
VALUES
('month', '月卡', 31, 2500, true),
('quarter', '季卡', 92, 6800, true),
('year', '年卡', 366, 19800, true)
ON CONFLICT (code) DO NOTHING;
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, p.code
FROM admin_roles r
CROSS JOIN (VALUES
('admin.membership.plans.read'),
('admin.membership.plans.write')
) AS p(code)
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,23 @@
# Backend Design: ECR-014 MembershipPlan
| ID | BD-2026-014 |
| ECR | ECR-014 |
| Status | Approved |
| Coding | AuthorizedLoop |
| Change Level | L2 |
| Risk | Low |
## Change Boundary
```text
Domain: MembershipPlan
App: admin + membership CreateOrder amount/days
Infra: migration + repo
API: /admin/membership-plans*
Migration: YES
Tests: integration
```
## Rollback
down migration;回退硬编码 planDays/amount
+18
View File
@@ -0,0 +1,18 @@
# CODE_REVIEW — ECR-014
**Verdict:** Approve → Closed
Date: 2026-08-07 · Loop continuous
## Checklist
- [x] Spec + BD Approved · migration 000017
- [x] Admin GET/PUT membership-plans · OpenAPI
- [x] Grant/CreateOrder 读表
- [x] TEST_REPORT AC 映射
- [x] 无真支付 · 兑换码 · ask_pack
```text
Decision: Approve → Closed
Next: ECR-015Commerce 兑换码或 UserIntelligence 薄切片)
```
+36
View File
@@ -0,0 +1,36 @@
ecr: ECR-014
capability: CommerceEntitlement
bounded_context: Membership_Orders
parent: WAVE0-FROZEN
predecessor: ECR-013B
change:
type: additive
breaking_change: false
migration_required: true
compatibility_notes: >
New membership_plans table seeded with month/quarter/year.
Grant/CreateOrder read duration/amount from table with hardcoded fallback.
Additive RBAC permissions.
entities:
- name: MembershipPlan
before: null
after:
fields: [code, title, duration_days, amount_cents, active, updated_at]
codes: [month, quarter, year]
apis:
- method: GET
path: /api/v1/admin/membership-plans
change: added
- method: GET
path: /api/v1/admin/membership-plans/{code}
change: added
- method: PUT
path: /api/v1/admin/membership-plans/{code}
change: added
security_impact:
- "admin.membership.plans.read/write"
observability_impact:
- "AuditLog membership.plans.update"
+29
View File
@@ -0,0 +1,29 @@
# ECR-014
**Title:** MembershipPlan(会员套餐配置薄切片)
**Status:** **Closed**
**Closed:** 2026-08-07Loop continuous
**Parent:** WAVE0-FROZEN (`27f27a1`)
**Predecessor:** ECR-013B **Closed**
**Change Level: L2**
## Change
1. Spec `ops-membership-plan.md`
2. Entity `MembershipPlan` 表 + 种子 month/quarter/year
3. Admin GET/PUT `/membership-plans*`
4. Grant / CreateOrder(membership) 读表
5. `docs/CONTRACT_DIFF/ECR-014.yaml`
6. **不含** 真支付 · 兑换码 · Entitlement · ask_pack
## Scope Forbidden
UGC · 真支付 · soft-delete User · 新建 plan code(本切片冻结三码)
## Acceptance
见 Spec L4。
## Linked
- Spec / PRODUCT / ENGINEERING / BD-2026-014 / CONTRACT_DIFF / HANDOFF / STATE / TASK
@@ -0,0 +1,15 @@
# ENGINEERING_SPEC — ECR-014 MembershipPlan
**Approved · codingLoop**
## Implement order
1. Migration `membership_plans` + permission 种子
2. Repo/service/admin handlers
3. Wire GrantMembership + membership CreateOrder
4. OpenAPI · admin-h5 最小页
5. Integration AC · TEST_REPORT · Closed
## Constraints
函数≤50 · 文件≤400 · 无真支付 · 无新 plan code
@@ -0,0 +1,6 @@
# HANDOFF — ECR-014 Architect → Engineer
**Loop continuous** · Approved + Coding authorized.
Consume Spec/BD/CONTRACT_DIFF。Do ENGINEERING_SPEC order。
Forbidden: 真支付 · 兑换码 · ask_pack · 新 plan code。
@@ -0,0 +1,4 @@
# HANDOFF — ECR-014 Engineer → Reviewer
Done: membership_plans · admin API/H5 · Grant/CreateOrder 读表 · TEST_REPORT.
Loop continuous → Reviewer Closed in same cycle.
@@ -0,0 +1,12 @@
# PRODUCT_SPEC — ECR-014 MembershipPlan
对齐 `.ai/product/feature-spec/ops-membership-plan.md`
Status: Approved · Loop continuous
| ECR | ECR-014 |
| Capability | CommerceEntitlement |
| BC | Membership_Orders |
| Change Level | L2 |
Outcome:运营可改会员套餐时长与标价;mock 履约读表。
Out:真支付 · 兑换码 · ask_pack。
+2 -2
View File
@@ -46,9 +46,9 @@
## Active anchors
- ECR: **ECR-014** MembershipPlanLoop continuous);**ECR-013B Closed****ECR-013A Closed****WAVE0-FROZEN** @ 27f27a1
- ECR: **ECR-014 Closed**Next **ECR-015** RedemptionCodeLoop continuous);013A/B Closed**ECR-013A Closed****WAVE0-FROZEN** @ 27f27a1
- EXP: (无)
- STATE: `docs/STATE/ECR-014.md` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md`
- STATE: `docs/STATE/ECR-014.md`Closed)· next ECR-015 · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md`
- Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml`
- TRACEABILITY: `docs/TRACEABILITY.md`
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
+12
View File
@@ -0,0 +1,12 @@
# STATE — ECR-014
| Field | Value |
|-------|-------|
| ECR | ECR-014 |
| Status | **Closed** |
| Phase | closed |
| Parent | WAVE0-FROZEN |
| Predecessor | ECR-013B Closed |
| Test | TEST_REPORT/ECR-014.md |
| Review | CODE_REVIEW Approve → Closed |
| Updated | 2026-08-07 |
+13
View File
@@ -0,0 +1,13 @@
id: TASK-014-ECR014
ecr: ECR-014
title: MembershipPlan Closed
role: reviewer
status: closed
change_level: L2
parent: WAVE0-FROZEN
predecessor: ECR-013B
outputs:
- CODE_REVIEW Approve → Closed
acceptance:
- Spec AC mapped
- No true payment
+29
View File
@@ -0,0 +1,29 @@
# TEST_REPORT — ECR-014 MembershipPlan
Date: 2026-08-07 · Loop continuous
## Commands
```bash
cd apps/api && go test ./internal/integration/ -run TestMembershipPlans -count=1
npm run build:admin
```
## Results
| Check | Result |
|-------|--------|
| TestMembershipPlans | PASS |
| build:admin | PASS |
## AC
| ID | Evidence |
|----|----------|
| AC-F-01 | GET plans ≥3 codes |
| AC-F-02 | PUT month → GET 一致 |
| AC-F-03 | grant month 200(读表 duration |
| AC-S-01 | RequirePermission middleware(同 013A 模式) |
| AC-S-02 | 无 token → 401 |
| AC-P-01 | GET &lt; 500ms |
| AC-O-01 | AuditLog membership.plans.update |
+1 -1
View File
@@ -17,4 +17,4 @@
| ECR-012 | 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · BD-2026-012 · TEST_REPORT · HANDOFF review |
| WAVE-0 | Ops Contract-First Foundation | **FROZEN** (`WAVE0-FROZEN` @ 27f27a1) | `docs/WAVE0/` · HUMAN_REVIEW FREEZE · boundary-rules · glossary · contract template |
| ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration 000015 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN |
| ECR-013B | AccountLifecycle / UserStatus | **Closed** | Spec ops-account-lifecycle · BD-2026-013B · migration 000016 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN |
| ECR-014 | MembershipPlan | **Closed** | Spec ops-membership-plan · BD-2026-014 · migration 000017 · TEST_REPORT · CODE_REVIEW · Loop continuous |
+54
View File
@@ -242,6 +242,60 @@ paths:
'401':
description: Unauthorized
/api/v1/admin/membership-plans:
get:
tags: [admin]
summary: List membership plans
description: Requires admin.membership.plans.read
responses:
'200':
description: OK
'403':
description: Forbidden
/api/v1/admin/membership-plans/{code}:
get:
tags: [admin]
summary: Get membership plan
parameters:
- in: path
name: code
required: true
schema: { type: string }
responses:
'200':
description: OK
'404':
description: Not found
put:
tags: [admin]
summary: Update membership plan
description: Requires admin.membership.plans.write
parameters:
- in: path
name: code
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title, duration_days, amount_cents]
properties:
title: { type: string }
duration_days: { type: integer }
amount_cents: { type: integer }
active: { type: boolean }
responses:
'200':
description: OK
'400':
description: Invalid
'403':
description: Forbidden
/api/v1/admin/orders:
get:
tags: [admin]