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