Files
digital-psychology/apps/api/internal/repository/redemption_repo.go
T
jackyu66gitandCursor 1eeb0b00e7 feat(ECR-015): RedemptionCode 兑换码并 Closed
批次生成/作废、C 端兑码延长会员;admin-h5 /codes。
Loop continuous。Next:ECR-016 UserIntelligence。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:16:13 +08:00

169 lines
4.9 KiB
Go

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
}