From 2b1c27c36ccf66e02487c131d719c05a15f1be3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E7=93=9C=C2=B0?= Date: Mon, 6 Jul 2026 13:54:22 +0800 Subject: [PATCH] remove --- backend/go/README.md | 53 - backend/go/cmd/dcalc/main.go | 52 - backend/go/go.mod | 20 - backend/go/go.sum | 53 - backend/go/internal/config/config.go | 90 -- backend/go/internal/httpserver/router.go | 305 ------ backend/go/internal/model/response.go | 15 - backend/go/internal/service/adventure.go | 137 --- backend/go/internal/service/cache.go | 44 - backend/go/internal/service/calc.go | 34 - backend/go/internal/service/character.go | 21 - backend/go/internal/service/colg.go | 304 ------ .../service/equipment_catalog_sqlite.go | 295 ------ backend/go/internal/service/openapi.go | 291 ------ backend/go/internal/service/python_adapter.go | 83 -- backend/go/internal/service/skilltree.go | 376 -------- backend/go/internal/service/token.go | 62 -- backend/go/python_bridge.py | 77 -- .../2026-06-04-backend-optimization-design.md | 237 ----- .../2026-06-04-backend-optimization-tasks.md | 908 ------------------ 20 files changed, 3457 deletions(-) delete mode 100644 backend/go/README.md delete mode 100644 backend/go/cmd/dcalc/main.go delete mode 100644 backend/go/go.mod delete mode 100644 backend/go/go.sum delete mode 100644 backend/go/internal/config/config.go delete mode 100644 backend/go/internal/httpserver/router.go delete mode 100644 backend/go/internal/model/response.go delete mode 100644 backend/go/internal/service/adventure.go delete mode 100644 backend/go/internal/service/cache.go delete mode 100644 backend/go/internal/service/calc.go delete mode 100644 backend/go/internal/service/character.go delete mode 100644 backend/go/internal/service/colg.go delete mode 100644 backend/go/internal/service/equipment_catalog_sqlite.go delete mode 100644 backend/go/internal/service/openapi.go delete mode 100644 backend/go/internal/service/python_adapter.go delete mode 100644 backend/go/internal/service/skilltree.go delete mode 100644 backend/go/internal/service/token.go delete mode 100644 backend/go/python_bridge.py delete mode 100644 docs/backend/2026-06-04-backend-optimization-design.md delete mode 100644 docs/backend/2026-06-04-backend-optimization-tasks.md diff --git a/backend/go/README.md b/backend/go/README.md deleted file mode 100644 index 6d7e37dc..00000000 --- a/backend/go/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# dcalc Go 重构骨架 - -这个目录是对现有 Python 后端的并行 Go 重构,不直接替换原服务。 - -当前已迁移: - -- 通用响应结构与 HTTP 入口 -- CORS、压缩、中间件基础设施 -- 冒险团职业列表与 token 生成 -- openapi 静态数据读取接口 -- 基于 alter_token 的技能详情接口 -- colg 金价查询接口 -- /api/character 静态职业与装备目录信息 -- /api/skillTree/ 编码解码逻辑(Go 实现) -- Python 适配层(职业工厂依赖收口) - -兼容性说明: - -- jobInfo 接口保持与 Python 一致,返回目录名风格的 jobId 和 jobGrowId -- skills 和 skillDetail 路由同时兼容目录名参数与 Neople UUID 参数 - -当前未迁移: - -- 角色构建与伤害计算公式本体 -- DNFHelper 集成 -- MCP 服务 - -当前架构: - -- skillTree 的 base64/zlib/binary 解码已经迁移到 Go -- createCharacter 依赖通过 go/python_bridge.py 适配层收口 -- calc 域已拆出 CharacterFactory 与 EquipmentCatalogRepository 边界,便于后续逐步替换 Python 依赖 - -运行: - -```bash -cd go -go run ./cmd/dcalc -``` - -可选环境变量: - -- PORT -- DEBUG_MODE -- COLG_TOKEN -- HELPER_TOKEN -- HELPER_ID - -建议迁移顺序: - -1. 先把 createCharacter 工厂后的领域模型拆成 Go interface 和 package。 -2. 再迁装备/套装/技能数据库读取层。 -3. 最后逐职业迁技能公式与计算逻辑。 diff --git a/backend/go/cmd/dcalc/main.go b/backend/go/cmd/dcalc/main.go deleted file mode 100644 index 1ca45b94..00000000 --- a/backend/go/cmd/dcalc/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "log" - "net/http" - - "dcalc-go/internal/config" - "dcalc-go/internal/httpserver" - "dcalc-go/internal/service" -) - -func main() { - cfg, err := config.Load() - if err != nil { - log.Fatalf("load config: %v", err) - } - - cache := service.NewCache() - adventureService, err := service.NewAdventureService(cfg.ProjectRoot, cache) - if err != nil { - log.Fatalf("load adventure data: %v", err) - } - - openAPIService := service.NewOpenAPIService(cfg.ProjectRoot, cache, adventureService) - colgService := service.NewColgService(cache, cfg) - tokenService := service.NewTokenService(adventureService) - characterFactory := service.NewPythonCharacterFactory(cfg) - characterService := service.NewCharacterService(cache, characterFactory) - skillTreeService, err := service.NewSkillTreeService(cfg.ProjectRoot, adventureService, characterFactory) - if err != nil { - log.Fatalf("load skill tree service: %v", err) - } - equipmentRepository := service.NewSQLiteEquipmentCatalogRepository(cfg.ProjectRoot) - calcService := service.NewCalcService(characterFactory, equipmentRepository) - - router := httpserver.NewRouter(httpserver.Dependencies{ - Config: cfg, - AdventureService: adventureService, - OpenAPIService: openAPIService, - ColgService: colgService, - TokenService: tokenService, - CharacterService: characterService, - SkillTreeService: skillTreeService, - CalcService: calcService, - }) - - addr := ":" + cfg.Port - log.Printf("dcalc go server listening on %s", addr) - if err := http.ListenAndServe(addr, router); err != nil { - log.Fatalf("listen and serve: %v", err) - } -} diff --git a/backend/go/go.mod b/backend/go/go.mod deleted file mode 100644 index f73c8732..00000000 --- a/backend/go/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module dcalc-go - -go 1.25.0 - -require ( - github.com/go-chi/chi/v5 v5.2.3 - modernc.org/sqlite v1.48.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect - modernc.org/libc v1.70.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) diff --git a/backend/go/go.sum b/backend/go/go.sum deleted file mode 100644 index 3a9318dc..00000000 --- a/backend/go/go.sum +++ /dev/null @@ -1,53 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= -modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/go/internal/config/config.go b/backend/go/internal/config/config.go deleted file mode 100644 index 4d9d881c..00000000 --- a/backend/go/internal/config/config.go +++ /dev/null @@ -1,90 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strconv" -) - -type Config struct { - Port string - DebugMode bool - ColgToken string - HelperToken string - HelperID string - PythonExec string - ProjectRoot string -} - -func Load() (Config, error) { - projectRoot, err := detectProjectRoot() - if err != nil { - return Config{}, err - } - - return Config{ - Port: envOrDefault("PORT", "27173"), - DebugMode: envBoolOrDefault("DEBUG_MODE", true), - ColgToken: os.Getenv("COLG_TOKEN"), - HelperToken: os.Getenv("HELPER_TOKEN"), - HelperID: os.Getenv("HELPER_ID"), - PythonExec: pythonExecOrDefault(projectRoot), - ProjectRoot: projectRoot, - }, nil -} - -func pythonExecOrDefault(projectRoot string) string { - if value := os.Getenv("PYTHON_EXEC"); value != "" { - return value - } - venvPython := filepath.Join(projectRoot, ".venv", "bin", "python") - if fileExists(venvPython) { - return venvPython - } - return "python3" -} - -func detectProjectRoot() (string, error) { - wd, err := os.Getwd() - if err != nil { - return "", err - } - - current := wd - for { - if fileExists(filepath.Join(current, "openapi", "jobs.json")) { - return current, nil - } - - parent := filepath.Dir(current) - if parent == current { - return "", fmt.Errorf("unable to locate project root from %s", wd) - } - current = parent - } -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} - -func envBoolOrDefault(key string, fallback bool) bool { - value := os.Getenv(key) - if value == "" { - return fallback - } - parsed, err := strconv.ParseBool(value) - if err != nil { - return fallback - } - return parsed -} diff --git a/backend/go/internal/httpserver/router.go b/backend/go/internal/httpserver/router.go deleted file mode 100644 index 37f4bf6e..00000000 --- a/backend/go/internal/httpserver/router.go +++ /dev/null @@ -1,305 +0,0 @@ -package httpserver - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - - "dcalc-go/internal/config" - "dcalc-go/internal/model" - "dcalc-go/internal/service" -) - -type Dependencies struct { - Config config.Config - AdventureService *service.AdventureService - OpenAPIService *service.OpenAPIService - ColgService *service.ColgService - TokenService *service.TokenService - CharacterService *service.CharacterService - SkillTreeService *service.SkillTreeService - CalcService *service.CalcService -} - -func NewRouter(dep Dependencies) http.Handler { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.Compress(5, "application/json")) - r.Use(cors) - - r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, model.Success(map[string]any{"service": "dcalc-go", "debug": dep.Config.DebugMode})) - }) - - r.Route("/api", func(r chi.Router) { - r.Get("/adventure", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.AdventureService.AdventureList() - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/token/get/{alter}", func(w http.ResponseWriter, r *http.Request) { - token, err := dep.TokenService.Create(chi.URLParam(r, "alter"), r.URL.Query().Get("equVersion")) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(400, err.Error(), nil)) - return - } - writeJSON(w, http.StatusOK, model.Success(token)) - }) - - r.Get("/character", func(w http.ResponseWriter, r *http.Request) { - payload, ok := requireAlterToken(w, r, dep.TokenService) - if !ok { - return - } - data, err := dep.CharacterService.StaticInfo(payload.Alter, payload.EquVersion) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Post("/calc", func(w http.ResponseWriter, r *http.Request) { - payload, ok := requireAlterToken(w, r, dep.TokenService) - if !ok { - return - } - - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, http.ErrBodyNotAllowed) { - writeJSON(w, http.StatusBadRequest, model.Error(400, "invalid request body", nil)) - return - } - - data, err := dep.CalcService.Calculate(payload.Alter, payload.EquVersion, body) - if err != nil { - writeNotImplemented(w, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/skill/{skillId}/{level}", func(w http.ResponseWriter, r *http.Request) { - payload, ok := requireAlterToken(w, r, dep.TokenService) - if !ok { - return - } - level, err := strconv.Atoi(chi.URLParam(r, "level")) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(400, "level must be an integer", nil)) - return - } - data, err := dep.OpenAPIService.SkillDetailByAlter(payload.Alter, chi.URLParam(r, "skillId"), level) - if err != nil { - writeJSON(w, http.StatusNotFound, model.Error(404, err.Error(), nil)) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/dnfhelper/{uid}/", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusNotImplemented, model.Error(501, "dnfhelper integration is pending migration", nil)) - }) - - r.Post("/skillTree/", func(w http.ResponseWriter, r *http.Request) { - payload, ok := requireAlterToken(w, r, dep.TokenService) - if !ok { - return - } - - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, model.Error(400, "invalid request body", nil)) - return - } - code, _ := body["code"].(string) - result, err := dep.SkillTreeService.Parse(code, payload.EquVersion) - if err != nil { - writeJSON(w, http.StatusInternalServerError, model.Error(500, "解析技能加点失败", nil)) - return - } - if result.Job["class"] != payload.Alter { - writeJSON(w, http.StatusInternalServerError, model.Error(500, "技能加点代码职业与登录职业不匹配", nil)) - return - } - writeJSON(w, http.StatusOK, model.Success(result)) - }) - - r.Route("/open", func(r chi.Router) { - r.Get("/skills/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.OpenAPIService.SearchSkills(r.URL.Query().Get("skillName")) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/jobInfo/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.OpenAPIService.JobsInfo(r.URL.Query().Get("jobName")) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/skillData/summary/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.OpenAPIService.SkillDataSummary( - r.URL.Query().Get("job"), - splitCSV(r.URL.Query().Get("skills")), - splitCSV(r.URL.Query().Get("weapons")), - ) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/gold/realtime/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.ColgService.RealTimeGold(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/gold/history/", func(w http.ResponseWriter, r *http.Request) { - region, err := strconv.Atoi(r.URL.Query().Get("region")) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(400, "region must be an integer", nil)) - return - } - timeRange := 7 - if raw := r.URL.Query().Get("timeRange"); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(400, "timeRange must be an integer", nil)) - return - } - timeRange = parsed - } - - data, err := dep.ColgService.HistoryGold(r.Context(), region, timeRange) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(404, err.Error(), nil)) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/gold/official/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.ColgService.OfficialGold(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/{jobId}/{jobGrowId}/skills/", func(w http.ResponseWriter, r *http.Request) { - data, err := dep.OpenAPIService.SkillsByJob( - chi.URLParam(r, "jobId"), - chi.URLParam(r, "jobGrowId"), - r.URL.Query().Get("descSpecial"), - ) - if err != nil { - writeError(w, http.StatusNotFound, err) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - - r.Get("/{jobId}/{jobGrowId}/{skillId}/", func(w http.ResponseWriter, r *http.Request) { - var level *int - if raw := r.URL.Query().Get("level"); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil { - writeJSON(w, http.StatusBadRequest, model.Error(400, "level must be an integer", nil)) - return - } - level = &parsed - } - data, err := dep.OpenAPIService.SkillDetail(chi.URLParam(r, "jobId"), chi.URLParam(r, "jobGrowId"), chi.URLParam(r, "skillId"), level) - if err != nil { - writeJSON(w, http.StatusNotFound, model.Error(404, err.Error(), nil)) - return - } - writeJSON(w, http.StatusOK, model.Success(data)) - }) - }) - }) - - return r -} - -func requireAlterToken(w http.ResponseWriter, r *http.Request, tokens *service.TokenService) (service.AlterTokenPayload, bool) { - token := r.Header.Get("alter_token") - if token == "" { - writeJSON(w, http.StatusUnauthorized, model.Error(401, "missing alter_token header", nil)) - return service.AlterTokenPayload{}, false - } - payload, err := tokens.Parse(token) - if err != nil { - writeJSON(w, http.StatusUnauthorized, model.Error(401, "invalid alter_token header", nil)) - return service.AlterTokenPayload{}, false - } - return payload, true -} - -func writeNotImplemented(w http.ResponseWriter, err error) { - writeJSON(w, http.StatusNotImplemented, model.Error(501, err.Error(), nil)) -} - -func writeError(w http.ResponseWriter, status int, err error) { - writeJSON(w, status, model.Error(status, err.Error(), nil)) -} - -func writeJSON(w http.ResponseWriter, status int, payload model.Envelope) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -func cors(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, alter_token") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func splitCSV(value string) []string { - if value == "" { - return nil - } - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} diff --git a/backend/go/internal/model/response.go b/backend/go/internal/model/response.go deleted file mode 100644 index d7529c16..00000000 --- a/backend/go/internal/model/response.go +++ /dev/null @@ -1,15 +0,0 @@ -package model - -type Envelope struct { - Code int `json:"code"` - Message string `json:"message"` - Data any `json:"data"` -} - -func Success(data any) Envelope { - return Envelope{Code: 200, Message: "Success", Data: data} -} - -func Error(code int, message string, data any) Envelope { - return Envelope{Code: code, Message: message, Data: data} -} diff --git a/backend/go/internal/service/adventure.go b/backend/go/internal/service/adventure.go deleted file mode 100644 index 036c6bf7..00000000 --- a/backend/go/internal/service/adventure.go +++ /dev/null @@ -1,137 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -type AdventureService struct { - projectRoot string - cache *Cache - jobs []Job - tokenToJob map[string]string -} - -type Job struct { - Name string `json:"name"` - JobID string `json:"jobId"` - Title string `json:"title"` - Children []JobChild `json:"children"` -} - -type JobChild struct { - Name string `json:"name"` - JobGrowID string `json:"jobGrowId"` - Title string `json:"title"` -} - -type AdventureParent struct { - ID int `json:"id"` - Name string `json:"name"` - Title string `json:"title"` - Children []AdventureChild `json:"children"` -} - -type AdventureChild struct { - ID int `json:"id"` - Name string `json:"name"` - Title string `json:"title"` - Open bool `json:"open"` - Value string `json:"value"` -} - -func NewAdventureService(projectRoot string, cache *Cache) (*AdventureService, error) { - jobsPath := filepath.Join(projectRoot, "openapi", "jobs.json") - content, err := os.ReadFile(jobsPath) - if err != nil { - return nil, err - } - - var jobs []Job - if err := json.Unmarshal(content, &jobs); err != nil { - return nil, err - } - - svc := &AdventureService{ - projectRoot: projectRoot, - cache: cache, - jobs: jobs, - tokenToJob: make(map[string]string), - } - - for parentIndex, parent := range jobs { - for childIndex, child := range parent.Children { - token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parentIndex, childIndex))) - svc.tokenToJob[token] = "GF." + parent.Name + "." + child.Name - } - } - - return svc, nil -} - -func (s *AdventureService) Jobs() []Job { - jobs := make([]Job, len(s.jobs)) - copy(jobs, s.jobs) - return jobs -} - -func (s *AdventureService) AdventureList() ([]AdventureParent, error) { - return GetOrLoad(s.cache, "adventure:list", 10*time.Minute, func() ([]AdventureParent, error) { - result := make([]AdventureParent, 0, len(s.jobs)) - for parentIndex, parent := range s.jobs { - children := make([]AdventureChild, 0, len(parent.Children)) - for childIndex, child := range parent.Children { - children = append(children, AdventureChild{ - ID: childIndex, - Name: child.Name, - Title: child.Title, - Open: true, - Value: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parentIndex, childIndex))), - }) - } - result = append(result, AdventureParent{ - ID: parentIndex, - Name: parent.Name, - Title: parent.Title, - Children: children, - }) - } - return result, nil - }) -} - -func (s *AdventureService) AlterByToken(token string) (string, error) { - alter, ok := s.tokenToJob[token] - if !ok { - return "", fmt.Errorf("invalid adventure token") - } - return alter, nil -} - -func (s *AdventureService) ResolveDirectories(jobIdentifier string, jobGrowIdentifier string) (string, string, error) { - for _, job := range s.jobs { - jobMatched := jobIdentifier == job.Name || jobIdentifier == job.JobID - if !jobMatched { - continue - } - for _, child := range job.Children { - if jobGrowIdentifier == child.Name || jobGrowIdentifier == child.JobGrowID { - return job.Name, child.Name, nil - } - } - } - return "", "", fmt.Errorf("unknown job path: %s/%s", jobIdentifier, jobGrowIdentifier) -} - -func (s *AdventureService) DirectoriesFromAlter(alter string) (string, string, error) { - parts := strings.Split(alter, ".") - if len(parts) != 3 || parts[0] != "GF" { - return "", "", fmt.Errorf("invalid alter value: %s", alter) - } - return parts[1], parts[2], nil -} diff --git a/backend/go/internal/service/cache.go b/backend/go/internal/service/cache.go deleted file mode 100644 index 0e0ce5d7..00000000 --- a/backend/go/internal/service/cache.go +++ /dev/null @@ -1,44 +0,0 @@ -package service - -import ( - "sync" - "time" -) - -type Cache struct { - mu sync.RWMutex - items map[string]cacheItem -} - -type cacheItem struct { - value any - expiresAt time.Time -} - -func NewCache() *Cache { - return &Cache{items: make(map[string]cacheItem)} -} - -func GetOrLoad[T any](cache *Cache, key string, ttl time.Duration, loader func() (T, error)) (T, error) { - var zero T - - cache.mu.RLock() - item, ok := cache.items[key] - cache.mu.RUnlock() - if ok && (item.expiresAt.IsZero() || item.expiresAt.After(time.Now())) { - if value, castOK := item.value.(T); castOK { - return value, nil - } - } - - value, err := loader() - if err != nil { - return zero, err - } - - cache.mu.Lock() - cache.items[key] = cacheItem{value: value, expiresAt: time.Now().Add(ttl)} - cache.mu.Unlock() - - return value, nil -} diff --git a/backend/go/internal/service/calc.go b/backend/go/internal/service/calc.go deleted file mode 100644 index c5e981dd..00000000 --- a/backend/go/internal/service/calc.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import "errors" - -var ErrNotImplemented = errors.New("calculation domain has not been migrated to Go yet") - -type EquipmentCatalogRepository interface { - Catalog(alter string, equVersion string) (map[string]any, error) -} - -type CalcService struct { - factory CharacterFactory - equipment EquipmentCatalogRepository -} - -func NewCalcService(factory CharacterFactory, equipment EquipmentCatalogRepository) *CalcService { - return &CalcService{factory: factory, equipment: equipment} -} - -func (s *CalcService) CharacterInfo(alter string, equVersion string) (any, error) { - return s.factory.CharacterInfo(alter, equVersion) -} - -func (s *CalcService) Calculate(_ string, _ string, _ map[string]any) (any, error) { - return nil, ErrNotImplemented -} - -func (s *CalcService) SkillTree(_ string, _ string) (any, error) { - return nil, ErrNotImplemented -} - -func (s *CalcService) DNFHelper(_ string, _ string) (any, error) { - return nil, ErrNotImplemented -} diff --git a/backend/go/internal/service/character.go b/backend/go/internal/service/character.go deleted file mode 100644 index 64dc0151..00000000 --- a/backend/go/internal/service/character.go +++ /dev/null @@ -1,21 +0,0 @@ -package service - -import ( - "time" -) - -type CharacterService struct { - cache *Cache - factory CharacterFactory -} - -func NewCharacterService(cache *Cache, factory CharacterFactory) *CharacterService { - return &CharacterService{cache: cache, factory: factory} -} - -func (s *CharacterService) StaticInfo(alter string, equVersion string) (map[string]any, error) { - key := "character:static:" + alter + ":" + equVersion - return GetOrLoad(s.cache, key, 15*time.Minute, func() (map[string]any, error) { - return s.factory.CharacterInfo(alter, equVersion) - }) -} diff --git a/backend/go/internal/service/colg.go b/backend/go/internal/service/colg.go deleted file mode 100644 index 30dad321..00000000 --- a/backend/go/internal/service/colg.go +++ /dev/null @@ -1,304 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "dcalc-go/internal/config" -) - -type ColgService struct { - cache *Cache - config config.Config - client *http.Client -} - -var regionMap = map[string]int{ - "跨1": 1, - "跨2": 2, - "跨3A": 3, - "跨3B": 4, - "跨4": 5, - "跨5": 6, - "跨6": 7, - "跨7": 8, -} - -func NewColgService(cache *Cache, cfg config.Config) *ColgService { - return &ColgService{ - cache: cache, - config: cfg, - client: &http.Client{Timeout: 12 * time.Second}, - } -} - -func (s *ColgService) RealTimeGold(ctx context.Context) ([]map[string]any, error) { - if s.config.ColgToken == "" { - return nil, fmt.Errorf("COLG_TOKEN is required") - } - - return GetOrLoad(s.cache, "colg:realtime", 30*time.Minute, func() ([]map[string]any, error) { - result := make([]map[string]any, 0, len(regionMap)) - now := time.Now().Format("2006-01-02") - for regionName, regionID := range regionMap { - data, err := s.requestTrendData(ctx, regionID, now, now) - if err != nil { - return nil, err - } - xValues, _ := data["x"].([]any) - yValues, _ := data["y"].([]any) - if len(xValues) == 0 || len(yValues) == 0 { - continue - } - result = append(result, map[string]any{ - "updataTime": formatBeijingTime(int64FromAny(xValues[len(xValues)-1])), - "region": regionName, - "goldPrice": float64FromAny(yValues[len(yValues)-1]), - }) - } - return result, nil - }) -} - -func (s *ColgService) HistoryGold(ctx context.Context, region int, timeRange int) (map[string]any, error) { - if s.config.ColgToken == "" { - return nil, fmt.Errorf("COLG_TOKEN is required") - } - if timeRange <= 0 { - timeRange = 7 - } - - regionName := "" - for name, id := range regionMap { - if id == region { - regionName = name - break - } - } - if regionName == "" { - return nil, fmt.Errorf("invalid region") - } - - endDate := time.Now() - startDate := endDate.AddDate(0, 0, -(timeRange - 1)) - trend, err := s.requestTrendData(ctx, region, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) - if err != nil { - return nil, err - } - - xValues, _ := trend["x"].([]any) - yValues, _ := trend["y"].([]any) - y1Values, _ := trend["y1"].([]any) - rows := make([]map[string]any, 0, len(xValues)) - minValue := 0.0 - maxValue := 0.0 - for index := range xValues { - avg := float64FromAny(yValues[index]) - zero := float64FromAny(y1Values[index]) - if index == 0 || avg < minValue { - minValue = avg - } - if zero < minValue { - minValue = zero - } - if index == 0 || avg > maxValue { - maxValue = avg - } - if zero > maxValue { - maxValue = zero - } - rows = append(rows, map[string]any{ - "date": strings.Split(formatBeijingTime(int64FromAny(xValues[index])), " ")[0], - "avg": avg, - "zero": zero, - }) - } - - title := fmt.Sprintf("DNF%s近%d天金币价格趋势分析", regionName, timeRange) - echarts := map[string]any{ - "title": map[string]any{"text": title, "left": "center"}, - "toolbox": map[string]any{"feature": map[string]any{"saveAsImage": map[string]any{}, "dataView": map[string]any{"readOnly": true}, "magicType": map[string]any{"type": []string{"line", "bar"}}}}, - "tooltip": map[string]any{"trigger": "axis", "axisPointer": map[string]any{"type": "cross", "label": map[string]any{"backgroundColor": "#6a7985"}}}, - "legend": map[string]any{"data": []string{"平均金价", "12时金价"}, "bottom": 0}, - "xAxis": map[string]any{"type": "category", "name": "日期", "boundaryGap": false, "data": extractField(rows, "date")}, - "yAxis": map[string]any{"type": "value", "name": "价格 (万金币/1人民币)", "min": floorToFive(minValue), "max": floorToFive(maxValue) + 5, "axisLabel": map[string]any{"formatter": "{value} 万金币"}}, - "series": []map[string]any{ - {"name": "平均金价", "type": "line", "smooth": true, "data": extractField(rows, "avg"), "markPoint": map[string]any{"data": []map[string]any{{"type": "max", "name": "最大值"}, {"type": "min", "name": "最小值"}}}}, - {"name": "12时金价", "type": "line", "smooth": true, "data": extractField(rows, "zero"), "markLine": map[string]any{"data": []map[string]any{{"type": "average", "name": "平均值"}}}}, - }, - } - - return map[string]any{"region": regionName, "data": rows, "echartsData": echarts}, nil -} - -func (s *ColgService) OfficialGold(ctx context.Context) ([]map[string]any, error) { - if s.config.HelperToken == "" || s.config.HelperID == "" { - return nil, fmt.Errorf("HELPER_TOKEN and HELPER_ID are required") - } - - return GetOrLoad(s.cache, "colg:official", 30*time.Minute, func() ([]map[string]any, error) { - servers := []string{"1", "2", "3A", "3B", "4", "5", "6", "7"} - result := make([]map[string]any, 0, len(servers)) - for _, server := range servers { - data, err := s.requestOfficialGold(ctx, server) - if err != nil { - return nil, err - } - result = append(result, data) - } - return result, nil - }) -} - -func (s *ColgService) requestTrendData(ctx context.Context, region int, startTime string, endTime string) (map[string]any, error) { - payload := map[string]any{ - "token": s.config.ColgToken, - "area": region, - "time_type": "other", - "start_time": startTime, - "end_time": endTime, - } - body, err := json.Marshal(payload) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://poservice.colg.cn/api/v1/trend/getCoinTrendDataKol", bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - content, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var payloadResp map[string]any - if err := json.Unmarshal(content, &payloadResp); err != nil { - return nil, err - } - data, _ := payloadResp["data"].(map[string]any) - return data, nil -} - -func (s *ColgService) requestOfficialGold(ctx context.Context, server string) (map[string]any, error) { - form := url.Values{} - form.Set("r", "godRank/godDealRank") - form.Set("userId", s.config.HelperID) - form.Set("token", s.config.HelperToken) - form.Set("serverNo", server) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://dzhu.qq.com/zangyi/activity/app", strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - content, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var payloadResp map[string]any - if err := json.Unmarshal(content, &payloadResp); err != nil { - return nil, err - } - data, _ := payloadResp["data"].(map[string]any) - return map[string]any{ - "updataTime": time.Now().Format("2006-01-02 15:04:05"), - "region": "跨" + server, - "goldDealRank": extractPrices(data["godDealRank"]), - "goldTopDealRank": extractPrices(data["godTopDealRank"]), - }, nil -} - -func extractPrices(value any) []int { - rows, _ := value.([]any) - result := make([]int, 0, len(rows)) - for _, row := range rows { - rowMap, _ := row.(map[string]any) - result = append(result, intFromAnyString(rowMap["price"])) - } - return result -} - -func extractField(rows []map[string]any, key string) []any { - result := make([]any, 0, len(rows)) - for _, row := range rows { - result = append(result, row[key]) - } - return result -} - -func formatBeijingTime(ts int64) string { - return time.Unix(ts, 0).UTC().Add(8 * time.Hour).Format("2006-01-02 15:04:05") -} - -func floorToFive(value float64) float64 { - return float64(int(value/5) * 5) -} - -func float64FromAny(value any) float64 { - switch typed := value.(type) { - case float64: - return typed - case float32: - return float64(typed) - case int: - return float64(typed) - case int64: - return float64(typed) - case json.Number: - parsed, _ := typed.Float64() - return parsed - default: - return 0 - } -} - -func int64FromAny(value any) int64 { - switch typed := value.(type) { - case int64: - return typed - case int: - return int64(typed) - case float64: - return int64(typed) - case json.Number: - parsed, _ := typed.Int64() - return parsed - default: - return 0 - } -} - -func intFromAnyString(value any) int { - switch typed := value.(type) { - case string: - parsed, _ := strconv.Atoi(typed) - return parsed - default: - return int(float64FromAny(value)) - } -} diff --git a/backend/go/internal/service/equipment_catalog_sqlite.go b/backend/go/internal/service/equipment_catalog_sqlite.go deleted file mode 100644 index 6f204cd1..00000000 --- a/backend/go/internal/service/equipment_catalog_sqlite.go +++ /dev/null @@ -1,295 +0,0 @@ -package service - -import ( - "database/sql" - "fmt" - "path/filepath" - "strconv" - "strings" - - _ "modernc.org/sqlite" -) - -type SQLiteEquipmentCatalogRepository struct { - projectRoot string -} - -func NewSQLiteEquipmentCatalogRepository(projectRoot string) *SQLiteEquipmentCatalogRepository { - return &SQLiteEquipmentCatalogRepository{projectRoot: projectRoot} -} - -func (r *SQLiteEquipmentCatalogRepository) Catalog(_ string, equVersion string) (map[string]any, error) { - if equVersion == "" { - equVersion = "0" - } - - dbPath := filepath.Join(r.projectRoot, "dataFiles", fmt.Sprintf("dcalc_%s.db", equVersion)) - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, err - } - defer db.Close() - - equips, err := queryEquipRows(db, "equ") - if err != nil { - return nil, err - } - stones, err := queryEquipRows(db, "stone") - if err != nil { - return nil, err - } - suits, err := querySuitRows(db) - if err != nil { - return nil, err - } - enchants, err := queryEnchantRows(db, "enchant") - if err != nil { - return nil, err - } - emblems, err := queryEnchantRows(db, "emblem") - if err != nil { - return nil, err - } - jades, err := queryJadeRows(db) - if err != nil { - return nil, err - } - - return map[string]any{ - "equips": equips, - "suits": suits, - "stones": stones, - "enchants": enchants, - "emblems": emblems, - "jades": jades, - "avatar": map[string]any{}, - "options": map[string]any{}, - "sundry": []any{}, - }, nil -} - -func queryEquipRows(db *sql.DB, table string) ([]map[string]any, error) { - query := fmt.Sprintf("SELECT id,name,rarity,itemType,itemDetailType,Point,categorize,imageUrl,detail,bufferDetail,STR,INT,Vitality,Spirit,AtkP,AtkM,AtkI,SkillAttack,Attack,Buffer,suit FROM %s", table) - rows, err := db.Query(query) - if err != nil { - return nil, err - } - defer rows.Close() - - result := make([]map[string]any, 0) - for rows.Next() { - var ( - id, name, rarity, itemType, itemDetailType, point, categorize, imageURL, detail, bufferDetail sql.NullString - strV, intV, vitV, spiV, atkPV, atkMV, atkIV, skillAttackV, attackV, bufferV, suitV sql.NullString - ) - if err := rows.Scan(&id, &name, &rarity, &itemType, &itemDetailType, &point, &categorize, &imageURL, &detail, &bufferDetail, &strV, &intV, &vitV, &spiV, &atkPV, &atkMV, &atkIV, &skillAttackV, &attackV, &bufferV, &suitV); err != nil { - return nil, err - } - - entry := map[string]any{ - "id": nullString(id), - "name": nullString(name), - "rarity": nullString(rarity), - "itemType": nullString(itemType), - "itemDetailType": nullString(itemDetailType), - "categorize": nullString(categorize), - "imageUrl": nullString(imageURL), - "detail": nullString(detail), - "bufferDetail": nullString(bufferDetail), - "Point": numberListOrDefault(nullString(point)), - "STR": numberListOrDefault(nullString(strV)), - "INT": numberListOrDefault(nullString(intV)), - "Vitality": numberListOrDefault(nullString(vitV)), - "Spirit": numberListOrDefault(nullString(spiV)), - "AtkP": numberListOrDefault(nullString(atkPV)), - "AtkM": numberListOrDefault(nullString(atkMV)), - "AtkI": numberListOrDefault(nullString(atkIV)), - "SkillAttack": numberListOrDefault(nullString(skillAttackV)), - "Attack": numberListOrDefault(nullString(attackV)), - "Buffer": numberListOrDefault(nullString(bufferV)), - "suit": suitListOrDefault(nullString(suitV)), - } - - maxAdaptation := 0 - for _, key := range []string{"Point", "STR", "INT", "Vitality", "Spirit", "AtkP", "AtkM", "AtkI", "SkillAttack", "Attack", "Buffer"} { - if values, ok := entry[key].([]float64); ok && len(values)-1 > maxAdaptation { - maxAdaptation = len(values) - 1 - } - } - entry["max_adaptation"] = maxAdaptation - result = append(result, entry) - } - return result, rows.Err() -} - -func querySuitRows(db *sql.DB) ([]map[string]any, error) { - rows, err := db.Query("SELECT id,suitId,suitName,rarity,name,point,level,count,SkillAttack,Attack,Buffer,imageUrl,value,bufferValue,fame FROM suit") - if err != nil { - return nil, err - } - defer rows.Close() - - result := make([]map[string]any, 0) - for rows.Next() { - var ( - id, suitID, point, level, count, fame sql.NullInt64 - suitName, rarity, name, imageURL, value, bufferValue sql.NullString - skillAttack, attack, buffer sql.NullFloat64 - ) - if err := rows.Scan(&id, &suitID, &suitName, &rarity, &name, &point, &level, &count, &skillAttack, &attack, &buffer, &imageURL, &value, &bufferValue, &fame); err != nil { - return nil, err - } - result = append(result, map[string]any{ - "id": nullInt64(id), - "suitId": nullInt64(suitID), - "suitName": nullString(suitName), - "rarity": nullString(rarity), - "name": nullString(name), - "point": nullInt64(point), - "level": nullInt64(level), - "count": nullInt64(count), - "SkillAttack": nullFloat64(skillAttack), - "Attack": nullFloat64(attack), - "Buffer": nullFloat64(buffer), - "imageUrl": nullString(imageURL), - "value": nullString(value), - "bufferValue": nullString(bufferValue), - "fame": nullInt64(fame), - }) - } - return result, rows.Err() -} - -func queryEnchantRows(db *sql.DB, table string) ([]map[string]any, error) { - query := fmt.Sprintf("SELECT id,detail,categorize,itemType,fame,rarity FROM %s", table) - rows, err := db.Query(query) - if err != nil { - return nil, err - } - defer rows.Close() - - result := make([]map[string]any, 0) - for rows.Next() { - var id, fame sql.NullInt64 - var detail, categorize, itemType, rarity sql.NullString - if err := rows.Scan(&id, &detail, &categorize, &itemType, &fame, &rarity); err != nil { - return nil, err - } - result = append(result, map[string]any{ - "id": nullInt64(id), - "detail": nullString(detail), - "categorize": splitCSV(nullString(categorize)), - "position": splitCSV(nullString(itemType)), - "fame": nullInt64(fame), - "rarity": nullString(rarity), - }) - } - return result, rows.Err() -} - -func queryJadeRows(db *sql.DB) ([]map[string]any, error) { - rows, err := db.Query("SELECT id,name,min,max,unit,pre FROM jade") - if err != nil { - return nil, err - } - defer rows.Close() - - result := make([]map[string]any, 0) - for rows.Next() { - var id, minV, maxV sql.NullInt64 - var name, unit sql.NullString - var pre sql.NullFloat64 - if err := rows.Scan(&id, &name, &minV, &maxV, &unit, &pre); err != nil { - return nil, err - } - result = append(result, map[string]any{ - "id": nullInt64(id), - "name": nullString(name), - "min": nullInt64(minV), - "max": nullInt64(maxV), - "unit": nullString(unit), - "pre": nullFloat64(pre), - }) - } - return result, rows.Err() -} - -func numberListOrDefault(raw string) []float64 { - raw = strings.TrimSpace(raw) - if raw == "" { - return []float64{0} - } - parts := strings.Split(raw, ",") - result := make([]float64, 0, len(parts)) - for _, part := range parts { - value, err := strconv.ParseFloat(strings.TrimSpace(part), 64) - if err != nil { - result = append(result, 0) - continue - } - result = append(result, value) - } - if len(result) == 0 { - return []float64{0} - } - return result -} - -func suitListOrDefault(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return []string{} - } - parts := strings.Split(raw, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - value, err := strconv.ParseFloat(part, 64) - if err != nil { - result = append(result, part) - continue - } - result = append(result, strconv.Itoa(int(value))) - } - return result -} - -func splitCSV(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return []string{} - } - parts := strings.Split(raw, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return result -} - -func nullString(value sql.NullString) string { - if !value.Valid { - return "" - } - return value.String -} - -func nullInt64(value sql.NullInt64) int64 { - if !value.Valid { - return 0 - } - return value.Int64 -} - -func nullFloat64(value sql.NullFloat64) float64 { - if !value.Valid { - return 0 - } - return value.Float64 -} diff --git a/backend/go/internal/service/openapi.go b/backend/go/internal/service/openapi.go deleted file mode 100644 index ea42cd82..00000000 --- a/backend/go/internal/service/openapi.go +++ /dev/null @@ -1,291 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -var placeholderPattern = regexp.MustCompile(`<(?:(?:int)|(?:float\d*))>`) -var filenameJobPrefix = regexp.MustCompile(`^\d+(?:-\d+){0,2}`) - -type OpenAPIService struct { - projectRoot string - cache *Cache - adventure *AdventureService -} - -func NewOpenAPIService(projectRoot string, cache *Cache, adventure *AdventureService) *OpenAPIService { - return &OpenAPIService{projectRoot: projectRoot, cache: cache, adventure: adventure} -} - -func (s *OpenAPIService) SearchSkills(skillName string) ([]map[string]any, error) { - skills, err := GetOrLoad(s.cache, "openapi:skills", 30*time.Minute, func() ([]map[string]any, error) { - var data []map[string]any - if err := s.readJSON(filepath.Join(s.projectRoot, "openapi", "data", "skills.json"), &data); err != nil { - return nil, err - } - return data, nil - }) - if err != nil { - return nil, err - } - - normalized := normalizeWhitespace(skillName) - if normalized == "" { - return skills, nil - } - - filtered := make([]map[string]any, 0) - for _, skill := range skills { - name, _ := skill["skillName"].(string) - if strings.Contains(normalizeWhitespace(name), normalized) { - filtered = append(filtered, skill) - } - } - return filtered, nil -} - -func (s *OpenAPIService) JobsInfo(jobName string) ([]map[string]string, error) { - jobName = strings.NewReplacer("(", "(", ")", ")").Replace(jobName) - result := make([]map[string]string, 0) - for _, job := range s.adventure.Jobs() { - for _, child := range job.Children { - if jobName == "" || strings.Contains(child.Title, jobName) || strings.Contains(child.Name, jobName) { - result = append(result, map[string]string{ - "jobId": job.Name, - "jobName": job.Title, - "jobGrowId": child.Name, - "jobGrowName": child.Title, - }) - } - } - } - return result, nil -} - -func (s *OpenAPIService) SkillsByJob(jobID string, jobGrowID string, descSpecial string) (any, error) { - jobDir, jobGrowDir, err := s.adventure.ResolveDirectories(jobID, jobGrowID) - if err != nil { - return nil, err - } - - var data []map[string]any - path := filepath.Join(s.projectRoot, "openapi", "data", jobDir, jobGrowDir, "cn", "skill_tree.json") - if err := s.readJSON(path, &data); err != nil { - return nil, err - } - detailDir := filepath.Join(s.projectRoot, "openapi", "data", jobDir, jobGrowDir, "cn", "skillDetail") - for _, skill := range data { - skillID, _ := skill["skillId"].(string) - var detail map[string]any - if skillID != "" { - err := s.readJSON(filepath.Join(detailDir, skillID+".json"), &detail) - if err != nil && !os.IsNotExist(err) { - return nil, err - } - } - skill["descSpecial"] = detail["descSpecial"] - } - if descSpecial == "" { - return data, nil - } - - filtered := make([]map[string]any, 0) - for _, skill := range data { - items, _ := skill["descSpecial"].([]any) - for _, item := range items { - text, _ := item.(string) - if strings.Contains(text, descSpecial) { - filtered = append(filtered, skill) - break - } - } - } - return filtered, nil -} - -func (s *OpenAPIService) SkillDetail(jobID string, jobGrowID string, skillID string, level *int) (any, error) { - jobDir, jobGrowDir, err := s.adventure.ResolveDirectories(jobID, jobGrowID) - if err != nil { - return nil, err - } - - var skillInfo map[string]any - path := filepath.Join(s.projectRoot, "openapi", "data", jobDir, jobGrowDir, "cn", "skillDetail", skillID+".json") - if err := s.readJSON(path, &skillInfo); err != nil { - return nil, err - } - - if levelInfo, ok := skillInfo["levelInfo"].(map[string]any); ok { - if optionDesc, ok := levelInfo["optionDesc"].(string); ok { - levelInfo["optionDesc"] = replacePlaceholders(optionDesc) - } - } - - if level == nil { - return skillInfo, nil - } - - maxLevel := intFromAny(skillInfo["maxLevel"]) - bounded := *level - if bounded < 0 { - bounded = 0 - } - if maxLevel > 0 && bounded > maxLevel { - bounded = maxLevel - } - - levelInfo, ok := skillInfo["levelInfo"].(map[string]any) - if !ok { - return skillInfo, nil - } - rows, ok := levelInfo["rows"].([]any) - if !ok { - return skillInfo, nil - } - - for _, row := range rows { - rowMap, ok := row.(map[string]any) - if !ok || intFromAny(rowMap["level"]) != bounded { - continue - } - - for key, value := range rowMap { - levelInfo[key] = value - } - detail, _ := levelInfo["optionDesc"].(string) - if optionValue, ok := levelInfo["optionValue"].(map[string]any); ok { - for key, value := range optionValue { - detail = strings.ReplaceAll(detail, "{"+key+"}", fmt.Sprint(value)) - } - } - levelInfo["detail"] = detail - delete(levelInfo, "rows") - skillInfo["attribute"] = levelInfo - delete(skillInfo, "levelInfo") - break - } - - return skillInfo, nil -} - -func (s *OpenAPIService) SkillDetailByAlter(alter string, skillID string, level int) (any, error) { - jobDir, jobGrowDir, err := s.adventure.DirectoriesFromAlter(alter) - if err != nil { - return nil, err - } - return s.SkillDetail(jobDir, jobGrowDir, skillID, &level) -} - -func (s *OpenAPIService) SkillDataSummary(job string, skills []string, weapons []string) ([]map[string]any, error) { - job = strings.NewReplacer("(", "(", ")", ")").Replace(job) - entries, err := os.ReadDir(filepath.Join(s.projectRoot, "openapi", "summary")) - if err != nil { - return nil, err - } - - skillFilter := make(map[string]struct{}) - for _, skill := range skills { - if skill != "" { - skillFilter[skill] = struct{}{} - } - } - weaponFilter := make(map[string]struct{}) - for _, weapon := range weapons { - if weapon != "" { - weaponFilter[weapon] = struct{}{} - } - } - - result := make([]map[string]any, 0) - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") || !strings.Contains(entry.Name(), job) { - continue - } - - parts := strings.Split(strings.TrimSuffix(entry.Name(), ".json"), "_") - weapon := "通用" - if len(parts) > 1 && parts[len(parts)-1] != "" { - weapon = parts[len(parts)-1] - } - if len(weaponFilter) > 0 { - if _, ok := weaponFilter[weapon]; !ok { - continue - } - } - - var rows []map[string]any - if err := s.readJSON(filepath.Join(s.projectRoot, "openapi", "summary", entry.Name()), &rows); err != nil { - return nil, err - } - - jobTitle := strings.TrimPrefix(parts[0], filenameJobPrefix.FindString(parts[0])) - for _, row := range rows { - if len(skillFilter) > 0 { - skillName, _ := row["技能名称"].(string) - if _, ok := skillFilter[skillName]; !ok { - continue - } - } - copied := cloneMap(row) - copied["武器类型"] = weapon - copied["职业"] = jobTitle - result = append(result, copied) - } - } - - return result, nil -} - -func (s *OpenAPIService) readJSON(path string, dest any) error { - content, err := os.ReadFile(path) - if err != nil { - return err - } - if err := json.Unmarshal(content, dest); err != nil { - return fmt.Errorf("unmarshal %s: %w", path, err) - } - return nil -} - -func replacePlaceholders(template string) string { - count := 0 - return placeholderPattern.ReplaceAllStringFunc(template, func(_ string) string { - count++ - return fmt.Sprintf("{value%d}", count) - }) -} - -func normalizeWhitespace(value string) string { - return strings.Join(strings.Fields(value), "") -} - -func intFromAny(value any) int { - switch typed := value.(type) { - case int: - return typed - case int32: - return int(typed) - case int64: - return int(typed) - case float64: - return int(typed) - case float32: - return int(typed) - default: - return 0 - } -} - -func cloneMap(source map[string]any) map[string]any { - cloned := make(map[string]any, len(source)) - for key, value := range source { - cloned[key] = value - } - return cloned -} diff --git a/backend/go/internal/service/python_adapter.go b/backend/go/internal/service/python_adapter.go deleted file mode 100644 index 70c9858f..00000000 --- a/backend/go/internal/service/python_adapter.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" - "time" - - "dcalc-go/internal/config" -) - -type CharacterSkillMeta struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - LearnLv int `json:"learnLv"` - MaxLearnLv int `json:"maxLearnLv"` - HasVP bool `json:"hasVP"` - HasUP bool `json:"hasUP"` - UUID string `json:"uuid"` -} - -type CharacterFactory interface { - CharacterInfo(alter string, equVersion string) (map[string]any, error) - CharacterSkills(alter string, equVersion string) ([]CharacterSkillMeta, error) -} - -type PythonCharacterFactory struct { - pythonExec string - projectRoot string - scriptPath string -} - -func NewPythonCharacterFactory(cfg config.Config) *PythonCharacterFactory { - return &PythonCharacterFactory{ - pythonExec: cfg.PythonExec, - projectRoot: cfg.ProjectRoot, - scriptPath: filepath.Join(cfg.ProjectRoot, "go", "python_bridge.py"), - } -} - -func (p *PythonCharacterFactory) CharacterInfo(alter string, equVersion string) (map[string]any, error) { - result := map[string]any{} - if err := p.invoke("character-info", alter, equVersion, &result); err != nil { - return nil, err - } - return result, nil -} - -func (p *PythonCharacterFactory) CharacterSkills(alter string, equVersion string) ([]CharacterSkillMeta, error) { - var result []CharacterSkillMeta - if err := p.invoke("character-skills", alter, equVersion, &result); err != nil { - return nil, err - } - return result, nil -} - -func (p *PythonCharacterFactory) invoke(command string, alter string, equVersion string, out any) error { - if equVersion == "" { - equVersion = "0" - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, p.pythonExec, p.scriptPath, p.projectRoot, command, alter, equVersion) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("python bridge failed: %w, stdout=%s, stderr=%s", err, stdout.String(), stderr.String()) - } - - if err := json.Unmarshal(stdout.Bytes(), out); err != nil { - return fmt.Errorf("unmarshal python bridge output: %w, raw=%s", err, stdout.String()) - } - return nil -} diff --git a/backend/go/internal/service/skilltree.go b/backend/go/internal/service/skilltree.go deleted file mode 100644 index 6e9ff251..00000000 --- a/backend/go/internal/service/skilltree.go +++ /dev/null @@ -1,376 +0,0 @@ -package service - -import ( - "bytes" - "compress/zlib" - "encoding/base64" - "encoding/binary" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "unicode/utf16" -) - -type SkillTreeService struct { - adventure *AdventureService - factory CharacterFactory - indexToUID map[int][]string -} - -type decodedSkillCode struct { - Header skillCodeHeader - Skills []skillLearnItem - Evolutions []skillChoice - Enhancements []skillChoice -} - -type skillCodeHeader struct { - Name string - Level int - JobBytes [2]int -} - -type skillLearnItem struct { - SkillIndex int - Level int - SkillType int -} - -type skillChoice struct { - SkillIndex int - Type int -} - -type LearnedSkill struct { - Lv int `json:"lv"` - Up int `json:"up"` - VP int `json:"vp"` - Name string `json:"name"` -} - -type SkillTreeResult struct { - Name string `json:"name"` - Level int `json:"level"` - Job map[string]string `json:"job"` - Skills map[string]LearnedSkill `json:"skills"` -} - -var roleMap = map[int]int{ - 0: 0, - 1: 3, - 2: 4, - 3: 7, - 4: 8, - 5: 5, - 6: 10, - 7: 2, - 8: 6, - 9: 15, - 10: 15, - 11: 1, - 12: 11, - 13: 12, - 14: 9, - 15: 13, - 16: 14, -} - -func NewSkillTreeService(projectRoot string, adventure *AdventureService, factory CharacterFactory) (*SkillTreeService, error) { - indexToUID, err := loadSkillIndexMap(filepath.Join(projectRoot, "dataFiles", "skillMap.json")) - if err != nil { - return nil, err - } - return &SkillTreeService{adventure: adventure, factory: factory, indexToUID: indexToUID}, nil -} - -func (s *SkillTreeService) Parse(code string, equVersion string) (SkillTreeResult, error) { - decoded, err := decodeSkillCode(code) - if err != nil { - return SkillTreeResult{}, err - } - - alter, err := alterFromJobBytes(decoded.Header.JobBytes[0], decoded.Header.JobBytes[1], s.adventure) - if err != nil { - return SkillTreeResult{}, err - } - - skills, err := s.factory.CharacterSkills(alter, equVersion) - if err != nil { - return SkillTreeResult{}, err - } - - skillByUUID := make(map[string][]CharacterSkillMeta) - for _, skill := range skills { - skillByUUID[skill.UUID] = append(skillByUUID[skill.UUID], skill) - } - - indexCandidates := make(map[int][]CharacterSkillMeta) - for _, item := range decoded.Skills { - if _, exists := indexCandidates[item.SkillIndex]; exists { - continue - } - indexCandidates[item.SkillIndex] = s.buildCandidates(item.SkillIndex, skillByUUID) - } - - learned := make(map[string]LearnedSkill) - for _, item := range decoded.Skills { - candidates := indexCandidates[item.SkillIndex] - chosen := chooseSkillCandidate(candidates, item.SkillType) - if chosen == nil { - continue - } - current, exists := learned[chosen.ID] - if !exists || current.Lv < item.Level { - learned[chosen.ID] = LearnedSkill{Lv: item.Level, Up: 0, VP: 0, Name: chosen.Name} - } - } - - for _, evo := range decoded.Evolutions { - for _, skill := range indexCandidates[evo.SkillIndex] { - if !skill.HasVP { - continue - } - if learnedSkill, ok := learned[skill.ID]; ok { - learnedSkill.VP = evo.Type - learned[skill.ID] = learnedSkill - } - } - } - - for _, up := range decoded.Enhancements { - for _, skill := range indexCandidates[up.SkillIndex] { - if !skill.HasUP { - continue - } - if learnedSkill, ok := learned[skill.ID]; ok { - learnedSkill.Up = up.Type - learned[skill.ID] = learnedSkill - } - } - } - - for _, skill := range skills { - if _, exists := learned[skill.ID]; exists { - continue - } - level := 0 - if skill.LearnLv == 50 || skill.LearnLv == 85 || skill.Name == "基础精通" { - level = skill.MaxLearnLv - } - learned[skill.ID] = LearnedSkill{Lv: level, Up: 0, VP: 0, Name: skill.Name} - } - - return SkillTreeResult{ - Name: decoded.Header.Name, - Level: decoded.Header.Level, - Job: map[string]string{"class": alter}, - Skills: learned, - }, nil -} - -func (s *SkillTreeService) buildCandidates(index int, skillByUUID map[string][]CharacterSkillMeta) []CharacterSkillMeta { - uuids := s.indexToUID[index] - seen := make(map[string]struct{}) - candidates := make([]CharacterSkillMeta, 0) - for _, uuid := range uuids { - for _, skill := range skillByUUID[uuid] { - if _, exists := seen[skill.ID]; exists { - continue - } - seen[skill.ID] = struct{}{} - candidates = append(candidates, skill) - } - } - if len(candidates) > 1 { - filtered := make([]CharacterSkillMeta, 0) - for _, skill := range candidates { - if skill.LearnLv == 50 || skill.LearnLv == 85 { - continue - } - filtered = append(filtered, skill) - } - if len(filtered) > 0 { - return filtered - } - } - return candidates -} - -func chooseSkillCandidate(candidates []CharacterSkillMeta, skillType int) *CharacterSkillMeta { - if len(candidates) == 0 { - return nil - } - if len(candidates) == 1 { - return &candidates[0] - } - wantedType := "passive" - if skillType == 1 { - wantedType = "active" - } - for i := range candidates { - if candidates[i].Type == wantedType { - return &candidates[i] - } - } - return &candidates[0] -} - -func loadSkillIndexMap(path string) (map[int][]string, error) { - content, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - uuidToIndexes := map[string][]int{} - if err := json.Unmarshal(content, &uuidToIndexes); err != nil { - return nil, err - } - - indexToUUID := make(map[int][]string) - for uuid, indexes := range uuidToIndexes { - for _, idx := range indexes { - indexToUUID[idx] = append(indexToUUID[idx], uuid) - } - } - return indexToUUID, nil -} - -func alterFromJobBytes(roleByte int, jobByte int, adventure *AdventureService) (string, error) { - parent, ok := roleMap[roleByte] - if !ok { - return "", fmt.Errorf("unsupported role byte: %d", roleByte) - } - - jobID := jobByte - 1 - if roleByte == 9 { - jobID = 1 - } - if roleByte == 10 { - jobID = 0 - } - - token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%02d%02d00", parent, jobID))) - return adventure.AlterByToken(token) -} - -func decodeSkillCode(encoded string) (decodedSkillCode, error) { - result := decodedSkillCode{} - - compressed, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return result, fmt.Errorf("decode base64: %w", err) - } - - r, err := zlib.NewReader(bytes.NewReader(compressed)) - if err != nil { - return result, fmt.Errorf("create zlib reader: %w", err) - } - defer r.Close() - - raw, err := io.ReadAll(r) - if err != nil { - return result, fmt.Errorf("read zlib stream: %w", err) - } - if len(raw) < 14 { - return result, fmt.Errorf("invalid skill code payload") - } - - nameLen := int(raw[13]) - if 14+nameLen > len(raw) { - return result, fmt.Errorf("invalid name length in skill code") - } - - result.Header = skillCodeHeader{ - Name: decodeUTF16LE(raw[14 : 14+nameLen]), - Level: int(raw[5]), - JobBytes: [2]int{int(raw[2]), int(raw[3])}, - } - - data := raw[14+nameLen:] - if len(data)%2 == 1 { - data = append(data, 0) - } - - uint16s := make([]uint16, 0, len(data)/2) - for i := 0; i < len(data); i += 2 { - uint16s = append(uint16s, binary.LittleEndian.Uint16(data[i:i+2])) - } - if len(uint16s) == 0 { - return result, nil - } - - high := make([]byte, len(uint16s)) - low := make([]byte, len(uint16s)) - for i, value := range uint16s { - high[i] = byte(value >> 8) - low[i] = byte(value & 0xFF) - } - - pairCount := int(high[0]) - for i := 0; i < pairCount; i++ { - base := 1 + i*2 - if base+1 >= len(high) { - break - } - result.Skills = append(result.Skills, skillLearnItem{ - SkillIndex: int(high[base]), - Level: int(high[base+1]), - SkillType: int(low[base+1]), - }) - } - - sectionStart := (1 + pairCount*2) * 2 - if sectionStart >= len(data) { - return result, nil - } - sec2 := data[sectionStart:] - if len(sec2) < 2 { - return result, nil - } - - offset := 1 - evoCount := int(sec2[offset]) - offset++ - for i := 0; i < evoCount; i++ { - if offset+3 > len(sec2) { - break - } - result.Evolutions = append(result.Evolutions, skillChoice{ - SkillIndex: int(sec2[offset]), - Type: int(sec2[offset+2]) + 1, - }) - offset += 3 - } - - if offset >= len(sec2) { - return result, nil - } - enhCount := int(sec2[offset]) - offset++ - for i := 0; i < enhCount; i++ { - if offset+3 > len(sec2) { - break - } - result.Enhancements = append(result.Enhancements, skillChoice{ - SkillIndex: int(sec2[offset]), - Type: int(sec2[offset+2]) + 1, - }) - offset += 3 - } - - return result, nil -} - -func decodeUTF16LE(value []byte) string { - if len(value)%2 == 1 { - value = append(value, 0) - } - words := make([]uint16, 0, len(value)/2) - for i := 0; i < len(value); i += 2 { - words = append(words, binary.LittleEndian.Uint16(value[i:i+2])) - } - return strings.TrimSpace(string(utf16.Decode(words))) -} diff --git a/backend/go/internal/service/token.go b/backend/go/internal/service/token.go deleted file mode 100644 index d3df0753..00000000 --- a/backend/go/internal/service/token.go +++ /dev/null @@ -1,62 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "time" -) - -const tokenTTL = 6 * time.Hour - -type TokenService struct { - adventure *AdventureService -} - -type AlterTokenPayload struct { - Alter string `json:"alter"` - EquVersion string `json:"equVersion"` - Time int64 `json:"time"` -} - -func NewTokenService(adventure *AdventureService) *TokenService { - return &TokenService{adventure: adventure} -} - -func (s *TokenService) Create(alterToken string, equVersion string) (string, error) { - if equVersion == "" { - equVersion = "0" - } - - alter, err := s.adventure.AlterByToken(alterToken) - if err != nil { - return "", err - } - - payload := AlterTokenPayload{ - Alter: alter, - EquVersion: equVersion, - Time: time.Now().UnixMilli(), - } - content, err := json.Marshal(payload) - if err != nil { - return "", err - } - - return base64.RawURLEncoding.EncodeToString(content), nil -} - -func (s *TokenService) Parse(token string) (AlterTokenPayload, error) { - var payload AlterTokenPayload - content, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return payload, fmt.Errorf("decode token: %w", err) - } - if err := json.Unmarshal(content, &payload); err != nil { - return payload, fmt.Errorf("unmarshal token: %w", err) - } - if payload.Time <= 0 || time.Since(time.UnixMilli(payload.Time)) > tokenTTL { - return AlterTokenPayload{}, fmt.Errorf("token expired") - } - return payload, nil -} diff --git a/backend/go/python_bridge.py b/backend/go/python_bridge.py deleted file mode 100644 index 7088324b..00000000 --- a/backend/go/python_bridge.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import os -import sys - - -def _fail(message: str) -> None: - print(json.dumps({'error': message}, ensure_ascii=False)) - sys.exit(1) - - -def _parse_args() -> tuple[str, str, str, str]: - if len(sys.argv) < 5: - _fail('usage: python_bridge.py ') - project_root = sys.argv[1] - command = sys.argv[2] - alter = sys.argv[3] - equ_version = sys.argv[4] - return project_root, command, alter, equ_version - - -def _ensure_path(project_root: str) -> None: - if project_root not in sys.path: - sys.path.insert(0, project_root) - sys.argv[0] = os.path.join(project_root, 'main.py') - os.chdir(project_root) - - -def _load_character(alter: str, equ_version: str): - from core.basic.character import createCharacter - - character = createCharacter(alter, equ_version or '0') - if character is None: - raise RuntimeError(f'failed to create character for {alter}') - return character - - -def _character_info(alter: str, equ_version: str): - character = _load_character(alter, equ_version) - return character.getInfo() - - -def _character_skills(alter: str, equ_version: str): - info = _character_info(alter, equ_version) - skills = info.get('skills', []) - return [ - { - 'id': str(skill.get('id', '')), - 'name': skill.get('name', ''), - 'type': skill.get('type', ''), - 'learnLv': int(skill.get('learnLv', 0)), - 'maxLearnLv': int(skill.get('maxLearnLv', 0)), - 'hasVP': bool(skill.get('hasVP', False)), - 'hasUP': bool(skill.get('hasUP', False)), - 'uuid': skill.get('uuid', ''), - } - for skill in skills - ] - - -def main() -> None: - project_root, command, alter, equ_version = _parse_args() - _ensure_path(project_root) - - try: - if command == 'character-info': - data = _character_info(alter, equ_version) - elif command == 'character-skills': - data = _character_skills(alter, equ_version) - else: - _fail(f'unknown command: {command}') - print(json.dumps(data, ensure_ascii=False)) - except Exception as ex: - _fail(str(ex)) - - -if __name__ == '__main__': - main() diff --git a/docs/backend/2026-06-04-backend-optimization-design.md b/docs/backend/2026-06-04-backend-optimization-design.md deleted file mode 100644 index 486d5478..00000000 --- a/docs/backend/2026-06-04-backend-optimization-design.md +++ /dev/null @@ -1,237 +0,0 @@ -# Backend 渐进式优化设计 - -> 本文面向 dcalc backend 的渐进式架构优化。目标是在不改变 `/calc` API 契约、不破坏 frontendNew 计算链路的前提下,逐步提升后端可维护性、扩展性、计算效率与开发规范。 - -## 背景 - -当前仓库前端迁移主线是 `frontend -> frontendNew`,backend 必须优先服务 Dcalc Character 模块的稳定计算链路:equipment、skill、forge、result。backend 现有结构已经完成过一次 `core/basic/character_old.py` 到 `core/basic/character/` 多文件 mixin 的拆分,说明当前更适合继续做小步收口,而不是推倒重写。 - -关键入口: - -- `/calc` 路由:`backend/api/routes/calc.py` -- 应用入口:`backend/main.py` -- 角色计算核心:`backend/core/basic/character/` -- 装备数据装载:`backend/core/basic/equipment.py` -- 数据库连接:`backend/database/connect.py` -- Redis 预热:`backend/api/core/RedisPreheat.py` - -## 非目标 - -- 不改 `/calc` 的请求语义、响应包装、`data` 主体字段结构。 -- 不改 frontendNew 依赖的 IResult 数据消费方式。 -- 不重写 Skill、Forge、装备效果、职业技能的核心业务逻辑。 -- 不在 backend 优化中引入新的 UI、前端状态管理或前端接口契约调整。 -- 不一次性格式化全仓 Python 文件,避免制造大规模无关 diff。 - -## 现状问题 - -### API 与计算耦合 - -`backend/api/routes/calc.py` 直接 `createCharacter(...).calc(config)`。这让鉴权、请求适配、计算编排、错误处理和未来耗时统计都挤在 API 调用路径上,不利于扩展。 - -### config 读取分散 - -计算过程中大量直接访问 `setInfo.get(...)`、`info['equips']`、`info['oaths']`。默认值、兼容逻辑和输入容错散落在 mixin 中,后续 Skill/Forge 配置演进时容易漏字段。 - -### 装备数据全局缓存边界不清 - -`backend/core/basic/equipment.py` 启动时构造 `equ0 = Equipments('0')`,`get_equipment()` 通过动态变量和 `eval` 获取版本。该方式对当前性能有帮助,但多版本扩展、测试隔离、热更新和错误定位较弱。 - -### 计算对象状态可变 - -`Character` 实例在 `calc_init -> calc_* -> calc_*_skills` 过程中持续变异属性、技能等级、装备效果和输出类型。该模式符合当前业务写法,但缺少明确的一次性计算上下文,测试和复用成本偏高。 - -### 性能缺少量化 - -已有内存化装备数据、技能类缓存、Redis 预热等优化点,但 `/calc` 的耗时瓶颈没有分段统计。后续不应凭感觉优化。 - -### 测试安全网不足 - -当前仅看到 `backend/test.py`,没有正式 `tests/` 体系。后续重构计算链路时,缺少 golden case 来确认字段与数值没有回退。 - -## 优化原则 - -1. 接口冻结:先用测试固定 `/calc` 外部行为,再改内部结构。 -2. 小步可回滚:每个阶段能单独合并、单独验证。 -3. 先边界后算法:优先整理 service、config view、repository/registry,再考虑公式与缓存。 -4. 只搬运不改义:拆文件、封 helper 时不改变公式含义。 -5. 先量化后性能:先加耗时指标,再按热点优化。 - -## 目标架构 - -```text -backend/ - api/ - routes/ - calc.py # 只负责 HTTP 层:依赖注入、Body、response - services/ - calc_service.py # /calc 应用服务:创建角色、执行计算、计时、错误上下文 - schemas/ - calc.py # 宽松 config schema 与响应类型别名 - response.py # 通用响应类型,保持旧格式兼容 - core/ - basic/ - character/ - config_view.py # CalcConfigView:统一默认值、兼容旧 config - effect_apply.py # 统一属性提取与 effect 执行 helper - equipment.py # 保留现有模型,逐步委托给 registry - equipment_registry.py # EquipmentRegistry:装备版本懒加载与缓存 - tests/ - contract/ - test_calc_contract.py - golden/ - test_calc_golden.py - unit/ - test_equipment_registry.py - test_calc_config_view.py -``` - -## 数据流 - -```text -HTTP POST /api/calc - -> AltersDep 解析 alter/equVersion - -> calc_service.calculate(state, raw_config) - -> CalcConfigView.from_raw(raw_config) - -> createCharacter(state.alter, state.equVersion) - -> character.calc(config_view 或兼容 dict) - -> calc_service 返回原始计算 data - -> response(data=data) -``` - -短期内 `Character.calc()` 仍接收原 `dict`,`CalcConfigView` 先作为内部读取辅助逐步接入。等测试稳定后,再考虑让 `Character.calc()` 接收 `CalcConfigView`。 - -## 分阶段设计 - -### Phase 0:测试安全网 - -建立 pytest 测试结构,固定 `/calc` 契约和典型职业结果结构。 - -重点: - -- 增加 `pytest` 开发依赖。 -- 增加可复用的最小 config fixture。 -- 增加 `/api/calc` HTTP contract 测试。 -- 增加 `createCharacter(...).calc(...)` golden smoke 测试。 - -成功标准: - -- `uv run pytest` 可执行。 -- `/calc` 响应仍包含 `code/message/data`。 -- `data` 至少包含 `uuid/skills/info/suits/buffer`。 - -### Phase 1:API service 边界 - -新增 `backend/services/calc_service.py`,API route 不再直接编排计算。 - -重点: - -- route 保持原路径、请求体和响应格式。 -- service 负责 `createCharacter`、空 config 兜底、计算执行。 -- 为后续日志、耗时、缓存预留 service 层。 - -成功标准: - -- `/api/calc` 行为不变。 -- route 文件只保留 HTTP 层职责。 -- Phase 0 测试全部通过。 - -### Phase 2:config view 与计算输入收口 - -新增 `CalcConfigView`,集中处理 config 的默认值与字段读取。 - -重点: - -- `equips/oaths/emblems/skills/avatar/sundry/jades/options` 都有稳定默认值。 -- 先在 `calc_init()`、`SetDetail()`、`SetSkills()` 等入口使用,不一次性替换所有 mixin。 -- 保留向后兼容,仍支持原始 dict。 - -成功标准: - -- 缺少可选字段时不抛出 KeyError。 -- Skill/Forge 配置链路不变:config 变化 -> result.execute -> saveConfig。 -- golden case 结果字段不变。 - -### Phase 3:装备数据 registry - -新增 `EquipmentRegistry`,替换 `get_equipment()` 内部的 `eval` 与模块全局变量访问。 - -重点: - -- `get_equipment(version)` 作为兼容门面继续存在。 -- registry 内部使用 `functools.cache` 或显式 dict 缓存。 -- 版本不存在时保持回退 `0` 的现有行为,但记录原因。 - -成功标准: - -- 现有调用方无需一次性改动。 -- 多版本装备数据加载路径清晰。 -- 单元测试覆盖缓存、回退和不存在版本。 - -### Phase 4:计算核心 helper 化 - -抽取重复且无业务争议的 helper,降低 mixin 复杂度。 - -重点: - -- `apply_uppercase_attrs(character, source)` 统一处理首字母大写属性应用。 -- `execute_effect(effects, key, *args)` 统一处理效果函数不存在时的行为。 -- 优先处理 `calc_suits/calc_equs/calc_oaths/calc_emblems` 中重复逻辑。 - -成功标准: - -- helper 有单元测试。 -- 业务公式位置不变或仅做等价搬迁。 -- `calc_equip.py` 可读性提升,结果不变。 - -### Phase 5:性能观测与热点优化 - -在 service 层引入轻量计时,再针对热点做优化。 - -重点: - -- 记录 `createCharacter`、`calc` 总耗时。 -- 可选记录 `calc_*` 分段耗时,默认不影响响应结构。 -- 优化重复 `获取基础属性()` 调用。 -- 评估技能实例模板缓存,但必须避免可变技能状态污染请求。 - -成功标准: - -- 能拿到典型配置的平均耗时和 p95。 -- 优化前后 golden case 一致。 -- 性能优化有数据支撑。 - -### Phase 6:规范与文档 - -清理运行缓存、废弃文件和文档入口。 - -重点: - -- 确认 `.venv/.ruff_cache/__pycache__/.DS_Store` 不进入 git。 -- 评估 `character_old.py` 移到归档或删除。 -- 补充 backend README:运行、测试、质量检查、目录职责。 -- 不在本阶段大规模格式化老文件。 - -成功标准: - -- 新人可通过文档理解 backend 入口和测试方式。 -- 废弃代码不会继续误导维护。 -- 规范变更不产生大规模业务 diff。 - -## 风险与缓解 - -| 风险 | 缓解 | -| --- | --- | -| 重构导致 `/calc` 字段变化 | Phase 0 先做 contract/golden 测试 | -| 技能对象可变状态污染缓存 | 技能模板缓存必须后置,且只缓存类或不可变模板 | -| 装备版本回退行为改变 | `get_equipment()` 保留兼容门面,并测试回退 | -| config 字段默认值与前端不一致 | `CalcConfigView` 先只集中现有默认值,不新增语义 | -| 性能统计污染响应 | 默认只写日志,不改变 API data | - -## 验收总标准 - -- `uv run pytest` 通过。 -- `/api/calc` 响应契约不变。 -- frontendNew 的 result.execute 与 saveConfig 链路无需调整。 -- 典型职业 golden case 在重构前后字段一致,关键数值在允许误差内。 -- backend 文档说明清晰,后续任务可以分阶段执行。 diff --git a/docs/backend/2026-06-04-backend-optimization-tasks.md b/docs/backend/2026-06-04-backend-optimization-tasks.md deleted file mode 100644 index 22182f40..00000000 --- a/docs/backend/2026-06-04-backend-optimization-tasks.md +++ /dev/null @@ -1,908 +0,0 @@ -# Backend 渐进式优化执行清单 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在保持 `/calc` API 契约和 frontendNew 计算链路不变的前提下,逐步提升 backend 的可维护性、扩展性、计算效率与开发规范。 - -**Architecture:** 先建立测试安全网,再把 HTTP route 与计算编排拆到 service 层,随后用 config view 和 equipment registry 收口隐式边界。最后基于耗时观测做性能优化,并补齐 backend 文档与规范。 - -**Tech Stack:** Python 3.10+、FastAPI、Pydantic、SQLAlchemy、Redis、SQLite、pytest、ruff、uv。 - ---- - -## 文件结构规划 - -- Create: `backend/tests/conftest.py` - 提供测试用 FastAPI client、alter token、最小 calc config fixture。 - -- Create: `backend/tests/contract/test_calc_contract.py` - 固定 `/api/calc` HTTP 响应契约。 - -- Create: `backend/tests/golden/test_calc_golden.py` - 固定典型职业计算结果结构,后续可扩展数值快照。 - -- Create: `backend/services/__init__.py` - 标记 service 包。 - -- Create: `backend/services/calc_service.py` - 承接 `/calc` 应用服务编排。 - -- Create: `backend/core/basic/character/config_view.py` - 集中处理 calc config 默认值与读取。 - -- Create: `backend/core/basic/equipment_registry.py` - 装备版本 registry 与缓存。 - -- Modify: `backend/api/routes/calc.py` - 改为调用 `calc_service.calculate()`,保持 route 契约不变。 - -- Modify: `backend/core/basic/character/calc_equip.py` - 逐步使用 `CalcConfigView` 与 helper,不改计算语义。 - -- Modify: `backend/core/basic/equipment.py` - `get_equipment()` 委托 `EquipmentRegistry`,保留兼容函数。 - -- Modify: `backend/pyproject.toml` - 增加 `pytest`、必要时增加测试配置。 - -- Create or Modify: `backend/README.md` - 说明运行、测试、质量检查、目录职责。 - -## Task 1:建立 pytest 基础与最小 contract 测试 - -**Files:** - -- Modify: `backend/pyproject.toml` -- Create: `backend/tests/conftest.py` -- Create: `backend/tests/contract/test_calc_contract.py` - -- [ ] **Step 1: 添加 pytest 开发依赖** - -在 `backend/pyproject.toml` 的 `[dependency-groups].dev` 中加入: - -```toml -[dependency-groups] -dev = [ - "pytest>=8.4.0", - "ruff>=0.15.15", -] -``` - -- [ ] **Step 2: 同步依赖** - -Run: - -```bash -cd backend && uv sync -``` - -Expected: `pytest` 安装完成,`uv.lock` 更新。 - -- [ ] **Step 3: 创建测试 fixture** - -Create `backend/tests/conftest.py`: - -```python -import base64 -import json -import time - -import pytest -from fastapi.testclient import TestClient - -from main import application - - -@pytest.fixture -def client(): - return TestClient(application) - - -@pytest.fixture -def alter_token(): - payload = { - 'alter': '1.1.1', - 'equVersion': '0', - 'time': int(time.time() * 1000), - } - return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') - - -@pytest.fixture -def minimal_calc_config(): - parts = [ - '武器', - '副武器', - '称号', - '上衣', - '头肩', - '下装', - '腰带', - '鞋', - '手镯', - '项链', - '戒指', - '辅助装备', - '魔法石', - '耳环', - ] - return { - 'equips': { - part: { - 'id': '', - 'reinforce': 0, - 'reinforceType': 0, - 'enchant': '0', - 'refine': 0, - 'adaptation': 0, - } - for part in parts - }, - 'oaths': {}, - 'emblems': {}, - 'skills': {}, - 'avatar': {}, - 'sundry': {}, - 'jades': {}, - 'options': {}, - 'DSB': False, - 'BUFF': False, - } -``` - -- [ ] **Step 4: 创建 contract 测试** - -Create `backend/tests/contract/test_calc_contract.py`: - -```python -def test_calc_response_contract(client, alter_token, minimal_calc_config): - response = client.post( - '/api/calc', - headers={'alter-token': alter_token}, - json=minimal_calc_config, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload['code'] == 200 - assert payload['message'] == 'Success' - assert isinstance(payload['data'], dict) - - data = payload['data'] - assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(data) - assert isinstance(data['uuid'], str) - assert isinstance(data['skills'], list) - assert isinstance(data['info'], list) - assert isinstance(data['buffer'], bool) -``` - -- [ ] **Step 5: 运行测试,记录真实失败** - -Run: - -```bash -cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v -``` - -Expected: 如果 Redis 依赖导致失败,记录失败原因;不要绕过 `/calc` 鉴权契约。后续可在 fixture 中 monkeypatch `api.dp.get_redis_info` 或提供本地 Redis。 - -- [ ] **Step 6: 提交本任务** - -Run: - -```bash -git add backend/pyproject.toml backend/uv.lock backend/tests/conftest.py backend/tests/contract/test_calc_contract.py -git commit -m "test: add backend calc contract coverage" -``` - -## Task 2:增加 core golden smoke 测试 - -**Files:** - -- Create: `backend/tests/golden/test_calc_golden.py` - -- [ ] **Step 1: 创建核心计算 smoke 测试** - -Create `backend/tests/golden/test_calc_golden.py`: - -```python -from core.basic.character import createCharacter - - -def test_character_calc_returns_result_shape(minimal_calc_config): - character = createCharacter('1.1.1', '0') - - result = character.calc(minimal_calc_config) - - assert isinstance(result, dict) - assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result) - assert isinstance(result['skills'], list) - assert isinstance(result['info'], list) - assert result['buffer'] is False -``` - -- [ ] **Step 2: 运行 golden smoke** - -Run: - -```bash -cd backend && uv run pytest backend/tests/golden/test_calc_golden.py -v -``` - -Expected: PASS。若职业 `1.1.1` 不存在,改用 `core.character.adventure` 中第一个 `open=True` 的职业,并同步更新 Task 1 fixture。 - -- [ ] **Step 3: 运行全部测试** - -Run: - -```bash -cd backend && uv run pytest -v -``` - -Expected: PASS,或仅 contract 测试因外部 Redis 环境失败且原因明确。 - -- [ ] **Step 4: 提交本任务** - -Run: - -```bash -git add backend/tests/golden/test_calc_golden.py -git commit -m "test: add backend calc golden smoke" -``` - -## Task 3:引入 calc service,瘦身 API route - -**Files:** - -- Create: `backend/services/__init__.py` -- Create: `backend/services/calc_service.py` -- Modify: `backend/api/routes/calc.py` -- Test: `backend/tests/contract/test_calc_contract.py` -- Test: `backend/tests/golden/test_calc_golden.py` - -- [ ] **Step 1: 创建 service 包** - -Create `backend/services/__init__.py`: - -```python -"""Application services for backend API orchestration.""" -``` - -- [ ] **Step 2: 创建 calc service** - -Create `backend/services/calc_service.py`: - -```python -from typing import Any - -from api.core.Auth import AlterState -from core.basic.character import createCharacter - - -def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]: - safe_config = config or {} - character = createCharacter(state.alter, state.equVersion) - return character.calc(safe_config) -``` - -- [ ] **Step 3: 修改 route 调用 service** - -Modify `backend/api/routes/calc.py`: - -```python -from api.core.Gzip import register_gzip_request -from fastapi import APIRouter, Body -from api.core.Response import response -from api.dp import AltersDep -from services.calc_service import calculate - -router = APIRouter() - -register_gzip_request(router) - - -@router.post('/calc') -async def calc(state: AltersDep, config=Body(None)): - return response(data=calculate(state, config)) -``` - -- [ ] **Step 4: 运行测试** - -Run: - -```bash -cd backend && uv run pytest backend/tests/contract/test_calc_contract.py backend/tests/golden/test_calc_golden.py -v -``` - -Expected: PASS。 - -- [ ] **Step 5: 提交本任务** - -Run: - -```bash -git add backend/services/__init__.py backend/services/calc_service.py backend/api/routes/calc.py -git commit -m "refactor: route calc through service layer" -``` - -## Task 4:增加 CalcConfigView,集中 config 默认值 - -**Files:** - -- Create: `backend/core/basic/character/config_view.py` -- Modify: `backend/core/basic/character/calc_equip.py` -- Test: `backend/tests/unit/test_calc_config_view.py` - -- [ ] **Step 1: 创建 config view** - -Create `backend/core/basic/character/config_view.py`: - -```python -from collections.abc import Mapping -from typing import Any - - -class CalcConfigView: - def __init__(self, raw: Mapping[str, Any] | None): - self.raw = dict(raw or {}) - - @property - def equips(self) -> dict[str, dict[str, Any]]: - return dict(self.raw.get('equips') or {}) - - @property - def oaths(self) -> dict[str, dict[str, Any]]: - return dict(self.raw.get('oaths') or {}) - - @property - def emblems(self) -> dict[str, dict[str, Any]]: - return dict(self.raw.get('emblems') or {}) - - @property - def skills(self) -> dict[str, dict[str, Any]]: - return dict(self.raw.get('skills') or {}) - - @property - def avatar(self) -> dict[str, Any]: - return dict(self.raw.get('avatar') or {}) - - @property - def sundry(self) -> dict[str, Any]: - return dict(self.raw.get('sundry') or {}) - - @property - def jades(self) -> dict[str, Any]: - return dict(self.raw.get('jades') or {}) - - @property - def options(self) -> dict[str, Any]: - return dict(self.raw.get('options') or {}) - - @property - def dsb(self) -> bool: - return bool(self.raw.get('DSB', False)) - - @property - def buff(self) -> bool: - return bool(self.raw.get('BUFF', False)) - - def as_legacy_dict(self) -> dict[str, Any]: - data = dict(self.raw) - data.setdefault('equips', {}) - data.setdefault('oaths', {}) - data.setdefault('emblems', {}) - data.setdefault('skills', {}) - data.setdefault('avatar', {}) - data.setdefault('sundry', {}) - data.setdefault('jades', {}) - data.setdefault('options', {}) - data.setdefault('DSB', False) - data.setdefault('BUFF', False) - return data -``` - -- [ ] **Step 2: 添加单元测试** - -Create `backend/tests/unit/test_calc_config_view.py`: - -```python -from core.basic.character.config_view import CalcConfigView - - -def test_config_view_provides_defaults(): - view = CalcConfigView(None) - - assert view.equips == {} - assert view.oaths == {} - assert view.emblems == {} - assert view.skills == {} - assert view.avatar == {} - assert view.sundry == {} - assert view.jades == {} - assert view.options == {} - assert view.dsb is False - assert view.buff is False - - -def test_config_view_preserves_legacy_keys(): - view = CalcConfigView({'skills': {'100': {'lv': 1}}, 'BUFF': True}) - - data = view.as_legacy_dict() - - assert data['skills'] == {'100': {'lv': 1}} - assert data['BUFF'] is True - assert data['DSB'] is False - assert data['equips'] == {} -``` - -- [ ] **Step 3: 在 calc_init 入口使用兼容 dict** - -Modify `backend/core/basic/character/calc_equip.py`: - -```python -from .config_view import CalcConfigView -``` - -在 `calc()` 起始处改为: - -```python -def calc(self, setInfo: dict[str, dict]): - config = CalcConfigView(setInfo).as_legacy_dict() - self.calc_init(config) - self.SetBaseStatus() - self.calc_jade(config.get('jades', [])) - self.calc_avatar(config.get('avatar', {})) - self.calc_basic() - self.calc_sundry(config.get('sundry', {})) - self.calc_emblems() - suit = self.calc_suits() - self.calc_equs() - self.calc_oaths() - skillInfos = [] - if self.buffer: - skillInfos = self.calc_buffer_skills() - info = self.get_buffer_info() - else: - if len(self.输出类型选项) > 1: - temp = self.输出类型选项[0] - if '固伤' in temp: - if self.INT > self.STR: - self.输出类型 = '魔法固伤' - else: - self.输出类型 = '物理固伤' - else: - if self.INT > self.STR and self.AtkM > self.AtkP: - self.输出类型 = '魔法百分比' - else: - self.输出类型 = '物理百分比' - skillInfos = self.calc_carry_skills(config.get('DSB', False), config.get('BUFF', False)) - info = self.get_carry_info() - return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer} -``` - -- [ ] **Step 4: 运行测试** - -Run: - -```bash -cd backend && uv run pytest backend/tests/unit/test_calc_config_view.py backend/tests/golden/test_calc_golden.py -v -``` - -Expected: PASS。 - -- [ ] **Step 5: 提交本任务** - -Run: - -```bash -git add backend/core/basic/character/config_view.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_calc_config_view.py -git commit -m "refactor: centralize calc config defaults" -``` - -## Task 5:引入 EquipmentRegistry,替换 eval 获取装备数据 - -**Files:** - -- Create: `backend/core/basic/equipment_registry.py` -- Modify: `backend/core/basic/equipment.py` -- Test: `backend/tests/unit/test_equipment_registry.py` - -- [ ] **Step 1: 创建 registry** - -Create `backend/core/basic/equipment_registry.py`: - -```python -from functools import cache - - -class EquipmentRegistry: - def __init__(self, equipment_cls): - self.equipment_cls = equipment_cls - - @cache - def get(self, version: str = '0'): - normalized = version or '0' - try: - return self.equipment_cls(normalized) - except Exception: - if normalized == '0': - raise - return self.get('0') -``` - -- [ ] **Step 2: 修改 equipment.py 兼容门面** - -Modify `backend/core/basic/equipment.py` 底部: - -```python -from core.basic.equipment_registry import EquipmentRegistry - - -_equipment_registry = EquipmentRegistry(Equipments) - - -def get_equipment(version: str = '0') -> Equipments: - return _equipment_registry.get(version) -``` - -删除或停止使用: - -```python -global equ0 -equ0 = Equipments('0') -``` - -以及 `eval(f'equ{version}'.format(version))` 相关逻辑。 - -- [ ] **Step 3: 添加 registry 测试** - -Create `backend/tests/unit/test_equipment_registry.py`: - -```python -from core.basic.equipment import get_equipment - - -def test_get_equipment_returns_cached_default_version(): - first = get_equipment('0') - second = get_equipment('0') - - assert first is second - assert first.version == '0' - - -def test_get_equipment_falls_back_to_default_version(): - equipment = get_equipment('missing-version') - - assert equipment.version == '0' -``` - -- [ ] **Step 4: 运行测试** - -Run: - -```bash -cd backend && uv run pytest backend/tests/unit/test_equipment_registry.py backend/tests/golden/test_calc_golden.py -v -``` - -Expected: PASS。 - -- [ ] **Step 5: 提交本任务** - -Run: - -```bash -git add backend/core/basic/equipment_registry.py backend/core/basic/equipment.py backend/tests/unit/test_equipment_registry.py -git commit -m "refactor: add equipment registry" -``` - -## Task 6:增加 calc service 耗时观测 - -**Files:** - -- Modify: `backend/services/calc_service.py` -- Test: `backend/tests/contract/test_calc_contract.py` - -- [ ] **Step 1: 添加轻量日志计时** - -Modify `backend/services/calc_service.py`: - -```python -import logging -import time -from typing import Any - -from api.core.Auth import AlterState -from core.basic.character import createCharacter - - -logger = logging.getLogger('dcalc.calc') - - -def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]: - started_at = time.perf_counter() - safe_config = config or {} - character = createCharacter(state.alter, state.equVersion) - created_at = time.perf_counter() - result = character.calc(safe_config) - finished_at = time.perf_counter() - logger.info( - 'calc completed alter=%s equVersion=%s create_ms=%.2f calc_ms=%.2f total_ms=%.2f', - state.alter, - state.equVersion, - (created_at - started_at) * 1000, - (finished_at - created_at) * 1000, - (finished_at - started_at) * 1000, - ) - return result -``` - -- [ ] **Step 2: 运行 contract 测试** - -Run: - -```bash -cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v -``` - -Expected: PASS。响应 data 不新增耗时字段。 - -- [ ] **Step 3: 手动观察日志** - -Run: - -```bash -cd backend && uv run python main.py -``` - -Expected: 服务正常启动。发起 `/api/calc` 后可在日志处理器中接入 `dcalc.calc` logger;本任务不要求改变线上日志格式。 - -- [ ] **Step 4: 提交本任务** - -Run: - -```bash -git add backend/services/calc_service.py -git commit -m "chore: add calc timing logs" -``` - -## Task 7:抽取 effect 应用 helper - -**Files:** - -- Create: `backend/core/basic/character/effect_apply.py` -- Modify: `backend/core/basic/character/calc_equip.py` -- Test: `backend/tests/unit/test_effect_apply.py` -- Test: `backend/tests/golden/test_calc_golden.py` - -- [ ] **Step 1: 创建 helper** - -Create `backend/core/basic/character/effect_apply.py`: - -```python -from typing import Any - - -def uppercase_attrs(source: Any) -> dict[str, Any]: - return { - key: value - for key, value in source.__dict__.items() - if key and key[0].isupper() - } - - -def apply_uppercase_attrs(character: Any, source: Any) -> None: - character.SetStatus(**uppercase_attrs(source)) - - -def execute_effect(effects: Any, key: str, *args: Any) -> bool: - fun = effects.execture(key) - if fun is None: - return False - fun(*args) - return True -``` - -- [ ] **Step 2: 添加 helper 测试** - -Create `backend/tests/unit/test_effect_apply.py`: - -```python -from core.basic.character.effect_apply import uppercase_attrs - - -class Source: - STR = 10 - - def __init__(self): - self.INT = 20 - self.name = 'ignored' - - -def test_uppercase_attrs_returns_only_uppercase_keys(): - assert uppercase_attrs(Source()) == {'INT': 20} -``` - -- [ ] **Step 3: 在 calc_equip.py 中逐步替换重复代码** - -Modify selected occurrences in `backend/core/basic/character/calc_equip.py`: - -```python -from .effect_apply import apply_uppercase_attrs, execute_effect, uppercase_attrs -``` - -将简单重复片段: - -```python -filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()} -self.SetStatus(**filtered_dict) -``` - -替换为: - -```python -apply_uppercase_attrs(self, suit) -``` - -将: - -```python -fun = effects.execture(f'equ_{equ.id}') -if fun is None: - continue -filtered_dict = {k: v for k, v in equ.__dict__.items() if k[0].isupper()} -self.SetStatus(**filtered_dict) -fun(self) -``` - -替换为: - -```python -fun = effects.execture(f'equ_{equ.id}') -if fun is None: - continue -apply_uppercase_attrs(self, equ) -fun(self) -``` - -- [ ] **Step 4: 运行测试** - -Run: - -```bash -cd backend && uv run pytest backend/tests/unit/test_effect_apply.py backend/tests/golden/test_calc_golden.py -v -``` - -Expected: PASS。 - -- [ ] **Step 5: 提交本任务** - -Run: - -```bash -git add backend/core/basic/character/effect_apply.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_effect_apply.py -git commit -m "refactor: extract character effect helpers" -``` - -## Task 8:补充 backend README - -**Files:** - -- Create or Modify: `backend/README.md` - -- [ ] **Step 1: 编写 backend README** - -Create or Modify `backend/README.md`: - -````markdown -# dcalc backend - -## Run - -```bash -uv sync -python main.py -``` - -## Test - -```bash -uv run pytest -v -``` - -## Quality - -```bash -uv run ruff check . -``` - -## Runtime Entry - -- `main.py`: FastAPI application and uvicorn entry. -- `api/routes/calc.py`: `/api/calc` HTTP route. -- `services/calc_service.py`: calculation orchestration. -- `core/basic/character/`: character calculation implementation. -- `core/basic/equipment.py`: equipment data models and compatibility access. -- `core/basic/equipment_registry.py`: equipment version registry and cache. -- `database/connect.py`: SQLite and Redis connections. - -## Compatibility Rules - -- Do not change `/calc` request and response contract without frontend migration approval. -- Do not change frontendNew result data shape during backend refactors. -- Keep Skill and Forge business formulas behavior-compatible unless a dedicated formula task says otherwise. -```` - -- [ ] **Step 2: 运行文档相关检查** - -Run: - -```bash -cd backend && uv run ruff check . -``` - -Expected: PASS,或只出现既有遗留问题。若有既有问题,不在本任务做大范围修复。 - -- [ ] **Step 3: 提交本任务** - -Run: - -```bash -git add backend/README.md -git commit -m "docs: document backend runtime structure" -``` - -## Task 9:阶段性全量验证 - -**Files:** - -- No source changes expected. - -- [ ] **Step 1: 运行测试** - -Run: - -```bash -cd backend && uv run pytest -v -``` - -Expected: PASS。 - -- [ ] **Step 2: 运行 lint** - -Run: - -```bash -cd backend && uv run ruff check . -``` - -Expected: PASS,或输出仅包含已登记的遗留问题。 - -- [ ] **Step 3: 启动服务** - -Run: - -```bash -cd backend && uv run python main.py -``` - -Expected: 输出 `Application startup complete.`,服务监听 `config.PORT`。 - -- [ ] **Step 4: 手动验证 frontendNew 链路** - -Run: - -```bash -cd frontendNew && pnpm dev -``` - -Expected: 在 Character 模块修改 Skill/Forge 配置后,仍满足 `config 变化 -> result.execute -> saveConfig`。 - -- [ ] **Step 5: 提交验证记录** - -Run: - -```bash -git status --short -``` - -Expected: 工作区干净,所有阶段任务已独立提交。