服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package player
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"hash/crc32"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ErrMySQLShardConfig 表示 MySQL 玩家 Profile 分片配置不合法。
|
||
var ErrMySQLShardConfig = errors.New("mysql profile shard config is invalid")
|
||
|
||
const mysqlShardSlots = 1024
|
||
|
||
// MySQLShardOptions 描述单个 MySQL 玩家 Profile 分片的连接和槽位配置。
|
||
type MySQLShardOptions struct {
|
||
ID string
|
||
DSN string
|
||
TableName string
|
||
TablePrefix string
|
||
HashSlots string
|
||
MaxOpenConns int
|
||
MaxIdleConns int
|
||
ConnMaxLifetime time.Duration
|
||
EnsureSchema bool
|
||
}
|
||
|
||
// ShardedMySQLStoreOptions 是分片 MySQL 玩家 Profile 存储的全局默认值和分片清单。
|
||
type ShardedMySQLStoreOptions struct {
|
||
DefaultDSN string
|
||
DefaultMaxOpenConns int
|
||
DefaultMaxIdleConns int
|
||
DefaultConnMaxLifetime time.Duration
|
||
EnsureSchema bool
|
||
Shards []MySQLShardOptions
|
||
}
|
||
|
||
// ShardedMySQLStore 按账号 ID 路由到多个 MySQL 玩家 Profile 存储。
|
||
type ShardedMySQLStore struct {
|
||
shards map[string]*MySQLStore
|
||
order []string
|
||
slotOwners []string
|
||
}
|
||
|
||
// NewShardedMySQLStore 创建分片 MySQL 玩家 Profile 存储。
|
||
func NewShardedMySQLStore(options ShardedMySQLStoreOptions) (*ShardedMySQLStore, error) {
|
||
if len(options.Shards) == 0 {
|
||
return nil, fmt.Errorf("%w: at least one mysql shard is required", ErrMySQLShardConfig)
|
||
}
|
||
out := &ShardedMySQLStore{
|
||
shards: make(map[string]*MySQLStore, len(options.Shards)),
|
||
}
|
||
normalizedShards := make([]MySQLShardOptions, 0, len(options.Shards))
|
||
for idx, shard := range options.Shards {
|
||
normalized, err := normMySQLShardOpts(options, shard, idx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, exists := out.shards[normalized.ID]; exists {
|
||
return nil, fmt.Errorf("%w: duplicated shard id %q", ErrMySQLShardConfig, normalized.ID)
|
||
}
|
||
normalizedShards = append(normalizedShards, normalized)
|
||
out.shards[normalized.ID] = NewMySQLStore(MySQLStoreOptions{
|
||
DSN: normalized.DSN,
|
||
TableName: normalized.TableName,
|
||
MaxOpenConns: normalized.MaxOpenConns,
|
||
MaxIdleConns: normalized.MaxIdleConns,
|
||
ConnMaxLifetime: normalized.ConnMaxLifetime,
|
||
EnsureSchema: normalized.EnsureSchema,
|
||
})
|
||
out.order = append(out.order, normalized.ID)
|
||
}
|
||
sort.Strings(out.order)
|
||
slotOwners, err := buildShardOwners(normalizedShards)
|
||
if err != nil {
|
||
_ = out.Close(context.Background())
|
||
return nil, err
|
||
}
|
||
out.slotOwners = slotOwners
|
||
return out, nil
|
||
}
|
||
|
||
func normMySQLShardOpts(defaults ShardedMySQLStoreOptions, shard MySQLShardOptions, idx int) (MySQLShardOptions, error) {
|
||
shard.ID = strings.TrimSpace(shard.ID)
|
||
if shard.ID == "" {
|
||
shard.ID = fmt.Sprintf("shard_%d", idx)
|
||
}
|
||
if !isSafeProfileShardID(shard.ID) {
|
||
return MySQLShardOptions{}, fmt.Errorf("%w: shard id %q can only contain letters, digits, dot, dash, or underscore", ErrMySQLShardConfig, shard.ID)
|
||
}
|
||
shard.DSN = strings.TrimSpace(shard.DSN)
|
||
if shard.DSN == "" {
|
||
shard.DSN = strings.TrimSpace(defaults.DefaultDSN)
|
||
}
|
||
if shard.DSN == "" {
|
||
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q dsn is required", ErrMySQLShardConfig, shard.ID)
|
||
}
|
||
shard.TableName = strings.TrimSpace(shard.TableName)
|
||
shard.TablePrefix = strings.TrimSpace(shard.TablePrefix)
|
||
shard.HashSlots = strings.TrimSpace(shard.HashSlots)
|
||
if shard.TableName == "" && shard.TablePrefix != "" {
|
||
shard.TableName = shard.TablePrefix + "_" + mysqlProfileTable
|
||
}
|
||
if shard.TableName == "" {
|
||
shard.TableName = mysqlProfileTable
|
||
}
|
||
if !isMySQLProfileID(shard.TableName) {
|
||
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q table name %q must be a sql identifier", ErrMySQLShardConfig, shard.ID, shard.TableName)
|
||
}
|
||
if shard.MaxOpenConns <= 0 {
|
||
shard.MaxOpenConns = defaults.DefaultMaxOpenConns
|
||
}
|
||
if shard.MaxIdleConns <= 0 {
|
||
shard.MaxIdleConns = defaults.DefaultMaxIdleConns
|
||
}
|
||
if shard.ConnMaxLifetime <= 0 {
|
||
shard.ConnMaxLifetime = defaults.DefaultConnMaxLifetime
|
||
}
|
||
shard.EnsureSchema = shard.EnsureSchema || defaults.EnsureSchema
|
||
return shard, nil
|
||
}
|
||
|
||
func buildShardOwners(shards []MySQLShardOptions) ([]string, error) {
|
||
useSlots := false
|
||
for _, shard := range shards {
|
||
if strings.TrimSpace(shard.HashSlots) != "" {
|
||
useSlots = true
|
||
break
|
||
}
|
||
}
|
||
if !useSlots {
|
||
return nil, nil
|
||
}
|
||
|
||
owners := make([]string, mysqlShardSlots)
|
||
for _, shard := range shards {
|
||
if strings.TrimSpace(shard.HashSlots) == "" {
|
||
return nil, fmt.Errorf("%w: shard %q hash_slots is required when any shard uses slot routing", ErrMySQLShardConfig, shard.ID)
|
||
}
|
||
ranges, err := parseShardSlots(shard.HashSlots)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: shard %q %w", ErrMySQLShardConfig, shard.ID, err)
|
||
}
|
||
for _, item := range ranges {
|
||
for slot := item.start; slot <= item.end; slot++ {
|
||
if owners[slot] != "" {
|
||
return nil, fmt.Errorf("%w: hash slot %d is assigned to both %q and %q", ErrMySQLShardConfig, slot, owners[slot], shard.ID)
|
||
}
|
||
owners[slot] = shard.ID
|
||
}
|
||
}
|
||
}
|
||
for slot, owner := range owners {
|
||
if owner == "" {
|
||
return nil, fmt.Errorf("%w: hash slot %d is not assigned to any shard", ErrMySQLShardConfig, slot)
|
||
}
|
||
}
|
||
return owners, nil
|
||
}
|
||
|
||
type mysqlShardSlotRange struct {
|
||
start int
|
||
end int
|
||
}
|
||
|
||
func parseShardSlots(value string) ([]mysqlShardSlotRange, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return nil, fmt.Errorf("hash_slots is required")
|
||
}
|
||
parts := strings.Split(value, ",")
|
||
out := make([]mysqlShardSlotRange, 0, len(parts))
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
return nil, fmt.Errorf("hash_slots contains empty range")
|
||
}
|
||
bounds := strings.Split(part, "-")
|
||
if len(bounds) > 2 {
|
||
return nil, fmt.Errorf("hash slot range %q is invalid", part)
|
||
}
|
||
start, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("hash slot range %q has invalid start", part)
|
||
}
|
||
end := start
|
||
if len(bounds) == 2 {
|
||
end, err = strconv.Atoi(strings.TrimSpace(bounds[1]))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("hash slot range %q has invalid end", part)
|
||
}
|
||
}
|
||
if start < 0 || end < start || end >= mysqlShardSlots {
|
||
return nil, fmt.Errorf("hash slot range %q must be within 0-%d", part, mysqlShardSlots-1)
|
||
}
|
||
out = append(out, mysqlShardSlotRange{start: start, end: end})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func isSafeProfileShardID(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if r == '.' || r == '-' || r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return !strings.HasPrefix(value, ".") && !strings.HasSuffix(value, ".") && !strings.Contains(value, "..")
|
||
}
|
||
|
||
func isMySQLProfileID(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for idx, r := range value {
|
||
if idx == 0 {
|
||
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Load 按账号 ID 路由到对应 MySQL 分片读取玩家 Profile。
|
||
func (s *ShardedMySQLStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
|
||
store, _, err := s.storeForAccount(accountID)
|
||
if err != nil {
|
||
return Profile{}, false, err
|
||
}
|
||
return store.Load(ctx, accountID)
|
||
}
|
||
|
||
// Save 按账号 ID 路由到对应 MySQL 分片保存完整玩家 Profile。
|
||
func (s *ShardedMySQLStore) Save(ctx context.Context, profile Profile) error {
|
||
return s.SaveFields(ctx, profile, AllProfileFields()...)
|
||
}
|
||
|
||
// SaveFields 按账号 ID 路由到对应 MySQL 分片保存指定玩家字段。
|
||
func (s *ShardedMySQLStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
|
||
profile, err := normMySQLIdentity(profile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
store, _, err := s.storeForAccount(profile.AccountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return store.SaveFields(ctx, profile, fields...)
|
||
}
|
||
|
||
// LoadByRoleID 遍历 MySQL 分片按角色 ID 查找玩家 Profile。
|
||
func (s *ShardedMySQLStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
|
||
ctx = contextOrBackground(ctx)
|
||
if err := ctx.Err(); err != nil {
|
||
return Profile{}, false, err
|
||
}
|
||
roleID = strings.TrimSpace(roleID)
|
||
if roleID == "" {
|
||
return Profile{}, false, fmt.Errorf("role id is required")
|
||
}
|
||
for _, shardID := range s.order {
|
||
profile, ok, err := s.shards[shardID].LoadByRoleID(ctx, roleID)
|
||
if err != nil {
|
||
return Profile{}, false, err
|
||
}
|
||
if ok {
|
||
return profile, true, nil
|
||
}
|
||
}
|
||
return Profile{}, false, nil
|
||
}
|
||
|
||
// ListProfiles 从所有 MySQL 分片按账号游标合并扫描玩家 Profile。
|
||
func (s *ShardedMySQLStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) ([]Profile, error) {
|
||
ctx = contextOrBackground(ctx)
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
afterAccountID = strings.TrimSpace(afterAccountID)
|
||
out := make([]Profile, 0, limit)
|
||
for _, shardID := range s.order {
|
||
items, err := s.shards[shardID].ListProfiles(ctx, afterAccountID, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, items...)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
return out[i].AccountID < out[j].AccountID
|
||
})
|
||
if len(out) > limit {
|
||
out = out[:limit]
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Check 检查全部 MySQL 玩家 Profile 分片。
|
||
func (s *ShardedMySQLStore) Check(ctx context.Context) error {
|
||
ctx = contextOrBackground(ctx)
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, shardID := range s.order {
|
||
if err := s.shards[shardID].Check(ctx); err != nil {
|
||
return fmt.Errorf("check mysql profile shard %s: %w", shardID, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Close 关闭全部 MySQL 玩家 Profile 分片连接。
|
||
func (s *ShardedMySQLStore) Close(ctx context.Context) error {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
var errs []error
|
||
for _, shardID := range s.order {
|
||
if err := s.shards[shardID].Close(ctx); err != nil {
|
||
errs = append(errs, fmt.Errorf("close mysql profile shard %s: %w", shardID, err))
|
||
}
|
||
}
|
||
return errors.Join(errs...)
|
||
}
|
||
|
||
// ResolveShardID 按账号 ID 解析目标 MySQL 分片 ID。
|
||
func (s *ShardedMySQLStore) ResolveShardID(accountID string) (string, error) {
|
||
if s == nil || len(s.order) == 0 {
|
||
return "", fmt.Errorf("%w: no mysql profile shard is configured", ErrMySQLShardConfig)
|
||
}
|
||
accountID = strings.TrimSpace(accountID)
|
||
if accountID == "" {
|
||
return "", fmt.Errorf("account id is required")
|
||
}
|
||
if len(s.order) == 1 {
|
||
return s.order[0], nil
|
||
}
|
||
checksum := crc32.ChecksumIEEE([]byte(accountID))
|
||
if len(s.slotOwners) == mysqlShardSlots {
|
||
slot := int(checksum % mysqlShardSlots)
|
||
return s.slotOwners[slot], nil
|
||
}
|
||
if len(s.order) > math.MaxUint32 {
|
||
return "", fmt.Errorf("%w: mysql profile shard count exceeds uint32 range", ErrMySQLShardConfig)
|
||
}
|
||
shardIndex := int(checksum % uint32(len(s.order))) //nolint:gosec // G115:len 已先校验不超过 uint32,取模结果可安全转回 slice 下标。
|
||
return s.order[shardIndex], nil
|
||
}
|
||
|
||
// TableNames 返回每个 MySQL 分片对应的 Profile 表名。
|
||
func (s *ShardedMySQLStore) TableNames() map[string]string {
|
||
out := make(map[string]string, len(s.shards))
|
||
for _, shardID := range s.order {
|
||
out[shardID] = s.shards[shardID].TableName()
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *ShardedMySQLStore) storeForAccount(accountID string) (*MySQLStore, string, error) {
|
||
shardID, err := s.ResolveShardID(accountID)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
store := s.shards[shardID]
|
||
if store == nil {
|
||
return nil, "", fmt.Errorf("%w: shard %q store is missing", ErrMySQLShardConfig, shardID)
|
||
}
|
||
return store, shardID, nil
|
||
}
|