Files
90CNServer/server/internal/platform/idempotency/mysql.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

923 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package idempotency
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"time"
)
const (
mysqlRequestsTable = "idempotency_requests"
mysqlSequencesTable = "idempotency_sequences"
mysqlResvTable = "idempotency_sequence_reservations"
mysqlResultsTable = "idempotency_results"
idemRecoverTO = 3 * time.Second
mysqlFingerprintTag = "fingerprint:v1:"
mysqlMetadataTag = "idempotency:v2:"
)
type mysqlRequestMeta struct {
fingerprint string
fingerprintHash string
ownerToken string
legacy bool
expiresAt time.Time
}
type MySQLOptions struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
KeyPrefix string
TTL time.Duration
Now func() time.Time
EnsureSchema bool
DB *sql.DB
CleanupInterval time.Duration
CleanupBatchSize int
}
type mySQLStore struct {
db *sql.DB
initErr error
ensureSchema bool
schemaMu sync.Mutex
schemaReady bool
schemaErr error
keyPrefix string
ttl time.Duration
now func() time.Time
cleanupEvery time.Duration
cleanupLimit int
cleanupOnce sync.Once
cleanupMu sync.Mutex
cleanupStop context.CancelFunc
cleanupWG sync.WaitGroup
closed bool
}
func NewMySQL(options MySQLOptions) *Guard {
options = normMySQLOpts(options)
return New(Options{
TTL: options.TTL,
Now: options.Now,
Kind: "mysql",
Store: newMySQLStore(options),
})
}
func EnsureMySQLSchema(ctx context.Context, options MySQLOptions) error {
options.EnsureSchema = true
options.CleanupInterval = -1
store := newMySQLStore(options)
if options.DB == nil {
defer func() { _ = store.Close(context.Background()) }()
}
return store.ready(ctx)
}
func newMySQLStore(options MySQLOptions) *mySQLStore {
options = normMySQLOpts(options)
conn := options.DB
var err error
if conn == nil {
conn, err = sql.Open("mysql", options.DSN)
}
if conn != nil {
conn.SetMaxOpenConns(options.MaxOpenConns)
conn.SetMaxIdleConns(options.MaxIdleConns)
conn.SetConnMaxLifetime(options.ConnMaxLifetime)
}
return &mySQLStore{
db: conn,
initErr: err,
ensureSchema: options.EnsureSchema,
keyPrefix: options.KeyPrefix,
ttl: options.TTL,
now: options.Now,
cleanupEvery: options.CleanupInterval,
cleanupLimit: options.CleanupBatchSize,
}
}
func normMySQLOpts(options MySQLOptions) MySQLOptions {
options.DSN = strings.TrimSpace(options.DSN)
if options.DSN == "" {
options.DSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
}
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
if options.KeyPrefix == "" {
options.KeyPrefix = "idempotency"
}
if options.MaxOpenConns <= 0 {
options.MaxOpenConns = 32
}
if options.MaxIdleConns <= 0 {
options.MaxIdleConns = 8
}
if options.ConnMaxLifetime <= 0 {
options.ConnMaxLifetime = 5 * time.Minute
}
if options.TTL <= 0 {
options.TTL = 10 * time.Minute
}
if options.Now == nil {
options.Now = time.Now
}
if options.CleanupInterval == 0 {
options.CleanupInterval = options.TTL / 2
if options.CleanupInterval < 30*time.Second {
options.CleanupInterval = 30 * time.Second
}
if options.CleanupInterval > 5*time.Minute {
options.CleanupInterval = 5 * time.Minute
}
}
if options.CleanupBatchSize <= 0 {
options.CleanupBatchSize = 500
}
return options
}
func (s *mySQLStore) Check(ctx context.Context, item Request) (Decision, error) {
decision, err := s.Begin(ctx, item)
if err != nil {
return Decision{}, err
}
if decision.Status == StatusAccepted {
if err := s.Commit(ctx, item, decision); err != nil {
return Decision{}, err
}
}
return decision, nil
}
func (s *mySQLStore) Begin(ctx context.Context, item Request) (Decision, error) {
if ctx == nil {
ctx = context.Background()
}
if err := s.ready(ctx); err != nil {
return Decision{}, err
}
now := item.Now
if now.IsZero() {
now = s.nowOrDefault()
} else {
now = now.UTC()
}
// Begin 在 MySQL 事务里同时检查 committed、pending 和 sequence 水位;这是多 logic 节点的权威防重入口。
key := item.Key
if key == "" {
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
}
guardKey := s.requestKey(key)
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
seqKey := s.sequenceKey(seqScope)
expiresAt := now.Add(s.ttlOrDefault())
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Decision{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值全部通过参数绑定。
"DELETE FROM "+mysqlQuoteIdentifier(mysqlRequestsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值全部通过参数绑定。
guardKey,
now,
); err != nil {
return Decision{}, err
}
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
if err != nil {
return Decision{}, err
}
if exists {
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
if metadataErr != nil {
return Decision{}, metadataErr
}
if metadata.conflicts(item.Fingerprint) {
return Decision{}, ErrRequestConflict
}
if err := tx.Commit(); err != nil {
if recovered, ok := s.beginDecisionRec(ctx, guardKey, now, key, item); ok {
committed = true
return recovered, nil
}
return Decision{}, err
}
committed = true
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), nil
}
if item.Sequence > 0 {
highest, err := s.lockSequence(ctx, tx, item, seqKey, now)
if err != nil {
return Decision{}, err
}
if highest >= item.Sequence {
if err := insertMySQLRequest(ctx, tx, guardKey, item, key, item.Fingerprint, item.reservationToken, StatusReplay, now, expiresAt); err != nil && !isMySQLDuplicate(err) {
return Decision{}, err
}
if err := tx.Commit(); err != nil {
if s.beginStatusRecovered(ctx, guardKey, key, now, StatusReplay, item.Fingerprint, item.reservationToken) {
committed = true
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence, fingerprint: item.Fingerprint, expiresAt: expiresAt}, nil
}
return Decision{}, err
}
committed = true
return Decision{Status: StatusReplay, Key: key, Sequence: item.Sequence, fingerprint: item.Fingerprint, expiresAt: expiresAt}, nil
}
pendingHighest, err := mysqlHighestSeq(ctx, tx, seqKey, now)
if err != nil {
return Decision{}, err
}
if pendingHighest > 0 {
if err := tx.Commit(); err != nil {
if s.beginSeqRecovered(ctx, seqKey, now) {
committed = true
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
}
return Decision{}, err
}
committed = true
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
}
}
if err := insertMySQLRequest(ctx, tx, guardKey, item, key, item.Fingerprint, item.reservationToken, statusPending, now, expiresAt); err != nil {
if isMySQLDuplicate(err) {
status, exists, statusErr := mysqlRequestStatus(ctx, tx, guardKey, now)
if statusErr != nil {
return Decision{}, statusErr
}
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
if metadataErr != nil {
return Decision{}, metadataErr
}
if metadata.conflicts(item.Fingerprint) {
return Decision{}, ErrRequestConflict
}
if err := tx.Commit(); err != nil {
if recovered, ok := s.beginDecisionRec(ctx, guardKey, now, key, item); ok {
committed = true
return recovered, nil
}
return Decision{}, err
}
committed = true
if exists {
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), nil
}
return Decision{Status: StatusInFlight, Key: key, Sequence: item.Sequence}, nil
}
return Decision{}, err
}
if item.Sequence > 0 {
if err := insertMySQLReserve(ctx, tx, seqKey, guardKey, item.Sequence, expiresAt); err != nil {
return Decision{}, err
}
}
if err := tx.Commit(); err != nil {
if s.beginStatusRecovered(ctx, guardKey, key, now, statusPending, item.Fingerprint, item.reservationToken) {
committed = true
return Decision{Status: StatusAccepted, Key: key, Sequence: item.Sequence, ownerToken: item.reservationToken}, nil
}
return Decision{}, err
}
committed = true
return Decision{Status: StatusAccepted, Key: key, Sequence: item.Sequence, ownerToken: item.reservationToken}, nil
}
func (s *mySQLStore) Commit(ctx context.Context, item Request, decision Decision) error {
_, err := s.commitWithExpiry(ctx, item, decision, nil, false)
return err
}
func (s *mySQLStore) CommitResult(ctx context.Context, item Request, decision Decision, payload []byte) error {
_, err := s.commitWithExpiry(ctx, item, decision, payload, true)
return err
}
func (s *mySQLStore) commitWithExpiry(ctx context.Context, item Request, decision Decision, payload []byte, withResult bool) (time.Time, error) {
if ctx == nil {
ctx = context.Background()
}
if err := s.ready(ctx); err != nil {
return time.Time{}, err
}
// committed/result 从真实提交时刻起算 TTL,不继承请求入口时间。
now := s.nowOrDefault()
expiresAt := now.Add(s.ttlOrDefault())
if err := s.commitAt(ctx, item, decision, payload, withResult, now, expiresAt); err != nil {
return time.Time{}, err
}
return expiresAt, nil
}
func (s *mySQLStore) commitAt(ctx context.Context, item Request, decision Decision, payload []byte, withResult bool, now, expiresAt time.Time) error {
// Commit 删除 reservation 并写 committed/sequence;业务成功前不能调用,否则会造成“请求未执行但重试被吞”。
key := decision.Key
if key == "" {
key = item.Key
}
if key == "" {
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
}
guardKey := s.requestKey(key)
seqScope := sequenceScope(item.Scope, item.Subject, item.Session)
seqKey := s.sequenceKey(seqScope)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
if err != nil {
return err
}
if exists {
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
if metadataErr != nil {
return metadataErr
}
if metadata.conflicts(item.Fingerprint) {
return ErrRequestConflict
}
if metadata.ownerToken != decision.ownerToken {
return ErrReservationLost
}
if decision.ownerToken != "" && status != statusPending && status != StatusAccepted {
return ErrReservationLost
}
} else if decision.ownerToken != "" {
return ErrReservationLost
}
if item.Sequence > 0 {
highest, err := s.lockSequence(ctx, tx, item, seqKey, now)
if err != nil {
return err
}
if highest < item.Sequence {
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 值通过参数绑定。
"UPDATE "+mysqlQuoteIdentifier(mysqlSequencesTable)+" SET "+mysqlQuoteIdentifier("highest_sequence")+" = ?, "+mysqlQuoteIdentifier("updated_at")+" = ? WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 值通过参数绑定。
item.Sequence,
now,
seqKey,
); err != nil {
return err
}
}
}
if err := upsertMySQLStatus(ctx, tx, guardKey, item, key, item.Fingerprint, decision.ownerToken, StatusAccepted, now, expiresAt); err != nil {
return err
}
if withResult {
if err := upsertMySQLResult(ctx, tx, guardKey, payload, now, expiresAt); err != nil {
return err
}
}
if item.Sequence > 0 {
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值通过参数绑定。
"DELETE FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,业务值通过参数绑定。
guardKey,
); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
recovered := s.commitRecovered(ctx, guardKey, key, now, decision.ownerToken)
if withResult {
recovered = recovered && s.resultRecovered(ctx, guardKey, now, payload)
}
if recovered {
committed = true
return nil
}
return err
}
committed = true
return nil
}
func (s *mySQLStore) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
if ctx == nil {
ctx = context.Background()
}
if err := s.ready(ctx); err != nil {
return nil, false, err
}
key := strings.TrimSpace(decision.Key)
if key == "" {
return nil, false, nil
}
payload, found, err := mysqlResultDB(ctx, s.db, s.requestKey(key), s.nowOrDefault())
if err != nil || !found {
return nil, found, err
}
return append([]byte(nil), payload...), true, nil
}
func (s *mySQLStore) commitRecovered(parent context.Context, guardKey, key string, now time.Time, ownerToken string) bool {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
if err != nil || !exists || status != StatusAccepted {
return false
}
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
return err == nil && metadata.ownerToken == ownerToken
}
func (s *mySQLStore) resultRecovered(parent context.Context, guardKey string, now time.Time, want []byte) bool {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
payload, found, err := mysqlResultDB(ctx, s.db, guardKey, now)
return err == nil && found && string(payload) == string(want)
}
func (s *mySQLStore) beginDecisionRec(parent context.Context, guardKey string, now time.Time, key string, item Request) (Decision, bool) {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
if err != nil || !exists {
return Decision{}, false
}
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
if err != nil || metadata.conflicts(item.Fingerprint) {
return Decision{}, false
}
return mysqlStatusDecision(status, key, item.Sequence, metadata.decisionFingerprint(item.Fingerprint), metadata.ownerToken, metadata.expiresAt), true
}
func (s *mySQLStore) beginStatusRecovered(parent context.Context, guardKey, key string, now time.Time, want Status, fingerprint, ownerToken string) bool {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
if err != nil || !exists || status != want {
return false
}
metadata, _, err := mysqlMetadataDB(ctx, s.db, guardKey, now, key)
return err == nil && !metadata.conflicts(fingerprint) && metadata.ownerToken == ownerToken
}
func (s *mySQLStore) beginSeqRecovered(parent context.Context, seqKey string, now time.Time) bool {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
highest, err := mysqlHighestSeqDB(ctx, s.db, seqKey, now)
return err == nil && highest > 0
}
func (s *mySQLStore) Abort(ctx context.Context, item Request, decision Decision) error {
if ctx == nil {
ctx = context.Background()
}
if err := s.ready(ctx); err != nil {
return err
}
// Abort 只删除 reservation,保留重试机会,不推进 committed 或 sequence 水位。
key := decision.Key
if key == "" {
key = item.Key
}
if key == "" {
key = DerivedKey(item.Scope, item.Subject, item.Session, item.Sequence)
}
guardKey := s.requestKey(key)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
now := s.nowOrDefault()
status, exists, err := mysqlRequestStatus(ctx, tx, guardKey, now)
if err != nil {
return err
}
if exists {
metadata, _, metadataErr := mysqlRequestMetadata(ctx, tx, guardKey, now, key)
if metadataErr != nil {
return metadataErr
}
if metadata.conflicts(item.Fingerprint) {
return ErrRequestConflict
}
if status != statusPending || metadata.ownerToken != decision.ownerToken {
return ErrReservationLost
}
} else if decision.ownerToken != "" {
return ErrReservationLost
}
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
"DELETE FROM "+mysqlQuoteIdentifier(mysqlRequestsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("status")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
guardKey,
string(statusPending),
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
"DELETE FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
guardKey,
); err != nil {
return err
}
if err := tx.Commit(); err != nil {
if s.abortRecovered(ctx, guardKey, s.nowOrDefault()) {
committed = true
return nil
}
return err
}
committed = true
return nil
}
func (s *mySQLStore) abortRecovered(parent context.Context, guardKey string, now time.Time) bool {
ctx, cancel := idemRecoverCtx(parent)
defer cancel()
status, exists, err := mysqlStatusDB(ctx, s.db, guardKey, now)
if err != nil {
return false
}
if exists && status == statusPending {
return false
}
exists, err = mysqlReservationDB(ctx, s.db, guardKey, now)
return err == nil && !exists
}
func (s *mySQLStore) Snapshot() map[string]any {
if s == nil {
return map[string]any{}
}
return map[string]any{
"backend": "mysql",
"ttl_seconds": int64(s.ttlOrDefault() / time.Second),
"key_prefix": s.keyPrefixOrDefault(),
"cleanup_interval_seconds": int64(s.cleanupEvery / time.Second),
"cleanup_batch_size": s.cleanupLimit,
}
}
func (s *mySQLStore) Close(context.Context) error {
if s == nil || s.db == nil {
return nil
}
s.cleanupMu.Lock()
s.closed = true
stop := s.cleanupStop
s.cleanupMu.Unlock()
if stop != nil {
// 先停后台清理 goroutine,再关闭 DB,避免清理任务和 Close 竞争同一个连接池。
stop()
s.cleanupWG.Wait()
}
return s.db.Close()
}
func (s *mySQLStore) ready(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if s == nil || s.db == nil {
return fmt.Errorf("mysql idempotency store is nil")
}
if s.initErr != nil {
return s.initErr
}
if s.ensureSchema {
if err := s.ensureSchemaReady(ctx); err != nil {
return err
}
}
s.startCleanup()
return nil
}
func (s *mySQLStore) ensureSchemaReady(ctx context.Context) error {
s.schemaMu.Lock()
defer s.schemaMu.Unlock()
if s.schemaReady {
return nil
}
if _, err := s.db.ExecContext(ctx, mysqlReqTableDDL()); err != nil {
s.schemaErr = err
return err
}
if _, err := s.db.ExecContext(ctx, mysqlSeqTableDDL()); err != nil {
s.schemaErr = err
return err
}
if _, err := s.db.ExecContext(ctx, mysqlResvTableDDL()); err != nil {
s.schemaErr = err
return err
}
if _, err := s.db.ExecContext(ctx, mysqlResultTableDDL()); err != nil {
s.schemaErr = err
return err
}
s.schemaErr = nil
s.schemaReady = true
return nil
}
func (s *mySQLStore) startCleanup() {
if s == nil || s.cleanupEvery <= 0 || s.cleanupLimit <= 0 {
return
}
s.cleanupOnce.Do(func() {
// 清理只删除过期 pending/committed/sequence,不能影响仍在 ttl 窗口内的幂等判定。
s.cleanupMu.Lock()
defer s.cleanupMu.Unlock()
if s.closed {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.cleanupStop = cancel
s.cleanupWG.Add(1)
go s.cleanupLoop(ctx)
})
}
func (s *mySQLStore) cleanupLoop(ctx context.Context) {
defer s.cleanupWG.Done()
// cleanupLoop 使用独立后台 ctx;Close 会 cancel 并等待它退出,防止测试和服务停机泄漏 goroutine。
ticker := time.NewTicker(s.cleanupEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
timeout := s.cleanupEvery
if timeout > 30*time.Second {
timeout = 30 * time.Second
}
cleanupCtx, cancel := context.WithTimeout(ctx, timeout)
_ = s.cleanupExpired(cleanupCtx)
cancel()
}
}
}
func (s *mySQLStore) cleanupExpired(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if err := s.ready(ctx); err != nil {
return err
}
now := s.nowOrDefault()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
limit := s.cleanupLimit
if limit <= 0 {
limit = 500
}
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,删除条件通过参数绑定。
mysqlCleanupSQL(mysqlResvTable, "expires_at", limit),
now,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
mysqlCleanupSQL(mysqlRequestsTable, "expires_at", limit),
now,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,过期时间通过参数绑定。
mysqlCleanupSQL(mysqlResultsTable, "expires_at", limit),
now,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,序列键通过参数绑定。
mysqlCleanupSQL(mysqlSequencesTable, "updated_at", limit),
mysqlSeqExpiryCutoff(now, s.ttlOrDefault()),
); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil
}
func (s *mySQLStore) lockSequence(ctx context.Context, tx *sql.Tx, item Request, seqKey string, now time.Time) (uint64, error) {
// sequence 行通过 SELECT ... FOR UPDATE 串行化同一账号/session 的序列水位,防止并发包乱序通过。
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,序列清理条件通过参数绑定。
"DELETE FROM "+mysqlQuoteIdentifier(mysqlSequencesTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? AND "+mysqlQuoteIdentifier("updated_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,序列清理条件通过参数绑定。
seqKey,
mysqlSeqExpiryCutoff(now, s.ttlOrDefault()),
); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 行字段通过参数绑定。
"INSERT INTO "+mysqlQuoteIdentifier(mysqlSequencesTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,sequence 行字段通过参数绑定。
strings.Join([]string{
mysqlQuoteIdentifier("sequence_key"),
mysqlQuoteIdentifier("scope"),
mysqlQuoteIdentifier("subject"),
mysqlQuoteIdentifier("session_id"),
mysqlQuoteIdentifier("highest_sequence"),
mysqlQuoteIdentifier("updated_at"),
}, ", ")+") VALUES (?, ?, ?, ?, 0, ?) ON DUPLICATE KEY UPDATE "+mysqlQuoteIdentifier("sequence_key")+" = "+mysqlQuoteIdentifier("sequence_key"),
seqKey,
limitStorageText(item.Scope, 128),
limitStorageText(item.Subject, 128),
limitStorageText(item.Session, 128),
now,
); err != nil {
return 0, err
}
var highest uint64
if err := tx.QueryRowContext(ctx,
"SELECT "+mysqlQuoteIdentifier("highest_sequence")+" FROM "+mysqlQuoteIdentifier(mysqlSequencesTable)+" WHERE "+mysqlQuoteIdentifier("sequence_key")+" = ? FOR UPDATE",
seqKey,
).Scan(&highest); err != nil {
return 0, err
}
return highest, nil
}
func mysqlSeqExpiryCutoff(now time.Time, ttl time.Duration) time.Time {
if ttl <= 0 {
return now
}
return now.Add(-ttl)
}
func mysqlRequestStatus(ctx context.Context, tx *sql.Tx, guardKey string, now time.Time) (Status, bool, error) {
var status string
err := tx.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
mysqlReqStatusSQL(),
guardKey,
now,
).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return Status(status), true, nil
}
func mysqlRequestMetadata(ctx context.Context, tx *sql.Tx, guardKey string, now time.Time, key string) (mysqlRequestMeta, bool, error) {
var stored string
var expiresAt time.Time
err := tx.QueryRowContext(ctx, mysqlReqMetaSQL(), guardKey, now).Scan(&stored, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return mysqlRequestMeta{}, false, nil
}
if err != nil {
return mysqlRequestMeta{}, false, err
}
metadata := mysqlMetaFromDB(stored, key)
metadata.expiresAt = expiresAt.UTC()
return metadata, true, nil
}
func mysqlStatusDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) (Status, bool, error) {
if db == nil {
return "", false, fmt.Errorf("mysql idempotency store is nil")
}
var status string
err := db.QueryRowContext(ctx, mysqlReqStatusSQL(), guardKey, now).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return Status(status), true, nil
}
func mysqlMetadataDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time, key string) (mysqlRequestMeta, bool, error) {
if db == nil {
return mysqlRequestMeta{}, false, fmt.Errorf("mysql idempotency store is nil")
}
var stored string
var expiresAt time.Time
err := db.QueryRowContext(ctx, mysqlReqMetaSQL(), guardKey, now).Scan(&stored, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return mysqlRequestMeta{}, false, nil
}
if err != nil {
return mysqlRequestMeta{}, false, err
}
metadata := mysqlMetaFromDB(stored, key)
metadata.expiresAt = expiresAt.UTC()
return metadata, true, nil
}
func mysqlResultDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) ([]byte, bool, error) {
if db == nil {
return nil, false, fmt.Errorf("mysql idempotency store is nil")
}
var payload []byte
err := db.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
"SELECT "+mysqlQuoteIdentifier("result_data")+" FROM "+mysqlQuoteIdentifier(mysqlResultsTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ? LIMIT 1", // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
guardKey,
now,
).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return append([]byte(nil), payload...), true, nil
}
func mysqlReservationDB(ctx context.Context, db *sql.DB, guardKey string, now time.Time) (bool, error) {
if db == nil {
return false, fmt.Errorf("mysql idempotency store is nil")
}
var count int
err := db.QueryRowContext(ctx, // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
"SELECT COUNT(*) FROM "+mysqlQuoteIdentifier(mysqlResvTable)+" WHERE "+mysqlQuoteIdentifier("guard_key")+" = ? AND "+mysqlQuoteIdentifier("expires_at")+" > ?", // #nosec G202 -- SQL 只拼接框架固定表名/列名,查询条件通过参数绑定。
guardKey,
now,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func idemRecoverCtx(parent context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if parent != nil {
base = context.WithoutCancel(parent)
}
return context.WithTimeout(base, idemRecoverTO)
}
func (s *mySQLStore) requestKey(key string) string {
return digestKey(s.keyPrefixOrDefault() + "\x00key\x00" + key)
}
func (s *mySQLStore) sequenceKey(scope string) string {
return digestKey(s.keyPrefixOrDefault() + "\x00seq\x00" + scope)
}
func (s *mySQLStore) nowOrDefault() time.Time {
if s == nil || s.now == nil {
return time.Now().UTC()
}
return s.now().UTC()
}
func (s *mySQLStore) ttlOrDefault() time.Duration {
if s == nil || s.ttl <= 0 {
return 10 * time.Minute
}
return s.ttl
}
func (s *mySQLStore) keyPrefixOrDefault() string {
if s == nil {
return "idempotency"
}
prefix := strings.TrimSpace(s.keyPrefix)
if prefix == "" {
return "idempotency"
}
return prefix
}