e128c96d22
Store 挂库 store(NewStore 内部构造 libcodes.Store,签名不变); generator.go 三函数委托 libcodes,ErrDuplicate 别名同一哨兵; Service.CreateBatch 走 libcodes.Mint(签名不变)。openMigratedSQLite 挪到共享 sqlite_helper_test.go,backfill_test.go/service_sqlite_test.go 共用。Store.CreateBatch/CreateCode/CodeExistsByHash 原样保留供 webhook.go(Task 6 改用新原语,Task 7 删除)。
356 lines
10 KiB
Go
356 lines
10 KiB
Go
package codes
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
libcodes "github.com/wangjia/codes"
|
||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||
)
|
||
|
||
// RedeemRequest carries the inputs to a single redemption attempt.
|
||
type RedeemRequest struct {
|
||
UserID int64 // authenticated user
|
||
Code string // raw code string from the client (will be canonicalized)
|
||
}
|
||
|
||
// RedeemResult carries the outcome of a successful redemption.
|
||
type RedeemResult struct {
|
||
// Idempotent indicates this user already redeemed this code; the result is
|
||
// from the original redemption.
|
||
Idempotent bool
|
||
PlanCode PlanCode
|
||
DurationDays int
|
||
// ExpiresAt is the updated/new subscription expiry (UTC).
|
||
ExpiresAt time.Time
|
||
// SubscriptionID is the subscription that was extended or newly created.
|
||
SubscriptionID int64
|
||
}
|
||
|
||
// Service handles activation-code redemption.
|
||
type Service struct {
|
||
store *Store
|
||
rdb *redis.Client
|
||
// redeemFailMax is the number of consecutive failures before a 1-hour lock.
|
||
redeemFailMax int
|
||
// redeemLockDur is how long the lock lasts.
|
||
redeemLockDur time.Duration
|
||
}
|
||
|
||
// NewService creates a Service.
|
||
func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
|
||
if failMax <= 0 {
|
||
failMax = 5
|
||
}
|
||
if lockDur <= 0 {
|
||
lockDur = time.Hour
|
||
}
|
||
return &Service{
|
||
store: store,
|
||
rdb: rdb,
|
||
redeemFailMax: failMax,
|
||
redeemLockDur: lockDur,
|
||
}
|
||
}
|
||
|
||
// redisKeyFail returns the Redis key for the per-user failure counter.
|
||
func redisKeyFail(userID int64) string {
|
||
return fmt.Sprintf("redeem:fail:%d", userID)
|
||
}
|
||
|
||
// isLocked returns true if the user has hit the failure cap.
|
||
// Returns false (not locked) if Redis is not configured.
|
||
func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) {
|
||
if svc.rdb == nil {
|
||
return false, nil
|
||
}
|
||
val, err := svc.rdb.Get(ctx, redisKeyFail(userID)).Int()
|
||
if err == redis.Nil {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return val >= svc.redeemFailMax, nil
|
||
}
|
||
|
||
// recordFail increments the failure counter, setting a 1-hour TTL on first
|
||
// increment so the counter resets automatically after the lock window.
|
||
// No-ops if Redis is not configured.
|
||
func (svc *Service) recordFail(ctx context.Context, userID int64) error {
|
||
if svc.rdb == nil {
|
||
return nil
|
||
}
|
||
key := redisKeyFail(userID)
|
||
pipe := svc.rdb.Pipeline()
|
||
pipe.Incr(ctx, key)
|
||
pipe.Expire(ctx, key, svc.redeemLockDur)
|
||
_, err := pipe.Exec(ctx)
|
||
return err
|
||
}
|
||
|
||
// clearFail removes the failure counter after a successful redemption.
|
||
// No-ops if Redis is not configured.
|
||
func (svc *Service) clearFail(ctx context.Context, userID int64) {
|
||
if svc.rdb == nil {
|
||
return
|
||
}
|
||
_ = svc.rdb.Del(ctx, redisKeyFail(userID)).Err()
|
||
}
|
||
|
||
// Redeem processes a redemption request inside a serialisable transaction.
|
||
//
|
||
// Flow:
|
||
// 1. Check rate-limit lock.
|
||
// 2. Canonicalize and hash the code.
|
||
// 3. BEGIN TRANSACTION (Serializable isolation).
|
||
// 4. SELECT … FOR UPDATE the codes row.
|
||
// 5. Idempotency: if already redeemed by this user, return cached success.
|
||
// 6. Fail if redeemed by someone else, or code is void.
|
||
// 7. MarkRedeemed, extend/create subscription, write audit_log.
|
||
// 8. COMMIT.
|
||
// 9. Clear the failure counter on success.
|
||
func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
|
||
// 1. Check lock.
|
||
locked, err := svc.isLocked(ctx, req.UserID)
|
||
if err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
if locked {
|
||
return nil, apierr.ErrLocked
|
||
}
|
||
|
||
// 2. Canonicalize and hash.
|
||
canonical, cerr := Canonicalize(req.Code)
|
||
if cerr != nil {
|
||
_ = svc.recordFail(ctx, req.UserID)
|
||
return nil, apierr.ErrInvalidCode
|
||
}
|
||
hash := Hash(canonical)
|
||
|
||
// 3. Begin transaction.
|
||
tx, err := svc.store.BeginTx(ctx)
|
||
if err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
// Roll back on any unhandled path.
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 4. SELECT … FOR UPDATE.
|
||
cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash)
|
||
if err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
if cr == nil {
|
||
_ = tx.Rollback()
|
||
committed = true // prevent double rollback
|
||
_ = svc.recordFail(ctx, req.UserID)
|
||
return nil, apierr.ErrCodeNotFound
|
||
}
|
||
|
||
// 5. Idempotency check.
|
||
// Same user has already redeemed this code → return a success result without
|
||
// re-applying any changes. Roll back the (read-only) transaction first.
|
||
if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID {
|
||
_ = tx.Rollback()
|
||
committed = true
|
||
return &RedeemResult{
|
||
Idempotent: true,
|
||
PlanCode: cr.PlanCode,
|
||
DurationDays: cr.DurationDays,
|
||
// ExpiresAt is omitted; the caller can query /v1/me if needed.
|
||
}, nil
|
||
}
|
||
|
||
// 6. Fail if already taken or voided.
|
||
switch cr.Status {
|
||
case "redeemed":
|
||
_ = tx.Rollback()
|
||
committed = true
|
||
_ = svc.recordFail(ctx, req.UserID)
|
||
return nil, apierr.ErrCodeRedeemed
|
||
case "void":
|
||
_ = tx.Rollback()
|
||
committed = true
|
||
_ = svc.recordFail(ctx, req.UserID)
|
||
return nil, apierr.ErrCodeVoid
|
||
}
|
||
|
||
// 7a. Mark the code as redeemed.
|
||
if err := svc.store.MarkRedeemed(ctx, tx, cr.ID, req.UserID); err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
|
||
// 7b. Extend or create subscription.
|
||
subID, expiresAt, apiErr := svc.applySubscription(ctx, tx, req.UserID, cr)
|
||
if apiErr != nil {
|
||
return nil, apiErr
|
||
}
|
||
|
||
// 7c. Write audit log.
|
||
meta := auditMeta(req.UserID, cr, subID)
|
||
if err := svc.store.WriteAuditLog(ctx, tx,
|
||
fmt.Sprintf("user:%d", req.UserID), "redeem",
|
||
"code_hash:"+cr.CodeHash[:16]+"...",
|
||
meta,
|
||
); err != nil {
|
||
// Audit log failure must not abort the business transaction.
|
||
// Log the error but continue.
|
||
_ = err
|
||
}
|
||
|
||
// 8. Commit.
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
committed = true
|
||
|
||
// 9. Clear failure counter on success.
|
||
svc.clearFail(ctx, req.UserID)
|
||
|
||
return &RedeemResult{
|
||
Idempotent: false,
|
||
PlanCode: cr.PlanCode,
|
||
DurationDays: cr.DurationDays,
|
||
ExpiresAt: expiresAt,
|
||
SubscriptionID: subID,
|
||
}, nil
|
||
}
|
||
|
||
// applySubscription implements the subscription-extension rules:
|
||
//
|
||
// - Same plan as code → extend the most-recently-expiring subscription of
|
||
// that plan: expires_at = max(expires_at, now) + duration_days
|
||
// - Different plan (or no existing sub for the code's plan) → create a new
|
||
// subscription row:
|
||
// expires_at = max(now, latest_expiry_for_code_plan) + duration_days
|
||
func (svc *Service) applySubscription(
|
||
ctx context.Context,
|
||
tx *sql.Tx,
|
||
userID int64,
|
||
cr *CodeRow,
|
||
) (subID int64, expiresAt time.Time, apiErr *apierr.Error) {
|
||
|
||
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
|
||
if err != nil {
|
||
return 0, time.Time{}, apierr.ErrInternal
|
||
}
|
||
|
||
// Find any existing subscription with the same plan as the code.
|
||
var samePlanSub *SubscriptionRow
|
||
var latestSamePlan time.Time
|
||
for i := range subs {
|
||
if subs[i].PlanID == cr.PlanID {
|
||
if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
|
||
samePlanSub = &subs[i]
|
||
}
|
||
if subs[i].ExpiresAt.After(latestSamePlan) {
|
||
latestSamePlan = subs[i].ExpiresAt
|
||
}
|
||
}
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
|
||
if samePlanSub != nil {
|
||
// Extend existing subscription: max(expires_at, now) + duration_days.
|
||
if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil {
|
||
return 0, time.Time{}, apierr.ErrInternal
|
||
}
|
||
base := samePlanSub.ExpiresAt
|
||
if now.After(base) {
|
||
base = now
|
||
}
|
||
expiresAt = base.AddDate(0, 0, cr.DurationDays)
|
||
return samePlanSub.ID, expiresAt, nil
|
||
}
|
||
|
||
// Create a new subscription.
|
||
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan)
|
||
if err != nil {
|
||
return 0, time.Time{}, apierr.ErrInternal
|
||
}
|
||
base := now
|
||
if latestSamePlan.After(now) {
|
||
base = latestSamePlan
|
||
}
|
||
expiresAt = base.AddDate(0, 0, cr.DurationDays)
|
||
return newSubID, expiresAt, nil
|
||
}
|
||
|
||
// auditMeta serialises a compact JSON string for the audit log meta field.
|
||
func auditMeta(userID int64, cr *CodeRow, subID int64) string {
|
||
m := map[string]interface{}{
|
||
"plan": string(cr.PlanCode),
|
||
"duration_days": cr.DurationDays,
|
||
"batch_id": cr.BatchID,
|
||
"sub_id": subID,
|
||
}
|
||
b, _ := json.Marshal(m)
|
||
return string(b)
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Batch generation service
|
||
// --------------------------------------------------------------------------
|
||
|
||
// BatchRequest holds the parameters for a bulk code-generation job.
|
||
type BatchRequest struct {
|
||
PlanCode PlanCode
|
||
DurationDays int
|
||
Count int
|
||
Channel BatchChannel
|
||
Note string
|
||
CreatedBy string // e.g. "admin:1" or "cli"
|
||
}
|
||
|
||
// BatchResult contains the generated plaintext codes (only occurrence ever)
|
||
// and the batch metadata.
|
||
type BatchResult struct {
|
||
BatchID int64
|
||
Codes []string // plaintext canonical codes – NOT stored in DB
|
||
PlanCode PlanCode
|
||
DurationDays int
|
||
Channel BatchChannel
|
||
}
|
||
|
||
// CreateBatch generates Count activation codes via the shared library's Mint
|
||
// (all-or-nothing: batch + code hashes commit in one tx) and returns the
|
||
// plaintext codes — the only time they ever appear.
|
||
func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) {
|
||
// 未知 plan 先拒(行为等价:旧实现先查 GetPlanID)。
|
||
if _, err := svc.store.GetPlanID(ctx, req.PlanCode); err != nil {
|
||
return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err)
|
||
}
|
||
ent, err := libcodes.NewDurationEntitlement(string(req.PlanCode), req.DurationDays)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("CreateBatch: %w", err)
|
||
}
|
||
res, err := libcodes.Mint(ctx, svc.store.Lib(), libcodes.MintRequest{
|
||
Channel: string(req.Channel),
|
||
Entitlement: ent,
|
||
Count: req.Count,
|
||
CreatedBy: req.CreatedBy,
|
||
Note: req.Note,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("CreateBatch: %w", err)
|
||
}
|
||
return &BatchResult{
|
||
BatchID: res.BatchID,
|
||
Codes: res.Codes,
|
||
PlanCode: req.PlanCode,
|
||
DurationDays: req.DurationDays,
|
||
Channel: req.Channel,
|
||
}, nil
|
||
}
|