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

+362
View File
@@ -0,0 +1,362 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
annotationrules "pvfine/internal/annotations"
"pvfine/internal/pvf"
)
const maxRequestBytes = 4 << 20
type editorServer struct {
mu sync.Mutex
rulesPath string
runtimePath string
listsPath string
runtimeListsPath string
htmlPath string
token string
}
type previewRequest struct {
PVFVersion string `json:"pvfVersion,omitempty"`
Path string `json:"path"`
Text string `json:"text"`
Document *annotationrules.Document `json:"document,omitempty"`
}
func main() {
root, err := findRepoRoot()
if err != nil {
log.Fatal(err)
}
server, err := newEditorServer(root)
if err != nil {
log.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
httpServer := &http.Server{
Handler: server.routes(),
ReadHeaderTimeout: 5 * time.Second,
}
url := "http://" + listener.Addr().String() + "/"
fmt.Printf("标注规则编辑器已启动: %s\n", url)
if err := openBrowser(url); err != nil {
fmt.Printf("无法自动打开浏览器,请手动访问上面的地址: %v\n", err)
}
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("规则编辑器服务异常: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
}
func newEditorServer(root string) (*editorServer, error) {
tokenBytes := make([]byte, 24)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, err
}
runtimePath, err := annotationrules.RuntimePath()
if err != nil {
return nil, err
}
runtimeListsPath, err := annotationrules.RuntimeListsPath()
if err != nil {
return nil, err
}
return &editorServer{
rulesPath: filepath.Join(root, "config", "annotations.json"),
runtimePath: runtimePath,
listsPath: filepath.Join(root, "config", "lists.json"),
runtimeListsPath: runtimeListsPath,
htmlPath: filepath.Join(root, "tools", "annotation-editor.html"),
token: hex.EncodeToString(tokenBytes),
}, nil
}
func (s *editorServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.serveHTML)
mux.Handle("/api/rules", s.authorized(http.HandlerFunc(s.handleRules)))
mux.Handle("/api/preview", s.authorized(http.HandlerFunc(s.handlePreview)))
return mux
}
func (s *editorServer) authorized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Annotation-Editor-Token") != s.token {
writeAPIError(w, http.StatusForbidden, "无效的编辑器会话")
return
}
next.ServeHTTP(w, r)
})
}
func (s *editorServer) serveHTML(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
data, err := os.ReadFile(s.htmlPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
page := strings.ReplaceAll(string(data), "__EDITOR_TOKEN__", s.token)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, page)
}
func (s *editorServer) handleRules(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
switch r.Method {
case http.MethodGet:
// The editor owns the hand-maintained rules only. config/paths.json is
// generated bulk data that must never round-trip into annotations.json.
document, err := annotationrules.LoadRulesFile(s.rulesPath)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
data, err := annotationrules.Marshal(document)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
case http.MethodPut:
data, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes+1))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if len(data) > maxRequestBytes {
writeAPIError(w, http.StatusRequestEntityTooLarge, "规则文件超过 4MB")
return
}
document, err := annotationrules.ParseRules(data)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
current, err := annotationrules.LoadRulesFile(s.rulesPath)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
document.Relations = current.Relations
if err := annotationrules.Validate(document); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
formatted, err := annotationrules.MarshalRules(document)
if s.listsPath == "" {
formatted, err = annotationrules.Marshal(document)
}
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if err := writeRulesWithBackup(s.rulesPath, formatted); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if s.runtimePath != "" {
if err := atomicWrite(s.runtimePath, formatted, 0o600); err != nil {
writeAPIError(w, http.StatusInternalServerError, "同步运行时规则失败: "+err.Error())
return
}
}
if s.runtimeListsPath != "" && s.listsPath != "" {
listsData, listsErr := os.ReadFile(s.listsPath)
if listsErr != nil {
writeAPIError(w, http.StatusInternalServerError, "读取列表配置失败: "+listsErr.Error())
return
}
if err := atomicWrite(s.runtimeListsPath, listsData, 0o600); err != nil {
writeAPIError(w, http.StatusInternalServerError, "同步运行时列表配置失败: "+err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": len(formatted)})
default:
w.Header().Set("Allow", "GET, PUT")
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
}
}
func (s *editorServer) handlePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
return
}
var request previewRequest
decoder := json.NewDecoder(io.LimitReader(r.Body, maxRequestBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeAPIError(w, http.StatusBadRequest, "预览参数无效: "+err.Error())
return
}
if request.PVFVersion != "" && !annotationrules.ValidPVFVersion(request.PVFVersion) {
writeAPIError(w, http.StatusBadRequest, "pvfVersion 只允许 90US、90CN、110US、115US、100CN、TW 或空值")
return
}
var document annotationrules.Document
if request.Document != nil {
document = *request.Document
if len(document.Relations) == 0 {
current, err := annotationrules.LoadRulesFile(s.rulesPath)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
document.Relations = current.Relations
}
if err := annotationrules.Validate(document); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
} else {
// Preview the rules the editor owns, not the generated bulk rule sets.
current, loadErr := annotationrules.LoadRulesFile(s.rulesPath)
if loadErr != nil {
writeAPIError(w, http.StatusInternalServerError, loadErr.Error())
return
}
document = current
}
engine, err := annotationrules.Compile(document)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
engine = engine.ForVersion(request.PVFVersion)
results := engine.Annotate(request.Path, pvf.ParseScriptView(request.Text), nil)
pathResults := engine.AnnotatePath(request.Path, false)
writeJSON(w, http.StatusOK, map[string]any{
"annotations": results,
"pathAnnotations": pathResults,
})
}
func writeRulesWithBackup(path string, data []byte) error {
previous, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
if err := atomicWrite(path+".bak", previous, 0o644); err != nil {
return fmt.Errorf("写入规则备份失败: %w", err)
}
}
if err := atomicWrite(path, data, 0o644); err != nil {
return fmt.Errorf("写入规则失败: %w", err)
}
return nil
}
func atomicWrite(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".annotation-rules-*.tmp")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(mode); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, path)
}
func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func findRepoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("未找到包含 go.mod 的项目根目录")
}
dir = parent
}
}
func openBrowser(url string) error {
var command *exec.Cmd
switch runtime.GOOS {
case "darwin":
command = exec.Command("open", url)
case "windows":
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
command = exec.Command("xdg-open", url)
}
return command.Start()
}
+314
View File
@@ -0,0 +1,314 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// TestAnnotationEditorGroupingFields verifies the standalone editor exposes
// both grouping controls on rules and shared fields.
func TestAnnotationEditorGroupingFields(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "tools", "annotation-editor.html"))
if err != nil {
t.Fatal(err)
}
for _, id := range []string{
"target-group-offset", "target-standalone-values",
"field-target-group-offset", "field-target-standalone-values",
"target-offset", "target-tokens-per-line",
} {
if !bytes.Contains(data, []byte(`"`+id+`"`)) {
t.Errorf("missing editor input %s", id)
}
}
}
func TestAnnotationEditorGroupingSaveAndPreview(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
document := []byte(`{
"version":1,
"rules":[{
"id":"world.drop",
"target":{"kind":"token","section":"world drop","index":0,"offset":1,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
"annotation":{"title":"掉落","type":"text"}
}],
"fields":[{
"id":"shared.drop",
"target":{"kind":"token","section":"shared drop","index":0,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
"annotation":{"title":"共享掉落","type":"text"}
}]
}`)
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("X-Annotation-Editor-Token", "test-token")
response := httptest.NewRecorder()
server.routes().ServeHTTP(response, request)
return response
}
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
t.Fatalf("save = %d: %s", response.Code, response.Body.String())
}
saved := call(http.MethodGet, "/api/rules", nil)
if saved.Code != http.StatusOK {
t.Fatalf("read = %d: %s", saved.Code, saved.Body.String())
}
var decoded struct {
Rules []struct {
Target struct {
GroupOffset int `json:"groupOffset"`
StandaloneValues []int32 `json:"standaloneValues"`
} `json:"target"`
} `json:"rules"`
Fields []struct {
Target struct {
GroupOffset int `json:"groupOffset"`
StandaloneValues []int32 `json:"standaloneValues"`
} `json:"target"`
} `json:"fields"`
}
if err := json.Unmarshal(saved.Body.Bytes(), &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.Rules) != 1 || len(decoded.Fields) != 1 ||
decoded.Rules[0].Target.GroupOffset != 1 || decoded.Fields[0].Target.GroupOffset != 1 ||
len(decoded.Rules[0].Target.StandaloneValues) != 1 || decoded.Rules[0].Target.StandaloneValues[0] != -1 ||
len(decoded.Fields[0].Target.StandaloneValues) != 1 || decoded.Fields[0].Target.StandaloneValues[0] != -1 {
t.Fatalf("grouping fields not preserved: %s", saved.Body.String())
}
text := "[world drop]\n999 10 100 200 -1 20 300 400"
payload, err := json.Marshal(map[string]any{"path": "item/a.etc", "text": text})
if err != nil {
t.Fatal(err)
}
response := call(http.MethodPost, "/api/preview", payload)
if response.Code != http.StatusOK {
t.Fatalf("preview = %d: %s", response.Code, response.Body.String())
}
var preview struct {
Annotations []struct {
Start int `json:"start"`
End int `json:"end"`
} `json:"annotations"`
}
if err := json.Unmarshal(response.Body.Bytes(), &preview); err != nil {
t.Fatal(err)
}
if len(preview.Annotations) != 2 {
t.Fatalf("preview annotation count = %d: %s", len(preview.Annotations), response.Body.String())
}
for i, want := range []string{"100", "300"} {
got := text[preview.Annotations[i].Start:preview.Annotations[i].End]
if got != want {
t.Errorf("annotation[%d] = %q, want %q", i, got, want)
}
}
}
func TestRulesAPIWritesBackupAndRejectsInvalidRules(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "default.json")
runtimePath := filepath.Join(dir, "config", "annotations.json")
original := []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n")
if err := os.WriteFile(rulesPath, original, 0o644); err != nil {
t.Fatal(err)
}
server := &editorServer{rulesPath: rulesPath, runtimePath: runtimePath, token: "test-token"}
updated := []byte(`{"version":1,"description":"updated","relations":{},"fields":[{"id":"equ.rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"稀有度","type":"enum","values":{"4":"史诗"}},"preview":{"provider":"equ","role":"rarity","group":"summary","order":10,"format":"enum"}}],"rules":[]}`)
request := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader(updated))
request.Header.Set("X-Annotation-Editor-Token", "test-token")
response := httptest.NewRecorder()
server.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
backup, err := os.ReadFile(rulesPath + ".bak")
if err != nil || !bytes.Equal(backup, original) {
t.Fatalf("backup = %q, err = %v", backup, err)
}
written, err := os.ReadFile(rulesPath)
if err != nil || !bytes.Contains(written, []byte("updated")) || !bytes.Contains(written, []byte("equ.rarity")) {
t.Fatalf("written = %q, err = %v", written, err)
}
runtimeRules, err := os.ReadFile(runtimePath)
if err != nil || !bytes.Equal(runtimeRules, written) {
t.Fatalf("runtime rules = %q, err = %v", runtimeRules, err)
}
invalid := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader([]byte("{}")))
invalid.Header.Set("X-Annotation-Editor-Token", "test-token")
invalidResponse := httptest.NewRecorder()
server.routes().ServeHTTP(invalidResponse, invalid)
if invalidResponse.Code != http.StatusBadRequest {
t.Fatalf("invalid status = %d", invalidResponse.Code)
}
afterInvalid, err := os.ReadFile(rulesPath)
if err != nil || !bytes.Equal(afterInvalid, written) {
t.Fatalf("invalid request changed rules: %q, err = %v", afterInvalid, err)
}
}
func TestPreviewAPIUsesSubmittedRulesAndIncludesPathAnnotations(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "default.json")
if err := os.WriteFile(rulesPath, []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n"), 0o644); err != nil {
t.Fatal(err)
}
server := &editorServer{rulesPath: rulesPath, token: "test-token"}
body := []byte(`{
"path":"equipment/example.equ",
"text":"[rarity]\n4",
"document":{
"version":1,
"relations":{},
"rules":[
{"id":"rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"装备品级","type":"enum","values":{"4":"史诗"}}},
{"id":"path","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备配置","type":"text"}}
]
}
}`)
request := httptest.NewRequest(http.MethodPost, "/api/preview", bytes.NewReader(body))
request.Header.Set("X-Annotation-Editor-Token", "test-token")
response := httptest.NewRecorder()
server.routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var result struct {
Annotations []map[string]any `json:"annotations"`
PathAnnotations []map[string]any `json:"pathAnnotations"`
}
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Annotations) != 1 || result.Annotations[0]["title"] != "史诗" {
t.Fatalf("annotations = %#v", result.Annotations)
}
if len(result.PathAnnotations) != 1 || result.PathAnnotations[0]["title"] != "装备配置" {
t.Fatalf("path annotations = %#v", result.PathAnnotations)
}
}
func TestVersionedRulesSaveAndPreview(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
document := json.RawMessage(`{"version":1,"fields":[{"id":"name","pvfVersions":["90US","90CN","115US"],"target":{"kind":"section","section":"name"},"annotation":{"title":"name","type":"text"}}],"rules":[{"id":"path","pvfVersions":["90US","110US"],"target":{"kind":"path"},"annotation":{"title":"path","type":"text"}}]}`)
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("X-Annotation-Editor-Token", "test-token")
response := httptest.NewRecorder()
server.routes().ServeHTTP(response, request)
return response
}
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
t.Fatalf("save: %s", response.Body.String())
}
response := call(http.MethodGet, "/api/rules", nil)
var saved struct {
Fields []struct {
PVFVersions []string `json:"pvfVersions"`
} `json:"fields"`
Rules []struct {
PVFVersions []string `json:"pvfVersions"`
} `json:"rules"`
}
if err := json.Unmarshal(response.Body.Bytes(), &saved); err != nil {
t.Fatal(err)
}
if len(saved.Fields) != 1 || len(saved.Fields[0].PVFVersions) != 3 || len(saved.Rules) != 1 || len(saved.Rules[0].PVFVersions) != 2 {
t.Fatalf("versions lost: %s", response.Body.String())
}
for _, submitted := range []bool{false, true} {
for _, tc := range []struct {
version string
annotations, paths int
}{{"90US", 1, 1}, {"90CN", 1, 0}, {"110US", 0, 1}, {"115US", 1, 0}, {"", 0, 0}} {
payload := map[string]any{"path": "a.equ", "text": "[name]\n`example`"}
if tc.version != "" {
payload["pvfVersion"] = tc.version
}
if submitted {
payload["document"] = document
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
response := call(http.MethodPost, "/api/preview", body)
if response.Code != http.StatusOK {
t.Fatalf("preview: %s", response.Body.String())
}
var result struct {
Annotations []any `json:"annotations"`
PathAnnotations []any `json:"pathAnnotations"`
}
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Annotations) != tc.annotations || len(result.PathAnnotations) != tc.paths {
t.Fatalf("%s submitted=%v: %s", tc.version, submitted, response.Body.String())
}
}
}
if response := call(http.MethodPost, "/api/preview", []byte(`{"pvfVersion":"invalid"}`)); response.Code != http.StatusBadRequest {
t.Fatal("invalid preview version accepted")
}
invalid := bytes.ReplaceAll(document, []byte("90US"), []byte("invalid"))
if response := call(http.MethodPut, "/api/rules", invalid); response.Code != http.StatusBadRequest {
t.Fatal("invalid config version accepted")
}
}
// TestRulesAPIIgnoresGeneratedPathNotes keeps the editor on the hand-maintained
// rules: a sibling paths.json must never round-trip into annotations.json.
func TestRulesAPIIgnoresGeneratedPathNotes(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
generated := []byte(`{"version":1,"rules":[{"id":"path.dir","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备","type":"text"}}]}`)
if err := os.WriteFile(filepath.Join(dir, "paths.json"), generated, 0o600); err != nil {
t.Fatal(err)
}
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
call := func(method string, body []byte) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, "/api/rules", bytes.NewReader(body))
request.Header.Set("X-Annotation-Editor-Token", "test-token")
response := httptest.NewRecorder()
server.routes().ServeHTTP(response, request)
return response
}
if read := call(http.MethodGet, nil); read.Code != http.StatusOK || bytes.Contains(read.Body.Bytes(), []byte("path.dir")) {
t.Fatalf("GET /api/rules returned generated path notes: %s", read.Body.String())
}
saved := call(http.MethodPut, []byte(`{"version":1,"rules":[{"id":"hand","target":{"kind":"section","section":"name"},"annotation":{"title":"名称","type":"text"}}]}`))
if saved.Code != http.StatusOK {
t.Fatalf("save = %d: %s", saved.Code, saved.Body.String())
}
for _, path := range []string{rulesPath, filepath.Join(dir, "runtime.json")} {
written, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(written, []byte("path.dir")) || bytes.Contains(written, []byte("equipment/**")) {
t.Fatalf("%s contains generated path rules: %s", path, written)
}
}
}