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

580 lines
17 KiB
Go

package services
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
const (
ImportModeText = "text"
ImportModeRaw = "raw"
maxImportPreviewEntries = 500
)
// ImportResult describes one atomically applied import operation.
type ImportResult struct {
TargetDir string `json:"targetDir"`
Mode string `json:"mode"`
ImportedCount int `json:"importedCount"`
OverwrittenCount int `json:"overwrittenCount"`
ChangedPaths []string `json:"changedPaths,omitempty"`
}
// ImportPreview describes the validated changes without modifying the live
// archive. Entries is capped for large directory imports; the counters remain
// complete.
type ImportPreview struct {
TargetDir string `json:"targetDir"`
Mode string `json:"mode"`
TotalFiles int `json:"totalFiles"`
ImportedCount int `json:"importedCount"`
OverwrittenCount int `json:"overwrittenCount"`
Entries []*ImportPreviewEntry `json:"entries"`
EntriesTruncated bool `json:"entriesTruncated"`
ConvertedCount int `json:"convertedCount"`
}
// ImportPreviewEntry is one validated source-to-archive mapping.
type ImportPreviewEntry struct {
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
DataType int32 `json:"dataType"`
Size int64 `json:"size"`
Overwrite bool `json:"overwrite"`
SourceEncoding string `json:"sourceEncoding"`
}
type importFile struct {
sourcePath string
targetPath string
data []byte
sourceEncoding string
text string
dataType int32
}
type importSelection struct {
path string
isDir bool
}
// ImportFilesDialog opens a native multi-selection picker and imports the
// selected files or directories into targetDir.
func (s *ArchiveService) ImportFilesDialog(targetDir, mode string) (*ImportResult, error) {
paths, err := s.SelectImportFilesDialog()
if err != nil || len(paths) == 0 {
return nil, err
}
return s.ImportFiles(paths, targetDir, mode)
}
// SelectImportFilesDialog opens the native multi-selection picker without
// changing the current archive. The returned paths can be previewed first.
func (s *ArchiveService) SelectImportFilesDialog() ([]string, error) {
return application.Get().Dialog.OpenFile().
CanChooseFiles(true).
CanChooseDirectories(true).
AddFilter("所有文件", "*").
SetTitle("导入文件").
PromptForMultipleSelection()
}
// PreviewImport validates and maps sourcePaths without modifying the live
// archive. It also stages the files on an isolated archive to catch encoding
// and index-building errors before the user confirms the operation.
func (s *ArchiveService) PreviewImport(sourcePaths []string, targetDir, mode string) (*ImportPreview, error) {
files, targetDir, mode, err := prepareImportFiles(sourcePaths, targetDir, mode)
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, errorsNoImportFiles()
}
finishTask := s.c.archiveTasks.begin()
defer finishTask()
s.c.mu.RLock()
a := s.c.archive
if a == nil {
s.c.mu.RUnlock()
return nil, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.RUnlock()
return nil, err
}
preview := &ImportPreview{
TargetDir: targetDir,
Mode: mode,
TotalFiles: len(files),
Entries: []*ImportPreviewEntry{},
}
for _, file := range files {
if file.sourceEncoding != "" && file.sourceEncoding != "UTF-8" {
preview.ConvertedCount++
}
_, overwrite := a.Find(file.targetPath)
if overwrite {
preview.OverwrittenCount++
} else {
preview.ImportedCount++
}
if len(preview.Entries) < maxImportPreviewEntries {
preview.Entries = append(preview.Entries, &ImportPreviewEntry{
SourcePath: file.sourcePath,
TargetPath: file.targetPath,
DataType: file.dataType,
Size: int64(len(file.data)),
Overwrite: overwrite,
SourceEncoding: file.sourceEncoding,
})
} else {
preview.EntriesTruncated = true
}
}
stage := a.CloneForBatch()
if _, err := applyPreparedImport(stage, files, mode); err != nil {
s.c.mu.RUnlock()
return nil, err
}
if stage.FileCount() < largeArchiveIndexThreshold {
if _, _, err := buildIndex(stage); err != nil {
s.c.mu.RUnlock()
return nil, err
}
}
if s.c.versionRepo != nil {
targetPaths := make([]string, 0, len(files))
for _, file := range files {
targetPaths = append(targetPaths, file.targetPath)
}
if _, err := pvfversion.ContentSnapshotFromArchive(stage, targetPaths); err != nil {
s.c.mu.RUnlock()
return nil, err
}
}
s.c.mu.RUnlock()
return preview, nil
}
// ImportFiles imports sourcePaths into targetDir. The source paths may be
// files or directories. All source data is read and staged before the live
// archive is replaced, so an error leaves the current archive unchanged.
func (s *ArchiveService) ImportFiles(sourcePaths []string, targetDir, mode string) (*ImportResult, error) {
files, targetDir, mode, err := prepareImportFiles(sourcePaths, targetDir, mode)
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, errorsNoImportFiles()
}
return s.importPrepared(files, targetDir, mode)
}
func (s *ArchiveService) importPrepared(files []importFile, targetDir, mode string) (*ImportResult, error) {
return s.importPreparedRevision(files, targetDir, mode, "", 0)
}
func (s *ArchiveService) importPreparedRevision(files []importFile, targetDir, mode, workspace string, expectedRevision uint64) (*ImportResult, error) {
return s.importPreparedChecked(files, targetDir, mode, "导入文件", func() error {
if workspace != "" && (s.c.mcpWorkspaceID != workspace || s.c.batchRevision != expectedRevision) {
return fmt.Errorf("STALE_IMPORT")
}
return nil
})
}
// check runs under the core lock, atomically with taking the staging snapshot.
func (s *ArchiveService) importPreparedChecked(files []importFile, targetDir, mode, label string, check func() error) (*ImportResult, error) {
var err error
s.c.mu.Lock()
if err := check(); err != nil {
s.c.mu.Unlock()
return nil, err
}
a := s.c.archive
if a == nil {
s.c.mu.Unlock()
return nil, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
targetPaths := make([]string, 0, len(files))
for _, file := range files {
targetPaths = append(targetPaths, file.targetPath)
}
var before pvfversion.ContentSnapshot
if s.c.versionRepo != nil {
before, err = pvfversion.ContentSnapshotFromArchive(a, targetPaths)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
}
stage := a.CloneForBatch()
result, err := applyPreparedImport(stage, files, mode)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
result.TargetDir = targetDir
result.Mode = mode
if len(stage.MutationsSince(0).Files) == 0 {
// An identical overwrite has no projection or semantic work to publish.
// In particular, do not reset a ready index with an empty mutation queue.
s.c.mu.Unlock()
return result, nil
}
// The staged archive is isolated, so prepare its directory/SQLite
// projection without holding the core lock. A concurrent archive mutation
// invalidates this import before it is installed.
revision := s.c.batchRevision
repo, loadID, headID := s.c.versionRepo, s.c.versionLoadID, s.c.versionHead.ID
s.c.batchRevision++
incrementalSQLite := s.c.diskIndex != nil && s.c.diskIndex.entries != nil
s.c.mu.Unlock()
// A live SQLite projection can be updated in place after the revision
// check. Do not build a second database merely to overwrite existing files.
var prepared *preparedArchiveIndexes
var prepareErr error
if !incrementalSQLite {
prepared, prepareErr = prepareArchiveDerivedIndexes(stage)
}
s.c.mu.Lock()
if prepareErr != nil {
closePreparedArchiveIndexes(prepared)
s.c.mu.Unlock()
return nil, prepareErr
}
if s.c.archive != a || s.c.batchRevision != revision+1 || s.c.versionRepo != repo || s.c.versionLoadID != loadID || s.c.versionHead.ID != headID {
closePreparedArchiveIndexes(prepared)
s.c.mu.Unlock()
return nil, ErrBatchPlanStale
}
var after pvfversion.ContentSnapshot
if s.c.versionRepo != nil {
var snapshotErr error
after, snapshotErr = pvfversion.ContentSnapshotFromArchive(stage, targetPaths)
if snapshotErr != nil {
closePreparedArchiveIndexes(prepared)
s.c.mu.Unlock()
return nil, snapshotErr
}
}
var installErr error
if incrementalSQLite {
installErr = s.c.rebuildArchiveIndexesLocked(stage)
} else {
installErr = s.c.installPreparedArchiveIndexesLocked(stage, prepared, true)
}
if installErr != nil {
closePreparedArchiveIndexes(prepared)
s.c.mu.Unlock()
return nil, installErr
}
if s.c.versionRepo != nil {
if err := s.c.recordVersionMutationLocked(label, before, after); err != nil {
s.c.mu.Unlock()
return nil, err
}
}
mutationSummary := stage.MutationsSince(0)
stage.ClearMutations()
info := stage.Info()
versioned := s.c.versionRepo != nil
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(stage, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:changed", info)
emitEvent("archive:advanced-search-stale", map[string]any{"import": true})
if versioned {
emitVersionState(s.c, "files-imported")
}
return result, nil
}
func errorsNoImportFiles() error {
return fmt.Errorf("没有可导入的文件")
}
func prepareImportFiles(sourcePaths []string, targetDir, mode string) ([]importFile, string, string, error) {
var err error
targetDir, err = normalizeImportTargetDir(targetDir)
if err != nil {
return nil, "", "", err
}
mode, err = normalizeImportMode(mode)
if err != nil {
return nil, "", "", err
}
files, err := collectImportFiles(sourcePaths, targetDir)
if err != nil {
return nil, "", "", err
}
if len(files) == 0 {
return files, targetDir, mode, nil
}
if err := readImportFiles(files, mode); err != nil {
return nil, "", "", err
}
return files, targetDir, mode, nil
}
func normalizeImportMode(mode string) (string, error) {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "" {
mode = ImportModeText
}
if mode != ImportModeText && mode != ImportModeRaw {
return "", fmt.Errorf("不支持的导入模式: %q", mode)
}
return mode, nil
}
func normalizeImportTargetDir(raw string) (string, error) {
dir := strings.Trim(strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/"), "/")
if dir == "" {
return "", nil
}
return normalizeNewFilePath(dir)
}
func collectImportFiles(sourcePaths []string, targetDir string) ([]importFile, error) {
return collectImportFilesLimited(sourcePaths, targetDir, 0)
}
func collectImportFilesLimited(sourcePaths []string, targetDir string, maxFiles int) ([]importFile, error) {
selections := make([]importSelection, 0, len(sourcePaths))
seenSelections := make(map[string]struct{}, len(sourcePaths))
for _, rawPath := range sourcePaths {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
continue
}
path, err := filepath.Abs(filepath.Clean(rawPath))
if err != nil {
return nil, fmt.Errorf("解析来源路径 %q 失败: %w", rawPath, err)
}
if _, exists := seenSelections[path]; exists {
continue
}
seenSelections[path] = struct{}{}
info, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("读取来源路径 %q 失败: %w", rawPath, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("不支持符号链接来源: %q", rawPath)
}
if info.IsDir() {
selections = append(selections, importSelection{path: path, isDir: true})
continue
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("来源不是普通文件或目录: %q", rawPath)
}
selections = append(selections, importSelection{path: path})
}
sort.Slice(selections, func(i, j int) bool {
return selections[i].path < selections[j].path
})
selectedDirs := make([]string, 0)
filtered := selections[:0]
for _, selection := range selections {
redundant := false
for _, dir := range selectedDirs {
if selection.path != dir && isPathWithin(dir, selection.path) {
redundant = true
break
}
}
if redundant {
continue
}
filtered = append(filtered, selection)
if selection.isDir {
selectedDirs = append(selectedDirs, selection.path)
}
}
files := make([]importFile, 0)
seenSources := make(map[string]struct{})
appendFile := func(sourcePath, relativePath string) error {
if _, exists := seenSources[sourcePath]; exists {
return nil
}
seenSources[sourcePath] = struct{}{}
targetPath := targetDir
if targetPath != "" {
targetPath += "/"
}
targetPath += filepath.ToSlash(relativePath)
targetPath, err := normalizeNewFilePath(targetPath)
if err != nil {
return fmt.Errorf("来源 %q 映射到归档路径失败: %w", sourcePath, err)
}
if maxFiles > 0 && len(files) >= maxFiles {
return fmt.Errorf("IMPORT_LIMIT: 导入文件过多")
}
files = append(files, importFile{sourcePath: sourcePath, targetPath: targetPath})
return nil
}
for _, selection := range filtered {
if !selection.isDir {
if err := appendFile(selection.path, filepath.Base(selection.path)); err != nil {
return nil, err
}
continue
}
directoryName := filepath.Base(selection.path)
err := filepath.WalkDir(selection.path, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return fs.SkipDir
}
return nil
}
if entry.IsDir() {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
return infoErr
}
if !info.Mode().IsRegular() {
return nil
}
relative, relErr := filepath.Rel(selection.path, path)
if relErr != nil {
return relErr
}
return appendFile(path, filepath.Join(directoryName, relative))
})
if err != nil {
return nil, fmt.Errorf("扫描目录 %q 失败: %w", selection.path, err)
}
}
if len(files) == 0 {
return []importFile{}, nil
}
sort.Slice(files, func(i, j int) bool {
left := strings.ToLower(strings.ReplaceAll(files[i].targetPath, "\\", "/"))
right := strings.ToLower(strings.ReplaceAll(files[j].targetPath, "\\", "/"))
if left != right {
return left < right
}
return files[i].sourcePath < files[j].sourcePath
})
seenTargets := make(map[string]string, len(files))
for _, file := range files {
key := strings.ToLower(file.targetPath)
if previous, exists := seenTargets[key]; exists {
return nil, fmt.Errorf("批次内归档路径冲突: %q 与 %q 都映射到 %q", previous, file.sourcePath, file.targetPath)
}
seenTargets[key] = file.sourcePath
}
return files, nil
}
func isPathWithin(root, candidate string) bool {
relative, err := filepath.Rel(root, candidate)
if err != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) {
return false
}
prefix := ".." + string(filepath.Separator)
return !strings.HasPrefix(relative, prefix)
}
func readImportFiles(files []importFile, mode string) error {
for index := range files {
file := &files[index]
data, err := os.ReadFile(file.sourcePath)
if err != nil {
return fmt.Errorf("读取 %q 失败: %w", file.sourcePath, err)
}
file.data = data
file.dataType = importDataType(file.targetPath)
if mode == ImportModeText {
if err := file.decodeText(); err != nil {
return err
}
}
}
return nil
}
func importDataType(path string) int32 {
ext := strings.ToLower(filepath.Ext(path))
if ext == ".str" || ext == ".nut" {
return pvf.TypeUnicode
}
return pvf.TypeScript
}
func applyImportFile(a *pvf.Archive, file importFile, mode string, index int32) error {
if mode == ImportModeText {
return a.SetText(index, file.text)
}
return a.SetRawBytes(index, file.data)
}
func addImportFile(a *pvf.Archive, file importFile, mode string) error {
if mode == ImportModeText {
_, err := a.AddFileText(file.targetPath, file.text, file.dataType)
return err
}
a.AddFile(file.targetPath, file.data, file.dataType)
return nil
}
func applyPreparedImport(a *pvf.Archive, files []importFile, mode string) (*ImportResult, error) {
result := &ImportResult{ChangedPaths: []string{}}
for _, file := range files {
index, exists := a.Find(file.targetPath)
if exists {
if err := a.SetDataType(index, file.dataType); err != nil {
return nil, fmt.Errorf("设置 %q 类型失败: %w", file.targetPath, err)
}
if err := applyImportFile(a, file, mode, index); err != nil {
return nil, fmt.Errorf("导入 %q 到 %q 失败: %w", file.sourcePath, file.targetPath, err)
}
result.OverwrittenCount++
result.ChangedPaths = append(result.ChangedPaths, a.Path(index))
continue
}
if err := addImportFile(a, file, mode); err != nil {
return nil, fmt.Errorf("导入 %q 到 %q 失败: %w", file.sourcePath, file.targetPath, err)
}
result.ImportedCount++
}
return result, nil
}