Files
90CNServer/server/internal/platform/idempotency/guard.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

433 lines
11 KiB
Go

package idempotency
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
)
var (
ErrInvalidRequest = errors.New("idempotency request is invalid")
ErrRequestConflict = errors.New("idempotency request fingerprint conflicts with existing request")
ErrReservationLost = errors.New("idempotency reservation ownership was lost")
ErrStoreRequired = errors.New("idempotency store is required")
ErrResultStoreRequired = errors.New("idempotency result store is required")
)
type Status string
const (
StatusAccepted Status = "accepted"
StatusDuplicate Status = "duplicate"
StatusInFlight Status = "in_flight"
StatusReplay Status = "replay"
statusPending Status = "pending"
)
type Store interface {
Check(context.Context, Request) (Decision, error)
Snapshot() map[string]any
}
type transactionalStore interface {
Begin(context.Context, Request) (Decision, error)
Commit(context.Context, Request, Decision) error
Abort(context.Context, Request, Decision) error
}
// resultStore 把幂等状态与可重放结果提交到同一个权威后端。
// 它只用于 handler 已成功、不能再安全重跑的路径,避免独立响应缓存失败后留下不可恢复的 committed 状态。
type resultStore interface {
CommitResult(context.Context, Request, Decision, []byte) error
LookupResult(context.Context, Decision) ([]byte, bool, error)
}
type Options struct {
TTL time.Duration
Now func() time.Time
Store Store
Kind string
}
type Request struct {
Scope string
Subject string
Session string
Key string
Fingerprint string
Sequence uint64
Now time.Time
// reservationToken 由 Guard.Begin 在进入后端前生成,不属于外部请求契约。
reservationToken string
}
type Decision struct {
Status Status `json:"status"`
Key string `json:"key,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
fingerprint string
ownerToken string
expiresAt time.Time
}
type Reservation struct {
guard *Guard
request Request
decision Decision
active bool
}
type Guard struct {
kind string
store Store
stats guardStats
}
type guardStats struct {
mu sync.Mutex
accepted int64
duplicate int64
inFlight int64
replay int64
backendError int64
latencyNanos int64
}
func New(options Options) *Guard {
options = normalizeOptions(options)
store := options.Store
if store == nil {
store = newMemoryStore(options.TTL, options.Now)
}
kind := strings.TrimSpace(options.Kind)
if kind == "" {
kind = "memory"
}
return &Guard{kind: kind, store: store}
}
func (g *Guard) Check(ctx context.Context, request Request) (Decision, error) {
if err := ctxErr(ctx); err != nil {
return Decision{}, err
}
reservation, decision, err := g.Begin(ctx, request)
if err != nil {
return Decision{}, err
}
if decision.Status == StatusAccepted {
if err := reservation.Commit(ctx); err != nil {
return Decision{}, err
}
}
return decision, nil
}
func (g *Guard) Begin(ctx context.Context, request Request) (Reservation, Decision, error) {
if err := ctxErr(ctx); err != nil {
return Reservation{}, Decision{}, err
}
if g == nil || g.store == nil {
return Reservation{}, Decision{Status: StatusAccepted}, nil
}
item, err := normalizeRequest(request)
if err != nil {
return Reservation{}, Decision{}, err
}
if item.Key == "" && item.Sequence == 0 {
// 没有 key 和 sequence 的旧协议请求无法提供幂等语义,只能放行。
// 新协议入口应尽量在网关侧派生 IdempotencyKey。
decision := Decision{Status: StatusAccepted}
g.stats.record(decision, nil, 0)
return Reservation{guard: g, request: item, decision: decision}, decision, nil
}
ownerToken, err := newReservationToken()
if err != nil {
return Reservation{}, Decision{}, err
}
item.reservationToken = ownerToken
start := time.Now()
var decision Decision
// 支持三段式后端时,Begin 只做占位;业务成功后必须 Commit,失败必须 Abort。
// 非事务后端退化为一次 Check,用于内存或纯缓存场景。
if store, ok := g.store.(transactionalStore); ok {
decision, err = store.Begin(ctx, item)
} else {
decision, err = g.store.Check(ctx, item)
}
g.stats.record(decision, err, time.Since(start))
if err != nil {
return Reservation{}, Decision{}, err
}
if decision.Status == StatusAccepted {
decision.ownerToken = ownerToken
}
reservation := Reservation{
guard: g,
request: item,
decision: decision,
active: decision.Status == StatusAccepted,
}
return reservation, decision, nil
}
func (r *Reservation) Commit(ctx context.Context) error {
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
return nil
}
store, ok := r.guard.store.(transactionalStore)
if !ok {
r.active = false
return nil
}
// Commit 只有在业务 handler 成功后调用,负责把 pending 占位转成可重放结果。
err := store.Commit(ctx, r.request, r.decision)
if err != nil {
r.guard.stats.record(r.decision, err, 0)
}
if err == nil {
r.active = false
}
return err
}
// CommitResult 原子提交幂等状态与可重放结果。
// 默认 memory/Redis/MySQL 后端都在自身锁、Lua 或 SQL 事务内实现,不跨后端拼接伪事务。
func (r *Reservation) CommitResult(ctx context.Context, payload []byte) error {
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
return nil
}
store, ok := r.guard.store.(resultStore)
if !ok {
return ErrResultStoreRequired
}
err := store.CommitResult(ctx, r.request, r.decision, append([]byte(nil), payload...))
if err != nil {
r.guard.stats.record(r.decision, err, 0)
return err
}
r.active = false
return nil
}
func (r *Reservation) Abort(ctx context.Context) error {
if r == nil || !r.active || r.guard == nil || r.guard.store == nil {
return nil
}
store, ok := r.guard.store.(transactionalStore)
if !ok {
r.active = false
return nil
}
// Abort 负责清掉 pending 占位;否则客户端重试会被误认为仍在处理。
err := store.Abort(ctx, r.request, r.decision)
if err != nil {
r.guard.stats.record(r.decision, err, 0)
}
if err == nil {
r.active = false
}
return err
}
// LookupResult 读取和 committed 状态同后端、同 TTL 的可重放结果。
// 自定义旧 Store 未实现结果契约时按未命中处理,避免破坏只使用 Check 的兼容调用方。
func (g *Guard) LookupResult(ctx context.Context, decision Decision) ([]byte, bool, error) {
if err := ctxErr(ctx); err != nil {
return nil, false, err
}
if g == nil || g.store == nil || strings.TrimSpace(decision.Key) == "" {
return nil, false, nil
}
store, ok := g.store.(resultStore)
if !ok {
return nil, false, nil
}
payload, found, err := store.LookupResult(ctx, decision)
if err != nil || !found {
return nil, found, err
}
return append([]byte(nil), payload...), true, nil
}
func (g *Guard) Snapshot() map[string]any {
if g == nil {
return map[string]any{}
}
out := map[string]any{
"kind": g.kind,
}
if g.store != nil {
for key, value := range g.store.Snapshot() {
out[key] = value
}
}
out["metrics"] = g.stats.snapshot()
return out
}
func (g *Guard) Close(ctx context.Context) error {
if g == nil || g.store == nil {
return nil
}
if closer, ok := g.store.(interface {
Close(context.Context) error
}); ok {
return closer.Close(ctx)
}
return nil
}
func DerivedKey(scope, subject, session string, sequence uint64) string {
return fmt.Sprintf("v1:%s:%s:%s:%d",
encodeDerivedKeyPart(normalizeToken(scope)),
encodeDerivedKeyPart(normalizeToken(subject)),
encodeDerivedKeyPart(normalizeToken(session)),
sequence,
)
}
// CanonicalKey 对任意文本字段做长度前缀编码后生成稳定键,不改变字段大小写。
func CanonicalKey(namespace string, parts ...string) string {
hash := sha256.New()
var size [8]byte
writePart := func(part string) {
binary.BigEndian.PutUint64(size[:], uint64(len(part)))
_, _ = hash.Write(size[:])
_, _ = hash.Write([]byte(part))
}
writePart(namespace)
for _, part := range parts {
writePart(part)
}
return "ck1_" + hex.EncodeToString(hash.Sum(nil))
}
func normalizeOptions(options Options) Options {
if options.TTL <= 0 {
options.TTL = 10 * time.Minute
}
if options.Now == nil {
options.Now = time.Now
}
return options
}
func normalizeRequest(request Request) (Request, error) {
request.Scope = normalizeToken(request.Scope)
if request.Scope == "" {
request.Scope = "default"
}
request.Subject = normalizeToken(request.Subject)
request.Session = normalizeToken(request.Session)
request.Key = normalizeToken(request.Key)
request.Fingerprint = strings.TrimSpace(request.Fingerprint)
if request.Subject == "" {
return Request{}, fmt.Errorf("%w: subject is required", ErrInvalidRequest)
}
if len(request.Fingerprint) > 512 || strings.ContainsAny(request.Fingerprint, "\r\n") {
return Request{}, fmt.Errorf("%w: fingerprint must be a single line of at most 512 bytes", ErrInvalidRequest)
}
if request.Sequence > maxRedisSeq {
return Request{}, fmt.Errorf("%w: sequence must not exceed %d", ErrInvalidRequest, maxRedisSeq)
}
if !request.Now.IsZero() {
request.Now = request.Now.UTC()
}
return request, nil
}
const maxRedisSeq = uint64(1<<53 - 1)
func newReservationToken() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate idempotency reservation token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
}
func resvOwnerMatch(present bool, stored, expected string) bool {
if expected == "" {
// 旧版 reservation 没有 owner token:只能操作同样无 token 的占位,
// 不得回退成可删除新版占位的“万能 token”。
return !present || stored == ""
}
return present && stored == expected
}
func reqFPConflict(stored, incoming string) bool {
stored = strings.TrimSpace(stored)
incoming = strings.TrimSpace(incoming)
return stored != "" && incoming != "" && stored != incoming
}
func (s *guardStats) record(decision Decision, err error, latency time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if err != nil {
s.backendError++
} else {
switch decision.Status {
case StatusDuplicate:
s.duplicate++
case StatusInFlight:
s.inFlight++
case StatusReplay:
s.replay++
default:
s.accepted++
}
}
s.latencyNanos += latency.Nanoseconds()
}
func (s *guardStats) snapshot() map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
total := s.accepted + s.duplicate + s.inFlight + s.replay + s.backendError
avgMillis := int64(0)
if total > 0 {
avgMillis = s.latencyNanos / total / int64(time.Millisecond)
}
return map[string]any{
"accepted": s.accepted,
"duplicate": s.duplicate,
"in_flight": s.inFlight,
"replay": s.replay,
"backend_error": s.backendError,
"checks": total,
"latency_ms_total": s.latencyNanos / int64(time.Millisecond),
"latency_ms_average": avgMillis,
}
}
func sequenceScope(scope, subject, session string) string {
return normalizeToken(scope) + "\x00" + normalizeToken(subject) + "\x00" + normalizeToken(session)
}
func encodeDerivedKeyPart(value string) string {
if value == "" {
return "_"
}
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
func normalizeToken(value string) string {
return strings.TrimSpace(strings.ToLower(value))
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}