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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:16:13 +08:00
co-authored by Cursor
parent 0e26aabef8
commit 1eeb0b00e7
33 changed files with 1073 additions and 36 deletions
+16
View File
@@ -158,6 +158,22 @@ export const adminApi = {
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) =>
request<{
batch: { id: string; label: string; plan_code: string; quantity: number }
codes: Array<{ id: string; code: string; status: string }>
}>('POST', '/redemption-batches', body),
redemptionBatches: () =>
request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>(
'GET',
'/redemption-batches',
),
redemptionCodes: (batchId: string) =>
request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>(
'GET',
`/redemption-batches/${batchId}/codes`,
),
disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -29,6 +29,7 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const label = ref('batch')
const plan = ref('month')
const qty = ref(5)
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
const activeBatch = ref('')
async function loadBatches() {
loading.value = true
error.value = ''
try {
const res = await adminApi.redemptionBatches()
batches.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createBatch() {
msg.value = ''
try {
const res = await adminApi.createRedemptionBatch({
label: label.value,
plan_code: plan.value,
quantity: Number(qty.value),
})
msg.value = `已生成 ${res.codes.length} 个码`
activeBatch.value = res.batch.id
codes.value = res.codes
await loadBatches()
} catch (e) {
msg.value = e instanceof Error ? e.message : '生成失败'
}
}
async function openBatch(id: string) {
activeBatch.value = id
const res = await adminApi.redemptionCodes(id)
codes.value = res.items || []
}
async function disable(id: string) {
await adminApi.disableRedemptionCode(id)
if (activeBatch.value) await openBatch(activeBatch.value)
}
onMounted(loadBatches)
</script>
<template>
<section>
<h1>兑换码</h1>
<p class="muted">批量生成会员兑换码禁真支付</p>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
<h2>生成批次</h2>
<div class="row">
<label>标签 <input v-model="label" /></label>
<label>套餐
<select v-model="plan">
<option value="month">month</option>
<option value="quarter">quarter</option>
<option value="year">year</option>
</select>
</label>
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
<button class="btn" type="button" @click="createBatch">生成</button>
</div>
</div>
<div class="card block">
<h2>批次</h2>
<p v-if="loading" class="muted">加载中</p>
<ul v-else>
<li v-for="b in batches" :key="b.id">
<button class="link" type="button" @click="openBatch(b.id)">
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
</button>
</li>
</ul>
</div>
<div v-if="codes.length" class="card block">
<h2>码列表</h2>
<table>
<thead><tr><th></th><th>状态</th><th></th></tr></thead>
<tbody>
<tr v-for="c in codes" :key="c.id">
<td><code>{{ c.code }}</code></td>
<td>{{ c.status }}</td>
<td>
<button
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
class="btn ghost"
type="button"
@click="disable(c.id)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
input, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
ul { margin: 0; padding-left: 1rem; }
code { font-size: 0.85rem; }
</style>
+1
View File
@@ -16,6 +16,7 @@ const router = createRouter({
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
+1
View File
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerRBAC(authed)
h.registerLifecycle(authed)
h.registerMembershipPlans(authed)
h.registerRedemption(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -0,0 +1,99 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerRedemption(authed *gin.RouterGroup) {
authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch)
authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches)
authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes)
authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode)
}
func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
var body struct {
Label string `json:"label"`
PlanCode string `json:"plan_code"`
Quantity int `json:"quantity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
return
}
batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity)
if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || errors.Is(err, admin.ErrInvalidPlanU) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"batch": batch, "codes": codes})
}
func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
return
}
items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id)
if errors.Is(err, admin.ErrBatchNotFound) {
response.Fail(c, http.StatusNotFound, 40400, "batch not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
return
}
err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id)
if errors.Is(err, admin.ErrCodeDisable) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"ok": true})
}
+32
View File
@@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.GET("/reports/latest", h.GetLatest)
rg.GET("/reports/:id", h.Get)
rg.GET("/membership/me", h.GetMembership)
rg.POST("/membership/redeem", h.RedeemCode)
rg.POST("/orders", h.CreateOrder)
rg.POST("/orders/:id/pay-mock", h.PayMock)
}
@@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
response.OK(c, me)
}
// RedeemCode handles POST /membership/redeem.
func (h *ReportHandler) RedeemCode(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
msvc, ok := h.requireMembership(c)
if !ok {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
response.Fail(c, http.StatusBadRequest, 40000, "code required")
return
}
plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code)
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
me, err := msvc.Get(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"plan": plan, "membership": me})
}
// CreateOrder handles POST /orders.
func (h *ReportHandler) CreateOrder(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
@@ -0,0 +1,100 @@
package integration_test
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestRedemptionCodes(t *testing.T) {
r, _ := setupAPIPool(t)
tok := adminLogin(t, r, "admin", "change-me")
_, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches",
map[string]any{"label": "t", "plan_code": "month", "quantity": 2}, "")
if code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", code)
}
start := time.Now()
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches",
map[string]any{"label": "ops-test", "plan_code": "month", "quantity": 3}, tok)
if code != 200 {
t.Fatalf("create batch http=%d msg=%s", code, env.Message)
}
var created struct {
Batch struct {
ID string `json:"id"`
} `json:"batch"`
Codes []struct {
ID string `json:"id"`
Code string `json:"code"`
Status string `json:"status"`
} `json:"codes"`
}
_ = json.Unmarshal(env.Data, &created)
if len(created.Codes) != 3 {
t.Fatalf("want 3 codes, got %#v", created.Codes)
}
raw := created.Codes[0].Code
disableID := created.Codes[2].ID
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches", nil, tok)
if code != 200 || time.Since(start) > 500*time.Millisecond {
t.Fatalf("list batches http=%d dur=%v", code, time.Since(start))
}
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches/"+created.Batch.ID+"/codes", nil, tok)
if code != 200 {
t.Fatalf("list codes %d", code)
}
key := mustRegister(t, r)
_, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
map[string]string{"code": raw}, key, 0)
if httpCode != 200 {
t.Fatalf("redeem http=%d", httpCode)
}
_, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
map[string]string{"code": raw}, key, 40000)
if httpCode != http.StatusBadRequest {
t.Fatalf("expected HTTP 400 re-redeem, got %d", httpCode)
}
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-codes/"+disableID+"/disable", nil, tok)
if code != 200 {
t.Fatalf("disable %d", code)
}
_, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem",
map[string]string{"code": created.Codes[2].Code}, key, 40000)
if httpCode != http.StatusBadRequest {
t.Fatalf("expected HTTP 400 disabled, got %d", httpCode)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
if code != 200 {
t.Fatalf("audit %d", code)
}
var audit struct {
Items []struct {
Action string `json:"action"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &audit)
found := false
for _, it := range audit.Items {
if it.Action == "redemption.batch.create" {
found = true
break
}
}
if !found {
t.Fatal("missing redemption.batch.create audit")
}
if code := deviceGET(t, r, "/api/v1/membership/me", "dev_orphan_"+time.Now().Format("150405"), ""); code != http.StatusUnauthorized {
t.Fatalf("expected 401 unregistered membership, got %d", code)
}
}
@@ -0,0 +1,125 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// MembershipPlanAmountCents returns catalog price or fallback for membership plans.
func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) {
var amount int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&amount, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipAmountFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active {
return 0, errString("plan inactive")
}
return amount, nil
}
func membershipAmountFallback(plan string) int {
switch plan {
case "month":
return 2500
case "quarter":
return 6800
case "year":
return 19800
default:
return 2500
}
}
// MembershipPlanDurationDays returns catalog days or fallback.
func (r *ReportRepo) MembershipPlanDurationDays(ctx context.Context, plan string) (int, error) {
var days int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT duration_days, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&days, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipDaysFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active || days <= 0 {
return membershipDaysFallback(plan), nil
}
return days, nil
}
func membershipDaysFallback(plan string) int {
switch plan {
case "month":
return 31
case "quarter":
return 92
case "year":
return 366
default:
return 31
}
}
// RedeemCode applies an unused redemption code to user membership.
func (r *ReportRepo) RedeemCode(ctx context.Context, userID uuid.UUID, rawCode string) (plan string, err error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
var codeID uuid.UUID
var status string
err = tx.QueryRow(ctx, `
SELECT id, plan_code, status FROM redemption_codes
WHERE code=$1 FOR UPDATE`, rawCode,
).Scan(&codeID, &plan, &status)
if errors.Is(err, pgx.ErrNoRows) {
return "", errString("invalid code")
}
if err != nil {
return "", err
}
if status != "unused" {
return "", errString("code not redeemable")
}
days, err := r.MembershipPlanDurationDays(ctx, plan)
if err != nil {
return "", err
}
if _, err := tx.Exec(ctx, `
UPDATE redemption_codes
SET status='redeemed', redeemed_by=$2, redeemed_at=now()
WHERE id=$1 AND status='unused'`, codeID, userID); err != nil {
return "", err
}
if _, err := tx.Exec(ctx, `
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
ON CONFLICT (user_id) DO UPDATE SET
plan=EXCLUDED.plan, status='active',
expires_at=(CASE
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
THEN memberships.expires_at ELSE now()
END) + ($3 * interval '1 day'),
ask_quota_left=100, updated_at=now()`,
userID, plan, days); err != nil {
return "", err
}
if err := tx.Commit(ctx); err != nil {
return "", err
}
return plan, nil
}
@@ -0,0 +1,168 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// RedemptionBatch is a generation batch of codes.
type RedemptionBatch struct {
ID uuid.UUID `json:"id"`
Label string `json:"label"`
PlanCode string `json:"plan_code"`
Quantity int `json:"quantity"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// RedemptionCodeRow is one redeemable code.
type RedemptionCodeRow struct {
ID uuid.UUID `json:"id"`
BatchID uuid.UUID `json:"batch_id"`
Code string `json:"code"`
PlanCode string `json:"plan_code"`
Status string `json:"status"`
RedeemedBy *uuid.UUID `json:"redeemed_by,omitempty"`
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CreateRedemptionBatchWithCodes inserts batch + codes + audit.
func (r *AdminRepo) CreateRedemptionBatchWithCodes(
ctx context.Context,
adminID uuid.UUID,
label, planCode string,
codes []string,
meta json.RawMessage,
) (*RedemptionBatch, []RedemptionCodeRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, nil, err
}
defer tx.Rollback(ctx)
var b RedemptionBatch
err = tx.QueryRow(ctx, `
INSERT INTO redemption_batches(label, plan_code, quantity, created_by)
VALUES ($1,$2,$3,$4)
RETURNING id, label, plan_code, quantity, created_by, created_at`,
label, planCode, len(codes), adminID,
).Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt)
if err != nil {
return nil, nil, err
}
out := make([]RedemptionCodeRow, 0, len(codes))
for _, code := range codes {
var row RedemptionCodeRow
err = tx.QueryRow(ctx, `
INSERT INTO redemption_codes(batch_id, code, plan_code, status)
VALUES ($1,$2,$3,'unused')
RETURNING id, batch_id, code, plan_code, status, created_at`,
b.ID, code, planCode,
).Scan(&row.ID, &row.BatchID, &row.Code, &row.PlanCode, &row.Status, &row.CreatedAt)
if err != nil {
return nil, nil, err
}
out = append(out, row)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'redemption.batch.create','redemption_batch',$2,$3)`,
adminID, b.ID.String(), meta,
); err != nil {
return nil, nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
return &b, out, nil
}
// ListRedemptionBatches newest first.
func (r *AdminRepo) ListRedemptionBatches(ctx context.Context, limit int) ([]RedemptionBatch, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.Pool.Query(ctx, `
SELECT id, label, plan_code, quantity, created_by, created_at
FROM redemption_batches ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RedemptionBatch
for rows.Next() {
var b RedemptionBatch
if err := rows.Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// ListRedemptionCodesByBatch returns codes for a batch.
func (r *AdminRepo) ListRedemptionCodesByBatch(ctx context.Context, batchID uuid.UUID) ([]RedemptionCodeRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, batch_id, code, plan_code, status, redeemed_by, redeemed_at, created_at
FROM redemption_codes WHERE batch_id=$1 ORDER BY created_at`, batchID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRedemptionCodes(rows)
}
// DisableRedemptionCode marks unused code disabled.
func (r *AdminRepo) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE redemption_codes SET status='disabled'
WHERE id=$1 AND status='unused'`, codeID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errString("code not unused")
}
meta, _ := json.Marshal(map[string]string{"code_id": codeID.String()})
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'redemption.code.disable','redemption_code',$2,$3)`,
adminID, codeID.String(), meta,
); err != nil {
return err
}
return tx.Commit(ctx)
}
func scanRedemptionCodes(rows pgx.Rows) ([]RedemptionCodeRow, error) {
var out []RedemptionCodeRow
for rows.Next() {
var c RedemptionCodeRow
if err := rows.Scan(&c.ID, &c.BatchID, &c.Code, &c.PlanCode, &c.Status, &c.RedeemedBy, &c.RedeemedAt, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// BatchExists reports whether batch id exists.
func (r *AdminRepo) BatchExists(ctx context.Context, id uuid.UUID) (bool, error) {
var ok bool
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM redemption_batches WHERE id=$1)`, id).Scan(&ok)
return ok, err
}
@@ -316,38 +316,6 @@ func AskPackAmountCents(plan string) int {
}
}
// MembershipPlanAmountCents returns catalog price or fallback for membership plans.
func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) {
var amount int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&amount, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipAmountFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active {
return 0, errString("plan inactive")
}
return amount, nil
}
func membershipAmountFallback(plan string) int {
switch plan {
case "month":
return 2500
case "quarter":
return 6800
case "year":
return 19800
default:
return 2500
}
}
var errMissingReport = errString("report_id required for deep_access")
type errString string
+3
View File
@@ -22,6 +22,8 @@ const (
PermUsersStatusWrite = "admin.users.status.write"
PermMembershipPlansRead = "admin.membership.plans.read"
PermMembershipPlansWrite = "admin.membership.plans.write"
PermMembershipCodesRead = "admin.membership.codes.read"
PermMembershipCodesWrite = "admin.membership.codes.write"
)
var knownPermissions = map[string]struct{}{
@@ -29,6 +31,7 @@ var knownPermissions = map[string]struct{}{
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
}
var (
@@ -0,0 +1,96 @@
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrBadBatchQty = errString("quantity must be 1..100")
ErrCodeDisable = errString("code not unused")
ErrBatchNotFound = errString("batch not found")
)
// CreateRedemptionBatch generates codes for a membership plan.
func (s *Service) CreateRedemptionBatch(
ctx context.Context, adminID uuid.UUID, label, planCode string, qty int,
) (*repository.RedemptionBatch, []repository.RedemptionCodeRow, error) {
label = strings.TrimSpace(label)
planCode = strings.TrimSpace(planCode)
if label == "" || qty < 1 || qty > 100 {
return nil, nil, ErrBadBatchQty
}
if _, err := s.GetMembershipPlan(ctx, planCode); err != nil {
return nil, nil, err
}
codes := make([]string, 0, qty)
seen := map[string]struct{}{}
for len(codes) < qty {
c, err := newRedemptionCode()
if err != nil {
return nil, nil, err
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
codes = append(codes, c)
}
meta, _ := json.Marshal(map[string]any{"label": label, "plan": planCode, "quantity": qty})
return s.Repo.CreateRedemptionBatchWithCodes(ctx, adminID, label, planCode, codes, meta)
}
// ListRedemptionBatches lists recent batches.
func (s *Service) ListRedemptionBatches(ctx context.Context, limit int) ([]repository.RedemptionBatch, error) {
items, err := s.Repo.ListRedemptionBatches(ctx, limit)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.RedemptionBatch{}
}
return items, nil
}
// ListRedemptionCodes lists codes in a batch.
func (s *Service) ListRedemptionCodes(ctx context.Context, batchID uuid.UUID) ([]repository.RedemptionCodeRow, error) {
ok, err := s.Repo.BatchExists(ctx, batchID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrBatchNotFound
}
items, err := s.Repo.ListRedemptionCodesByBatch(ctx, batchID)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.RedemptionCodeRow{}
}
return items, nil
}
// DisableRedemptionCode voids an unused code.
func (s *Service) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
err := s.Repo.DisableRedemptionCode(ctx, adminID, codeID)
if err != nil && err.Error() == "code not unused" {
return ErrCodeDisable
}
return err
}
func newRedemptionCode() (string, error) {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "YXG-" + strings.ToUpper(hex.EncodeToString(b)), nil
}
@@ -4,6 +4,7 @@ package membership
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
@@ -58,6 +59,15 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount)
}
// Redeem applies a redemption code for the current registered user.
func (s *Service) Redeem(ctx context.Context, userID uuid.UUID, code string) (string, error) {
code = strings.TrimSpace(code)
if code == "" {
return "", errors.New("code required")
}
return s.Reports.RedeemCode(ctx, userID, code)
}
// PayMock completes mock payment.
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
return s.Reports.PayMock(ctx, userID, orderID)
@@ -0,0 +1,4 @@
DELETE FROM admin_role_permissions
WHERE code IN ('admin.membership.codes.read', 'admin.membership.codes.write');
DROP TABLE IF EXISTS redemption_codes;
DROP TABLE IF EXISTS redemption_batches;
@@ -0,0 +1,35 @@
-- ECR-015 RedemptionCode
CREATE TABLE IF NOT EXISTS redemption_batches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
label varchar(64) NOT NULL,
plan_code varchar(32) NOT NULL REFERENCES membership_plans(code),
quantity int NOT NULL CHECK (quantity > 0 AND quantity <= 100),
created_by uuid NOT NULL REFERENCES admin_accounts(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS redemption_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id uuid NOT NULL REFERENCES redemption_batches(id) ON DELETE CASCADE,
code varchar(32) NOT NULL UNIQUE,
plan_code varchar(32) NOT NULL REFERENCES membership_plans(code),
status varchar(16) NOT NULL DEFAULT 'unused'
CHECK (status IN ('unused','redeemed','disabled')),
redeemed_by uuid NULL REFERENCES users(id),
redeemed_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_batch ON redemption_codes(batch_id);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_status ON redemption_codes(status);
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, p.code
FROM admin_roles r
CROSS JOIN (VALUES
('admin.membership.codes.read'),
('admin.membership.codes.write')
) AS p(code)
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;