Files
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

792 lines
21 KiB
Go

package accountcenter
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"hash/fnv"
"sort"
"strings"
"sync"
"time"
)
type TokenIssuer func(context.Context, Account, Shard, GateNode) (string, error)
type CenterOptions struct {
Now func() time.Time
TokenIssuer TokenIssuer
Store Store
}
type Center struct {
mu sync.Mutex
saveMu sync.Mutex
now func() time.Time
issuer TokenIssuer
store Store
version uint64
nextNum int64
accounts map[string]Account
identity map[string]string
bans map[string]BanRecord
allows map[string]AllowRecord
banIndex map[string]map[string]struct{}
allowIndex map[string]map[string]struct{}
shards map[string]Shard
gates map[string]GateNode
}
func NewMemoryCenter(options CenterOptions) *Center {
center, err := NewCenter(context.Background(), options)
if err != nil {
panic(err)
}
return center
}
func NewCenter(ctx context.Context, options CenterOptions) (*Center, error) {
now := options.Now
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
issuer := options.TokenIssuer
if issuer == nil {
issuer = randomTokenIssuer
}
center := &Center{
now: now,
issuer: issuer,
store: options.Store,
nextNum: 100000,
accounts: make(map[string]Account),
identity: make(map[string]string),
bans: make(map[string]BanRecord),
allows: make(map[string]AllowRecord),
banIndex: make(map[string]map[string]struct{}),
allowIndex: make(map[string]map[string]struct{}),
shards: make(map[string]Shard),
gates: make(map[string]GateNode),
}
if options.Store != nil {
exported, ok, err := options.Store.Load(ctx)
if err != nil {
return nil, fmt.Errorf("%w: load account center state: %w", ErrStoreUnavailable, err)
}
if ok {
center.importLocked(exported)
}
}
return center, nil
}
func (c *Center) Login(ctx context.Context, request LoginRequest) (LoginResult, error) {
if err := ctxErr(ctx); err != nil {
return LoginResult{}, err
}
identity, err := normalizeIdentity(request.Identity)
if err != nil {
return LoginResult{}, err
}
c.saveMu.Lock()
c.mu.Lock()
account, created, err := c.findOrCreateAcct(identity)
if err != nil {
c.mu.Unlock()
c.saveMu.Unlock()
return LoginResult{}, err
}
shard, err := c.selectShardLocked(request.PreferredShardID)
if err != nil {
if created {
c.rollbackCreated(account, identity)
}
c.mu.Unlock()
c.saveMu.Unlock()
return LoginResult{}, err
}
if err := c.checkAccessLocked(account.ID, shard.ID, request.RequireAllowList); err != nil {
if created {
c.rollbackCreated(account, identity)
}
c.mu.Unlock()
c.saveMu.Unlock()
return LoginResult{}, err
}
gate, err := c.selectGateLocked(account.ID, shard)
if err != nil {
if created {
c.rollbackCreated(account, identity)
}
c.mu.Unlock()
c.saveMu.Unlock()
return LoginResult{}, err
}
if created {
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
if err := c.persistAfterUnlock(ctx, exported, version, func() {
c.rollbackCreated(account, identity)
}); err != nil {
return LoginResult{}, err
}
} else {
c.mu.Unlock()
c.saveMu.Unlock()
}
token, err := c.issuer(ctx, account, shard, gate)
if err != nil {
return LoginResult{}, fmt.Errorf("%w: %w", ErrLoginTokenUnavailable, err)
}
return LoginResult{
Account: account,
Shard: shard,
Gate: GateSelection{
Address: gate.Address,
LoginToken: token,
NodeID: gate.NodeID,
},
Created: created,
}, nil
}
func (c *Center) Bind(ctx context.Context, request BindRequest) (Account, error) {
if err := ctxErr(ctx); err != nil {
return Account{}, err
}
identity, err := normalizeIdentity(request.Identity)
if err != nil {
return Account{}, err
}
accountID := strings.TrimSpace(request.AccountID)
if accountID == "" {
return Account{}, ErrAccountNotFound
}
id := identityID(identity)
c.saveMu.Lock()
c.mu.Lock()
account, ok := c.accounts[accountID]
if !ok {
c.mu.Unlock()
c.saveMu.Unlock()
return Account{}, ErrAccountNotFound
}
if bound, ok := c.identity[id]; ok && bound != accountID {
c.mu.Unlock()
c.saveMu.Unlock()
return Account{}, ErrIdentityConflict
}
if _, ok := c.identity[id]; !ok {
previous := cloneAccount(account)
account.Bindings = append(account.Bindings, identity)
account.UpdatedAt = c.now().UTC()
c.accounts[accountID] = cloneAccount(account)
c.identity[id] = accountID
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
if err := c.persistAfterUnlock(ctx, exported, version, func() {
c.accounts[accountID] = previous
delete(c.identity, id)
}); err != nil {
return Account{}, err
}
return cloneAccount(account), nil
}
out := cloneAccount(c.accounts[accountID])
c.mu.Unlock()
c.saveMu.Unlock()
return out, nil
}
func (c *Center) SetBan(ctx context.Context, record BanRecord) error {
subject := strings.TrimSpace(record.Subject)
if subject == "" {
return ErrAccountNotFound
}
record.Subject = subject
record.Scope = normalizeScope(record.Scope)
if record.CreatedAt.IsZero() {
record.CreatedAt = c.now().UTC()
}
c.saveMu.Lock()
c.mu.Lock()
key := accessKey(record.Subject, record.Scope)
previous, hadPrevious := c.bans[key]
if hadPrevious {
c.unindexAccessLocked(c.banIndex, key, previous.Subject)
}
c.bans[accessKey(record.Subject, record.Scope)] = cloneBanRecord(record)
c.indexAccessLocked(c.banIndex, key, record.Subject)
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
return c.persistAfterUnlock(ctx, exported, version, func() {
c.unindexAccessLocked(c.banIndex, key, record.Subject)
if hadPrevious {
c.bans[key] = previous
c.indexAccessLocked(c.banIndex, key, previous.Subject)
} else {
delete(c.bans, key)
}
})
}
func (c *Center) SetAllow(ctx context.Context, record AllowRecord) error {
subject := strings.TrimSpace(record.Subject)
if subject == "" {
return ErrAccountNotFound
}
record.Subject = subject
record.Scope = normalizeScope(record.Scope)
if record.CreatedAt.IsZero() {
record.CreatedAt = c.now().UTC()
}
c.saveMu.Lock()
c.mu.Lock()
key := accessKey(record.Subject, record.Scope)
previous, hadPrevious := c.allows[key]
if hadPrevious {
c.unindexAccessLocked(c.allowIndex, key, previous.Subject)
}
c.allows[key] = cloneAllowRecord(record)
c.indexAccessLocked(c.allowIndex, key, record.Subject)
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
return c.persistAfterUnlock(ctx, exported, version, func() {
c.unindexAccessLocked(c.allowIndex, key, record.Subject)
if hadPrevious {
c.allows[key] = previous
c.indexAccessLocked(c.allowIndex, key, previous.Subject)
} else {
delete(c.allows, key)
}
})
}
func (c *Center) SetShards(ctx context.Context, shards []Shard) error {
c.saveMu.Lock()
c.mu.Lock()
previous := c.shards
c.shards = make(map[string]Shard, len(shards))
for _, shard := range shards {
shard.ID = strings.TrimSpace(shard.ID)
if shard.ID == "" {
continue
}
shard.State = strings.TrimSpace(shard.State)
if shard.State == "" {
shard.State = ShardOpen
}
if shard.Weight <= 0 {
shard.Weight = 1
}
c.shards[shard.ID] = shard
}
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
return c.persistAfterUnlock(ctx, exported, version, func() {
c.shards = previous
})
}
func (c *Center) SetGates(ctx context.Context, gates []GateNode) error {
c.saveMu.Lock()
c.mu.Lock()
previous := c.gates
c.gates = make(map[string]GateNode, len(gates))
for _, gate := range gates {
gate.NodeID = strings.TrimSpace(gate.NodeID)
gate.Address = strings.TrimSpace(gate.Address)
if gate.NodeID == "" || gate.Address == "" {
continue
}
if gate.Weight <= 0 {
gate.Weight = 1
}
c.gates[gate.NodeID] = gate
}
exported, version := c.exportForSaveLocked()
c.mu.Unlock()
return c.persistAfterUnlock(ctx, exported, version, func() {
c.gates = previous
})
}
func (c *Center) Snapshot() map[string]any {
c.mu.Lock()
defer c.mu.Unlock()
return map[string]any{
"accounts": len(c.accounts),
"bindings": len(c.identity),
"bans": len(c.bans),
"allows": len(c.allows),
"shards": len(c.shards),
"gates": len(c.gates),
}
}
func (c *Center) GetAccount(ctx context.Context, accountID string) (Account, bool, error) {
if err := ctxErr(ctx); err != nil {
return Account{}, false, err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return Account{}, false, ErrAccountNotFound
}
c.mu.Lock()
defer c.mu.Unlock()
account, ok := c.accounts[accountID]
if !ok {
return Account{}, false, nil
}
return cloneAccount(account), true, nil
}
func (c *Center) FindAccountByIdentity(ctx context.Context, identity Identity) (Account, bool, error) {
if err := ctxErr(ctx); err != nil {
return Account{}, false, err
}
normalized, err := normalizeIdentity(identity)
if err != nil {
return Account{}, false, err
}
c.mu.Lock()
defer c.mu.Unlock()
accountID, ok := c.identity[identityID(normalized)]
if !ok {
return Account{}, false, nil
}
account, ok := c.accounts[accountID]
if !ok {
return Account{}, false, nil
}
return cloneAccount(account), true, nil
}
func (c *Center) AccountAccess(ctx context.Context, accountID string) (AccountAccess, error) {
if err := ctxErr(ctx); err != nil {
return AccountAccess{}, err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return AccountAccess{}, ErrAccountNotFound
}
c.mu.Lock()
defer c.mu.Unlock()
access := AccountAccess{}
for key := range c.banIndex[accountID] {
if record, ok := c.bans[key]; ok {
access.Bans = append(access.Bans, cloneBanRecord(record))
}
}
for key := range c.allowIndex[accountID] {
if record, ok := c.allows[key]; ok {
access.Allows = append(access.Allows, cloneAllowRecord(record))
}
}
sort.Slice(access.Bans, func(i, j int) bool {
return accessKey(access.Bans[i].Subject, access.Bans[i].Scope) < accessKey(access.Bans[j].Subject, access.Bans[j].Scope)
})
sort.Slice(access.Allows, func(i, j int) bool {
return accessKey(access.Allows[i].Subject, access.Allows[i].Scope) < accessKey(access.Allows[j].Subject, access.Allows[j].Scope)
})
return access, nil
}
func (c *Center) Export() Export {
c.mu.Lock()
defer c.mu.Unlock()
return c.exportLocked()
}
func (c *Center) exportLocked() Export {
out := Export{
Accounts: make([]Account, 0, len(c.accounts)),
Bans: make([]BanRecord, 0, len(c.bans)),
Allows: make([]AllowRecord, 0, len(c.allows)),
Shards: make([]Shard, 0, len(c.shards)),
Gates: make([]GateNode, 0, len(c.gates)),
}
for _, account := range c.accounts {
out.Accounts = append(out.Accounts, cloneAccount(account))
}
for _, ban := range c.bans {
out.Bans = append(out.Bans, cloneBanRecord(ban))
}
for _, allow := range c.allows {
out.Allows = append(out.Allows, cloneAllowRecord(allow))
}
for _, shard := range c.shards {
out.Shards = append(out.Shards, shard)
}
for _, gate := range c.gates {
out.Gates = append(out.Gates, gate)
}
sort.Slice(out.Accounts, func(i, j int) bool { return out.Accounts[i].ID < out.Accounts[j].ID })
sort.Slice(out.Bans, func(i, j int) bool {
return accessKey(out.Bans[i].Subject, out.Bans[i].Scope) < accessKey(out.Bans[j].Subject, out.Bans[j].Scope)
})
sort.Slice(out.Allows, func(i, j int) bool {
return accessKey(out.Allows[i].Subject, out.Allows[i].Scope) < accessKey(out.Allows[j].Subject, out.Allows[j].Scope)
})
sort.Slice(out.Shards, func(i, j int) bool { return out.Shards[i].ID < out.Shards[j].ID })
sort.Slice(out.Gates, func(i, j int) bool { return out.Gates[i].NodeID < out.Gates[j].NodeID })
return out
}
func (c *Center) importLocked(exported Export) {
c.accounts = make(map[string]Account, len(exported.Accounts))
c.identity = make(map[string]string)
c.bans = make(map[string]BanRecord, len(exported.Bans))
c.allows = make(map[string]AllowRecord, len(exported.Allows))
c.banIndex = make(map[string]map[string]struct{})
c.allowIndex = make(map[string]map[string]struct{})
c.shards = make(map[string]Shard, len(exported.Shards))
c.gates = make(map[string]GateNode, len(exported.Gates))
c.nextNum = 100000
for _, account := range exported.Accounts {
account = cloneAccount(account)
c.accounts[account.ID] = account
if account.NumID > c.nextNum {
c.nextNum = account.NumID
}
for _, identity := range account.Bindings {
normalized, err := normalizeIdentity(identity)
if err != nil {
continue
}
c.identity[identityID(normalized)] = account.ID
}
}
for _, ban := range exported.Bans {
ban.Subject = strings.TrimSpace(ban.Subject)
if ban.Subject == "" {
continue
}
ban.Scope = normalizeScope(ban.Scope)
key := accessKey(ban.Subject, ban.Scope)
c.bans[key] = cloneBanRecord(ban)
c.indexAccessLocked(c.banIndex, key, ban.Subject)
}
for _, allow := range exported.Allows {
allow.Subject = strings.TrimSpace(allow.Subject)
if allow.Subject == "" {
continue
}
allow.Scope = normalizeScope(allow.Scope)
key := accessKey(allow.Subject, allow.Scope)
c.allows[key] = cloneAllowRecord(allow)
c.indexAccessLocked(c.allowIndex, key, allow.Subject)
}
for _, shard := range exported.Shards {
shard.ID = strings.TrimSpace(shard.ID)
if shard.ID == "" {
continue
}
if shard.State == "" {
shard.State = ShardOpen
}
c.shards[shard.ID] = shard
}
for _, gate := range exported.Gates {
gate.NodeID = strings.TrimSpace(gate.NodeID)
if gate.NodeID == "" {
continue
}
c.gates[gate.NodeID] = gate
}
}
func (c *Center) exportForSaveLocked() (Export, uint64) {
c.version++
return c.exportLocked(), c.version
}
// saveMu 固定先于 mu 获取,避免落盘失败回滚覆盖后续已提交写入。
func (c *Center) persistAfterUnlock(ctx context.Context, exported Export, version uint64, rollback func()) error {
defer c.saveMu.Unlock()
if c.store == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
if err := c.store.Save(ctx, exported); err != nil {
c.mu.Lock()
if c.version == version && rollback != nil {
rollback()
c.version++
}
c.mu.Unlock()
return fmt.Errorf("%w: save account center state: %w", ErrStoreUnavailable, err)
}
return nil
}
func (c *Center) Close() error {
if c == nil || c.store == nil {
return nil
}
c.saveMu.Lock()
defer c.saveMu.Unlock()
if closer, ok := c.store.(interface{ Close() error }); ok {
return closer.Close()
}
return nil
}
func (c *Center) findOrCreateAcct(identity Identity) (Account, bool, error) {
id := identityID(identity)
if accountID, ok := c.identity[id]; ok {
return cloneAccount(c.accounts[accountID]), false, nil
}
now := c.now().UTC()
c.nextNum++
account := Account{
ID: fmt.Sprintf("acc-%d", c.nextNum),
NumID: c.nextNum,
Bindings: []Identity{identity},
CreatedAt: now,
UpdatedAt: now,
}
c.accounts[account.ID] = account
c.identity[id] = account.ID
return cloneAccount(account), true, nil
}
func (c *Center) rollbackCreated(account Account, identity Identity) {
delete(c.accounts, account.ID)
delete(c.identity, identityID(identity))
if account.NumID == c.nextNum {
c.nextNum--
}
}
func (c *Center) indexAccessLocked(index map[string]map[string]struct{}, key, subject string) {
subject = strings.TrimSpace(subject)
if subject == "" {
return
}
items := index[subject]
if items == nil {
items = make(map[string]struct{})
index[subject] = items
}
items[key] = struct{}{}
}
func (c *Center) unindexAccessLocked(index map[string]map[string]struct{}, key, subject string) {
subject = strings.TrimSpace(subject)
if subject == "" {
return
}
items := index[subject]
if len(items) == 0 {
return
}
delete(items, key)
if len(items) == 0 {
delete(index, subject)
}
}
func (c *Center) selectShardLocked(preferred string) (Shard, error) {
preferred = strings.TrimSpace(preferred)
if preferred != "" {
shard, ok := c.shards[preferred]
if !ok {
return Shard{}, ErrShardNotFound
}
if shard.State == ShardClosed {
return Shard{}, ErrShardClosed
}
return shard, nil
}
shards := make([]Shard, 0, len(c.shards))
for _, shard := range c.shards {
if shard.State != ShardClosed {
shards = append(shards, shard)
}
}
if len(shards) == 0 {
return Shard{}, ErrShardNotFound
}
sort.Slice(shards, func(i, j int) bool {
if shards[i].Weight != shards[j].Weight {
return shards[i].Weight > shards[j].Weight
}
return shards[i].ID < shards[j].ID
})
return shards[0], nil
}
func (c *Center) selectGateLocked(accountID string, shard Shard) (GateNode, error) {
candidates := make([]GateNode, 0, len(c.gates))
for _, gate := range c.gates {
if gate.Draining || gate.Address == "" {
continue
}
if shard.GateGroupID != "" && gate.GateGroupID != "" && shard.GateGroupID != gate.GateGroupID {
continue
}
if len(gate.ShardIDs) > 0 && !containsString(gate.ShardIDs, shard.ID) {
continue
}
candidates = append(candidates, gate)
}
if len(candidates) == 0 {
return GateNode{}, ErrGateUnavailable
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].NodeID < candidates[j].NodeID })
idx := int(hashString(accountID+"|"+shard.ID) % uint64(len(candidates))) //nolint:gosec // G115:取模结果严格小于 candidates 长度,可安全作为 slice 下标。
return candidates[idx], nil
}
func (c *Center) checkAccessLocked(accountID, shardID string, requireAllow bool) error {
now := c.now().UTC()
for _, scope := range []string{"global", "shard:" + shardID} {
if ban, ok := c.bans[accessKey(accountID, scope)]; ok {
if ban.Until.IsZero() || ban.Until.After(now) {
return ErrAccountBanned
}
}
}
if !requireAllow {
return nil
}
if _, ok := c.allows[accessKey(accountID, "global")]; ok {
return nil
}
if _, ok := c.allows[accessKey(accountID, "shard:"+shardID)]; ok {
return nil
}
return ErrAllowListRequired
}
func normalizeIdentity(identity Identity) (Identity, error) {
identity.Kind = strings.ToLower(strings.TrimSpace(identity.Kind))
identity.Issuer = strings.TrimSpace(identity.Issuer)
identity.Subject = strings.TrimSpace(identity.Subject)
identity.Email = strings.ToLower(strings.TrimSpace(identity.Email))
identity.Channel = strings.ToLower(strings.TrimSpace(identity.Channel))
identity.DeviceID = strings.TrimSpace(identity.DeviceID)
switch identity.Kind {
case IdentityDevice:
if identity.Subject == "" {
identity.Subject = identity.DeviceID
}
case IdentityEmail:
if identity.Subject == "" {
identity.Subject = identity.Email
}
case IdentityOIDC:
if identity.Issuer == "" || identity.Subject == "" {
return Identity{}, ErrInvalidIdentity
}
case IdentityChannel:
if identity.Issuer == "" {
identity.Issuer = identity.Channel
}
}
if identity.Kind == "" || identity.Subject == "" {
return Identity{}, ErrInvalidIdentity
}
return identity, nil
}
func identityID(identity Identity) string {
identity, _ = normalizeIdentity(identity)
return "v1:" + encodeIdentityPart(identity.Kind) + ":" + encodeIdentityPart(identity.Issuer) + ":" + encodeIdentityPart(identity.Subject)
}
func encodeIdentityPart(value string) string {
if value == "" {
return "_"
}
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
func normalizeScope(scope string) string {
scope = strings.TrimSpace(scope)
if scope == "" {
return "global"
}
return scope
}
func accessKey(subject, scope string) string {
return "v1:" + encodeIdentityPart(strings.TrimSpace(subject)) + ":" + encodeIdentityPart(normalizeScope(scope))
}
func containsString(values []string, target string) bool {
for _, value := range values {
if strings.TrimSpace(value) == target {
return true
}
}
return false
}
func hashString(value string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(value))
return h.Sum64()
}
func randomTokenIssuer(_ context.Context, account Account, shard Shard, _ GateNode) (string, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return "lh-login-" + account.ID + "-" + shard.ID + "-" + hex.EncodeToString(buf[:]), nil
}
func cloneAccount(account Account) Account {
bindings := make([]Identity, 0, len(account.Bindings))
for _, identity := range account.Bindings {
bindings = append(bindings, cloneIdentity(identity))
}
account.Bindings = bindings
account.Metadata = cloneStringMap(account.Metadata)
return account
}
func cloneIdentity(identity Identity) Identity {
identity.Metadata = cloneStringMap(identity.Metadata)
return identity
}
func cloneBanRecord(record BanRecord) BanRecord {
record.Metadata = cloneStringMap(record.Metadata)
return record
}
func cloneAllowRecord(record AllowRecord) AllowRecord {
record.Metadata = cloneStringMap(record.Metadata)
return record
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}