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:
wangjia
2026-06-18 00:01:26 +08:00
parent ece51d7e3d
commit 86b67c557b
9 changed files with 265 additions and 113 deletions
+20 -15
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"fmt"
"time"
dbx "github.com/wangjia/pangolin/server/internal/db"
)
// Unlimited is the sentinel returned by CheckFreeConnect for plans with no
@@ -37,11 +39,12 @@ type DailyUsage struct {
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
// usage package needs.
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)} }
// AggregateUsage accumulates one usage delta into usage_daily for (userID, day)
// using INSERT … ON DUPLICATE KEY UPDATE so concurrent reports from multiple
@@ -51,10 +54,10 @@ func (s *Store) AggregateUsage(ctx context.Context, userID int64, day time.Time,
_, err := s.db.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
bytes_up = bytes_up + VALUES(bytes_up),
bytes_down = bytes_down + VALUES(bytes_down),
minutes_used = minutes_used + VALUES(minutes_used)`,
`+s.dialect.Upsert([]string{"user_id", "date"},
"bytes_up = bytes_up + EXCLUDED.bytes_up",
"bytes_down = bytes_down + EXCLUDED.bytes_down",
"minutes_used = minutes_used + EXCLUDED.minutes_used"),
userID, d, bytesUp, bytesDown, minutes)
if err != nil {
return fmt.Errorf("store.AggregateUsage: %w", err)
@@ -128,6 +131,7 @@ func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*Daily
// SELECT … FOR UPDATE to be safe under concurrent unlock attempts.
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
d := day.UTC().Format(dateLayout)
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
@@ -142,7 +146,7 @@ func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time)
var unlockedAt sql.NullTime
row := tx.QueryRowContext(ctx,
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? FOR UPDATE`,
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(),
userID, d)
switch err := row.Scan(&unlockedAt); err {
case nil:
@@ -155,16 +159,16 @@ func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time)
return true, nil
}
if _, uErr := tx.ExecContext(ctx,
`UPDATE usage_daily SET ad_unlocked_at = UTC_TIMESTAMP(6)
`UPDATE usage_daily SET ad_unlocked_at = ?
WHERE user_id = ? AND date = ? AND ad_unlocked_at IS NULL`,
userID, d); uErr != nil {
now, userID, d); uErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked update: %w", uErr)
}
case sql.ErrNoRows:
if _, iErr := tx.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, ad_unlocked_at)
VALUES (?, ?, UTC_TIMESTAMP(6))`,
userID, d); iErr != nil {
VALUES (?, ?, ?)`,
userID, d, now); iErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked insert: %w", iErr)
}
default:
@@ -187,10 +191,11 @@ func (s *Store) EffectivePlan(ctx context.Context, userID int64) (*Plan, error)
`SELECT p.code, p.daily_minutes, p.ad_gate
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP(6)
ORDER BY FIELD(p.code, 'team', 'pro', 'free'), s.expires_at DESC
WHERE s.user_id = ? AND s.expires_at > ?
ORDER BY CASE p.code WHEN 'team' THEN 0 WHEN 'pro' THEN 1 WHEN 'free' THEN 2 ELSE 3 END,
s.expires_at DESC
LIMIT 1`,
userID).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
userID, time.Now().UTC()).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
if err == nil {
return &p, nil
}