Files
pangolin/server/internal/codes/handler.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

96 lines
2.6 KiB
Go

package codes
import (
"encoding/json"
"net/http"
"pangolin/server/internal/apierr"
)
// RedeemHandler handles POST /v1/redeem requests.
// It expects the request context to carry the authenticated userID under the
// key ctxKeyUserID (set by the JWT auth middleware from the auth module).
type RedeemHandler struct {
svc *Service
}
// NewRedeemHandler creates a RedeemHandler backed by svc.
func NewRedeemHandler(svc *Service) *RedeemHandler {
return &RedeemHandler{svc: svc}
}
// ctxKeyUserID is the context key for the authenticated user ID.
// Defined here to avoid an import cycle; the auth middleware uses the same key.
type ctxKey string
const CtxKeyUserID ctxKey = "user_id"
// redeemRequest is the JSON body for POST /v1/redeem.
type redeemRequest struct {
Code string `json:"code"`
}
// redeemResponse is the JSON body on success.
type redeemResponse struct {
Idempotent bool `json:"idempotent"`
Plan string `json:"plan"`
DurationDays int `json:"duration_days"`
ExpiresAt string `json:"expires_at,omitempty"` // RFC 3339 UTC
}
// ServeHTTP implements http.Handler.
func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract authenticated userID from context (set by JWT middleware).
userID, ok := r.Context().Value(CtxKeyUserID).(int64)
if !ok || userID == 0 {
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
Code: "UNAUTHORIZED",
MessageZH: "请先登录",
MessageEn: "Authentication required",
})
return
}
var req redeemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
result, apiErr := h.svc.Redeem(r.Context(), RedeemRequest{
UserID: userID,
Code: req.Code,
})
if apiErr != nil {
status := http.StatusBadRequest
switch apiErr.Code {
case "ACCOUNT_LOCKED", "RATE_LIMITED":
status = http.StatusTooManyRequests
case "INTERNAL_ERROR":
status = http.StatusInternalServerError
case "CODE_REDEEMED", "CODE_NOT_FOUND", "CODE_VOID", "INVALID_CODE":
status = http.StatusBadRequest
}
apierr.WriteJSON(w, status, apiErr)
return
}
resp := redeemResponse{
Idempotent: result.Idempotent,
Plan: string(result.PlanCode),
DurationDays: result.DurationDays,
}
if !result.ExpiresAt.IsZero() {
resp.ExpiresAt = result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z")
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}