7370b17bd9
CreateSubscription/applySubscription 加 source 参数(兑换路径传 "code" 零行为变化);新导出 GrantPaidSubscriptionTx(ctx, tx, ...) 供 pay 侧在 调用方事务内以 source='pay' 复用同一段叠加语义(同 plan 活跃订阅原地 延长,否则新建行),写 pay_grant 审计。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
package codes
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
libcodes "github.com/wangjia/codes"
|
||
"github.com/wangjia/codes/redisx"
|
||
"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 on top of the shared codes lib.
|
||
type Service struct {
|
||
store *Store
|
||
limiter libcodes.RateLimiter // nil → GuardedRedeem 内部 Noop(cmd/codegen 传 rdb=nil 的场景)
|
||
}
|
||
|
||
// NewService creates a Service. failMax/lockDur mirror the old per-user
|
||
// redeem lockout (5 次失败锁 1 小时), now backed by codes/redisx.
|
||
func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
|
||
var limiter libcodes.RateLimiter
|
||
if rdb != nil {
|
||
limiter = redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur)
|
||
}
|
||
return &Service{store: store, limiter: limiter}
|
||
}
|
||
|
||
// grantOutcome is the host-side grant result threaded through Redeem[T].
|
||
type grantOutcome struct {
|
||
subID int64
|
||
expiresAt time.Time
|
||
}
|
||
|
||
// Redeem processes a redemption request through the shared codes library's
|
||
// GuardedRedeem (rate-limit + code state-machine + idempotency), with a host
|
||
// grant callback that extends/creates the pangolin subscription and writes
|
||
// the legacy audit_log row inside the SAME transaction as the code flip.
|
||
func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
|
||
ref := "user:" + strconv.FormatInt(req.UserID, 10)
|
||
|
||
// 1. 锁检查先于格式检查(行为等价:旧 service.go:118-124 顺序)。
|
||
if svc.limiter != nil {
|
||
allowed, err := svc.limiter.Allowed(ctx, ref)
|
||
if err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
if !allowed {
|
||
return nil, apierr.ErrLocked
|
||
}
|
||
}
|
||
|
||
// 2. Canonicalize;格式错也计一次失败(旧 service.go:127-131)。
|
||
canonical, cerr := Canonicalize(req.Code)
|
||
if cerr != nil {
|
||
if svc.limiter != nil {
|
||
_ = svc.limiter.RecordFailure(ctx, ref)
|
||
}
|
||
return nil, apierr.ErrInvalidCode
|
||
}
|
||
|
||
// 3. 宿主事务:码状态翻转 + 订阅授予 + 双审计,一个 tx 原子提交。
|
||
tx, err := svc.store.BeginTx(ctx)
|
||
if err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
res, err := libcodes.GuardedRedeem(ctx, svc.store.Lib(), svc.limiter, tx,
|
||
Hash(canonical), ref, svc.grantSubscription(req.UserID))
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, libcodes.ErrLocked):
|
||
return nil, apierr.ErrLocked
|
||
case errors.Is(err, libcodes.ErrCodeNotFound):
|
||
return nil, apierr.ErrCodeNotFound
|
||
case errors.Is(err, libcodes.ErrCodeRedeemed):
|
||
return nil, apierr.ErrCodeRedeemed
|
||
case errors.Is(err, libcodes.ErrCodeVoid):
|
||
return nil, apierr.ErrCodeVoid
|
||
default:
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
}
|
||
|
||
dur, derr := res.Code.Entitlement.DecodeDuration()
|
||
if derr != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
|
||
if res.Idempotent {
|
||
// 只读路径:回滚(旧 service.go:162-171 同款),ExpiresAt 留空由 /v1/me 兜底。
|
||
_ = tx.Rollback()
|
||
committed = true
|
||
return &RedeemResult{
|
||
Idempotent: true,
|
||
PlanCode: PlanCode(dur.Plan),
|
||
DurationDays: dur.Days,
|
||
}, nil
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, apierr.ErrInternal
|
||
}
|
||
committed = true
|
||
|
||
return &RedeemResult{
|
||
Idempotent: false,
|
||
PlanCode: PlanCode(dur.Plan),
|
||
DurationDays: dur.Days,
|
||
ExpiresAt: res.Grant.expiresAt,
|
||
SubscriptionID: res.Grant.subID,
|
||
}, nil
|
||
}
|
||
|
||
// grantSubscription returns the GrantFunc executed by the lib INSIDE the same
|
||
// tx as the code flip. It reproduces the old applySubscription semantics
|
||
// byte-for-byte (service.go:235-287 pre-migration) and writes the legacy
|
||
// audit_log row the admin audit page reads (non-fatal, old behavior).
|
||
func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutcome] {
|
||
return func(ctx context.Context, tx *sql.Tx, code libcodes.Code) (grantOutcome, error) {
|
||
dur, err := code.Entitlement.DecodeDuration()
|
||
if err != nil {
|
||
return grantOutcome{}, err
|
||
}
|
||
planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanCode(dur.Plan))
|
||
if err != nil {
|
||
return grantOutcome{}, err
|
||
}
|
||
subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, dur.Days, "code")
|
||
if err != nil {
|
||
return grantOutcome{}, err
|
||
}
|
||
|
||
// 旧式审计(数据源:admin/store.go:188,197 读 audit_log);失败不中断。
|
||
meta, _ := json.Marshal(map[string]any{
|
||
"plan": dur.Plan,
|
||
"duration_days": dur.Days,
|
||
"batch_id": code.BatchID,
|
||
"sub_id": subID,
|
||
})
|
||
_ = svc.store.WriteAuditLog(ctx, tx,
|
||
fmt.Sprintf("user:%d", userID), "redeem",
|
||
"code_hash:"+code.CodeHash[:16]+"...", string(meta))
|
||
|
||
return grantOutcome{subID: subID, expiresAt: expiresAt}, nil
|
||
}
|
||
}
|
||
|
||
// applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days
|
||
// 原地延长;否则新建一行 max(now, latestSamePlan)+days,source 记录新建行的
|
||
// 创建者("code"=兑换码,"pay"=支付)。算法体与迁移前完全一致,仅签名从
|
||
// (*CodeRow, *apierr.Error) 改为 (planID, days, source, error)。
|
||
// ExtendSubscription 路径不看 source:延长的是既有行,行上的 source 仍是
|
||
// 该行最初创建者,不因后续延长(不论来源)而改写——台账/审计保留完整出处。
|
||
func (svc *Service) applySubscription(
|
||
ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, source string,
|
||
) (int64, time.Time, error) {
|
||
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
|
||
if err != nil {
|
||
return 0, time.Time{}, err
|
||
}
|
||
var samePlanSub *SubscriptionRow
|
||
var latestSamePlan time.Time
|
||
for i := range subs {
|
||
if subs[i].PlanID == 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 {
|
||
if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, durationDays); err != nil {
|
||
return 0, time.Time{}, err
|
||
}
|
||
base := samePlanSub.ExpiresAt
|
||
if now.After(base) {
|
||
base = now
|
||
}
|
||
return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil
|
||
}
|
||
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan, source)
|
||
if err != nil {
|
||
return 0, time.Time{}, err
|
||
}
|
||
base := now
|
||
if latestSamePlan.After(now) {
|
||
base = latestSamePlan
|
||
}
|
||
return newSubID, base.AddDate(0, 0, durationDays), nil
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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
|
||
}
|