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,339 @@
|
||||
// 本文件负责 DNF 仓储的 Redis 可重建缓存包装。
|
||||
// MySQL 仍是权威存储;Redis 只保存角色读模型、名字索引和数字 ID 分配游标。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
const defaultRedisCachePrefix = "dnf:repository"
|
||||
|
||||
const redisNextIDScript = `
|
||||
local key = KEYS[1]
|
||||
local seed = tonumber(ARGV[1])
|
||||
local current = tonumber(redis.call("GET", key) or "0")
|
||||
if current < seed then
|
||||
redis.call("SET", key, seed)
|
||||
end
|
||||
return redis.call("INCR", key)
|
||||
`
|
||||
|
||||
// RedisCacheOptions 描述 DNF 仓储 Redis 缓存参数。
|
||||
type RedisCacheOptions struct {
|
||||
KeyPrefix string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type RedisCachedCharStore struct {
|
||||
backing CharacterRepository
|
||||
executor db.RedisExecutor
|
||||
options RedisCacheOptions
|
||||
}
|
||||
|
||||
// NewCachedGroup 使用 Redis 包装 DNF 仓储聚合。
|
||||
// 包装层只替换角色仓储的读模型缓存和 ID 游标;账号、背包、技能等权威写入仍走原 MySQL 仓储。
|
||||
func NewCachedGroup(group Group, executor db.RedisExecutor, options RedisCacheOptions) Group {
|
||||
if executor == nil || group.Character == nil {
|
||||
return group
|
||||
}
|
||||
group.Character = NewRedisCachedCharacterRepository(group.Character, executor, options)
|
||||
return group
|
||||
}
|
||||
|
||||
// NewRedisCachedCharacterRepository 创建角色仓储的 Redis 缓存装饰器。
|
||||
// Save/SaveFields 会先写 MySQL,再 best-effort 写 Redis;Load/List/Find 允许 Redis 失败后回退 MySQL。
|
||||
func NewRedisCachedCharacterRepository(backing CharacterRepository, executor db.RedisExecutor, options RedisCacheOptions) CharacterRepository {
|
||||
return &RedisCachedCharStore{
|
||||
backing: backing,
|
||||
executor: executor,
|
||||
options: normalizeRedisCacheOptions(options),
|
||||
}
|
||||
}
|
||||
|
||||
// Check 校验底层 MySQL 仓储并探测 Redis 连接。
|
||||
// 启动期显式启用 Redis 时应尽早发现连接错误;运行期读写仍允许回退 MySQL。
|
||||
func (s *RedisCachedCharStore) Check(ctx context.Context) error {
|
||||
if s == nil || s.backing == nil {
|
||||
return ErrRepoMissing
|
||||
}
|
||||
if s.executor == nil {
|
||||
return db.ErrRedisExecutorClosed
|
||||
}
|
||||
if err := db.Check(ctx, s.backing); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.executor.Do(ctx, "PING")
|
||||
return err
|
||||
}
|
||||
|
||||
// Load 优先从 Redis 读取角色缓存,未命中或缓存损坏时回退 MySQL 并回填。
|
||||
func (s *RedisCachedCharStore) Load(ctx context.Context, characterID string) (CharacterRecord, bool, error) {
|
||||
record, ok, err := s.backing.Load(ctx, characterID)
|
||||
if err != nil || !ok {
|
||||
return CharacterRecord{}, ok, err
|
||||
}
|
||||
_ = s.CacheRecord(ctx, record)
|
||||
return CloneCharacter(record), true, nil
|
||||
}
|
||||
|
||||
// Save 先写权威 MySQL,再刷新 Redis 角色缓存和可重建索引。
|
||||
func (s *RedisCachedCharStore) Save(ctx context.Context, record CharacterRecord) error {
|
||||
if err := s.backing.Save(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.CacheRecord(ctx, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCharacter 透传底层 insert-only 新建语义,成功后再刷新 Redis 读模型。
|
||||
func (s *RedisCachedCharStore) CreateCharacter(ctx context.Context, record CharacterRecord) error {
|
||||
var err error
|
||||
if creator, ok := s.backing.(CharacterCreator); ok {
|
||||
err = creator.CreateCharacter(ctx, record)
|
||||
} else {
|
||||
err = ErrCharacterCreateMissing
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.CacheRecord(ctx, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) LastDeletedProfession(ctx context.Context, accountID string, job, grow byte) (CharacterRecord, bool, error) {
|
||||
if history, ok := s.backing.(CharacterDeletedProfessionReader); ok {
|
||||
return history.LastDeletedProfession(ctx, accountID, job, grow)
|
||||
}
|
||||
return CharacterRecord{}, false, ErrCharacterDeleteUnavailable
|
||||
}
|
||||
|
||||
// DeleteCharacterAndCompact delegates the atomic roster mutation to the
|
||||
// authoritative backing store, then rebuilds the account read model.
|
||||
func (s *RedisCachedCharStore) DeleteCharacterAndCompact(ctx context.Context, accountID string, slot int, name string) error {
|
||||
deleter, ok := s.backing.(CharacterRosterDeleter)
|
||||
if !ok {
|
||||
return ErrCharacterDeleteUnavailable
|
||||
}
|
||||
if err := deleter.DeleteCharacterAndCompact(ctx, accountID, slot, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if records, err := s.backing.ListByAccount(ctx, accountID, MaxCharacterSlotLimit); err == nil {
|
||||
_ = s.cacheAccountList(ctx, accountID, records)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveFields 先执行底层字段化保存,再按 MySQL 最新记录回填 Redis。
|
||||
// Redis 回填失败不会改变本次 MySQL 写入结果。
|
||||
func (s *RedisCachedCharStore) SaveFields(ctx context.Context, record CharacterRecord, fields ...CharacterField) error {
|
||||
var err error
|
||||
if fieldStore, ok := s.backing.(interface {
|
||||
SaveFields(context.Context, CharacterRecord, ...CharacterField) error
|
||||
}); ok {
|
||||
err = fieldStore.SaveFields(ctx, record, fields...)
|
||||
} else {
|
||||
err = s.backing.Save(ctx, record)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if loaded, ok, loadErr := s.backing.Load(ctx, record.CharacterID); loadErr == nil && ok {
|
||||
_ = s.CacheRecord(ctx, loaded)
|
||||
return nil
|
||||
}
|
||||
_ = s.CacheRecord(ctx, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) AdvanceStoryDigest(ctx context.Context, characterID string, level, migrationVersion uint32) error {
|
||||
advancer, ok := s.backing.(CharacterStoryDigestAdvancer)
|
||||
if !ok {
|
||||
return ErrCharacterStoryDigestAdvanceUnavailable
|
||||
}
|
||||
if err := advancer.AdvanceStoryDigest(ctx, characterID, level, migrationVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if loaded, found, err := s.backing.Load(ctx, characterID); err == nil && found {
|
||||
_ = s.CacheRecord(ctx, loaded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByAccount 优先读取 Redis 账号角色索引,并用 MySQL 权威列表校验命中结果。
|
||||
// Redis 只是可重建读模型;历史修复、手工删库或旧缓存都不能让选角页继续看到脏角色。
|
||||
func (s *RedisCachedCharStore) ListByAccount(ctx context.Context, accountID string, limit int) ([]CharacterRecord, error) {
|
||||
records, err := s.backing.ListByAccount(ctx, accountID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.cacheAccountList(ctx, accountID, records)
|
||||
return cloneCharacters(records), nil
|
||||
}
|
||||
|
||||
// FindIDByName 以 MySQL 名字索引为准,Redis 只做成功后的可重建回填。
|
||||
func (s *RedisCachedCharStore) FindIDByName(ctx context.Context, name string) (string, bool, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
id, ok, err := s.backing.FindIDByName(ctx, name)
|
||||
if err != nil || !ok {
|
||||
return "", ok, err
|
||||
}
|
||||
_ = s.setValue(ctx, s.NameKey(name), id)
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// NextNumericID 用 Redis 原子游标分配数字角色 ID,并用 MySQL 最大值作为游标种子。
|
||||
// Redis 不可用时回退 MySQL 分配;该兜底只保证单进程重建链路可用。
|
||||
func (s *RedisCachedCharStore) NextNumericID(ctx context.Context) (int, error) {
|
||||
nextFromDB, err := s.backing.NextNumericID(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seed := nextFromDB - 1
|
||||
if seed < 0 {
|
||||
seed = 0
|
||||
}
|
||||
next, err := redis.Int(s.executor.Do(ctx, "EVAL", redisNextIDScript, 1, s.nextIDKey(), seed))
|
||||
if err != nil {
|
||||
return nextFromDB, nil
|
||||
}
|
||||
if next < nextFromDB {
|
||||
return nextFromDB, nil
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) CacheRecord(ctx context.Context, record CharacterRecord) error {
|
||||
record = CloneCharacter(record)
|
||||
if strings.TrimSpace(record.CharacterID) == "" {
|
||||
return db.ErrRecordKeyRequired
|
||||
}
|
||||
commands, err := s.recordCommands(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.DoRedisBatch(ctx, s.executor, commands)
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) cacheAccountList(ctx context.Context, accountID string, records []CharacterRecord) error {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return db.ErrRecordKeyRequired
|
||||
}
|
||||
commands := []db.RedisCommand{{Name: "DEL", Args: []any{s.AccountKey(accountID)}}}
|
||||
for _, record := range records {
|
||||
if strings.TrimSpace(record.CharacterID) == "" {
|
||||
continue
|
||||
}
|
||||
commands = append(commands, db.RedisCommand{
|
||||
Name: "ZADD",
|
||||
Args: []any{s.AccountKey(accountID), record.Slot, record.CharacterID},
|
||||
})
|
||||
recordCommands, err := s.recordCommands(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commands = append(commands, recordCommands...)
|
||||
}
|
||||
commands = s.appendSet(commands, s.accountReadyKey(accountID), "1")
|
||||
if s.options.TTL > 0 {
|
||||
commands = append(commands, db.RedisCommand{Name: "EXPIRE", Args: []any{s.AccountKey(accountID), ttlSeconds(s.options.TTL)}})
|
||||
}
|
||||
return db.DoRedisBatch(ctx, s.executor, commands)
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) recordCommands(record CharacterRecord) ([]db.RedisCommand, error) {
|
||||
commands := make([]db.RedisCommand, 0, 6)
|
||||
if strings.TrimSpace(record.AccountID) != "" {
|
||||
commands = append(commands, db.RedisCommand{
|
||||
Name: "ZADD",
|
||||
Args: []any{s.AccountKey(record.AccountID), record.Slot, record.CharacterID},
|
||||
})
|
||||
if s.options.TTL > 0 {
|
||||
commands = append(commands, db.RedisCommand{Name: "EXPIRE", Args: []any{s.AccountKey(record.AccountID), ttlSeconds(s.options.TTL)}})
|
||||
}
|
||||
commands = s.appendSet(commands, s.accountReadyKey(record.AccountID), "1")
|
||||
}
|
||||
if strings.TrimSpace(record.Name) != "" {
|
||||
commands = s.appendSet(commands, s.NameKey(record.Name), record.CharacterID)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) setValue(ctx context.Context, key string, value any) error {
|
||||
commands := s.appendSet(nil, key, value)
|
||||
return db.DoRedisBatch(ctx, s.executor, commands)
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) appendSet(commands []db.RedisCommand, key string, value any) []db.RedisCommand {
|
||||
if s.options.TTL > 0 {
|
||||
return append(commands, db.RedisCommand{
|
||||
Name: "SETEX",
|
||||
Args: []any{key, ttlSeconds(s.options.TTL), value},
|
||||
})
|
||||
}
|
||||
return append(commands, db.RedisCommand{
|
||||
Name: "SET",
|
||||
Args: []any{key, value},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) AccountKey(accountID string) string {
|
||||
return s.options.KeyPrefix + ":account:" + digestRedisKey(accountID) + ":characters"
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) accountReadyKey(accountID string) string {
|
||||
return s.AccountKey(accountID) + ":ready"
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) NameKey(name string) string {
|
||||
return s.options.KeyPrefix + ":character_name:" + digestRedisKey(name)
|
||||
}
|
||||
|
||||
func (s *RedisCachedCharStore) nextIDKey() string {
|
||||
return s.options.KeyPrefix + ":character:next_id"
|
||||
}
|
||||
|
||||
func normalizeRedisCacheOptions(options RedisCacheOptions) RedisCacheOptions {
|
||||
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
|
||||
if options.KeyPrefix == "" {
|
||||
options.KeyPrefix = defaultRedisCachePrefix
|
||||
}
|
||||
if options.TTL < 0 {
|
||||
options.TTL = 0
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func digestRedisKey(value string) string {
|
||||
sum := sha1.Sum([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ttlSeconds(ttl time.Duration) int {
|
||||
seconds := int(ttl / time.Second)
|
||||
if seconds <= 0 {
|
||||
return 1
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func cloneCharacters(records []CharacterRecord) []CharacterRecord {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]CharacterRecord, len(records))
|
||||
for idx, record := range records {
|
||||
out[idx] = CloneCharacter(record)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user