Files
pangolin/server/internal/codes/service.go
T
wangjia e128c96d22 feat(server): CreateBatch/生成器切换到 codes 库(Mint 单事务全成或全无)(#codes-lib)
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 删除)。
2026-07-10 14:24:18 +08:00

356 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}