服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
266 lines
7.9 KiB
Go
266 lines
7.9 KiB
Go
package servergroup
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrMergeArchiveNotFound 表示找不到指定合服工作流归档。
|
|
var ErrMergeArchiveNotFound = errors.New("server group merge workflow archive not found")
|
|
|
|
// MergeArchiveStore 保存合服工作流归档,项目侧可以替换成 MySQL、对象存储或工单系统。
|
|
type MergeArchiveStore interface {
|
|
SaveMergeArchive(context.Context, MergeArchive) error
|
|
GetMergeArchive(context.Context, string) (MergeArchive, bool, error)
|
|
ListMergeArchives(context.Context) ([]MergeArchive, error)
|
|
}
|
|
|
|
// MemoryMergeArchiveStore 是测试和单进程工具使用的内存归档存储。
|
|
type MemoryMergeArchiveStore struct {
|
|
mu sync.RWMutex
|
|
archives map[string]MergeArchive
|
|
}
|
|
|
|
// NewMemoryMergeArchiveStore 创建内存归档存储,并可预置一组归档。
|
|
func NewMemoryMergeArchiveStore(archives ...MergeArchive) (*MemoryMergeArchiveStore, error) {
|
|
store := &MemoryMergeArchiveStore{archives: make(map[string]MergeArchive, len(archives))}
|
|
for _, archive := range archives {
|
|
normalized, err := normArchiveStore(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
store.archives[normalized.ArchiveID] = normalized
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
// SaveMergeArchive 保存或覆盖内存中的合服归档。
|
|
func (s *MemoryMergeArchiveStore) SaveMergeArchive(ctx context.Context, archive MergeArchive) error {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return err
|
|
}
|
|
if s == nil {
|
|
return ErrStoreEmpty
|
|
}
|
|
normalized, err := normArchiveStore(archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.archives == nil {
|
|
s.archives = make(map[string]MergeArchive)
|
|
}
|
|
s.archives[normalized.ArchiveID] = normalized
|
|
return nil
|
|
}
|
|
|
|
// GetMergeArchive 从内存归档存储读取指定归档。
|
|
func (s *MemoryMergeArchiveStore) GetMergeArchive(ctx context.Context, archiveID string) (MergeArchive, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return MergeArchive{}, false, err
|
|
}
|
|
if s == nil {
|
|
return MergeArchive{}, false, ErrStoreEmpty
|
|
}
|
|
archiveID = normalizeID(archiveID)
|
|
if archiveID == "" {
|
|
return MergeArchive{}, false, nil
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
archive, ok := s.archives[archiveID]
|
|
return cloneMergeArchive(archive), ok, nil
|
|
}
|
|
|
|
// ListMergeArchives 返回内存中的全部合服归档。
|
|
func (s *MemoryMergeArchiveStore) ListMergeArchives(ctx context.Context) ([]MergeArchive, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if s == nil {
|
|
return nil, ErrStoreEmpty
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
archives := make([]MergeArchive, 0, len(s.archives))
|
|
for _, archive := range s.archives {
|
|
archives = append(archives, cloneMergeArchive(archive))
|
|
}
|
|
sort.Slice(archives, func(i, j int) bool { return archives[i].ArchiveID < archives[j].ArchiveID })
|
|
return archives, nil
|
|
}
|
|
|
|
// FileMergeArchiveStore 把每份归档保存为独立 JSON 文件,适合本地演练和离线交付。
|
|
type FileMergeArchiveStore struct {
|
|
dir string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewFileMergeArchiveStore 创建文件归档存储。
|
|
func NewFileMergeArchiveStore(dir string) (*FileMergeArchiveStore, error) {
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "" {
|
|
return nil, fmt.Errorf("%w: archive store dir is required", ErrInvalidMigration)
|
|
}
|
|
return &FileMergeArchiveStore{dir: dir}, nil
|
|
}
|
|
|
|
// SaveMergeArchive 将合服归档保存为 JSON 文件。
|
|
func (s *FileMergeArchiveStore) SaveMergeArchive(ctx context.Context, archive MergeArchive) error {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return err
|
|
}
|
|
if s == nil {
|
|
return ErrStoreEmpty
|
|
}
|
|
normalized, err := normArchiveStore(archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, err := s.pathForArchive(normalized.ArchiveID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(normalized, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return writeFileReplace(path, data)
|
|
}
|
|
|
|
// GetMergeArchive 从文件归档存储读取指定归档。
|
|
func (s *FileMergeArchiveStore) GetMergeArchive(ctx context.Context, archiveID string) (MergeArchive, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return MergeArchive{}, false, err
|
|
}
|
|
if s == nil {
|
|
return MergeArchive{}, false, ErrStoreEmpty
|
|
}
|
|
path, err := s.pathForArchive(archiveID)
|
|
if err != nil {
|
|
return MergeArchive{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return MergeArchive{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return MergeArchive{}, false, err
|
|
}
|
|
archive, err := decodeMergeArchive(data)
|
|
if err != nil {
|
|
return MergeArchive{}, false, err
|
|
}
|
|
return archive, true, nil
|
|
}
|
|
|
|
// ListMergeArchives 返回文件目录中的全部合服归档。
|
|
func (s *FileMergeArchiveStore) ListMergeArchives(ctx context.Context) ([]MergeArchive, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if s == nil {
|
|
return nil, ErrStoreEmpty
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entries, err := os.ReadDir(s.dir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
archives := make([]MergeArchive, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(s.dir, entry.Name())) //nolint:gosec // G703:路径来自框架配置或 CLI 显式输出目标,调用点负责限定输入范围。
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
archive, err := decodeMergeArchive(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
archives = append(archives, archive)
|
|
}
|
|
sort.Slice(archives, func(i, j int) bool { return archives[i].ArchiveID < archives[j].ArchiveID })
|
|
return archives, nil
|
|
}
|
|
|
|
func (s *FileMergeArchiveStore) pathForArchive(archiveID string) (string, error) {
|
|
if s == nil {
|
|
return "", ErrStoreEmpty
|
|
}
|
|
fileName, err := archiveFileName(archiveID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(s.dir, fileName), nil
|
|
}
|
|
|
|
func archiveFileName(archiveID string) (string, error) {
|
|
archiveID = normalizeID(archiveID)
|
|
if archiveID == "" {
|
|
return "", fmt.Errorf("%w: archive id is required", ErrInvalidMigration)
|
|
}
|
|
if archiveID == "." || archiveID == ".." || strings.ContainsAny(archiveID, `/\`) {
|
|
return "", fmt.Errorf("%w: archive id %q must be a file name", ErrInvalidMigration, archiveID)
|
|
}
|
|
return archiveID + ".json", nil
|
|
}
|
|
|
|
func decodeMergeArchive(data []byte) (MergeArchive, error) {
|
|
var archive MergeArchive
|
|
if err := json.Unmarshal(data, &archive); err != nil {
|
|
return MergeArchive{}, err
|
|
}
|
|
return normArchiveStore(archive)
|
|
}
|
|
|
|
func normArchiveStore(archive MergeArchive) (MergeArchive, error) {
|
|
archive = cloneMergeArchive(archive)
|
|
archive.ArchiveID = normalizeID(archive.ArchiveID)
|
|
archive.Workflow = firstNonEmpty(archive.Workflow, MergeWorkflowName)
|
|
archive.Stage = normalizeID(archive.Stage)
|
|
if archive.Stage == "" {
|
|
archive.Stage = MergeStageDryRun
|
|
}
|
|
if archive.ArchiveID == "" {
|
|
return MergeArchive{}, fmt.Errorf("%w: archive id is required", ErrInvalidMigration)
|
|
}
|
|
if archive.Workflow != MergeWorkflowName {
|
|
return MergeArchive{}, fmt.Errorf("%w: archive workflow %s is invalid", ErrInvalidMigration, archive.Workflow)
|
|
}
|
|
if !validMergeStage(archive.Stage) {
|
|
return MergeArchive{}, fmt.Errorf("%w: archive stage %s is invalid", ErrInvalidMigration, archive.Stage)
|
|
}
|
|
archive.GeneratedAt = normalizeTime(archive.GeneratedAt)
|
|
if archive.GeneratedAt.IsZero() {
|
|
archive.GeneratedAt = time.Now().UTC()
|
|
}
|
|
archive.WorkflowID = firstNonEmpty(archive.WorkflowID)
|
|
archive.ApprovalID = firstNonEmpty(archive.ApprovalID)
|
|
archive.IdempotencyKey = firstNonEmpty(archive.IdempotencyKey)
|
|
archive.OperatorID = firstNonEmpty(archive.OperatorID)
|
|
archive.Reason = firstNonEmpty(archive.Reason)
|
|
archive.Meta = normalizeStringMap(archive.Meta)
|
|
return archive, nil
|
|
}
|