DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
// Package version implements pvfine's local, logical-file version store.
//
// A version repository stores PVF entry payloads and paths. The packed PVF
// file remains an export artifact and is never used as a binary diff target.
package version
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"pvfine/internal/pvf"
)
// Operation describes how a path changed between two logical snapshots.
type Operation string
const (
OperationAdd Operation = "add"
OperationModify Operation = "modify"
OperationDelete Operation = "delete"
OperationRename Operation = "rename"
)
// Entry is the versioned state of one logical PVF file.
type Entry struct {
Path string
DataType int32
Hash string
}
// Snapshot is keyed by a canonical, case-insensitive PVF path.
type Snapshot map[string]Entry
// Content is an entry together with its logical payload. Script and Unicode
// files use stable canonical rendered text because script token bytes refer to
// PVF string pools; unknown types retain their raw bytes. It is used by the
// in-memory undo stack, while committed content is wrapped and stored in
// ObjectStore.
type Content struct {
Entry
Raw []byte
}
// ContentSnapshot contains only the paths affected by one mutation.
type ContentSnapshot map[string]Content
// FileChange is the compact change record stored in a commit.
type FileChange struct {
Path string
DisplayPath string
OldPath string
OldDisplayPath string
Operation Operation
BeforeHash string
AfterHash string
BeforeType int32
AfterType int32
}
// Commit is the public repository commit model.
type Commit struct {
ID string
ParentID string
Message string
CreatedAt int64
ChangeCount int
}
// ArtifactState identifies the last PVF artifact written by the editor.
// ArtifactCommitID is the HEAD at the time of that save; it may differ from
// the repository's current HEAD when a commit was created without saving.
type ArtifactState struct {
PVFHash string
TreeHash string
CommitID string
}
// Meta describes repository compatibility and recovery information.
type Meta struct {
Format int
BaseFile string
BaseHash string
HashAlgorithm string
Artifact ArtifactState
}
// CanonicalPath returns the path identity used by the repository. PVF path
// lookup is case-insensitive, so case-only differences are intentionally not
// treated as distinct files in V1.
func CanonicalPath(path string) string {
path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
path = strings.Trim(path, "/")
return strings.ToLower(path)
}
// DisplayPath normalizes separators while retaining the original casing for
// UI display and newly-created archive entries.
func DisplayPath(path string) string {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/")
}
// HashBytes returns the V1 content hash format used in the object store.
func HashBytes(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// EncodeContent makes an object self-describing. The four-byte type prefix is
// part of the hashed object, so identical text in different PVF data types is
// never confused during checkout.
func EncodeContent(dataType int32, data []byte) []byte {
result := make([]byte, 4+len(data))
binary.LittleEndian.PutUint32(result, uint32(dataType))
copy(result[4:], data)
return result
}
// DecodeContent validates and unwraps an object payload.
func DecodeContent(raw []byte) (int32, []byte, error) {
if len(raw) < 4 {
return 0, nil, fmt.Errorf("版本对象内容过短")
}
return int32(binary.LittleEndian.Uint32(raw)), append([]byte(nil), raw[4:]...), nil
}
func contentFromArchive(a *pvf.Archive, index int32) (Content, error) {
file := a.File(index)
var data []byte
var err error
switch file.DataType {
case pvf.TypeScript, pvf.TypeUnicode:
var text string
text, err = a.CanonicalText(index)
data = []byte(text)
default:
var raw []byte
raw, err = a.RawBytes(index)
data = append([]byte(nil), raw...)
}
if err != nil {
return Content{}, err
}
return Content{
Entry: Entry{Path: DisplayPath(a.Path(index)), DataType: file.DataType},
Raw: data,
}, nil
}
func finalizeContent(content Content) Content {
content.Entry.Hash = HashBytes(EncodeContent(content.DataType, content.Raw))
content.Raw = append([]byte(nil), content.Raw...)
return content
}
// HashFile hashes a packed PVF without retaining a second copy in memory.
func HashFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// SnapshotFromArchive reads raw payloads from an archive and returns a
// logical snapshot. It rejects duplicate canonical paths because an index is
// not a stable identity across PVF structural edits.
func SnapshotFromArchive(a *pvf.Archive) (Snapshot, error) {
if a == nil {
return nil, fmt.Errorf("版本快照的归档为空")
}
result := make(Snapshot, a.FileCount())
for index := int32(0); index < a.FileCount(); index++ {
path := DisplayPath(a.Path(index))
key := CanonicalPath(path)
if key == "" {
return nil, fmt.Errorf("文件 %d 的路径为空,无法建立版本身份", index)
}
if _, exists := result[key]; exists {
return nil, fmt.Errorf("归档包含重复路径 %q, V1 版本控制暂不支持", path)
}
content, err := contentFromArchive(a, index)
if err != nil {
return nil, fmt.Errorf("读取 %q 失败: %w", path, err)
}
content.Entry.Path = path
result[key] = finalizeContent(content).Entry
}
return result, nil
}
// ContentSnapshotFromArchive captures selected paths for an undoable
// mutation. Missing paths are intentionally omitted.
func ContentSnapshotFromArchive(a *pvf.Archive, paths []string) (ContentSnapshot, error) {
if a == nil {
return nil, fmt.Errorf("版本快照的归档为空")
}
result := make(ContentSnapshot, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, rawPath := range paths {
key := CanonicalPath(rawPath)
if key == "" {
continue
}
if _, done := seen[key]; done {
continue
}
seen[key] = struct{}{}
index, ok := a.Find(key)
if !ok {
continue
}
path := DisplayPath(a.Path(index))
content, err := contentFromArchive(a, index)
if err != nil {
return nil, fmt.Errorf("读取 %q 失败: %w", path, err)
}
content.Entry.Path = path
result[key] = finalizeContent(content)
}
return result, nil
}
// ContentObject returns the self-describing object bytes for a content value.
func ContentObject(content Content) []byte {
return EncodeContent(content.DataType, content.Raw)
}
// SameContent reports whether two content values represent the same logical
// object, including the PVF data type.
func SameContent(left, right Content) bool {
return left.DataType == right.DataType && bytes.Equal(left.Raw, right.Raw)
}
// SnapshotFromContent converts a partial content snapshot to a regular
// snapshot, preserving only present entries.
func SnapshotFromContent(values ContentSnapshot) Snapshot {
result := make(Snapshot, len(values))
for key, value := range values {
result[key] = value.Entry
}
return result
}
// CloneSnapshot makes a detached snapshot copy.
func CloneSnapshot(values Snapshot) Snapshot {
result := make(Snapshot, len(values))
for key, value := range values {
result[key] = value
}
return result
}
// CloneContentSnapshot makes a detached content snapshot copy.
func CloneContentSnapshot(values ContentSnapshot) ContentSnapshot {
result := make(ContentSnapshot, len(values))
for key, value := range values {
result[key] = Content{Entry: value.Entry, Raw: append([]byte(nil), value.Raw...)}
}
return result
}
// TreeHash is a deterministic hash of a logical snapshot, independent of
// map iteration order and PVF binary layout.
func TreeHash(values Snapshot) string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
h := sha256.New()
for _, key := range keys {
entry := values[key]
// Length-prefix strings so unusual PVF paths cannot create ambiguous
// concatenations.
_, _ = h.Write([]byte(strconv.Itoa(len(key))))
_, _ = h.Write([]byte(":"))
_, _ = h.Write([]byte(key))
_, _ = h.Write([]byte(":"))
_, _ = h.Write([]byte(strconv.FormatInt(int64(entry.DataType), 10)))
_, _ = h.Write([]byte(":"))
_, _ = h.Write([]byte(entry.Hash))
_, _ = h.Write([]byte("\n"))
}
return hex.EncodeToString(h.Sum(nil))
}
// Diff returns deterministic path-level changes from before to after.
func Diff(before, after Snapshot) []FileChange {
keys := make(map[string]struct{}, len(before)+len(after))
for key := range before {
keys[key] = struct{}{}
}
for key := range after {
keys[key] = struct{}{}
}
ordered := make([]string, 0, len(keys))
for key := range keys {
ordered = append(ordered, key)
}
sort.Strings(ordered)
changes := make([]FileChange, 0, len(ordered))
for _, key := range ordered {
oldEntry, hadOld := before[key]
newEntry, hasNew := after[key]
switch {
case !hadOld && hasNew:
changes = append(changes, FileChange{
Path: key,
DisplayPath: newEntry.Path,
Operation: OperationAdd,
AfterHash: newEntry.Hash,
AfterType: newEntry.DataType,
})
case hadOld && !hasNew:
changes = append(changes, FileChange{
Path: key,
DisplayPath: oldEntry.Path,
Operation: OperationDelete,
BeforeHash: oldEntry.Hash,
BeforeType: oldEntry.DataType,
})
case hadOld && hasNew && (oldEntry.Hash != newEntry.Hash || oldEntry.DataType != newEntry.DataType):
changes = append(changes, FileChange{
Path: key,
DisplayPath: newEntry.Path,
Operation: OperationModify,
BeforeHash: oldEntry.Hash,
AfterHash: newEntry.Hash,
BeforeType: oldEntry.DataType,
AfterType: newEntry.DataType,
})
}
}
return changes
}
+128
View File
@@ -0,0 +1,128 @@
package version
import (
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ObjectStore stores raw logical file payloads by SHA-256.
type ObjectStore struct {
root string
}
func newObjectStore(root string) *ObjectStore { return &ObjectStore{root: root} }
// Put atomically writes raw content and returns its content hash. Existing
// objects are immutable and are reused.
func (s *ObjectStore) Put(raw []byte) (string, error) {
if s == nil {
return "", errors.New("对象存储为空")
}
hash := HashBytes(raw)
target, err := s.pathFor(hash)
if err != nil {
return "", err
}
if _, err := os.Stat(target); err == nil {
return hash, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
tmp, err := os.CreateTemp(filepath.Dir(target), ".object-*.tmp")
if err != nil {
return "", err
}
tmpPath := tmp.Name()
removeTemp := true
defer func() {
if removeTemp {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
return "", err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
if err := os.Rename(tmpPath, target); err != nil {
// Another writer may have installed the immutable object first.
if _, statErr := os.Stat(target); statErr == nil {
return hash, nil
}
return "", err
}
removeTemp = false
return hash, nil
}
// Read returns an immutable object payload.
func (s *ObjectStore) Read(hash string) ([]byte, error) {
path, err := s.pathFor(hash)
if err != nil {
return nil, err
}
return os.ReadFile(path)
}
func (s *ObjectStore) pathFor(hash string) (string, error) {
hash = strings.ToLower(strings.TrimSpace(hash))
if len(hash) != 64 {
return "", fmt.Errorf("对象 hash 长度无效: %q", hash)
}
if _, err := hex.DecodeString(hash); err != nil {
return "", fmt.Errorf("对象 hash 无效: %w", err)
}
return filepath.Join(s.root, hash[:2], hash[2:]), nil
}
func copyFileAtomic(source, target string) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(target), ".copy-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
removeTemp := true
defer func() {
if removeTemp {
_ = os.Remove(tmpPath)
}
}()
if _, err := io.Copy(tmp, input); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, target); err != nil {
return err
}
removeTemp = false
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package version
import (
"testing"
"pvfine/internal/pvf"
"pvfine/internal/rendering"
)
func TestSnapshotHashIgnoresPresentationRenderer(t *testing.T) {
a := pvf.New()
index, err := a.AddFileText("skills/test.skl", "[records]\n1 2 3 4 5\n[/records]", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
before, err := ContentSnapshotFromArchive(a, []string{a.Path(index)})
if err != nil {
t.Fatal(err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "skl.records",
"match": {"extensions": [".skl"]},
"target": {"kind": "section", "section": "records"},
"format": {"tokensPerLine": 2}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
after, err := ContentSnapshotFromArchive(a, []string{a.Path(index)})
if err != nil {
t.Fatal(err)
}
key := CanonicalPath(a.Path(index))
if before[key].Hash != after[key].Hash {
t.Fatalf("snapshot hash changed after presentation override: before=%s after=%s", before[key].Hash, after[key].Hash)
}
}
+816
View File
@@ -0,0 +1,816 @@
package version
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
const (
repositoryFormat = 1
hashAlgorithm = "sha256"
mainRef = "main"
snapshotCacheMax = 4
)
var (
ErrRepositoryNotFound = errors.New("版本库不存在")
ErrRepositoryCorrupt = errors.New("版本库损坏")
)
// Repository is a local sidecar version database and immutable object store.
type Repository struct {
mu sync.Mutex
root string
db *sql.DB
objects *ObjectStore
meta Meta
baseSnapshot Snapshot
snapshotCache map[string]Snapshot
snapshotOrder []string
commitCache map[string]Commit
}
// SidecarPath returns the repository directory for an artifact path.
func SidecarPath(artifactPath string) string { return artifactPath + ".pvfine" }
// Open opens an existing sidecar repository.
func Open(root string) (*Repository, error) {
root = filepath.Clean(root)
if _, err := os.Stat(filepath.Join(root, "repo.db")); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrRepositoryNotFound
}
return nil, err
}
db, err := sql.Open("sqlite", filepath.Join(root, "repo.db"))
if err != nil {
return nil, err
}
repo := &Repository{
root: root,
db: db,
objects: newObjectStore(filepath.Join(root, "objects")),
snapshotCache: make(map[string]Snapshot),
commitCache: make(map[string]Commit),
}
if err := repo.configure(); err != nil {
_ = db.Close()
return nil, err
}
if err := repo.loadMeta(); err != nil {
_ = db.Close()
return nil, err
}
return repo, nil
}
// Init creates a repository with one immutable base snapshot and an initial
// commit. The caller must pass an unmodified archive snapshot.
func Init(root, baseFilePath, baseHash string, base Snapshot) (*Repository, error) {
root = filepath.Clean(root)
if _, err := os.Stat(filepath.Join(root, "repo.db")); err == nil {
return nil, fmt.Errorf("版本库已存在: %s", root)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(filepath.Join(root, "objects"), 0o755); err != nil {
return nil, err
}
basePath := filepath.Join(root, "base.pvf")
if err := copyFileAtomic(baseFilePath, basePath); err != nil {
return nil, fmt.Errorf("保存版本基线失败: %w", err)
}
db, err := sql.Open("sqlite", filepath.Join(root, "repo.db"))
if err != nil {
_ = os.Remove(basePath)
return nil, err
}
repo := &Repository{
root: root,
db: db,
objects: newObjectStore(filepath.Join(root, "objects")),
snapshotCache: make(map[string]Snapshot),
commitCache: make(map[string]Commit),
}
if err := repo.configure(); err != nil {
_ = db.Close()
return nil, err
}
if err := repo.initialize(baseFilePath, baseHash, base); err != nil {
_ = db.Close()
return nil, err
}
return repo, nil
}
func (r *Repository) configure() error {
statements := []string{
"PRAGMA busy_timeout = 5000",
"PRAGMA foreign_keys = ON",
"PRAGMA journal_mode = WAL",
"PRAGMA synchronous = FULL",
`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS refs (
name TEXT PRIMARY KEY,
commit_id TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS commits (
id TEXT PRIMARY KEY,
parent_id TEXT,
message TEXT NOT NULL,
created_at INTEGER NOT NULL,
change_count INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS changes (
commit_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
path TEXT NOT NULL,
display_path TEXT NOT NULL,
old_path TEXT NOT NULL DEFAULT '',
old_display_path TEXT NOT NULL DEFAULT '',
operation TEXT NOT NULL,
before_hash TEXT NOT NULL DEFAULT '',
after_hash TEXT NOT NULL DEFAULT '',
before_type INTEGER NOT NULL DEFAULT 0,
after_type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (commit_id, sequence),
FOREIGN KEY (commit_id) REFERENCES commits(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS changes_path_idx ON changes(path, commit_id)`,
`CREATE TABLE IF NOT EXISTS base_entries (
path TEXT PRIMARY KEY,
display_path TEXT NOT NULL,
data_type INTEGER NOT NULL,
hash TEXT NOT NULL
)`,
}
for _, statement := range statements {
if _, err := r.db.Exec(statement); err != nil {
return err
}
}
return nil
}
func (r *Repository) initialize(baseFilePath, baseHash string, base Snapshot) error {
rootID, err := newID()
if err != nil {
return err
}
now := time.Now().UTC().UnixNano()
tx, err := r.db.Begin()
if err != nil {
return err
}
rollback := true
defer func() {
if rollback {
_ = tx.Rollback()
}
}()
meta := map[string]string{
"format": fmt.Sprint(repositoryFormat),
"base_file": filepath.Base(baseFilePath),
"base_hash": baseHash,
"hash_algorithm": hashAlgorithm,
"artifact_hash": baseHash,
"artifact_tree_hash": TreeHash(base),
"artifact_commit_id": rootID,
}
for key, value := range meta {
if _, err := tx.Exec(`INSERT INTO meta(key, value) VALUES(?, ?)`, key, value); err != nil {
return err
}
}
if _, err := tx.Exec(
`INSERT INTO commits(id, parent_id, message, created_at, change_count) VALUES(?, NULL, ?, ?, 0)`,
rootID, "初始版本", now,
); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO refs(name, commit_id) VALUES(?, ?)`, mainRef, rootID); err != nil {
return err
}
keys := make([]string, 0, len(base))
for key := range base {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
entry := base[key]
if _, err := tx.Exec(
`INSERT INTO base_entries(path, display_path, data_type, hash) VALUES(?, ?, ?, ?)`,
key, entry.Path, entry.DataType, entry.Hash,
); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
rollback = false
r.baseSnapshot = CloneSnapshot(base)
r.snapshotCache = make(map[string]Snapshot)
r.snapshotOrder = nil
r.commitCache = map[string]Commit{
rootID: {ID: rootID, Message: "初始版本", CreatedAt: now},
}
return r.loadMeta()
}
func (r *Repository) loadMeta() error {
values := make(map[string]string)
rows, err := r.db.Query(`SELECT key, value FROM meta`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return err
}
values[key] = value
}
if err := rows.Err(); err != nil {
return err
}
if values["format"] != "1" || values["hash_algorithm"] != hashAlgorithm || values["base_hash"] == "" {
return fmt.Errorf("%w:不支持的格式或元数据不完整", ErrRepositoryCorrupt)
}
if _, err := os.Stat(filepath.Join(r.root, "base.pvf")); err != nil {
return fmt.Errorf("%w:基线文件不存在", ErrRepositoryCorrupt)
}
r.meta = Meta{
Format: repositoryFormat,
BaseFile: values["base_file"],
BaseHash: values["base_hash"],
HashAlgorithm: values["hash_algorithm"],
Artifact: ArtifactState{
PVFHash: values["artifact_hash"],
TreeHash: values["artifact_tree_hash"],
CommitID: values["artifact_commit_id"],
},
}
return nil
}
// Close closes the SQLite handle. It is safe to call more than once.
func (r *Repository) Close() error {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if r.db == nil {
return nil
}
err := r.db.Close()
r.db = nil
r.baseSnapshot = nil
r.snapshotCache = nil
r.snapshotOrder = nil
r.commitCache = nil
return err
}
func (r *Repository) Root() string { return r.root }
func (r *Repository) BasePath() string { return filepath.Join(r.root, "base.pvf") }
func (r *Repository) Meta() Meta {
r.mu.Lock()
defer r.mu.Unlock()
return r.meta
}
func (r *Repository) Head() (Commit, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.headLocked()
}
// GetCommit returns a commit by ID and verifies that it exists in the
// repository's commit graph.
func (r *Repository) GetCommit(id string) (Commit, error) {
r.mu.Lock()
defer r.mu.Unlock()
if strings.TrimSpace(id) == "" {
return Commit{}, errors.New("提交 ID 不能为空")
}
return r.commitLocked(strings.TrimSpace(id))
}
func (r *Repository) headLocked() (Commit, error) {
var id string
if err := r.db.QueryRow(`SELECT commit_id FROM refs WHERE name = ?`, mainRef).Scan(&id); err != nil {
return Commit{}, err
}
return r.commitLocked(id)
}
func (r *Repository) commitLocked(id string) (Commit, error) {
if commit, ok := r.commitCache[id]; ok {
return commit, nil
}
var commit Commit
var parent sql.NullString
if err := r.db.QueryRow(
`SELECT id, parent_id, message, created_at, change_count FROM commits WHERE id = ?`, id,
).Scan(&commit.ID, &parent, &commit.Message, &commit.CreatedAt, &commit.ChangeCount); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Commit{}, fmt.Errorf("%w:找不到提交 %s", ErrRepositoryCorrupt, id)
}
return Commit{}, err
}
if parent.Valid {
commit.ParentID = parent.String
}
if r.commitCache == nil {
r.commitCache = make(map[string]Commit)
}
r.commitCache[id] = commit
return commit, nil
}
// History walks the main branch from HEAD backwards. Cursor is an offset in
// that chain rather than a timestamp, which keeps pagination deterministic.
func (r *Repository) History(cursor, limit int) ([]Commit, int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if cursor < 0 {
cursor = 0
}
if limit <= 0 || limit > 200 {
limit = 50
}
head, err := r.headLocked()
if err != nil {
return nil, -1, err
}
chain := make([]Commit, 0)
current := head
for {
chain = append(chain, current)
if current.ParentID == "" {
break
}
current, err = r.commitLocked(current.ParentID)
if err != nil {
return nil, -1, err
}
if len(chain) > 1_000_000 {
return nil, -1, fmt.Errorf("%w:提交链过长或存在循环", ErrRepositoryCorrupt)
}
}
if cursor >= len(chain) {
return []Commit{}, -1, nil
}
end := cursor + limit
if end > len(chain) {
end = len(chain)
}
result := make([]Commit, end-cursor)
copy(result, chain[cursor:end])
next := -1
if end < len(chain) {
next = end
}
return result, next, nil
}
// Changes returns one commit's compact path changes.
func (r *Repository) Changes(commitID string) ([]FileChange, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.changesLocked(commitID)
}
// ChangedPathsBetween returns the union of paths changed between two commits
// on the single main branch. It intentionally returns only identities, so a
// caller can materialize the target snapshot without scanning every file.
func (r *Repository) ChangedPathsBetween(fromID, toID string) ([]string, error) {
r.mu.Lock()
defer r.mu.Unlock()
fromID = strings.TrimSpace(fromID)
toID = strings.TrimSpace(toID)
if fromID == "" || toID == "" {
return nil, errors.New("起止提交 ID 不能为空")
}
if fromID == toID {
return []string{}, nil
}
fromAncestors := make(map[string]struct{})
for currentID := fromID; currentID != ""; {
if _, exists := fromAncestors[currentID]; exists {
return nil, fmt.Errorf("%w:提交链存在循环", ErrRepositoryCorrupt)
}
fromAncestors[currentID] = struct{}{}
commit, err := r.commitLocked(currentID)
if err != nil {
return nil, err
}
currentID = commit.ParentID
if len(fromAncestors) > 1_000_000 {
return nil, fmt.Errorf("%w:提交链过长", ErrRepositoryCorrupt)
}
}
pathSet := make(map[string]struct{})
addCommitChanges := func(commitID string) error {
changes, err := r.changesLocked(commitID)
if err != nil {
return err
}
for _, change := range changes {
pathSet[CanonicalPath(change.Path)] = struct{}{}
}
return nil
}
// Walk from the target backwards until the first common ancestor. This
// covers the target-only side of the interval.
targetOnly := make([]string, 0)
commonID := ""
for currentID := toID; currentID != ""; {
if _, common := fromAncestors[currentID]; common {
commonID = currentID
break
}
targetOnly = append(targetOnly, currentID)
commit, err := r.commitLocked(currentID)
if err != nil {
return nil, err
}
currentID = commit.ParentID
if len(targetOnly) > 1_000_000 {
return nil, fmt.Errorf("%w:提交链过长", ErrRepositoryCorrupt)
}
}
if commonID == "" {
return nil, fmt.Errorf("%w:提交不属于同一主分支", ErrRepositoryCorrupt)
}
for _, commitID := range targetOnly {
if err := addCommitChanges(commitID); err != nil {
return nil, err
}
}
// Walk from the source back to the common ancestor. These are the source
// commits that must be undone when moving to the target.
for currentID := fromID; currentID != commonID; {
if err := addCommitChanges(currentID); err != nil {
return nil, err
}
commit, err := r.commitLocked(currentID)
if err != nil {
return nil, err
}
currentID = commit.ParentID
if currentID == "" {
return nil, fmt.Errorf("%w:找不到共同祖先", ErrRepositoryCorrupt)
}
}
result := make([]string, 0, len(pathSet))
for path := range pathSet {
if path != "" {
result = append(result, path)
}
}
sort.Strings(result)
return result, nil
}
func (r *Repository) changesLocked(commitID string) ([]FileChange, error) {
rows, err := r.db.Query(`
SELECT path, display_path, old_path, old_display_path, operation,
before_hash, after_hash, before_type, after_type
FROM changes WHERE commit_id = ? ORDER BY sequence`, commitID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]FileChange, 0)
for rows.Next() {
var change FileChange
if err := rows.Scan(
&change.Path, &change.DisplayPath, &change.OldPath, &change.OldDisplayPath,
&change.Operation, &change.BeforeHash, &change.AfterHash,
&change.BeforeType, &change.AfterType,
); err != nil {
return nil, err
}
result = append(result, change)
}
return result, rows.Err()
}
// Snapshot reconstructs a commit's logical tree from the base manifest and
// its parent deltas.
func (r *Repository) Snapshot(commitID string) (Snapshot, error) {
r.mu.Lock()
defer r.mu.Unlock()
if commitID == "" {
commit, err := r.headLocked()
if err != nil {
return nil, err
}
commitID = commit.ID
}
return r.snapshotLocked(commitID)
}
func (r *Repository) baseSnapshotLocked() (Snapshot, error) {
if r.baseSnapshot != nil {
return CloneSnapshot(r.baseSnapshot), nil
}
rows, err := r.db.Query(`SELECT path, display_path, data_type, hash FROM base_entries ORDER BY path`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(Snapshot)
for rows.Next() {
var key, path, hash string
var dataType int32
if err := rows.Scan(&key, &path, &dataType, &hash); err != nil {
return nil, err
}
result[key] = Entry{Path: path, DataType: dataType, Hash: hash}
}
if err := rows.Err(); err != nil {
return nil, err
}
r.baseSnapshot = CloneSnapshot(result)
return result, nil
}
// PutObject stores one after-state payload before its commit transaction.
func (r *Repository) PutObject(raw []byte) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.objects.Put(raw)
}
func (r *Repository) ReadObject(hash string) ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.objects.Read(hash)
}
// Commit appends a commit to main after validating its parent and before
// hashes. Object writes are immutable; an interrupted database transaction
// can leave harmless unreachable objects which can be collected later.
func (r *Repository) Commit(message string, changes []FileChange, objects map[string][]byte) (Commit, error) {
r.mu.Lock()
defer r.mu.Unlock()
message = strings.TrimSpace(message)
if message == "" {
return Commit{}, errors.New("提交说明不能为空")
}
if len(changes) == 0 {
return Commit{}, errors.New("没有可提交的变更")
}
head, err := r.headLocked()
if err != nil {
return Commit{}, err
}
headSnapshot, err := r.snapshotLocked(head.ID)
if err != nil {
return Commit{}, err
}
for _, change := range changes {
if err := validateChange(headSnapshot, change); err != nil {
return Commit{}, err
}
if change.AfterHash != "" {
raw, ok := objects[change.AfterHash]
if !ok {
existing, readErr := r.objects.Read(change.AfterHash)
if readErr != nil {
return Commit{}, fmt.Errorf("提交 %q 缺少对象 %s: %w", change.DisplayPath, change.AfterHash, readErr)
}
if HashBytes(existing) != change.AfterHash {
return Commit{}, fmt.Errorf("对象 hash 校验失败: %s", change.AfterHash)
}
continue
}
if HashBytes(raw) != change.AfterHash {
return Commit{}, fmt.Errorf("对象 hash 校验失败: %s", change.AfterHash)
}
if _, err := r.objects.Put(raw); err != nil {
return Commit{}, err
}
}
}
id, err := newID()
if err != nil {
return Commit{}, err
}
now := time.Now().UTC().UnixNano()
tx, err := r.db.Begin()
if err != nil {
return Commit{}, err
}
rollback := true
defer func() {
if rollback {
_ = tx.Rollback()
}
}()
if _, err := tx.Exec(
`INSERT INTO commits(id, parent_id, message, created_at, change_count) VALUES(?, ?, ?, ?, ?)`,
id, head.ID, message, now, len(changes),
); err != nil {
return Commit{}, err
}
for index, change := range changes {
if _, err := tx.Exec(`
INSERT INTO changes(
commit_id, sequence, path, display_path, old_path, old_display_path,
operation, before_hash, after_hash, before_type, after_type
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, index, CanonicalPath(change.Path), DisplayPath(change.DisplayPath),
CanonicalPath(change.OldPath), DisplayPath(change.OldDisplayPath),
string(change.Operation), change.BeforeHash, change.AfterHash,
change.BeforeType, change.AfterType,
); err != nil {
return Commit{}, err
}
}
if _, err := tx.Exec(`UPDATE refs SET commit_id = ? WHERE name = ?`, id, mainRef); err != nil {
return Commit{}, err
}
if err := tx.Commit(); err != nil {
return Commit{}, err
}
rollback = false
commit := Commit{ID: id, ParentID: head.ID, Message: message, CreatedAt: now, ChangeCount: len(changes)}
if r.commitCache == nil {
r.commitCache = make(map[string]Commit)
}
r.commitCache[id] = commit
nextSnapshot := CloneSnapshot(headSnapshot)
for _, change := range changes {
key := CanonicalPath(change.Path)
if change.Operation == OperationDelete {
delete(nextSnapshot, key)
continue
}
nextSnapshot[key] = Entry{
Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash,
}
}
if r.snapshotCache == nil {
r.snapshotCache = make(map[string]Snapshot)
}
r.cacheSnapshotLocked(id, nextSnapshot)
return commit, nil
}
func validateChange(head Snapshot, change FileChange) error {
key := CanonicalPath(change.Path)
entry, exists := head[key]
switch change.Operation {
case OperationAdd:
if exists {
return fmt.Errorf("新增文件已存在: %s", change.DisplayPath)
}
if change.AfterHash == "" {
return fmt.Errorf("新增文件缺少内容: %s", change.DisplayPath)
}
case OperationModify:
if !exists {
return fmt.Errorf("修改文件不存在: %s", change.DisplayPath)
}
if entry.Hash != change.BeforeHash || entry.DataType != change.BeforeType {
return fmt.Errorf("文件在提交前已变化: %s", change.DisplayPath)
}
if change.AfterHash == "" {
return fmt.Errorf("修改文件缺少新内容: %s", change.DisplayPath)
}
case OperationDelete:
if !exists {
return fmt.Errorf("删除文件不存在: %s", change.DisplayPath)
}
if entry.Hash != change.BeforeHash || entry.DataType != change.BeforeType {
return fmt.Errorf("文件在删除前已变化: %s", change.DisplayPath)
}
default:
return fmt.Errorf("V1 暂不支持版本操作: %s", change.Operation)
}
return nil
}
func (r *Repository) snapshotLocked(commitID string) (Snapshot, error) {
if cached, ok := r.snapshotCache[commitID]; ok {
return CloneSnapshot(cached), nil
}
base, err := r.baseSnapshotLocked()
if err != nil {
return nil, err
}
chain := make([]Commit, 0)
for currentID := commitID; currentID != ""; {
commit, err := r.commitLocked(currentID)
if err != nil {
return nil, err
}
chain = append(chain, commit)
currentID = commit.ParentID
if len(chain) > 1_000_000 {
return nil, fmt.Errorf("%w:提交链过长或存在循环", ErrRepositoryCorrupt)
}
}
for index := len(chain) - 1; index >= 0; index-- {
changes, err := r.changesLocked(chain[index].ID)
if err != nil {
return nil, err
}
for _, change := range changes {
key := CanonicalPath(change.Path)
if change.Operation == OperationDelete {
delete(base, key)
continue
}
base[key] = Entry{Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash}
}
}
r.cacheSnapshotLocked(commitID, base)
return base, nil
}
func (r *Repository) cacheSnapshotLocked(commitID string, snapshot Snapshot) {
if r.snapshotCache == nil {
r.snapshotCache = make(map[string]Snapshot)
}
if _, exists := r.snapshotCache[commitID]; exists {
for index, cachedID := range r.snapshotOrder {
if cachedID == commitID {
r.snapshotOrder = append(r.snapshotOrder[:index], r.snapshotOrder[index+1:]...)
break
}
}
}
r.snapshotCache[commitID] = CloneSnapshot(snapshot)
r.snapshotOrder = append(r.snapshotOrder, commitID)
for len(r.snapshotOrder) > snapshotCacheMax {
oldest := r.snapshotOrder[0]
r.snapshotOrder = r.snapshotOrder[1:]
delete(r.snapshotCache, oldest)
}
}
// SetArtifactState records the last successful PVF save. It is intentionally
// separate from HEAD so save and commit remain independent operations.
func (r *Repository) SetArtifactState(state ArtifactState) error {
r.mu.Lock()
defer r.mu.Unlock()
values := map[string]string{
"artifact_hash": state.PVFHash,
"artifact_tree_hash": state.TreeHash,
"artifact_commit_id": state.CommitID,
}
tx, err := r.db.Begin()
if err != nil {
return err
}
rollback := true
defer func() {
if rollback {
_ = tx.Rollback()
}
}()
for key, value := range values {
if _, err := tx.Exec(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
rollback = false
r.meta.Artifact = state
return nil
}
func newID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
+148
View File
@@ -0,0 +1,148 @@
package version
import (
"bytes"
"os"
"path/filepath"
"testing"
"pvfine/internal/pvf"
)
func makeVersionFixture(t *testing.T) (string, *pvf.Archive, Snapshot) {
t.Helper()
a := pvf.New()
if _, err := a.AddFileText("skill/a.stk", "[name]\n`初始`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if _, err := a.AddFileText("text/name.str", "初始名称", pvf.TypeUnicode); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "Script.pvf")
if err := a.SaveAs(path); err != nil {
t.Fatal(err)
}
parsed, err := pvf.Open(path)
if err != nil {
t.Fatal(err)
}
snapshot, err := SnapshotFromArchive(parsed)
if err != nil {
t.Fatal(err)
}
return path, parsed, snapshot
}
func TestRepositoryCommitReopenAndMaterialize(t *testing.T) {
basePath, archive, base := makeVersionFixture(t)
baseHash, err := HashFile(basePath)
if err != nil {
t.Fatal(err)
}
root := SidecarPath(basePath)
repo, err := Init(root, basePath, baseHash, base)
if err != nil {
t.Fatal(err)
}
rootCommit, err := repo.Head()
if err != nil {
t.Fatal(err)
}
index, ok := archive.Find("skill/a.stk")
if !ok {
t.Fatal("fixture file missing")
}
if err := archive.SetText(index, "[name]\n`修改后`"); err != nil {
t.Fatal(err)
}
after, err := SnapshotFromArchive(archive)
if err != nil {
t.Fatal(err)
}
changes := Diff(base, after)
if len(changes) != 1 || changes[0].Operation != OperationModify {
t.Fatalf("changes = %#v", changes)
}
content, err := ContentSnapshotFromArchive(archive, []string{"skill/a.stk"})
if err != nil {
t.Fatal(err)
}
objects := map[string][]byte{}
for _, value := range content {
objects[value.Hash] = ContentObject(value)
}
commit, err := repo.Commit("修改技能名称", changes, objects)
if err != nil {
t.Fatal(err)
}
if commit.ParentID == "" || commit.ChangeCount != 1 {
t.Fatalf("commit = %#v", commit)
}
paths, err := repo.ChangedPathsBetween(rootCommit.ID, commit.ID)
if err != nil || len(paths) != 1 || paths[0] != "skill/a.stk" {
t.Fatalf("changed paths forward = %#v err=%v", paths, err)
}
paths, err = repo.ChangedPathsBetween(commit.ID, rootCommit.ID)
if err != nil || len(paths) != 1 || paths[0] != "skill/a.stk" {
t.Fatalf("changed paths reverse = %#v err=%v", paths, err)
}
materialized, err := repo.Snapshot(commit.ID)
if err != nil {
t.Fatal(err)
}
if materialized["skill/a.stk"].Hash != after["skill/a.stk"].Hash {
t.Fatalf("materialized = %#v after = %#v", materialized, after)
}
if materialized["text/name.str"].Hash != base["text/name.str"].Hash {
t.Fatalf("unchanged file was changed: %#v", materialized["text/name.str"])
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
head, err := reopened.Head()
if err != nil || head.ID != commit.ID {
t.Fatalf("head = %#v err=%v", head, err)
}
history, next, err := reopened.History(0, 10)
if err != nil || next >= 0 || len(history) != 2 {
t.Fatalf("history = %#v next=%d err=%v", history, next, err)
}
got, err := reopened.Snapshot("")
if err != nil {
t.Fatal(err)
}
if TreeHash(got) != TreeHash(after) {
t.Fatalf("reopened tree hash = %s want %s", TreeHash(got), TreeHash(after))
}
}
func TestObjectStoreDeduplicatesAndRejectsInvalidHash(t *testing.T) {
store := newObjectStore(filepath.Join(t.TempDir(), "objects"))
raw := []byte("object")
first, err := store.Put(raw)
if err != nil {
t.Fatal(err)
}
second, err := store.Put(raw)
if err != nil || first != second {
t.Fatalf("duplicate object = %q %q err=%v", first, second, err)
}
got, err := store.Read(first)
if err != nil || !bytes.Equal(got, raw) {
t.Fatalf("read object = %q err=%v", got, err)
}
if _, err := store.Read("not-a-hash"); err == nil {
t.Fatal("invalid hash unexpectedly accepted")
}
if _, err := os.Stat(filepath.Join(store.root, first[:2], first[2:])); err != nil {
t.Fatal(err)
}
}