feat(server/db): 多库(3/4)— Dialect 抽象(upsert/锁)+ 合并 directory_version
- db.Dialect:LockForUpdate(mysql "FOR UPDATE" / sqlite "")、Upsert(中性 EXCLUDED.col → mysql VALUES()/ sqlite excluded.);DialectForDB 从连接驱动推导 - 9 处 ON DUPLICATE KEY、11 处 FOR UPDATE 全走 dialect;sqlite 靠 _txlock= immediate 取得 BEGIN IMMEDIATE 悲观锁等价语义 - directory_version 三处重复合并为 store.BumpDirectoryVersion(dialect 感知) - 这些文件同时含(2/4)的 UTC→Go 改动(与 upsert/锁同语句交错,无法拆分) - 顺带:usage 的 FIELD()、codes 的 DATE_ADD/GREATEST 续期、nodes 的 UNIX_TIMESTAMP、NULLIF 等 MySQL 专属构造一并退回 Go/可移植写法 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbx "github.com/wangjia/pangolin/server/internal/db"
|
||||
)
|
||||
|
||||
// PlanCode represents a plan tier.
|
||||
@@ -76,11 +78,12 @@ type BatchRow struct {
|
||||
// outside an explicit transaction; methods that accept a *sql.Tx run within
|
||||
// that transaction.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
dialect dbx.Dialect
|
||||
}
|
||||
|
||||
// NewStore creates a Store backed by the given MySQL connection pool.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
// NewStore creates a Store backed by the given connection pool (MySQL or SQLite).
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} }
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Batch and code creation (used by Service.CreateBatch and webhook)
|
||||
@@ -88,10 +91,14 @@ func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// 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 (?, ?, NULLIF(?, ''), UTC_TIMESTAMP(6))`,
|
||||
string(channel), createdBy, note)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
string(channel), createdBy, notePtr, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.CreateBatch: %w", err)
|
||||
}
|
||||
@@ -133,7 +140,7 @@ func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash st
|
||||
FROM codes c
|
||||
JOIN plans p ON p.id = c.plan_id
|
||||
WHERE c.code_hash = ?
|
||||
FOR UPDATE`,
|
||||
`+s.dialect.LockForUpdate(),
|
||||
hash)
|
||||
|
||||
var cr CodeRow
|
||||
@@ -151,9 +158,9 @@ func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash st
|
||||
// MarkRedeemed updates a codes row to status='redeemed' within tx.
|
||||
func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, codeID, userID int64) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=UTC_TIMESTAMP(6)
|
||||
`UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=?
|
||||
WHERE id=? AND status='unused'`,
|
||||
userID, codeID)
|
||||
userID, time.Now().UTC(), codeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.MarkRedeemed: %w", err)
|
||||
}
|
||||
@@ -181,9 +188,9 @@ func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID i
|
||||
`SELECT s.id, s.user_id, s.plan_id, p.code, s.expires_at, s.source
|
||||
FROM subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id=? AND s.expires_at > UTC_TIMESTAMP(6)
|
||||
WHERE s.user_id=? AND s.expires_at > ?
|
||||
ORDER BY s.expires_at DESC`,
|
||||
userID)
|
||||
userID, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetActiveSubscriptions: %w", err)
|
||||
}
|
||||
@@ -201,15 +208,24 @@ func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID i
|
||||
}
|
||||
|
||||
// ExtendSubscription sets expires_at to max(current_expires_at, now) + duration.
|
||||
// Called inside the redeem transaction.
|
||||
// Called inside the redeem transaction. The base date is computed in Go (the
|
||||
// subscription row is read inside the locked redeem tx, then updated) — portable
|
||||
// across engines and avoids DB-side DATE_ADD/GREATEST.
|
||||
func (s *Store) ExtendSubscription(ctx context.Context, tx *sql.Tx, subID int64, durationDays int) error {
|
||||
var current time.Time
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM subscriptions WHERE id=?`, subID).Scan(¤t); err != nil {
|
||||
return fmt.Errorf("store.ExtendSubscription read: %w", err)
|
||||
}
|
||||
base := time.Now().UTC()
|
||||
if current.After(base) {
|
||||
base = current.UTC()
|
||||
}
|
||||
newExpiry := base.AddDate(0, 0, durationDays)
|
||||
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE subscriptions
|
||||
SET expires_at = DATE_ADD(
|
||||
GREATEST(expires_at, UTC_TIMESTAMP(6)),
|
||||
INTERVAL ? DAY)
|
||||
WHERE id=?`,
|
||||
durationDays, subID)
|
||||
`UPDATE subscriptions SET expires_at = ? WHERE id=?`,
|
||||
newExpiry, subID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.ExtendSubscription: %w", err)
|
||||
}
|
||||
@@ -235,8 +251,8 @@ func (s *Store) CreateSubscription(
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
|
||||
VALUES (?, ?, ?, 'code', UTC_TIMESTAMP(6))`,
|
||||
userID, planID, expiresAt)
|
||||
VALUES (?, ?, ?, 'code', ?)`,
|
||||
userID, planID, expiresAt, now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.CreateSubscription: %w", err)
|
||||
}
|
||||
@@ -255,14 +271,15 @@ func (s *Store) WriteAuditLog(ctx context.Context, tx *sql.Tx, actor, action, ta
|
||||
if metaJSON == "" {
|
||||
metaJSON = "null"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
|
||||
actor, action, target, metaJSON)
|
||||
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`,
|
||||
actor, action, target, metaJSON, now)
|
||||
} else {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
|
||||
actor, action, target, metaJSON)
|
||||
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`,
|
||||
actor, action, target, metaJSON, now)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.WriteAuditLog: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user