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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user