服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
580 lines
17 KiB
Go
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
|
|
}
|