feat(server): Redeem 换芯 GuardedRedeem——grant 回调同事务复刻订阅叠加+双审计(#codes-lib)

This commit is contained in:
wangjia
2026-07-10 14:34:30 +08:00
parent e128c96d22
commit 0822d22e2c
5 changed files with 321 additions and 324 deletions
+10 -55
View File
@@ -42,19 +42,6 @@ const (
ChannelManual BatchChannel = "manual"
)
// CodeRow mirrors the `codes` DB row (hash only; no plaintext).
type CodeRow struct {
ID int64
CodeHash string // SHA-256 hex of canonical plaintext
PlanID int64
PlanCode PlanCode
DurationDays int
BatchID int64
Status string // unused | redeemed | void
RedeemedBy sql.NullInt64
RedeemedAt sql.NullTime
}
// SubscriptionRow mirrors the `subscriptions` DB row.
type SubscriptionRow struct {
ID int64
@@ -137,48 +124,6 @@ func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, d
return nil
}
// --------------------------------------------------------------------------
// Redemption (used by Service.Redeem inside a transaction)
// --------------------------------------------------------------------------
// FindCodeByHashForUpdate looks up a codes row by its SHA-256 hash using
// SELECT … FOR UPDATE so that the row is locked for the duration of the
// surrounding transaction. It also fetches the plan code from the plans
// table in the same query.
func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash string) (*CodeRow, error) {
row := tx.QueryRowContext(ctx,
`SELECT c.id, c.code_hash, c.plan_id, p.code, c.duration_days,
c.batch_id, c.status, c.redeemed_by, c.redeemed_at
FROM codes c
JOIN plans p ON p.id = c.plan_id
WHERE c.code_hash = ?
`+s.dialect.LockForUpdate(),
hash)
var cr CodeRow
if err := row.Scan(
&cr.ID, &cr.CodeHash, &cr.PlanID, &cr.PlanCode, &cr.DurationDays,
&cr.BatchID, &cr.Status, &cr.RedeemedBy, &cr.RedeemedAt,
); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("store.FindCodeByHashForUpdate: %w", err)
}
return &cr, nil
}
// MarkRedeemed updates a codes row to status='redeemed' within tx.
func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, codeID, userID int64) error {
_, err := tx.ExecContext(ctx,
`UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=?
WHERE id=? AND status='unused'`,
userID, time.Now().UTC(), codeID)
if err != nil {
return fmt.Errorf("store.MarkRedeemed: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Subscription helpers (used inside redeem transaction)
// --------------------------------------------------------------------------
@@ -193,6 +138,16 @@ func (s *Store) GetPlanID(ctx context.Context, code PlanCode) (int64, error) {
return id, nil
}
// GetPlanIDTx is GetPlanID inside a transaction (grant callback runs inside
// the redeem tx and must not touch the pool).
func (s *Store) GetPlanIDTx(ctx context.Context, tx *sql.Tx, code PlanCode) (int64, error) {
var id int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, string(code)).Scan(&id); err != nil {
return 0, fmt.Errorf("store.GetPlanIDTx(%s): %w", code, err)
}
return id, nil
}
// GetActiveSubscriptions returns all non-expired subscriptions for userID,
// ordered by expires_at DESC. Called inside the redeem transaction.
func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) {