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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user