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 | 未来读模型 |
|
| PsychologicalTagSet | Identity_Profile | UserIntelligence | 未来读模型 |
|
||||||
| MembershipPlan | Membership_Orders | CommerceEntitlement | 配置面后置 ECR |
|
| MembershipPlan | Membership_Orders | CommerceEntitlement | 配置面后置 ECR |
|
||||||
| Entitlement | Membership_Orders | CommerceEntitlement | 后置 |
|
| Entitlement | Membership_Orders | CommerceEntitlement | 后置 |
|
||||||
| RedemptionCode | Membership_Orders | CommerceEntitlement | 后置 |
|
| RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** |
|
||||||
| Membership | Membership_Orders | CommerceEntitlement | 已存在 |
|
| Membership | Membership_Orders | CommerceEntitlement | 已存在 |
|
||||||
| Order | Membership_Orders | CommerceEntitlement | 已存在 |
|
| Order | Membership_Orders | CommerceEntitlement | 已存在 |
|
||||||
| DeepAccess | 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
|
amount_cents: number
|
||||||
active: boolean
|
active: boolean
|
||||||
}>('PUT', `/membership-plans/${code}`, body),
|
}>('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: () =>
|
orders: () =>
|
||||||
request<{
|
request<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function onLogout() {
|
|||||||
<RouterLink to="/content">内容</RouterLink>
|
<RouterLink to="/content">内容</RouterLink>
|
||||||
<RouterLink to="/users">用户</RouterLink>
|
<RouterLink to="/users">用户</RouterLink>
|
||||||
<RouterLink to="/plans">套餐</RouterLink>
|
<RouterLink to="/plans">套餐</RouterLink>
|
||||||
|
<RouterLink to="/codes">兑换码</RouterLink>
|
||||||
<RouterLink to="/orders">订单</RouterLink>
|
<RouterLink to="/orders">订单</RouterLink>
|
||||||
<RouterLink to="/audit">审计</RouterLink>
|
<RouterLink to="/audit">审计</RouterLink>
|
||||||
</nav>
|
</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: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
|
||||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
||||||
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.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') },
|
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
|||||||
h.registerRBAC(authed)
|
h.registerRBAC(authed)
|
||||||
h.registerLifecycle(authed)
|
h.registerLifecycle(authed)
|
||||||
h.registerMembershipPlans(authed)
|
h.registerMembershipPlans(authed)
|
||||||
|
h.registerRedemption(authed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) Login(c *gin.Context) {
|
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/latest", h.GetLatest)
|
||||||
rg.GET("/reports/:id", h.Get)
|
rg.GET("/reports/:id", h.Get)
|
||||||
rg.GET("/membership/me", h.GetMembership)
|
rg.GET("/membership/me", h.GetMembership)
|
||||||
|
rg.POST("/membership/redeem", h.RedeemCode)
|
||||||
rg.POST("/orders", h.CreateOrder)
|
rg.POST("/orders", h.CreateOrder)
|
||||||
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
||||||
}
|
}
|
||||||
@@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
|
|||||||
response.OK(c, me)
|
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.
|
// CreateOrder handles POST /orders.
|
||||||
func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
||||||
userID, ok := middleware.UserIDFromContext(c)
|
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")
|
var errMissingReport = errString("report_id required for deep_access")
|
||||||
|
|
||||||
type errString string
|
type errString string
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const (
|
|||||||
PermUsersStatusWrite = "admin.users.status.write"
|
PermUsersStatusWrite = "admin.users.status.write"
|
||||||
PermMembershipPlansRead = "admin.membership.plans.read"
|
PermMembershipPlansRead = "admin.membership.plans.read"
|
||||||
PermMembershipPlansWrite = "admin.membership.plans.write"
|
PermMembershipPlansWrite = "admin.membership.plans.write"
|
||||||
|
PermMembershipCodesRead = "admin.membership.codes.read"
|
||||||
|
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||||
)
|
)
|
||||||
|
|
||||||
var knownPermissions = map[string]struct{}{
|
var knownPermissions = map[string]struct{}{
|
||||||
@@ -29,6 +31,7 @@ var knownPermissions = map[string]struct{}{
|
|||||||
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
||||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||||
|
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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)
|
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.
|
// PayMock completes mock payment.
|
||||||
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||||
return s.Reports.PayMock(ctx, userID, orderID)
|
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
|
## 2026-08-07
|
||||||
|
|
||||||
|
- **ECR-015 Closed**:RedemptionCode(批次生成 · C端兑码 · 作废 · 审计)
|
||||||
- **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表)
|
- **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表)
|
||||||
- **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认
|
- **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认
|
||||||
- **ECR-013B Closed**:AccountLifecycle Reviewer Approve
|
- **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
|
## 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: (无)
|
- 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`
|
- Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml`
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.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 |
|
| 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 |
|
| 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-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':
|
'403':
|
||||||
description: Forbidden
|
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:
|
/api/v1/admin/orders:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
@@ -1010,6 +1053,27 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/EnvelopeMembershipMe'
|
$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:
|
/api/v1/orders:
|
||||||
post:
|
post:
|
||||||
tags: [commerce]
|
tags: [commerce]
|
||||||
|
|||||||
Reference in New Issue
Block a user