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
+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")
}