Files
pangolin/server/internal/codes/service.go
T
wangjia afcd7b325c feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)
Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 02:16:16 +08:00

384 lines
11 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"
"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, writes the batch + code hashes
// to MySQL, and returns the plaintext codes. The plaintext codes are the ONLY
// time they ever appear; the caller is responsible for delivering them securely
// (e.g., streaming to CSV without logging).
//
// Duplicate-hash retries (birthday collision, astronomically unlikely) are
// handled automatically up to maxRetries per slot.
func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) {
const maxRetries = 10
// Look up plan ID.
planID, err := svc.store.GetPlanID(ctx, req.PlanCode)
if err != nil {
return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err)
}
// Insert the batch record.
batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note)
if err != nil {
return nil, fmt.Errorf("CreateBatch: %w", err)
}
plaintexts := make([]string, 0, req.Count)
for i := 0; i < req.Count; i++ {
var code string
var h string
ok := false
for attempt := 0; attempt < maxRetries; attempt++ {
c, err := GenerateCode()
if err != nil {
return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err)
}
h = Hash(c)
err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID)
if err == ErrDuplicate {
continue // collision generate a fresh code
}
if err != nil {
return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err)
}
code = c
ok = true
break
}
if !ok {
return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i)
}
plaintexts = append(plaintexts, code)
}
return &BatchResult{
BatchID: batchID,
Codes: plaintexts,
PlanCode: req.PlanCode,
DurationDays: req.DurationDays,
Channel: req.Channel,
}, nil
}