This commit is contained in:
2026-07-06 13:54:22 +08:00
parent 332bcb5326
commit 2b1c27c36c
20 changed files with 0 additions and 3457 deletions
-53
View File
@@ -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. 最后逐职业迁技能公式与计算逻辑。
-52
View File
@@ -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)
}
}
-20
View File
@@ -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
)
-53
View File
@@ -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=
-90
View File
@@ -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
}
-305
View File
@@ -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
}
-15
View File
@@ -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}
}
-137
View File
@@ -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
}
-44
View File
@@ -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
}
-34
View File
@@ -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
}
-21
View File
@@ -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)
})
}
-304
View File
@@ -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
}
-291
View File
@@ -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
}
-376
View File
@@ -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)))
}
-62
View File
@@ -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
}
-77
View File
@@ -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()