Files
pangolin/server/internal/codes/service.go
T
wangjia 7370b17bd9 feat(server): codes.GrantPaidSubscriptionTx 提炼订阅叠加语义供 pay 复用
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
2026-07-10 20:49:25 +08:00

280 lines
8.9 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"
"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
}