feat(ECR-015): RedemptionCode 兑换码并 Closed

批次生成/作废、C 端兑码延长会员;admin-h5 /codes。
Loop continuous。Next:ECR-016 UserIntelligence。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:16:13 +08:00
co-authored by Cursor
parent 0e26aabef8
commit 1eeb0b00e7
33 changed files with 1073 additions and 36 deletions
+1 -1
View File
@@ -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 | 已存在 |
@@ -0,0 +1,43 @@
# Feature Spec: 兑换码 RedemptionCodeOps · 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\|disabledredeemed 不可再兑 |
## 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 &lt; 500ms |
| AC-O-01 | 生成 AuditLog `redemption.batch.create` |
| AC-O-02 | 兑换可追溯 redeemed_by |
contract_diff: `docs/CONTRACT_DIFF/ECR-015.yaml`
+16
View File
@@ -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<{
+1
View File
@@ -29,6 +29,7 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const label = ref('batch')
const plan = ref('month')
const qty = ref(5)
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
const activeBatch = ref('')
async function loadBatches() {
loading.value = true
error.value = ''
try {
const res = await adminApi.redemptionBatches()
batches.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createBatch() {
msg.value = ''
try {
const res = await adminApi.createRedemptionBatch({
label: label.value,
plan_code: plan.value,
quantity: Number(qty.value),
})
msg.value = `已生成 ${res.codes.length} 个码`
activeBatch.value = res.batch.id
codes.value = res.codes
await loadBatches()
} catch (e) {
msg.value = e instanceof Error ? e.message : '生成失败'
}
}
async function openBatch(id: string) {
activeBatch.value = id
const res = await adminApi.redemptionCodes(id)
codes.value = res.items || []
}
async function disable(id: string) {
await adminApi.disableRedemptionCode(id)
if (activeBatch.value) await openBatch(activeBatch.value)
}
onMounted(loadBatches)
</script>
<template>
<section>
<h1>兑换码</h1>
<p class="muted">批量生成会员兑换码禁真支付</p>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
<h2>生成批次</h2>
<div class="row">
<label>标签 <input v-model="label" /></label>
<label>套餐
<select v-model="plan">
<option value="month">month</option>
<option value="quarter">quarter</option>
<option value="year">year</option>
</select>
</label>
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
<button class="btn" type="button" @click="createBatch">生成</button>
</div>
</div>
<div class="card block">
<h2>批次</h2>
<p v-if="loading" class="muted">加载中</p>
<ul v-else>
<li v-for="b in batches" :key="b.id">
<button class="link" type="button" @click="openBatch(b.id)">
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
</button>
</li>
</ul>
</div>
<div v-if="codes.length" class="card block">
<h2>码列表</h2>
<table>
<thead><tr><th></th><th>状态</th><th></th></tr></thead>
<tbody>
<tr v-for="c in codes" :key="c.id">
<td><code>{{ c.code }}</code></td>
<td>{{ c.status }}</td>
<td>
<button
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
class="btn ghost"
type="button"
@click="disable(c.id)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</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; }
.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, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
ul { margin: 0; padding-left: 1rem; }
code { font-size: 0.85rem; }
</style>
+1
View File
@@ -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') },
],
},
+1
View File
@@ -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) {
@@ -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})
}
+32
View File
@@ -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)
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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
+3
View File
@@ -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 (
@@ -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
}
@@ -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)
@@ -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;
@@ -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;
@@ -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
+1
View File
@@ -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
+14
View File
@@ -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 薄切片(只读用户洞察看板)或停
```
+28
View File
@@ -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 }
+19
View File
@@ -0,0 +1,19 @@
# ECR-015
**Title:** RedemptionCode(兑换码薄切片)
**Status:** **Closed**
**Closed:** 2026-08-07Loop 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
@@ -0,0 +1,8 @@
# ENGINEERING_SPEC — ECR-015
Approved · codingLoop
1. Migration batches+codes + RBAC permissions
2. Admin generate/list/disable · membership Redeem
3. OpenAPI · admin-h5 最小页
4. Integration AC · Closed
@@ -0,0 +1,4 @@
# HANDOFF — ECR-015 Architect → Engineer
Loop continuous · Approved + Coding authorized.
Do Spec order. Forbidden: 真支付 · UGC · ask_pack redeem.
@@ -0,0 +1,3 @@
# HANDOFF — ECR-015 Engineer → Reviewer
Done · Loop continuous Closed.
@@ -0,0 +1,4 @@
# PRODUCT_SPEC — ECR-015
对齐 Spec ops-redemption-code.md · Approved · Loop continuous
Capability CommerceEntitlement · BC Membership_Orders · L2
+2 -2
View File
@@ -46,9 +46,9 @@
## Active anchors
- ECR: **ECR-014 Closed**Next **ECR-015** RedemptionCodeLoop continuous);013A/B Closed**ECR-013A Closed****WAVE0-FROZEN** @ 27f27a1
- ECR: **ECR-015 Closed**Next **ECR-016** UserIntelligenceLoop);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`
+9
View File
@@ -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 |
+11
View File
@@ -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
+22
View File
@@ -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 &lt; 500ms |
| AC-O-01 | AuditLog redemption.batch.create |
+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-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 |
+64
View File
@@ -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]