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

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
}