feat(server): webhook 薄壳化——保留 X-Pangolin HMAC 协议,铸码走库单事务原语(#codes-lib)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = ¬e
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user