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
+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)