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:
@@ -0,0 +1,75 @@
|
||||
package codes
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CSVRow represents one row in the export CSV.
|
||||
// The Code field is the only time the plaintext appears outside the
|
||||
// generation call; it must be streamed directly to the response writer
|
||||
// or written to an encrypted file and never logged.
|
||||
type CSVRow struct {
|
||||
Index int
|
||||
Code string // plaintext activation code (canonical form)
|
||||
Plan PlanCode
|
||||
DurationDays int
|
||||
BatchID int64
|
||||
Channel BatchChannel
|
||||
GeneratedAt time.Time
|
||||
}
|
||||
|
||||
// ExportCSV writes the given code rows to w in CSV format.
|
||||
// Columns: index, code, plan, duration_days, batch_id, channel, generated_at_utc
|
||||
//
|
||||
// The caller must ensure that w is the final output destination (HTTP response,
|
||||
// encrypted file, etc.) and that no intermediate buffer retains the data.
|
||||
func ExportCSV(w io.Writer, rows []CSVRow) error {
|
||||
cw := csv.NewWriter(w)
|
||||
|
||||
// Header row.
|
||||
if err := cw.Write([]string{
|
||||
"index", "code", "plan", "duration_days",
|
||||
"batch_id", "channel", "generated_at_utc",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
rec := []string{
|
||||
strconv.Itoa(r.Index),
|
||||
r.Code,
|
||||
string(r.Plan),
|
||||
strconv.Itoa(r.DurationDays),
|
||||
strconv.FormatInt(r.BatchID, 10),
|
||||
string(r.Channel),
|
||||
r.GeneratedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := cw.Write(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
// BatchResultToCSVRows converts a BatchResult to a slice of CSVRow for export.
|
||||
// generatedAt should be the time.Now() captured immediately after generation.
|
||||
func BatchResultToCSVRows(br *BatchResult, generatedAt time.Time) []CSVRow {
|
||||
rows := make([]CSVRow, len(br.Codes))
|
||||
for i, code := range br.Codes {
|
||||
rows[i] = CSVRow{
|
||||
Index: i + 1,
|
||||
Code: code,
|
||||
Plan: br.PlanCode,
|
||||
DurationDays: br.DurationDays,
|
||||
BatchID: br.BatchID,
|
||||
Channel: br.Channel,
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
Reference in New Issue
Block a user