From 882c01d81adf49a6c3bbe3025de3df15b919a873 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:03:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(ECR-014):=20MembershipPlan=20=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E9=85=8D=E7=BD=AE=E5=B9=B6=20Closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit membership_plans 表、admin 套餐页、Grant/CreateOrder 读表; Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。 Co-authored-by: Cursor --- .ai/product/feature-map.md | 2 +- .../feature-spec/ops-membership-plan.md | 77 +++++++++++++++ apps/admin-h5/src/api/client.ts | 22 +++++ apps/admin-h5/src/layouts/AdminShell.vue | 1 + .../src/pages/MembershipPlansPage.vue | 87 +++++++++++++++++ apps/admin-h5/src/router/index.ts | 1 + apps/api/internal/handler/admin.go | 1 + .../handler/admin_membership_plans.go | 78 +++++++++++++++ .../integration/membership_plans_test.go | 97 +++++++++++++++++++ .../repository/membership_plan_repo.go | 94 ++++++++++++++++++ apps/api/internal/repository/report_repo.go | 32 ++++++ .../service/admin/membership_plans.go | 86 ++++++++++++++++ apps/api/internal/service/admin/rbac.go | 24 ++--- apps/api/internal/service/admin/service.go | 13 +-- .../internal/service/membership/service.go | 12 ++- .../000017_membership_plans.down.sql | 3 + .../migrations/000017_membership_plans.up.sql | 27 ++++++ .../BD-2026-014-membership-plan.md | 23 +++++ docs/CODE_REVIEW/ECR-014.md | 18 ++++ docs/CONTRACT_DIFF/ECR-014.yaml | 36 +++++++ docs/ECR/ECR-014-membership-plan.md | 29 ++++++ .../ECR-014-membership-plan.md | 15 +++ docs/HANDOFF/ECR-014-architect-to-engineer.md | 6 ++ docs/HANDOFF/ECR-014-engineer-to-reviewer.md | 4 + docs/PRODUCT_SPEC/ECR-014-membership-plan.md | 12 +++ docs/PROJECT_PROFILE.md | 4 +- docs/STATE/ECR-014.md | 12 +++ docs/TASKS/TASK-014-ECR014.yaml | 13 +++ docs/TEST_REPORT/ECR-014.md | 29 ++++++ docs/TRACEABILITY.md | 2 +- proto/openapi.yaml | 54 +++++++++++ 31 files changed, 887 insertions(+), 27 deletions(-) create mode 100644 .ai/product/feature-spec/ops-membership-plan.md create mode 100644 apps/admin-h5/src/pages/MembershipPlansPage.vue create mode 100644 apps/api/internal/handler/admin_membership_plans.go create mode 100644 apps/api/internal/integration/membership_plans_test.go create mode 100644 apps/api/internal/repository/membership_plan_repo.go create mode 100644 apps/api/internal/service/admin/membership_plans.go create mode 100644 apps/api/migrations/000017_membership_plans.down.sql create mode 100644 apps/api/migrations/000017_membership_plans.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md create mode 100644 docs/CODE_REVIEW/ECR-014.md create mode 100644 docs/CONTRACT_DIFF/ECR-014.yaml create mode 100644 docs/ECR/ECR-014-membership-plan.md create mode 100644 docs/ENGINEERING_SPEC/ECR-014-membership-plan.md create mode 100644 docs/HANDOFF/ECR-014-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-014-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-014-membership-plan.md create mode 100644 docs/STATE/ECR-014.md create mode 100644 docs/TASKS/TASK-014-ECR014.yaml create mode 100644 docs/TEST_REPORT/ECR-014.md diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 56fc73c..f9833b8 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -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 Implemented(review)**。详见 `docs/WAVE0/`。 | +| Phase D+ | **Contract-First**:**ECR-013A/013B Closed** → **ECR-014** MembershipPlan(Loop continuous)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | | 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 | 不计入 P1 Complete;不进入五 Tab。 diff --git a/.ai/product/feature-spec/ops-membership-plan.md b/.ai/product/feature-spec/ops-membership-plan.md new file mode 100644 index 0000000..9183618 --- /dev/null +++ b/.ai/product/feature-spec/ops-membership-plan.md @@ -0,0 +1,77 @@ +# Feature Spec: 会员套餐 MembershipPlan(Ops · 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 < 500ms 本机 | +| AC-O-01 | PUT 成功 → AuditLog `membership.plans.update` | + +--- + +## Implementation Notes + +Migration `membership_plans` + RBAC additive permissions · admin-h5 最小列表编辑页 diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 5bbc8c8..6d3384f 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -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<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 8e2a894..94b8e51 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -28,6 +28,7 @@ async function onLogout() { 数据 内容 用户 + 套餐 订单 审计 diff --git a/apps/admin-h5/src/pages/MembershipPlansPage.vue b/apps/admin-h5/src/pages/MembershipPlansPage.vue new file mode 100644 index 0000000..2bc3388 --- /dev/null +++ b/apps/admin-h5/src/pages/MembershipPlansPage.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 825919f..9681fbe 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -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') }, ], }, diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index ba00d82..1aace97 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -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) { diff --git a/apps/api/internal/handler/admin_membership_plans.go b/apps/api/internal/handler/admin_membership_plans.go new file mode 100644 index 0000000..475c0df --- /dev/null +++ b/apps/api/internal/handler/admin_membership_plans.go @@ -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) +} diff --git a/apps/api/internal/integration/membership_plans_test.go b/apps/api/internal/integration/membership_plans_test.go new file mode 100644 index 0000000..fd3989b --- /dev/null +++ b/apps/api/internal/integration/membership_plans_test.go @@ -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") + } +} diff --git a/apps/api/internal/repository/membership_plan_repo.go b/apps/api/internal/repository/membership_plan_repo.go new file mode 100644 index 0000000..2250d14 --- /dev/null +++ b/apps/api/internal/repository/membership_plan_repo.go @@ -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) +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index 3e5fe88..b652bc7 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -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 diff --git a/apps/api/internal/service/admin/membership_plans.go b/apps/api/internal/service/admin/membership_plans.go new file mode 100644 index 0000000..76b9a23 --- /dev/null +++ b/apps/api/internal/service/admin/membership_plans.go @@ -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 + } +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 7daa28f..75ef951 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -10,23 +10,25 @@ import ( // Permission catalog frozen in ECR-013A Spec. const ( - PermUsersRead = "admin.users.read" - PermMembershipGrant = "admin.users.membership.grant" - PermAskQuotaGrant = "admin.users.ask_quota.grant" - PermOrdersRead = "admin.orders.read" - PermAuditRead = "admin.audit.read" - PermAnalyticsRead = "admin.analytics.read" - PermContentWrite = "admin.content.write" - PermRolesRead = "admin.roles.read" - PermRolesWrite = "admin.roles.write" - PermUsersStatusWrite = "admin.users.status.write" + PermUsersRead = "admin.users.read" + PermMembershipGrant = "admin.users.membership.grant" + PermAskQuotaGrant = "admin.users.ask_quota.grant" + PermOrdersRead = "admin.orders.read" + PermAuditRead = "admin.audit.read" + PermAnalyticsRead = "admin.analytics.read" + PermContentWrite = "admin.content.write" + 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 ( diff --git a/apps/api/internal/service/admin/service.go b/apps/api/internal/service/admin/service.go index 94ceab4..9e06bff 100644 --- a/apps/api/internal/service/admin/service.go +++ b/apps/api/internal/service/admin/service.go @@ -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) { diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go index befc34e..4a4f8ee 100644 --- a/apps/api/internal/service/membership/service.go +++ b/apps/api/internal/service/membership/service.go @@ -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 == "" { diff --git a/apps/api/migrations/000017_membership_plans.down.sql b/apps/api/migrations/000017_membership_plans.down.sql new file mode 100644 index 0000000..99faa56 --- /dev/null +++ b/apps/api/migrations/000017_membership_plans.down.sql @@ -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; diff --git a/apps/api/migrations/000017_membership_plans.up.sql b/apps/api/migrations/000017_membership_plans.up.sql new file mode 100644 index 0000000..70dae5d --- /dev/null +++ b/apps/api/migrations/000017_membership_plans.up.sql @@ -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; diff --git a/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md b/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md new file mode 100644 index 0000000..f83a652 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md @@ -0,0 +1,23 @@ +# Backend Design: ECR-014 MembershipPlan + +| ID | BD-2026-014 | +| ECR | ECR-014 | +| Status | Approved | +| Coding | Authorized(Loop) | +| 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 diff --git a/docs/CODE_REVIEW/ECR-014.md b/docs/CODE_REVIEW/ECR-014.md new file mode 100644 index 0000000..aa4c2d3 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-014.md @@ -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-015(Commerce 兑换码或 UserIntelligence 薄切片) +``` diff --git a/docs/CONTRACT_DIFF/ECR-014.yaml b/docs/CONTRACT_DIFF/ECR-014.yaml new file mode 100644 index 0000000..88a6ec7 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-014.yaml @@ -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" diff --git a/docs/ECR/ECR-014-membership-plan.md b/docs/ECR/ECR-014-membership-plan.md new file mode 100644 index 0000000..7711fa8 --- /dev/null +++ b/docs/ECR/ECR-014-membership-plan.md @@ -0,0 +1,29 @@ +# ECR-014 + +**Title:** MembershipPlan(会员套餐配置薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop 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 diff --git a/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md b/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md new file mode 100644 index 0000000..4727225 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md @@ -0,0 +1,15 @@ +# ENGINEERING_SPEC — ECR-014 MembershipPlan + +**Approved · coding(Loop)** + +## 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 diff --git a/docs/HANDOFF/ECR-014-architect-to-engineer.md b/docs/HANDOFF/ECR-014-architect-to-engineer.md new file mode 100644 index 0000000..d656bb7 --- /dev/null +++ b/docs/HANDOFF/ECR-014-architect-to-engineer.md @@ -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。 diff --git a/docs/HANDOFF/ECR-014-engineer-to-reviewer.md b/docs/HANDOFF/ECR-014-engineer-to-reviewer.md new file mode 100644 index 0000000..b0ab82e --- /dev/null +++ b/docs/HANDOFF/ECR-014-engineer-to-reviewer.md @@ -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. diff --git a/docs/PRODUCT_SPEC/ECR-014-membership-plan.md b/docs/PRODUCT_SPEC/ECR-014-membership-plan.md new file mode 100644 index 0000000..8992934 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-014-membership-plan.md @@ -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。 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index efb38e8..0a450b8 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -46,9 +46,9 @@ ## Active anchors -- ECR: **ECR-014** MembershipPlan(Loop continuous);**ECR-013B Closed**;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 +- ECR: **ECR-014 Closed**;Next **ECR-015** RedemptionCode(Loop 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` diff --git a/docs/STATE/ECR-014.md b/docs/STATE/ECR-014.md new file mode 100644 index 0000000..2e79f3c --- /dev/null +++ b/docs/STATE/ECR-014.md @@ -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 | diff --git a/docs/TASKS/TASK-014-ECR014.yaml b/docs/TASKS/TASK-014-ECR014.yaml new file mode 100644 index 0000000..bbd13f4 --- /dev/null +++ b/docs/TASKS/TASK-014-ECR014.yaml @@ -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 diff --git a/docs/TEST_REPORT/ECR-014.md b/docs/TEST_REPORT/ECR-014.md new file mode 100644 index 0000000..68f7b4c --- /dev/null +++ b/docs/TEST_REPORT/ECR-014.md @@ -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 < 500ms | +| AC-O-01 | AuditLog membership.plans.update | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ab5d80e..24803bb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -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 | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index b1fc995..cf49e5a 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -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]