feat(server): webhook 薄壳化——保留 X-Pangolin HMAC 协议,铸码走库单事务原语(#codes-lib)

This commit is contained in:
wangjia
2026-07-10 14:45:08 +08:00
parent 0822d22e2c
commit d1e2fed563
3 changed files with 78 additions and 76 deletions
@@ -3,9 +3,11 @@ package codes_test
import (
"context"
"database/sql"
"errors"
"testing"
"time"
libcodes "github.com/wangjia/codes"
"github.com/wangjia/pangolin/server/internal/codes"
)
@@ -113,6 +115,50 @@ func TestRedeemCreatesSubscription(t *testing.T) {
}
}
// TestWebhookMintsIntoLibTables verifies Store.MintOne (webhook 铸码核心):
// lands in the shared lib's new tables, entitlement is a duration payload,
// and a duplicate code_hash leaves no orphan batch row (the old
// two-statement webhook implementation leaked one batch per dupe delivery).
func TestWebhookMintsIntoLibTables(t *testing.T) {
ctx := context.Background()
db := openMigratedSQLite(t)
store := codes.NewStore(db)
plain, err := codes.GenerateCode()
if err != nil {
t.Fatal(err)
}
canonical := mustCanonical(t, plain)
ent, err := libcodesEnt("pro", 30)
if err != nil {
t.Fatal(err)
}
if err := store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n1"); err != nil {
t.Fatalf("MintOne: %v", err)
}
var batches, ccount int
if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 {
t.Fatalf("batches = %d (err=%v)", batches, err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE code_hash=?`, codes.Hash(canonical)).Scan(&ccount); err != nil || ccount != 1 {
t.Fatalf("codes = %d (err=%v)", ccount, err)
}
// 同 hash 再灌:ErrDuplicate 且 batch 数不变(无孤儿——旧实现的 I2 缺陷在此修复)。
err = store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n2")
if !errors.Is(err, codes.ErrDuplicate) {
t.Fatalf("err = %v, want ErrDuplicate", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 {
t.Fatalf("batches after dup = %d, want 1 (no orphan)", batches)
}
}
// libcodesEnt: 测试侧小工具,避免测试文件 import 库包名与宿主包名混淆。
func libcodesEnt(plan string, days int) (libcodes.Entitlement, error) {
return libcodes.NewDurationEntitlement(plan, days)
}
func mustCanonical(t *testing.T, code string) string {
t.Helper()
c, err := codes.Canonicalize(code)
+21 -64
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
libcodes "github.com/wangjia/codes"
@@ -84,46 +83,6 @@ func NewStore(db *sql.DB) *Store {
// Lib exposes the shared library store (used by Service / webhook shim).
func (s *Store) Lib() *libcodes.Store { return s.lib }
// --------------------------------------------------------------------------
// Batch and code creation (used by Service.CreateBatch and webhook)
// --------------------------------------------------------------------------
// CreateBatch inserts a new code_batches row and returns its ID.
func (s *Store) CreateBatch(ctx context.Context, channel BatchChannel, createdBy, note string) (int64, error) {
var notePtr *string
if note != "" {
notePtr = &note
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO code_batches (channel, created_by, note, created_at)
VALUES (?, ?, ?, ?)`,
string(channel), createdBy, notePtr, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("store.CreateBatch: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("store.CreateBatch last id: %w", err)
}
return id, nil
}
// CreateCode inserts a single codes row. It returns ErrDuplicate if a row
// with the same code_hash already exists (UNIQUE constraint violation).
func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, durationDays int, batchID int64) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id, status)
VALUES (?, ?, ?, ?, 'unused')`,
codeHash, planID, durationDays, batchID)
if err != nil {
if isDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("store.CreateCode: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Subscription helpers (used inside redeem transaction)
// --------------------------------------------------------------------------
@@ -255,17 +214,31 @@ func (s *Store) WriteAuditLog(ctx context.Context, tx *sql.Tx, actor, action, ta
}
// --------------------------------------------------------------------------
// Webhook-specific lookup
// Webhook ingestion (single externally-generated code per call)
// --------------------------------------------------------------------------
// CodeExistsByHash returns true if a codes row with the given hash already exists.
func (s *Store) CodeExistsByHash(ctx context.Context, hash string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
// MintOne writes one externally-generated code (card-store webhook ingestion)
// as one batch + one code row in a single transaction. A duplicate code_hash
// returns ErrDuplicate with NOTHING committed — no orphan batch (the old
// two-statement implementation leaked one batch row per duplicate delivery).
func (s *Store) MintOne(ctx context.Context, codeHash string, ent libcodes.Entitlement, channel BatchChannel, createdBy, note string) error {
tx, err := s.lib.BeginTx(ctx)
if err != nil {
return false, fmt.Errorf("store.CodeExistsByHash: %w", err)
return fmt.Errorf("store.MintOne: begin: %w", err)
}
return count > 0, nil
defer tx.Rollback()
batchID, err := s.lib.CreateBatchTx(ctx, tx, string(channel), ent, createdBy, note)
if err != nil {
return fmt.Errorf("store.MintOne: batch: %w", err)
}
if err := s.lib.CreateCodeTx(ctx, tx, codeHash, batchID, ent); err != nil {
return err // ErrDuplicate passes through; defer 回滚保证无孤儿
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store.MintOne: commit: %w", err)
}
return nil
}
// BeginTx starts a new transaction at Read Committed isolation level.
@@ -276,19 +249,3 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
// --------------------------------------------------------------------------
// helper
// --------------------------------------------------------------------------
// isDuplicateKey returns true if err is a MySQL duplicate-key error (1062).
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
// go-sql-driver wraps MySQL errors; the error number is accessible via
// the mysql.MySQLError type. We do a string match to avoid importing
// the mysql package here.
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") ||
strings.Contains(msg, "1062")
}
+11 -12
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/redis/go-redis/v9"
libcodes "github.com/wangjia/codes"
"github.com/wangjia/pangolin/server/internal/apierr"
)
@@ -132,25 +134,22 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
hash := Hash(canonical)
// --- Idempotent code insertion ---
// --- Idempotent code insertion (via shared lib primitives) ---
ctx := r.Context()
planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan)))
// 未知 plan 先拒(行为不变,旧 webhook.go:138-142)。
if _, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))); err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
ent, err := libcodes.NewDurationEntitlement(strings.ToLower(payload.Plan), payload.DurationDays)
if err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
// Create batch (one per webhook call; the store may aggregate externally).
batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note)
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID)
if err == ErrDuplicate {
// Same code_hash already exists idempotent, do not error.
err = h.store.MintOne(ctx, hash, ent, ChannelStore, "webhook", payload.Note)
if errors.Is(err, ErrDuplicate) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"})