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:
@@ -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: 兑换码 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`
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 薄切片(只读用户洞察看板)或停
|
||||
```
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user