remove
This commit is contained in:
@@ -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. 最后逐职业迁技能公式与计算逻辑。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> <command> <alter> <equ_version>')
|
||||
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()
|
||||
@@ -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 文档说明清晰,后续任务可以分阶段执行。
|
||||
@@ -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: 工作区干净,所有阶段任务已独立提交。
|
||||
Reference in New Issue
Block a user