afcd7b325c
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>
138 lines
4.0 KiB
Go
138 lines
4.0 KiB
Go
// Command codegen is a CLI transition tool for batch-generating activation codes
|
|
// before the admin panel (task #8) is ready.
|
|
//
|
|
// Usage:
|
|
//
|
|
// codegen -plan=pro -days=30 -count=100 -channel=manual \
|
|
// -note="telegram batch june" -dsn="root:pass@tcp(127.0.0.1:3306)/pangolin?parseTime=true" \
|
|
// [-out=codes.csv]
|
|
//
|
|
// The generated plaintext codes are written to stdout (or -out) as a CSV file.
|
|
// They are NEVER logged or stored in the database.
|
|
// The database receives only the SHA-256 hashes.
|
|
//
|
|
// Run `codegen -help` for full flag documentation.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"pangolin/server/internal/codes"
|
|
"pangolin/server/internal/db"
|
|
)
|
|
|
|
func main() {
|
|
plan := flag.String("plan", "", "Plan code: free | pro | team (required)")
|
|
days := flag.Int("days", 0, "Duration in days the code grants (required, >0)")
|
|
count := flag.Int("count", 0, "Number of codes to generate (required, >0)")
|
|
channel := flag.String("channel", "manual", "Distribution channel: store | tg | line | manual")
|
|
note := flag.String("note", "", "Human-readable batch note (optional)")
|
|
dsn := flag.String("dsn", "", "MySQL DSN, e.g. user:pass@tcp(host:port)/dbname?parseTime=true (required, or set DB_DSN env)")
|
|
out := flag.String("out", "", "Output CSV file path (default: stdout)")
|
|
createdBy := flag.String("by", "cli", "Identifier of the operator creating this batch")
|
|
flag.Parse()
|
|
|
|
// Resolve DSN from flag or environment.
|
|
dsnVal := *dsn
|
|
if dsnVal == "" {
|
|
dsnVal = os.Getenv("DB_DSN")
|
|
}
|
|
|
|
// Validation.
|
|
if dsnVal == "" {
|
|
log.Fatal("codegen: -dsn or DB_DSN is required")
|
|
}
|
|
if *plan == "" {
|
|
log.Fatal("codegen: -plan is required")
|
|
}
|
|
if *days <= 0 {
|
|
log.Fatal("codegen: -days must be > 0")
|
|
}
|
|
if *count <= 0 {
|
|
log.Fatal("codegen: -count must be > 0")
|
|
}
|
|
|
|
planCode := codes.PlanCode(*plan)
|
|
switch planCode {
|
|
case codes.PlanFree, codes.PlanPro, codes.PlanTeam:
|
|
default:
|
|
log.Fatalf("codegen: unknown plan %q; must be free | pro | team", *plan)
|
|
}
|
|
|
|
ch := codes.BatchChannel(*channel)
|
|
switch ch {
|
|
case codes.ChannelStore, codes.ChannelTG, codes.ChannelLine, codes.ChannelManual:
|
|
default:
|
|
log.Fatalf("codegen: unknown channel %q; must be store | tg | line | manual", *channel)
|
|
}
|
|
|
|
// Open database.
|
|
dbConn, err := db.Open(dsnVal)
|
|
if err != nil {
|
|
log.Fatalf("codegen: database: %v", err)
|
|
}
|
|
defer dbConn.Close()
|
|
|
|
store := codes.NewStore(dbConn)
|
|
// Service is created without Redis (batch generation doesn't need rate limiting).
|
|
svc := codes.NewService(store, nil, 5, time.Hour)
|
|
|
|
log.Printf("codegen: generating %d %s/%dd codes for channel=%s …", *count, planCode, *days, ch)
|
|
|
|
result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
|
|
PlanCode: planCode,
|
|
DurationDays: *days,
|
|
Count: *count,
|
|
Channel: ch,
|
|
Note: *note,
|
|
CreatedBy: *createdBy,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("codegen: CreateBatch: %v", err)
|
|
}
|
|
|
|
log.Printf("codegen: batch %d created; writing CSV …", result.BatchID)
|
|
|
|
// Determine output writer.
|
|
output := os.Stdout
|
|
if *out != "" {
|
|
f, err := os.Create(*out)
|
|
if err != nil {
|
|
log.Fatalf("codegen: open output file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
output = f
|
|
}
|
|
|
|
csvRows := make([]codes.CSVRow, len(result.Codes))
|
|
now := time.Now().UTC()
|
|
for i, c := range result.Codes {
|
|
csvRows[i] = codes.CSVRow{
|
|
Index: i + 1,
|
|
Code: c,
|
|
Plan: planCode,
|
|
DurationDays: *days,
|
|
BatchID: result.BatchID,
|
|
Channel: ch,
|
|
GeneratedAt: now,
|
|
}
|
|
}
|
|
|
|
if err := codes.ExportCSV(output, csvRows); err != nil {
|
|
log.Fatalf("codegen: ExportCSV: %v", err)
|
|
}
|
|
|
|
if *out != "" {
|
|
fmt.Printf("codegen: %d codes written to %s\n", *count, *out)
|
|
fmt.Printf("codegen: ⚠️ Treat this file as a secret — it contains plaintext activation codes.\n")
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "codegen: %d codes written to stdout\n", *count)
|
|
fmt.Fprintf(os.Stderr, "codegen: ⚠️ Treat the output as a secret — it contains plaintext activation codes.\n")
|
|
}
|
|
}
|