merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块

解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server
(codes 分支原用 pangolin/server,7 个源文件 import 已改写);
go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入
build-codegen/test-unit/test-integration target。
go build/vet 通过,codes 单测通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:03:11 +08:00
18 changed files with 2851 additions and 20 deletions
+586
View File
@@ -0,0 +1,586 @@
//go:build integration
package codes_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
// MySQL driver registration.
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/wangjia/pangolin/server/internal/codes"
)
// --------------------------------------------------------------------------
// Container setup helpers
// --------------------------------------------------------------------------
func setupMySQL(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
ctr, err := tcmysql.Run(ctx, "mysql:8.0",
tcmysql.WithDatabase("pangolin_test"),
tcmysql.WithUsername("root"),
tcmysql.WithPassword("test"),
)
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("mysql container: %v", err)
}
dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
if err != nil {
t.Fatalf("mysql dsn: %v", err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open mysql: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := applySchema(db); err != nil {
t.Fatalf("schema: %v", err)
}
return db
}
func setupRedis(t *testing.T) *redis.Client {
t.Helper()
ctx := context.Background()
ctr, err := tcredis.Run(ctx, "redis:7-alpine")
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("redis container: %v", err)
}
addr, err := ctr.ConnectionString(ctx)
if err != nil {
t.Fatalf("redis addr: %v", err)
}
// Remove the redis:// scheme prefix if present.
addr = stripScheme(addr)
rdb := redis.NewClient(&redis.Options{Addr: addr})
t.Cleanup(func() { rdb.Close() })
return rdb
}
func stripScheme(s string) string {
for _, prefix := range []string{"redis://", "rediss://"} {
if len(s) > len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
}
return s
}
// applySchema runs the minimum DDL required by the codes package.
func applySchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code ENUM('free','pro','team') NOT NULL UNIQUE,
max_devices INT NOT NULL DEFAULT 1,
daily_minutes INT NULL,
ad_gate BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS code_batches (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel ENUM('store','tg','line','manual') NOT NULL,
created_by VARCHAR(64) NOT NULL,
note VARCHAR(255) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code_hash CHAR(64) NOT NULL UNIQUE,
plan_id BIGINT UNSIGNED NOT NULL,
duration_days INT NOT NULL,
batch_id BIGINT UNSIGNED NOT NULL,
status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
redeemed_by BIGINT UNSIGNED NULL,
redeemed_at DATETIME(6) NULL,
FOREIGN KEY (plan_id) REFERENCES plans(id),
FOREIGN KEY (batch_id) REFERENCES code_batches(id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
plan_id BIGINT UNSIGNED NOT NULL,
expires_at DATETIME(6) NOT NULL,
source ENUM('trial','code') NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
FOREIGN KEY (plan_id) REFERENCES plans(id),
INDEX idx_user_exp (user_id, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL,
meta JSON NULL,
at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
// Seed plan rows.
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
}
}
return nil
}
// insertCode inserts a test code directly into the DB and returns it.
func insertCode(t *testing.T, db *sql.DB, plaintext string, plan codes.PlanCode, durationDays int) {
t.Helper()
canonical, err := codes.Canonicalize(plaintext)
if err != nil {
t.Fatalf("Canonicalize: %v", err)
}
hash := codes.Hash(canonical)
var planID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, string(plan)).Scan(&planID); err != nil {
t.Fatalf("plan lookup: %v", err)
}
_, err = db.Exec(
`INSERT INTO code_batches (channel, created_by, created_at) VALUES ('manual','test',UTC_TIMESTAMP(6))`,
)
if err != nil {
t.Fatalf("batch insert: %v", err)
}
var batchID int64
db.QueryRow(`SELECT LAST_INSERT_ID()`).Scan(&batchID)
_, err = db.Exec(
`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id) VALUES (?,?,?,?)`,
hash, planID, durationDays, batchID,
)
if err != nil {
t.Fatalf("code insert: %v", err)
}
}
// --------------------------------------------------------------------------
// Integration tests
// --------------------------------------------------------------------------
// TestRedeemConcurrentSingleWinner verifies that when N goroutines try to
// redeem the same code simultaneously, exactly 1 succeeds and N-1 fail.
func TestRedeemConcurrentSingleWinner(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const N = 20
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
results := make([]bool, N) // true = success
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
userID := int64(1000 + idx) // different user for each goroutine
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
results[idx] = (apiErr == nil)
}(i)
}
wg.Wait()
winners := 0
for _, ok := range results {
if ok {
winners++
}
}
if winners != 1 {
t.Errorf("expected exactly 1 winner, got %d", winners)
}
}
// TestRedeemIdempotent verifies that the same user re-submitting the same
// code gets a 200 with Idempotent=true.
func TestRedeemIdempotent(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
const userID = int64(2001)
// First redemption.
r1, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
if apiErr != nil {
t.Fatalf("first redeem failed: %v", apiErr)
}
if r1.Idempotent {
t.Error("first redeem should not be idempotent")
}
// Second redemption same user, same code.
r2, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
if apiErr2 != nil {
t.Fatalf("second redeem failed: %v", apiErr2)
}
if !r2.Idempotent {
t.Error("second redeem should be idempotent")
}
}
// TestRedeemOtherUserFails verifies that a different user trying to redeem an
// already-redeemed code gets CODE_REDEEMED and that the failure is counted.
func TestRedeemOtherUserFails(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
// User A redeems.
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3001, Code: code})
if apiErr != nil {
t.Fatalf("user A redeem: %v", apiErr)
}
// User B tries the same code.
_, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3002, Code: code})
if apiErr2 == nil {
t.Fatal("user B should have failed")
}
if apiErr2.Code != "CODE_REDEEMED" {
t.Errorf("expected CODE_REDEEMED, got %s", apiErr2.Code)
}
}
// TestRedeemFailLock verifies that 5 consecutive failures lock the account for
// 1 hour and subsequent attempts return ACCOUNT_LOCKED.
func TestRedeemFailLock(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
// Use a very short lock for the test (we won't wait).
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(4001)
// Attempt 5 redemptions with invalid codes.
for i := 0; i < 5; i++ {
fakeCode, _ := codes.GenerateCode() // valid format but not in DB
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: fakeCode,
})
if apiErr == nil {
t.Fatalf("attempt %d: expected failure", i+1)
}
if apiErr.Code == "ACCOUNT_LOCKED" {
t.Fatalf("locked too early at attempt %d", i+1)
}
}
// 6th attempt should return ACCOUNT_LOCKED.
fakeCode, _ := codes.GenerateCode()
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: fakeCode,
})
if apiErr == nil || apiErr.Code != "ACCOUNT_LOCKED" {
t.Errorf("expected ACCOUNT_LOCKED, got %v", apiErr)
}
}
// TestSamePlanExtension verifies that redeeming a pro code when the user
// already has a pro subscription extends the existing subscription.
func TestSamePlanExtension(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(5001)
// Pre-insert a pro subscription expiring in 10 days.
var planID int64
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&planID)
initial := time.Now().UTC().AddDate(0, 0, 10)
db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,'trial')`,
userID, planID, initial,
)
// Redeem a 30-day pro code.
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
// expires_at should be initial + 30 days (since initial > now).
wantExpiry := initial.AddDate(0, 0, 30)
diff := result.ExpiresAt.Sub(wantExpiry)
if diff < -2*time.Second || diff > 2*time.Second {
t.Errorf("expires_at = %v, want ≈%v (diff=%v)", result.ExpiresAt, wantExpiry, diff)
}
}
// TestCrossPlanNewSubscription verifies that redeeming a team code when the
// user only has a pro subscription creates a new team subscription.
func TestCrossPlanNewSubscription(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(6001)
// Pre-insert a pro subscription.
var proID int64
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID)
db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,'2099-01-01','trial')`,
userID, proID,
)
// Redeem a 30-day team code.
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanTeam, 30)
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
// Should have created a new team subscription starting from now + 30 days.
wantMin := time.Now().UTC().AddDate(0, 0, 29)
wantMax := time.Now().UTC().AddDate(0, 0, 31)
if result.ExpiresAt.Before(wantMin) || result.ExpiresAt.After(wantMax) {
t.Errorf("team expires_at = %v, want within [%v, %v]", result.ExpiresAt, wantMin, wantMax)
}
// Confirm the new row is a team subscription.
var count int
var teamID int64
db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID)
db.QueryRow(
`SELECT COUNT(1) FROM subscriptions WHERE user_id=? AND plan_id=? AND source='code'`,
userID, teamID,
).Scan(&count)
if count != 1 {
t.Errorf("expected 1 new team subscription, got %d", count)
}
}
// TestAuditLogWritten verifies that every successful redemption produces an
// audit_log row.
func TestAuditLogWritten(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(7001)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
var count int
db.QueryRow(
`SELECT COUNT(1) FROM audit_log WHERE action='redeem' AND actor=?`,
fmt.Sprintf("user:%d", userID),
).Scan(&count)
if count != 1 {
t.Errorf("expected 1 audit_log row, got %d", count)
}
}
// TestCSVExportNoPlaintextInDB verifies that after a batch generation and
// CSV export, no plaintext code is stored in the database.
func TestCSVExportNoPlaintextInDB(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
PlanCode: codes.PlanPro,
DurationDays: 30,
Count: 10,
Channel: codes.ChannelManual,
Note: "integration test",
CreatedBy: "test",
})
if err != nil {
t.Fatalf("CreateBatch: %v", err)
}
if len(result.Codes) != 10 {
t.Fatalf("expected 10 codes, got %d", len(result.Codes))
}
// Write CSV.
var buf bytes.Buffer
rows := codes.BatchResultToCSVRows(result, time.Now())
if err := codes.ExportCSV(&buf, rows); err != nil {
t.Fatalf("ExportCSV: %v", err)
}
// Verify no plaintext appears in the codes table.
for _, c := range result.Codes {
var count int
// The table stores only code_hash; search for the plaintext as a string.
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, c).Scan(&count)
if count > 0 {
t.Errorf("plaintext code %s found in codes.code_hash column!", c)
}
// Also verify the hash IS present.
h := codes.Hash(c)
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, h).Scan(&count)
if count != 1 {
t.Errorf("hash for code %s not found in DB", c)
}
}
}
// TestWebhookNonceReplay verifies that replaying a webhook nonce is rejected
// with status 200 (duplicate_ignored) and does NOT create a second DB row.
func TestWebhookNonceReplay(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
secret := "integration-secret"
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
nonce := fmt.Sprintf("unique-nonce-%d", time.Now().UnixNano())
makeReq := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", nonce)
return r
}
// First request: should succeed with 201.
w1 := httptest.NewRecorder()
h.ServeHTTP(w1, makeReq())
if w1.Code != http.StatusCreated {
t.Errorf("first request: expected 201, got %d body=%s", w1.Code, w1.Body.String())
}
// Second request with same nonce: should return 200 duplicate_ignored.
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, makeReq())
if w2.Code != http.StatusOK {
t.Errorf("replay request: expected 200, got %d body=%s", w2.Code, w2.Body.String())
}
// Only one row should exist in codes.
hash := codes.Hash(code)
var count int
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
if count != 1 {
t.Errorf("expected 1 code row, got %d", count)
}
}
// TestWebhookSameHashIdempotent verifies that pushing the same code_hash twice
// (different nonce) is idempotent: no duplicate row is created.
func TestWebhookSameHashIdempotent(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
secret := "integration-secret-2"
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
sendReq := func(nonce string) int {
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", nonce)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
status1 := sendReq(fmt.Sprintf("nonce-a-%d", time.Now().UnixNano()))
if status1 != http.StatusCreated {
t.Fatalf("first request: expected 201, got %d", status1)
}
status2 := sendReq(fmt.Sprintf("nonce-b-%d", time.Now().UnixNano()))
if status2 != http.StatusOK {
t.Fatalf("second (same hash) request: expected 200, got %d", status2)
}
// Still only one row.
hash := codes.Hash(code)
var count int
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
if count != 1 {
t.Errorf("expected 1 code row after idempotent push, got %d", count)
}
}