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