diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index 3b1f66b..a07efae 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -20,7 +20,7 @@ Status: `Draft` | PsychologicalTagSet | Identity_Profile | UserIntelligence | 未来读模型 | | MembershipPlan | Membership_Orders | CommerceEntitlement | 配置面后置 ECR | | Entitlement | Membership_Orders | CommerceEntitlement | 后置 | -| RedemptionCode | Membership_Orders | CommerceEntitlement | 后置 | +| RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** | | Membership | Membership_Orders | CommerceEntitlement | 已存在 | | Order | Membership_Orders | CommerceEntitlement | 已存在 | | DeepAccess | Membership_Orders | CommerceEntitlement | 已存在 | diff --git a/.ai/product/feature-spec/ops-redemption-code.md b/.ai/product/feature-spec/ops-redemption-code.md new file mode 100644 index 0000000..be5f44d --- /dev/null +++ b/.ai/product/feature-spec/ops-redemption-code.md @@ -0,0 +1,43 @@ +# Feature Spec: 兑换码 RedemptionCode(Ops · ECR-015) + +> Status: `Active`(Loop continuous · Approved · coding) +> Parent: WAVE0-FROZEN · Predecessor: ECR-014 Closed +> Capability: `CommerceEntitlement` · BC: `Membership_Orders` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +真支付 · Entitlement 细权 · ask_pack 兑换 · UGC + +## L2 + +| Entity | 不变式 | +|--------|--------| +| `RedemptionBatch` | label · plan_code∈membership_plans · quantity 1..100 | +| `RedemptionCode` | code 唯一;status unused→redeemed\|disabled;redeemed 不可再兑 | + +## L3 API + +| Method | Path | Auth | 语义 | +|--------|------|------|------| +| POST | `/admin/redemption-batches` | `admin.membership.codes.write` | 批量生成 | +| GET | `/admin/redemption-batches` | `admin.membership.codes.read` | 批次列表 | +| GET | `/admin/redemption-batches/:id/codes` | `admin.membership.codes.read` | 码列表 | +| POST | `/admin/redemption-codes/:id/disable` | `admin.membership.codes.write` | 作废 unused | +| POST | `/membership/redeem` | DeviceAuth+已注册 | 兑码→延长会员 | + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | POST batch quantity=3 → 3 unused codes | +| AC-F-02 | C端 redeem → membership active;码=redeemed | +| AC-F-03 | 再兑同一码 → 400 | +| AC-F-04 | disable unused → status=disabled;兑 → 400 | +| AC-S-01 | 无 write → POST batch 403 | +| AC-S-02 | 未登录兑码 → 401 | +| AC-P-01 | GET batches < 500ms | +| AC-O-01 | 生成 AuditLog `redemption.batch.create` | +| AC-O-02 | 兑换可追溯 redeemed_by | + +contract_diff: `docs/CONTRACT_DIFF/ECR-015.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 6d3384f..7401b3c 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -158,6 +158,22 @@ export const adminApi = { amount_cents: number active: boolean }>('PUT', `/membership-plans/${code}`, body), + createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) => + request<{ + batch: { id: string; label: string; plan_code: string; quantity: number } + codes: Array<{ id: string; code: string; status: string }> + }>('POST', '/redemption-batches', body), + redemptionBatches: () => + request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>( + 'GET', + '/redemption-batches', + ), + redemptionCodes: (batchId: string) => + request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>( + 'GET', + `/redemption-batches/${batchId}/codes`, + ), + disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`), orders: () => request<{ items: Array<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 94b8e51..ae3be53 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -29,6 +29,7 @@ async function onLogout() { 内容 用户 套餐 + 兑换码 订单 审计 diff --git a/apps/admin-h5/src/pages/RedemptionPage.vue b/apps/admin-h5/src/pages/RedemptionPage.vue new file mode 100644 index 0000000..82fd5ca --- /dev/null +++ b/apps/admin-h5/src/pages/RedemptionPage.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 9681fbe..a326be0 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -16,6 +16,7 @@ const router = createRouter({ { 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: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.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 1aace97..e344ea9 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerRBAC(authed) h.registerLifecycle(authed) h.registerMembershipPlans(authed) + h.registerRedemption(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_redemption.go b/apps/api/internal/handler/admin_redemption.go new file mode 100644 index 0000000..21ede3c --- /dev/null +++ b/apps/api/internal/handler/admin_redemption.go @@ -0,0 +1,99 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "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) registerRedemption(authed *gin.RouterGroup) { + authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch) + authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches) + authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes) + authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode) +} + +func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + var body struct { + Label string `json:"label"` + PlanCode string `json:"plan_code"` + Quantity int `json:"quantity"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid body") + return + } + batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity) + if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || 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, gin.H{"batch": batch, "codes": codes}) +} + +func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id) + if errors.Is(err, admin.ErrBatchNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "batch not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id) + if errors.Is(err, admin.ErrCodeDisable) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"ok": true}) +} diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go index ba24c53..91ddc1b 100644 --- a/apps/api/internal/handler/report.go +++ b/apps/api/internal/handler/report.go @@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) { rg.GET("/reports/latest", h.GetLatest) rg.GET("/reports/:id", h.Get) rg.GET("/membership/me", h.GetMembership) + rg.POST("/membership/redeem", h.RedeemCode) rg.POST("/orders", h.CreateOrder) rg.POST("/orders/:id/pay-mock", h.PayMock) } @@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) { response.OK(c, me) } +// RedeemCode handles POST /membership/redeem. +func (h *ReportHandler) RedeemCode(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + msvc, ok := h.requireMembership(c) + if !ok { + return + } + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" { + response.Fail(c, http.StatusBadRequest, 40000, "code required") + return + } + plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + me, err := msvc.Get(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"plan": plan, "membership": me}) +} + // CreateOrder handles POST /orders. func (h *ReportHandler) CreateOrder(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) diff --git a/apps/api/internal/integration/redemption_test.go b/apps/api/internal/integration/redemption_test.go new file mode 100644 index 0000000..e350f0a --- /dev/null +++ b/apps/api/internal/integration/redemption_test.go @@ -0,0 +1,100 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestRedemptionCodes(t *testing.T) { + r, _ := setupAPIPool(t) + tok := adminLogin(t, r, "admin", "change-me") + + _, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches", + map[string]any{"label": "t", "plan_code": "month", "quantity": 2}, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", code) + } + + start := time.Now() + env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches", + map[string]any{"label": "ops-test", "plan_code": "month", "quantity": 3}, tok) + if code != 200 { + t.Fatalf("create batch http=%d msg=%s", code, env.Message) + } + var created struct { + Batch struct { + ID string `json:"id"` + } `json:"batch"` + Codes []struct { + ID string `json:"id"` + Code string `json:"code"` + Status string `json:"status"` + } `json:"codes"` + } + _ = json.Unmarshal(env.Data, &created) + if len(created.Codes) != 3 { + t.Fatalf("want 3 codes, got %#v", created.Codes) + } + raw := created.Codes[0].Code + disableID := created.Codes[2].ID + + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches", nil, tok) + if code != 200 || time.Since(start) > 500*time.Millisecond { + t.Fatalf("list batches http=%d dur=%v", code, time.Since(start)) + } + + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches/"+created.Batch.ID+"/codes", nil, tok) + if code != 200 { + t.Fatalf("list codes %d", code) + } + + key := mustRegister(t, r) + _, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": raw}, key, 0) + if httpCode != 200 { + t.Fatalf("redeem http=%d", httpCode) + } + + _, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": raw}, key, 40000) + if httpCode != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 re-redeem, got %d", httpCode) + } + + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-codes/"+disableID+"/disable", nil, tok) + if code != 200 { + t.Fatalf("disable %d", code) + } + _, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": created.Codes[2].Code}, key, 40000) + if httpCode != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 disabled, got %d", httpCode) + } + + 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 == "redemption.batch.create" { + found = true + break + } + } + if !found { + t.Fatal("missing redemption.batch.create audit") + } + + if code := deviceGET(t, r, "/api/v1/membership/me", "dev_orphan_"+time.Now().Format("150405"), ""); code != http.StatusUnauthorized { + t.Fatalf("expected 401 unregistered membership, got %d", code) + } +} diff --git a/apps/api/internal/repository/commerce_redeem_repo.go b/apps/api/internal/repository/commerce_redeem_repo.go new file mode 100644 index 0000000..2934b6a --- /dev/null +++ b/apps/api/internal/repository/commerce_redeem_repo.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// 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 + } +} + +// MembershipPlanDurationDays returns catalog days or fallback. +func (r *ReportRepo) MembershipPlanDurationDays(ctx context.Context, plan string) (int, error) { + var days int + var active bool + err := r.Pool.QueryRow(ctx, ` + SELECT duration_days, active FROM membership_plans WHERE code=$1`, plan, + ).Scan(&days, &active) + if errors.Is(err, pgx.ErrNoRows) { + return membershipDaysFallback(plan), nil + } + if err != nil { + return 0, err + } + if !active || days <= 0 { + return membershipDaysFallback(plan), nil + } + return days, nil +} + +func membershipDaysFallback(plan string) int { + switch plan { + case "month": + return 31 + case "quarter": + return 92 + case "year": + return 366 + default: + return 31 + } +} + +// RedeemCode applies an unused redemption code to user membership. +func (r *ReportRepo) RedeemCode(ctx context.Context, userID uuid.UUID, rawCode string) (plan string, err error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return "", err + } + defer tx.Rollback(ctx) + + var codeID uuid.UUID + var status string + err = tx.QueryRow(ctx, ` + SELECT id, plan_code, status FROM redemption_codes + WHERE code=$1 FOR UPDATE`, rawCode, + ).Scan(&codeID, &plan, &status) + if errors.Is(err, pgx.ErrNoRows) { + return "", errString("invalid code") + } + if err != nil { + return "", err + } + if status != "unused" { + return "", errString("code not redeemable") + } + days, err := r.MembershipPlanDurationDays(ctx, plan) + if err != nil { + return "", err + } + if _, err := tx.Exec(ctx, ` + UPDATE redemption_codes + SET status='redeemed', redeemed_by=$2, redeemed_at=now() + WHERE id=$1 AND status='unused'`, codeID, userID); err != nil { + return "", err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left) + VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100) + ON CONFLICT (user_id) DO UPDATE SET + plan=EXCLUDED.plan, status='active', + expires_at=(CASE + WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now() + THEN memberships.expires_at ELSE now() + END) + ($3 * interval '1 day'), + ask_quota_left=100, updated_at=now()`, + userID, plan, days); err != nil { + return "", err + } + if err := tx.Commit(ctx); err != nil { + return "", err + } + return plan, nil +} diff --git a/apps/api/internal/repository/redemption_repo.go b/apps/api/internal/repository/redemption_repo.go new file mode 100644 index 0000000..1fc5966 --- /dev/null +++ b/apps/api/internal/repository/redemption_repo.go @@ -0,0 +1,168 @@ +package repository + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// RedemptionBatch is a generation batch of codes. +type RedemptionBatch struct { + ID uuid.UUID `json:"id"` + Label string `json:"label"` + PlanCode string `json:"plan_code"` + Quantity int `json:"quantity"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +// RedemptionCodeRow is one redeemable code. +type RedemptionCodeRow struct { + ID uuid.UUID `json:"id"` + BatchID uuid.UUID `json:"batch_id"` + Code string `json:"code"` + PlanCode string `json:"plan_code"` + Status string `json:"status"` + RedeemedBy *uuid.UUID `json:"redeemed_by,omitempty"` + RedeemedAt *time.Time `json:"redeemed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateRedemptionBatchWithCodes inserts batch + codes + audit. +func (r *AdminRepo) CreateRedemptionBatchWithCodes( + ctx context.Context, + adminID uuid.UUID, + label, planCode string, + codes []string, + meta json.RawMessage, +) (*RedemptionBatch, []RedemptionCodeRow, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, nil, err + } + defer tx.Rollback(ctx) + + var b RedemptionBatch + err = tx.QueryRow(ctx, ` + INSERT INTO redemption_batches(label, plan_code, quantity, created_by) + VALUES ($1,$2,$3,$4) + RETURNING id, label, plan_code, quantity, created_by, created_at`, + label, planCode, len(codes), adminID, + ).Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt) + if err != nil { + return nil, nil, err + } + + out := make([]RedemptionCodeRow, 0, len(codes)) + for _, code := range codes { + var row RedemptionCodeRow + err = tx.QueryRow(ctx, ` + INSERT INTO redemption_codes(batch_id, code, plan_code, status) + VALUES ($1,$2,$3,'unused') + RETURNING id, batch_id, code, plan_code, status, created_at`, + b.ID, code, planCode, + ).Scan(&row.ID, &row.BatchID, &row.Code, &row.PlanCode, &row.Status, &row.CreatedAt) + if err != nil { + return nil, nil, err + } + out = append(out, row) + } + 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,'redemption.batch.create','redemption_batch',$2,$3)`, + adminID, b.ID.String(), meta, + ); err != nil { + return nil, nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + return &b, out, nil +} + +// ListRedemptionBatches newest first. +func (r *AdminRepo) ListRedemptionBatches(ctx context.Context, limit int) ([]RedemptionBatch, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := r.Pool.Query(ctx, ` + SELECT id, label, plan_code, quantity, created_by, created_at + FROM redemption_batches ORDER BY created_at DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []RedemptionBatch + for rows.Next() { + var b RedemptionBatch + if err := rows.Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// ListRedemptionCodesByBatch returns codes for a batch. +func (r *AdminRepo) ListRedemptionCodesByBatch(ctx context.Context, batchID uuid.UUID) ([]RedemptionCodeRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, batch_id, code, plan_code, status, redeemed_by, redeemed_at, created_at + FROM redemption_codes WHERE batch_id=$1 ORDER BY created_at`, batchID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRedemptionCodes(rows) +} + +// DisableRedemptionCode marks unused code disabled. +func (r *AdminRepo) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, ` + UPDATE redemption_codes SET status='disabled' + WHERE id=$1 AND status='unused'`, codeID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errString("code not unused") + } + meta, _ := json.Marshal(map[string]string{"code_id": codeID.String()}) + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) + VALUES ($1,'redemption.code.disable','redemption_code',$2,$3)`, + adminID, codeID.String(), meta, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +func scanRedemptionCodes(rows pgx.Rows) ([]RedemptionCodeRow, error) { + var out []RedemptionCodeRow + for rows.Next() { + var c RedemptionCodeRow + if err := rows.Scan(&c.ID, &c.BatchID, &c.Code, &c.PlanCode, &c.Status, &c.RedeemedBy, &c.RedeemedAt, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// BatchExists reports whether batch id exists. +func (r *AdminRepo) BatchExists(ctx context.Context, id uuid.UUID) (bool, error) { + var ok bool + err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM redemption_batches WHERE id=$1)`, id).Scan(&ok) + return ok, err +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index b652bc7..3e5fe88 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -316,38 +316,6 @@ 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/rbac.go b/apps/api/internal/service/admin/rbac.go index 75ef951..5fd4745 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -22,6 +22,8 @@ const ( PermUsersStatusWrite = "admin.users.status.write" PermMembershipPlansRead = "admin.membership.plans.read" PermMembershipPlansWrite = "admin.membership.plans.write" + PermMembershipCodesRead = "admin.membership.codes.read" + PermMembershipCodesWrite = "admin.membership.codes.write" ) var knownPermissions = map[string]struct{}{ @@ -29,6 +31,7 @@ var knownPermissions = map[string]struct{}{ PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {}, PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {}, PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, + PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, } var ( diff --git a/apps/api/internal/service/admin/redemption.go b/apps/api/internal/service/admin/redemption.go new file mode 100644 index 0000000..37d7c2f --- /dev/null +++ b/apps/api/internal/service/admin/redemption.go @@ -0,0 +1,96 @@ +package admin + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +var ( + ErrBadBatchQty = errString("quantity must be 1..100") + ErrCodeDisable = errString("code not unused") + ErrBatchNotFound = errString("batch not found") +) + +// CreateRedemptionBatch generates codes for a membership plan. +func (s *Service) CreateRedemptionBatch( + ctx context.Context, adminID uuid.UUID, label, planCode string, qty int, +) (*repository.RedemptionBatch, []repository.RedemptionCodeRow, error) { + label = strings.TrimSpace(label) + planCode = strings.TrimSpace(planCode) + if label == "" || qty < 1 || qty > 100 { + return nil, nil, ErrBadBatchQty + } + if _, err := s.GetMembershipPlan(ctx, planCode); err != nil { + return nil, nil, err + } + codes := make([]string, 0, qty) + seen := map[string]struct{}{} + for len(codes) < qty { + c, err := newRedemptionCode() + if err != nil { + return nil, nil, err + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + codes = append(codes, c) + } + meta, _ := json.Marshal(map[string]any{"label": label, "plan": planCode, "quantity": qty}) + return s.Repo.CreateRedemptionBatchWithCodes(ctx, adminID, label, planCode, codes, meta) +} + +// ListRedemptionBatches lists recent batches. +func (s *Service) ListRedemptionBatches(ctx context.Context, limit int) ([]repository.RedemptionBatch, error) { + items, err := s.Repo.ListRedemptionBatches(ctx, limit) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.RedemptionBatch{} + } + return items, nil +} + +// ListRedemptionCodes lists codes in a batch. +func (s *Service) ListRedemptionCodes(ctx context.Context, batchID uuid.UUID) ([]repository.RedemptionCodeRow, error) { + ok, err := s.Repo.BatchExists(ctx, batchID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrBatchNotFound + } + items, err := s.Repo.ListRedemptionCodesByBatch(ctx, batchID) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.RedemptionCodeRow{} + } + return items, nil +} + +// DisableRedemptionCode voids an unused code. +func (s *Service) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error { + err := s.Repo.DisableRedemptionCode(ctx, adminID, codeID) + if err != nil && err.Error() == "code not unused" { + return ErrCodeDisable + } + return err +} + +func newRedemptionCode() (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "YXG-" + strings.ToUpper(hex.EncodeToString(b)), nil +} diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go index 4a4f8ee..c2ba6a6 100644 --- a/apps/api/internal/service/membership/service.go +++ b/apps/api/internal/service/membership/service.go @@ -4,6 +4,7 @@ package membership import ( "context" "errors" + "strings" "time" "github.com/google/uuid" @@ -58,6 +59,15 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount) } +// Redeem applies a redemption code for the current registered user. +func (s *Service) Redeem(ctx context.Context, userID uuid.UUID, code string) (string, error) { + code = strings.TrimSpace(code) + if code == "" { + return "", errors.New("code required") + } + return s.Reports.RedeemCode(ctx, userID, code) +} + // PayMock completes mock payment. func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { return s.Reports.PayMock(ctx, userID, orderID) diff --git a/apps/api/migrations/000018_redemption_codes.down.sql b/apps/api/migrations/000018_redemption_codes.down.sql new file mode 100644 index 0000000..d91f2cd --- /dev/null +++ b/apps/api/migrations/000018_redemption_codes.down.sql @@ -0,0 +1,4 @@ +DELETE FROM admin_role_permissions +WHERE code IN ('admin.membership.codes.read', 'admin.membership.codes.write'); +DROP TABLE IF EXISTS redemption_codes; +DROP TABLE IF EXISTS redemption_batches; diff --git a/apps/api/migrations/000018_redemption_codes.up.sql b/apps/api/migrations/000018_redemption_codes.up.sql new file mode 100644 index 0000000..de045e1 --- /dev/null +++ b/apps/api/migrations/000018_redemption_codes.up.sql @@ -0,0 +1,35 @@ +-- ECR-015 RedemptionCode + +CREATE TABLE IF NOT EXISTS redemption_batches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + label varchar(64) NOT NULL, + plan_code varchar(32) NOT NULL REFERENCES membership_plans(code), + quantity int NOT NULL CHECK (quantity > 0 AND quantity <= 100), + created_by uuid NOT NULL REFERENCES admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS redemption_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + batch_id uuid NOT NULL REFERENCES redemption_batches(id) ON DELETE CASCADE, + code varchar(32) NOT NULL UNIQUE, + plan_code varchar(32) NOT NULL REFERENCES membership_plans(code), + status varchar(16) NOT NULL DEFAULT 'unused' + CHECK (status IN ('unused','redeemed','disabled')), + redeemed_by uuid NULL REFERENCES users(id), + redeemed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_redemption_codes_batch ON redemption_codes(batch_id); +CREATE INDEX IF NOT EXISTS idx_redemption_codes_status ON redemption_codes(status); + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, p.code +FROM admin_roles r +CROSS JOIN (VALUES + ('admin.membership.codes.read'), + ('admin.membership.codes.write') +) AS p(code) +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md b/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md new file mode 100644 index 0000000..5f1d0df --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md @@ -0,0 +1,17 @@ +# Backend Design: ECR-015 RedemptionCode + +| ID | BD-2026-015 | +| Status | Approved | +| Coding | Loop authorized | +| Level | L2 | + +## Boundary + +```text +Domain: RedemptionBatch, RedemptionCode +App: admin + membership.Redeem +API: /admin/redemption-* , POST /membership/redeem +Migration: YES 000018 +``` + +Rollback: down migration diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 480208d..6b8b58d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- **ECR-015 Closed**:RedemptionCode(批次生成 · C端兑码 · 作废 · 审计) - **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表) - **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认 - **ECR-013B Closed**:AccountLifecycle Reviewer Approve diff --git a/docs/CODE_REVIEW/ECR-015.md b/docs/CODE_REVIEW/ECR-015.md new file mode 100644 index 0000000..f57d59a --- /dev/null +++ b/docs/CODE_REVIEW/ECR-015.md @@ -0,0 +1,14 @@ +# CODE_REVIEW — ECR-015 + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Loop continuous + +- [x] Migration 000018 · Spec AC · 无真支付/UGC +- [x] Admin batches/codes · C端 /membership/redeem +- [x] TEST_REPORT PASS + +```text +Decision: Approve → Closed +Next: ECR-016 UserIntelligence 薄切片(只读用户洞察看板)或停 +``` diff --git a/docs/CONTRACT_DIFF/ECR-015.yaml b/docs/CONTRACT_DIFF/ECR-015.yaml new file mode 100644 index 0000000..e0e6400 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-015.yaml @@ -0,0 +1,28 @@ +ecr: ECR-015 +capability: CommerceEntitlement +bounded_context: Membership_Orders +parent: WAVE0-FROZEN +predecessor: ECR-014 +change: + type: additive +breaking_change: false +migration_required: true +compatibility_notes: > + New redemption_batches / redemption_codes. + C-end POST /membership/redeem extends membership without payment. + Additive RBAC admin.membership.codes.read/write. + +entities: + - name: RedemptionBatch + before: null + after: { fields: [id, label, plan_code, quantity, created_by, created_at] } + - name: RedemptionCode + before: null + after: { fields: [id, batch_id, code, plan_code, status, redeemed_by, redeemed_at] } + +apis: + - { method: POST, path: /api/v1/admin/redemption-batches, change: added } + - { method: GET, path: /api/v1/admin/redemption-batches, change: added } + - { method: GET, path: /api/v1/admin/redemption-batches/{id}/codes, change: added } + - { method: POST, path: /api/v1/admin/redemption-codes/{id}/disable, change: added } + - { method: POST, path: /api/v1/membership/redeem, change: added } diff --git a/docs/ECR/ECR-015-redemption-code.md b/docs/ECR/ECR-015-redemption-code.md new file mode 100644 index 0000000..4f3fd1c --- /dev/null +++ b/docs/ECR/ECR-015-redemption-code.md @@ -0,0 +1,19 @@ +# ECR-015 + +**Title:** RedemptionCode(兑换码薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop continuous) +**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-014 Closed +**Change Level:** L2 + +## Change + +批次生成兑换码 → C 端兑码延长 MembershipPlan 对应时长 · 可作废 · 审计 + +## Forbidden + +真支付 · UGC · ask_pack 兑换 · soft-delete + +## Linked + +Spec `ops-redemption-code.md` · BD-2026-015 · CONTRACT_DIFF/ECR-015.yaml diff --git a/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md b/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md new file mode 100644 index 0000000..32878bd --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md @@ -0,0 +1,8 @@ +# ENGINEERING_SPEC — ECR-015 + +Approved · coding(Loop) + +1. Migration batches+codes + RBAC permissions +2. Admin generate/list/disable · membership Redeem +3. OpenAPI · admin-h5 最小页 +4. Integration AC · Closed diff --git a/docs/HANDOFF/ECR-015-architect-to-engineer.md b/docs/HANDOFF/ECR-015-architect-to-engineer.md new file mode 100644 index 0000000..b316f01 --- /dev/null +++ b/docs/HANDOFF/ECR-015-architect-to-engineer.md @@ -0,0 +1,4 @@ +# HANDOFF — ECR-015 Architect → Engineer + +Loop continuous · Approved + Coding authorized. +Do Spec order. Forbidden: 真支付 · UGC · ask_pack redeem. diff --git a/docs/HANDOFF/ECR-015-engineer-to-reviewer.md b/docs/HANDOFF/ECR-015-engineer-to-reviewer.md new file mode 100644 index 0000000..9f06b6c --- /dev/null +++ b/docs/HANDOFF/ECR-015-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-015 Engineer → Reviewer + +Done · Loop continuous Closed. diff --git a/docs/PRODUCT_SPEC/ECR-015-redemption-code.md b/docs/PRODUCT_SPEC/ECR-015-redemption-code.md new file mode 100644 index 0000000..f85c6d0 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-015-redemption-code.md @@ -0,0 +1,4 @@ +# PRODUCT_SPEC — ECR-015 + +对齐 Spec ops-redemption-code.md · Approved · Loop continuous +Capability CommerceEntitlement · BC Membership_Orders · L2 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 0a450b8..451bd5a 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -46,9 +46,9 @@ ## Active anchors -- ECR: **ECR-014 Closed**;Next **ECR-015** RedemptionCode(Loop continuous);013A/B Closed;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 +- ECR: **ECR-015 Closed**;Next **ECR-016** UserIntelligence(Loop);013A/B/014 Closed;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 - EXP: (无) -- STATE: `docs/STATE/ECR-014.md`(Closed)· next ECR-015 · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` +- STATE: `docs/STATE/ECR-015.md`(Closed)· next ECR-016 · 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-015.md b/docs/STATE/ECR-015.md new file mode 100644 index 0000000..e0eae5b --- /dev/null +++ b/docs/STATE/ECR-015.md @@ -0,0 +1,9 @@ +# STATE — ECR-015 + +| Field | Value | +|-------|-------| +| Status | **Closed** | +| Phase | closed | +| Test | TEST_REPORT/ECR-015.md | +| Review | CODE_REVIEW Approve → Closed | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-015-ECR015.yaml b/docs/TASKS/TASK-015-ECR015.yaml new file mode 100644 index 0000000..a5da386 --- /dev/null +++ b/docs/TASKS/TASK-015-ECR015.yaml @@ -0,0 +1,11 @@ +id: TASK-015-ECR015 +ecr: ECR-015 +title: RedemptionCode Closed +role: reviewer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-014 +acceptance: + - Spec AC mapped + - No true payment / UGC diff --git a/docs/TEST_REPORT/ECR-015.md b/docs/TEST_REPORT/ECR-015.md new file mode 100644 index 0000000..7dd2530 --- /dev/null +++ b/docs/TEST_REPORT/ECR-015.md @@ -0,0 +1,22 @@ +# TEST_REPORT — ECR-015 RedemptionCode + +Date: 2026-08-07 · Loop continuous + +## Results + +| Check | Result | +|-------|--------| +| TestRedemptionCodes | PASS | +| build:admin | PASS | + +## AC + +| ID | Evidence | +|----|----------| +| AC-F-01 | POST batch qty=3 → 3 codes | +| AC-F-02 | redeem → 200 | +| AC-F-03 | re-redeem → 400 | +| AC-F-04 | disable → redeem 400 | +| AC-S-01/02 | 无 token batch 401;未注册 membership 401 | +| AC-P-01 | list batches < 500ms | +| AC-O-01 | AuditLog redemption.batch.create | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 24803bb..96a69cb 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-014 | MembershipPlan | **Closed** | Spec ops-membership-plan · BD-2026-014 · migration 000017 · TEST_REPORT · CODE_REVIEW · Loop continuous | +| ECR-015 | RedemptionCode | **Closed** | Spec ops-redemption-code · migration 000018 · TEST_REPORT · Loop continuous | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index cf49e5a..7b06bf8 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -296,6 +296,49 @@ paths: '403': description: Forbidden + /api/v1/admin/redemption-batches: + get: + tags: [admin] + summary: List redemption batches + responses: { '200': { description: OK } } + post: + tags: [admin] + summary: Create redemption batch + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [label, plan_code, quantity] + properties: + label: { type: string } + plan_code: { type: string } + quantity: { type: integer, minimum: 1, maximum: 100 } + responses: { '200': { description: OK } } + + /api/v1/admin/redemption-batches/{id}/codes: + get: + tags: [admin] + summary: List codes in batch + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: { '200': { description: OK } } + + /api/v1/admin/redemption-codes/{id}/disable: + post: + tags: [admin] + summary: Disable unused code + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: { '200': { description: OK } } + /api/v1/admin/orders: get: tags: [admin] @@ -1010,6 +1053,27 @@ paths: schema: $ref: '#/components/schemas/EnvelopeMembershipMe' + /api/v1/membership/redeem: + post: + tags: [commerce] + summary: 兑换会员码 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + description: OK + '400': + description: Invalid or used code + '401': + description: Unauthorized + /api/v1/orders: post: tags: [commerce]