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
+178
View File
@@ -0,0 +1,178 @@
// Package codes implements the activation-code lifecycle:
// batch generation, card-store webhook ingestion, idempotent redemption,
// subscription extension, and CSV export.
//
// Security invariant: plaintext codes are NEVER written to the database or
// to any log. The database stores only SHA-256(canonical_plaintext).
// Plaintext appears exactly once: in the batch-generation response / CSV export.
package codes
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// Crockford Base32 encoding alphabet (32 symbols, excludes I L O U).
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// crockfordCheck is the extended 37-symbol check-character alphabet.
// Symbols 031 are identical to crockfordAlphabet; 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), or to -1 if the character is not valid.
var crockfordDecode [128]int8
func init() {
for i := range crockfordDecode {
crockfordDecode[i] = -1
}
for i, ch := range crockfordAlphabet {
crockfordDecode[ch] = int8(i)
// Lower-case equivalents.
if ch >= 'A' && ch <= 'Z' {
crockfordDecode[ch-'A'+'a'] = int8(i)
}
}
// Normalisation rules per Crockford spec:
// I, i, l, L → 1
// O, o → 0
crockfordDecode['I'] = crockfordDecode['1']
crockfordDecode['i'] = crockfordDecode['1']
crockfordDecode['l'] = crockfordDecode['1']
crockfordDecode['L'] = crockfordDecode['1']
crockfordDecode['O'] = crockfordDecode['0']
crockfordDecode['o'] = crockfordDecode['0']
}
// Canonicalize converts an activation-code string into canonical form:
// uppercase, with I/L→1 and O→0 substitutions applied.
// Returns an error if the string contains characters outside the normalised
// Crockford alphabet or if the length is not exactly 16.
func Canonicalize(code string) (string, error) {
code = strings.TrimSpace(code)
// Strip any hyphens/spaces inserted for readability (e.g., XXXX-XXXX-XXXX-XXXX).
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
if len(code) != 16 {
return "", fmt.Errorf("codes: code must be exactly 16 characters, got %d", len(code))
}
var buf [16]byte
for i := 0; i < 16; i++ {
ch := code[i]
if ch >= 128 {
return "", fmt.Errorf("codes: non-ASCII character at position %d", i)
}
v := crockfordDecode[ch]
if v < 0 {
// For the check symbol position (last char) we allow the full 37-symbol set.
// Check separately below after we have the string.
if i < 15 {
return "", fmt.Errorf("codes: invalid character %q at position %d", ch, i)
}
// Position 15 is the check character; validate separately.
buf[i] = byte(strings.ToUpper(string(ch))[0])
continue
}
buf[i] = crockfordAlphabet[v]
}
canonical := string(buf[:])
// Validate the check character.
if err := validateCheckChar(canonical); err != nil {
return "", err
}
return canonical, nil
}
// Hash returns the hex-encoded SHA-256 of the canonical plaintext code.
// This is the value stored in the database; the plaintext is never stored.
func Hash(canonical string) string {
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
// computeCheckValue uses Horner's method to compute the Crockford check value
// (mod 37) of the 15 data characters in s[0:15].
// s must already be in canonical (uppercase) form.
func computeCheckValue(s string) int {
result := 0
for i := 0; i < 15; i++ {
ch := s[i]
if ch >= 128 {
return -1
}
v := int(crockfordDecode[ch])
if v < 0 {
return -1
}
result = (result*32 + v) % 37
}
return result
}
// validateCheckChar verifies that the 16th character of canonical is the
// correct Crockford mod-37 check symbol.
func validateCheckChar(canonical string) error {
if len(canonical) != 16 {
return errors.New("codes: invalid length for check validation")
}
expected := computeCheckValue(canonical)
if expected < 0 {
return errors.New("codes: invalid data characters")
}
want := rune(crockfordCheck[expected])
got := rune(canonical[15])
// The check character might be in the extended set (*~$=U) so compare directly.
if got != want {
return fmt.Errorf("codes: check character mismatch: want %c, got %c", want, got)
}
return nil
}
// GenerateCode generates a single random activation code in canonical Crockford
// Base32 form (15 random data chars + 1 check char = 16 chars total).
// Uses crypto/rand for cryptographically-secure randomness.
func GenerateCode() (string, error) {
// We need 15 random values each in [0, 32).
// To avoid modular bias, use rejection sampling with bytes from crypto/rand.
// Each byte gives a value in [0, 256); we accept values in [0, 224) to ensure
// uniform distribution over [0, 32) (224 = 7*32).
const dataLen = 15
var buf [dataLen]byte
i := 0
for i < dataLen {
var tmp [dataLen * 2]byte // over-read to reduce syscall count
if _, err := rand.Read(tmp[:]); err != nil {
return "", fmt.Errorf("codes: crypto/rand: %w", err)
}
for _, b := range tmp {
if b < 224 { // 224 = 7*32; accept to avoid bias
buf[i] = crockfordAlphabet[b%32]
i++
if i == dataLen {
break
}
}
}
}
data := string(buf[:])
checkVal := computeCheckValue(data + "0") // dummy 16th char, only first 15 used
if checkVal < 0 {
// Should never happen since we only use valid chars.
return "", errors.New("codes: internal check computation error")
}
return data + string(crockfordCheck[checkVal]), nil
}
// ErrDuplicate is returned by the batch generator when a generated code
// already exists in the database (hash collision). The caller should retry.
var ErrDuplicate = errors.New("codes: duplicate code hash")