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>
This commit is contained in:
wangjia
2026-06-13 02:16:16 +08:00
parent a642bf16a2
commit afcd7b325c
17 changed files with 2750 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
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
}