DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,432 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user