From 799ee33045e9f9dfc9b753f980169b1b370987e2 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 6 Aug 2026 16:33:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=AD=E5=BB=BA=20Admin=20=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E9=AA=A8=E6=9E=B6=EF=BC=88Go=20+=20Gin=20+=20html/tem?= =?UTF-8?q?plate=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可运行的骨架:四个页面能打开、数据库自动建表、给 Client 的三个接口 按契约响应。业务逻辑留 35 处 TODO(骨架),每处标明要点和文档章节。 结构(对应 docs/admin/02-architecture.md §2/§3) - handler/web + handler/api 分开:页面要 CSRF、出错渲染错误页; 接口要认证、出错返回 JSON。混在一起迟早写错 - service 不认识 *gin.Context,方便不起服务器直接单测 - 只有 repository 能写 SQL - 模板用 {{define "目录/名字"}},一次全部 ParseFS 进来不冲突 - 模板和静态资源用 //go:embed 打进二进制 已实测(Go 1.23.0,本机验证) - go build / go vet / gofmt 全部通过 - 单文件产物 34MB,无需 cgo - 四个页面 + 静态资源均 200,/ 正确 302 到 /shopee - 建表 7 张 + 索引 10 个,user_version=1,二次启动不重复建表 - claim 带 X-Client-Id 返回 204(无任务可领), 缺 X-Client-Id 返回 400 + 契约格式的 JSON 错误体 依赖版本被 Go 1.23.0 卡死,已钉住并写进文档 - gin v1.11.0 (v1.12.0 起要求 Go >= 1.25.0) - modernc.org/sqlite v1.38.0(v1.40.0 起要求 >= 1.24.0,v1.48.0 起 >= 1.25.0) - excelize v2.9.1 (尚未入 go.mod,写导入功能时再加) 直接 go get 不带版本会拉到最新版并报 requires go >= 1.25.0, 所以文档里的命令一律带版本号。要升依赖就得先升 Go。 一处主动调整:迁移语句从「一个字符串装多条 SQL」改成 [][]string 逐条执行。 database/sql 的 Exec 对一次多条语句的支持因驱动而异,拆开最稳妥, 报错还能精确到第几条。 说明:Gitea 尚未配置,本次无对应工单号。 admin/testdata/ 只有 README,脱敏小样本需人工制作。 Co-Authored-By: Claude Opus 5 --- admin/AGENTS.md | 28 +++- admin/config/config.go | 71 +++++++++ admin/go.mod | 47 ++++++ admin/go.sum | 124 ++++++++++++++++ admin/handler/api/client_api.go | 166 +++++++++++++++++++++ admin/handler/web/others.go | 120 +++++++++++++++ admin/handler/web/shopee.go | 85 +++++++++++ admin/handler/web/web.go | 80 ++++++++++ admin/main.go | 85 +++++++++++ admin/model/model.go | 170 +++++++++++++++++++++ admin/repository/db.go | 214 +++++++++++++++++++++++++++ admin/service/service.go | 134 +++++++++++++++++ admin/static/css/app.css | 147 ++++++++++++++++++ admin/static/js/app.js | 87 +++++++++++ admin/templates/client/list.html | 52 +++++++ admin/templates/partials/error.html | 21 +++ admin/templates/partials/footer.html | 10 ++ admin/templates/partials/header.html | 20 +++ admin/templates/shopee/list.html | 65 ++++++++ admin/templates/syb/list.html | 67 +++++++++ admin/templates/task/list.html | 53 +++++++ admin/testdata/README.md | 39 +++++ docs/admin/00-getting-started.md | 42 ++++-- 23 files changed, 1910 insertions(+), 17 deletions(-) create mode 100644 admin/config/config.go create mode 100644 admin/go.sum create mode 100644 admin/handler/api/client_api.go create mode 100644 admin/handler/web/others.go create mode 100644 admin/handler/web/shopee.go create mode 100644 admin/handler/web/web.go create mode 100644 admin/main.go create mode 100644 admin/model/model.go create mode 100644 admin/repository/db.go create mode 100644 admin/service/service.go create mode 100644 admin/static/css/app.css create mode 100644 admin/static/js/app.js create mode 100644 admin/templates/client/list.html create mode 100644 admin/templates/partials/error.html create mode 100644 admin/templates/partials/footer.html create mode 100644 admin/templates/partials/header.html create mode 100644 admin/templates/shopee/list.html create mode 100644 admin/templates/syb/list.html create mode 100644 admin/templates/task/list.html create mode 100644 admin/testdata/README.md diff --git a/admin/AGENTS.md b/admin/AGENTS.md index f02d1d8..25b598a 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -22,13 +22,37 @@ ## 技术栈 +**Go 版本固定 1.23.0。** 依赖版本受它约束,见下面的表。 + - 固定使用 **Go + Gin + Go 标准库 `html/template`**。 - 数据库固定 **SQLite**,驱动固定 `modernc.org/sqlite`。 **不得改用 `mattn/go-sqlite3`** —— 那个要 cgo,Windows 上得装 gcc, 交叉编译和打包 exe 都会变得很麻烦。 - Excel 读取固定 `github.com/xuri/excelize/v2`。 -- Go 版本固定 **1.23.0**,依赖版本以 `admin/go.mod` / `go.sum` 为准。 -- 新增或升级依赖前先过工单;确认后把 `go.mod` 和 `go.sum` 一起提交。 + +### 依赖版本已钉死,不要随手升 + +| 依赖 | 固定版本 | 为什么不能用更新的 | +|---|---|---| +| `github.com/gin-gonic/gin` | **v1.11.0** | v1.12.0 起要求 Go ≥ 1.25.0 | +| `modernc.org/sqlite` | **v1.38.0** | v1.40.0 起要求 Go ≥ 1.24.0,v1.48.0 起要求 ≥ 1.25.0 | +| `github.com/xuri/excelize/v2` | **v2.9.1** | v2.10.0 要求 Go ≥ 1.24.0,v2.11.0 要求 ≥ 1.25.0 | + +> excelize 目前**还不在 `go.mod` 里**——导入功能还没写,没有代码 import 它, +> `go mod tidy` 会把它去掉,这是 Go 的正常行为。写导入功能时用 +> `go get github.com/xuri/excelize/v2@v2.9.1` 加进来。 + +这三个版本是**在 Go 1.23.0 下实测能编译通过的最高版本**。 + +`[必须]` 直接跑 `go get <包名>`(不带版本)会拉到最新版,然后报 +`requires go >= 1.25.0`。拉依赖要带版本号,见 +[00 上手指南](../docs/admin/00-getting-started.md) §2。 + +`[必须]` **要升依赖就得先升 Go**,这是一个决定不是两个。 +升 Go 属于会影响构建的变更,先过工单。 + +- 实际版本以 `admin/go.mod` / `go.sum` 为准,这两个文件都要提交 Git。 +- 新增或升级依赖前先过工单。 ### 前端约束 diff --git a/admin/config/config.go b/admin/config/config.go new file mode 100644 index 0000000..54ca16d --- /dev/null +++ b/admin/config/config.go @@ -0,0 +1,71 @@ +// Package config 负责运行参数和文件路径。 +// +// 改动本文件前必读 admin/AGENTS.md。最容易踩的一条: +// 路径必须走 DataDir(),不许硬编码、不许直接用 os.Getwd(), +// 打包成 exe 后那些写法会失效。详见 docs/admin/02-architecture.md §6。 +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// DataDir 返回可写数据目录,不存在就创建。 +// +// 打包成 exe 后 = exe 旁边的 data/ +// go run 时 = 当前工作目录下的 data/ +// +// 目录结构见 docs/admin/02-architecture.md §6: +// +// data/ +// ├── admin.db +// ├── logs/ +// └── uploads/ +func DataDir() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + + dir := filepath.Join(filepath.Dir(exe), "data") + + // go run 会把程序编译到系统临时目录再执行, + // 这种情况下 exe 旁边不是项目目录,要改用当前工作目录。 + if isTempBuild(exe) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + dir = filepath.Join(wd, "data") + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +// SubDir 返回 data/ 下的子目录,不存在就创建。 +// 例如 SubDir("logs") -> /logs +func SubDir(name string) (string, error) { + base, err := DataDir() + if err != nil { + return "", err + } + dir := filepath.Join(base, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +// isTempBuild 判断这个可执行文件是不是 go run 生成的临时产物。 +func isTempBuild(exe string) bool { + tmp := os.TempDir() + if tmp != "" && strings.HasPrefix(exe, tmp) { + return true + } + // go run 的产物路径里通常带 go-build 字样 + return strings.Contains(filepath.ToSlash(exe), "/go-build") +} diff --git a/admin/go.mod b/admin/go.mod index f9dc677..bdbed20 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -10,3 +10,50 @@ go 1.23.0 // // 不得改用 github.com/mattn/go-sqlite3(需要 cgo,Windows 上要装 gcc, // 交叉编译和打包 exe 都会变麻烦)。 + +require ( + github.com/gin-gonic/gin v1.11.0 + modernc.org/sqlite v1.38.0 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + modernc.org/libc v1.65.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/admin/go.sum b/admin/go.sum new file mode 100644 index 0000000..88ec8df --- /dev/null +++ b/admin/go.sum @@ -0,0 +1,124 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= +modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= +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.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/admin/handler/api/client_api.go b/admin/handler/api/client_api.go new file mode 100644 index 0000000..dbac946 --- /dev/null +++ b/admin/handler/api/client_api.go @@ -0,0 +1,166 @@ +// Package api 是给 Client 用的 JSON 接口。 +// +// **权威契约在 Client 那边**:docs/client/04-admin-api-contract.md。 +// 本包只是实现,两边说法不一致时以 Client 契约为准,要改先走工单。 +// +// 一共只有三个接口,**不得新增"让 Client 查询状态"类接口**, +// 也不得加回租约和心跳,理由见 docs/admin/04-client-api.md §1。 +package api + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Handler 持有接口共用的依赖。 +type Handler struct { + db *sql.DB +} + +// Register 挂上三个接口。 +func Register(r *gin.Engine, db *sql.DB) { + h := &Handler{db: db} + + g := r.Group("/api/v1/client") + { + g.POST("/tasks/claim", h.Claim) + g.POST("/tasks/:task_id/result", h.SubmitResult) + g.POST("/tasks/:task_id/failure", h.SubmitFailure) + } +} + +// ClaimRequest 是领取任务的请求体。 +// 字段对应 docs/client/04-admin-api-contract.md §5。 +type ClaimRequest struct { + Client struct { + // Name 是对 Client 契约的一处小扩展,需联合评审确认。 + // **要容忍它缺失**:没有就用 X-Client-Id 当显示名。 + Name string `json:"name"` + } `json:"client"` + SupportedTypes []string `json:"supported_types"` + Device struct { + Address string `json:"address"` + Platform string `json:"platform"` + PddPackage string `json:"pdd_package"` + } `json:"device"` + Capabilities struct { + PurchaseMode string `json:"purchase_mode"` + SchemaVersions []int `json:"schema_versions"` + } `json:"capabilities"` +} + +// Claim 领取一个任务。 +// +// 处理顺序(见 docs/admin/04-client-api.md §2): +// 1. 先做客户端注册/更新——**注册就在这里做,没有单独的注册接口**; +// 2. 查 assigned_client = X-Client-Id 且 status = 'assigned' 的任务,取一条; +// 3. 没有返回 204 No Content(不是 200 加空对象); +// 4. 有就用条件更新把状态改成 claimed,检查影响行数防并发。 +// +// 响应**不含租约**,也不含 Admin 侧状态。 +func (h *Handler) Claim(c *gin.Context) { + clientID := c.GetHeader("X-Client-Id") + if clientID == "" { + apiError(c, http.StatusBadRequest, "MISSING_CLIENT_ID", + "缺少 X-Client-Id 请求头", false) + return + } + + var req ClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + apiError(c, http.StatusBadRequest, "INVALID_BODY", + "请求体不是合法 JSON", false) + return + } + + // TODO(骨架): 1. service.RegisterClient(h.db, clientID, req) + // 新 client_id 就新增,已有就更新 device/capabilities/last_seen_at。 + // name 若已被人工改过,不要覆盖。 + + // TODO(骨架): 2. service.ClaimNextTask(h.db, clientID) + // 只返回分配给这个客户端的任务,一次一条。 + // UPDATE ... WHERE task_id = ? AND status = 'assigned' + // 检查影响行数,为 0 说明被抢先了,取下一条。 + + // 新客户端第一次来必然拿不到任务(还没人给它分配),返回 204 是正常的。 + c.Status(http.StatusNoContent) +} + +// SubmitResult 接收成功结果。 +// +// **最容易写错的一条**(docs/admin/04-client-api.md §4.1): +// - 不得因为任务已取消而拒绝; +// - 不得因为任务已重派给别的客户端而拒绝; +// - 必须能接受同一任务来自多个客户端的多份结果; +// - 只有**从未分配给该客户端**的任务才返回 403。 +// +// 原因:Client 中途不查任务状态,所以它必然会提交一些 +// "Admin 这边已经不要了"的结果。而它可能真的已经下单了, +// 这些数据必须能交上来留痕。 +// +// accepted: true 的意思是"我收到并存下了",不代表任务还算数。 +func (h *Handler) SubmitResult(c *gin.Context) { + taskID := c.Param("task_id") + idemKey := c.GetHeader("Idempotency-Key") + if idemKey == "" { + apiError(c, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY", + "缺少 Idempotency-Key 请求头", false) + return + } + + // TODO(骨架): 1. 查 idempotency_keys: + // 键同、内容哈希同 -> 直接返回上次的 response_body,不重复落库 + // 键同、内容哈希不同 -> 409 IDEMPOTENCY_CONFLICT + // TODO(骨架): 2. 在**同一个事务**里: + // 写 tasks.result_data; + // 采集任务同时写 shopee_products.pdd_data 并置 collect_status = collected; + // tasks.status = 'succeeded'; + // 写 idempotency_keys。 + // TODO(骨架): 3. 刷新客户端 last_seen_at + // (长任务期间不调 claim,不刷新会被误判成离线)。 + + _ = taskID + apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED", + "提交结果接口尚未实现", false) +} + +// SubmitFailure 接收失败或需人工处理的结果。 +// +// Client 报告的 status 只会是 retry_wait / manual_review / failed / cancelled, +// 按下表落地(docs/admin/04-client-api.md §5): +// +// retry_wait -> assigned(放回去,等它再来领) +// manual_review -> manual_review +// failed -> failed +// cancelled -> cancelled +// +// §4.1 的无条件接受规则同样适用于本接口。 +func (h *Handler) SubmitFailure(c *gin.Context) { + taskID := c.Param("task_id") + + // TODO(骨架): 同 SubmitResult 的幂等处理; + // 采集任务失败时同步把 shopee_products.collect_status 置 failed, + // 错误信息写 collect_error,界面上要看得见。 + + _ = taskID + apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED", + "提交失败接口尚未实现", false) +} + +// apiError 按契约格式返回错误。 +// +// 接口出错返回 JSON,页面出错渲染错误页,两者不要混。 +// 格式见 docs/client/04-admin-api-contract.md §3。 +func apiError(c *gin.Context, status int, code, message string, retryable bool) { + c.JSON(status, gin.H{ + "error": gin.H{ + "code": code, + "message": message, + "retryable": retryable, + "request_id": c.GetHeader("X-Request-Id"), + "details": gin.H{}, + }, + }) +} diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go new file mode 100644 index 0000000..8e463cb --- /dev/null +++ b/admin/handler/web/others.go @@ -0,0 +1,120 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ---------- 2. 顺运宝数据 ---------- + +// SybList 渲染货运单列表页。 +// 「匹配状态」是算出来的(sku_mappings 里有没有记录),不是存的字段。 +func (h *Handler) SybList(c *gin.Context) { + keyword := c.Query("order_no") + + // TODO(骨架): 查货运单,并左联 sku_mappings 得出匹配状态 + var rows []gin.H + + c.HTML(http.StatusOK, "syb/list", page("syb", "顺运宝数据", gin.H{ + "Keyword": keyword, + "Rows": rows, + "Status": "尚未实现:同步或手工录入后这里显示货运单", + })) +} + +// SybSync 从顺运宝同步待处理货运单。 +// +// MVP 阶段只做占位,见 docs/admin/01-requirements.md §11 待确认 #1。 +func (h *Handler) SybSync(c *gin.Context) { + // TODO(等待确认): 顺运宝的同步方式(接口?导出文件?)还没定。 + // 定了之后在这里实现,注意完整响应要原样存进 syb_data 字段。 + fail(c, http.StatusNotImplemented, + "同步功能待接入。顺运宝的对接方式尚未确定,"+ + "当前可以先手工录入货运单用于联调。") +} + +// SybMatch 保存规格匹配结果。 +// +// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings), +// 不是这一张订单的临时数据。下次遇到同一个蝦皮 SKU 自动带出, +// 操作员只需确认。见 docs/admin/03-data-model.md §5。 +func (h *Handler) SybMatch(c *gin.Context) { + // TODO(骨架): upsert sku_mappings + fail(c, http.StatusNotImplemented, "规格匹配尚未实现。") +} + +// SybCreateTask 由勾选的货运单创建采购任务。 +// +// 六条校验缺一不可,见 docs/admin/01-requirements.md §5: +// 1. 已填 PDD 链接 +// 2. 已采集成功(pdd_data 非空) +// 3. 已有 SKU 映射 +// 4. 数量 > 0 +// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行) +// 6. 已选择分配的客户端 +// +// 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么。 +func (h *Handler) SybCreateTask(c *gin.Context) { + // TODO(骨架): 调 service.CreatePurchaseTasks(sybIDs, clientID) + fail(c, http.StatusNotImplemented, "创建采购任务尚未实现。") +} + +// SybDelete 批量删除货运单。 +func (h *Handler) SybDelete(c *gin.Context) { + // TODO(骨架) + fail(c, http.StatusNotImplemented, "删除功能尚未实现。") +} + +// ---------- 3. 采购任务 ---------- + +// TaskList 渲染采购任务列表页。 +// +// 注意颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。 +// 价格上限显示成 ¥42.00,底层存的是整数分。 +func (h *Handler) TaskList(c *gin.Context) { + keyword := c.Query("order_no") + + // TODO(骨架): 查 tasks,task_type = 'purchase' + var rows []gin.H + + c.HTML(http.StatusOK, "task/list", page("tasks", "采购任务", gin.H{ + "Keyword": keyword, + "Rows": rows, + "Status": "尚未实现:创建任务后这里显示执行进度", + })) +} + +// TaskDelete 批量删除任务。 +func (h *Handler) TaskDelete(c *gin.Context) { + // TODO(骨架) + fail(c, http.StatusNotImplemented, "删除功能尚未实现。") +} + +// ---------- 4. 客户端列表 ---------- + +// ClientList 渲染客户端列表页。 +// +// 「状态」是**算出来的**:last_seen_at 在 N 分钟内为在线,否则离线。 +// 数据库里没有 status 字段,存成字段会和真实情况不同步。 +// +// 客户端执行长任务期间不调 claim,可能显示为离线,属正常现象 +// (没有心跳是有意的,见 docs/admin/04-client-api.md §3)。 +func (h *Handler) ClientList(c *gin.Context) { + keyword := c.Query("name") + + // TODO(骨架): 查 clients,并按 last_seen_at 算在线状态 + var rows []gin.H + + c.HTML(http.StatusOK, "client/list", page("clients", "客户端列表", gin.H{ + "Keyword": keyword, + "Rows": rows, + "Status": "尚未实现:客户端第一次调领取接口后会自动出现在这里", + })) +} + +// ClientDelete 批量删除客户端。 +func (h *Handler) ClientDelete(c *gin.Context) { + // TODO(骨架) + fail(c, http.StatusNotImplemented, "删除功能尚未实现。") +} diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go new file mode 100644 index 0000000..ca7f938 --- /dev/null +++ b/admin/handler/web/shopee.go @@ -0,0 +1,85 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ShopeeList 渲染蝦皮数据列表页。 +// +// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。 +// 列定义见 docs/admin/05-ui-specification.md §4.2。 +func (h *Handler) ShopeeList(c *gin.Context) { + keyword := c.Query("goods_id") + + // TODO(骨架): 调 service 查列表 + // rows, err := service.ListShopeeSKUs(h.db, keyword, page, size) + // 注意:搜索要走数据库查询,不要一次查全量再在内存里过滤。 + var rows []gin.H + + c.HTML(http.StatusOK, "shopee/list", page("shopee", "蝦皮数据", gin.H{ + "Keyword": keyword, + "Rows": rows, + "Status": "尚未实现:导入后这里显示商品和规格", + })) +} + +// ShopeeImport 处理 Excel 上传导入。 +// +// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3): +// - 商品汇总行(商品規格ID 为 "-")进 shopee_products, +// SKU 行进 shopee_skus,两者要分开处理; +// - 按列名找索引,不要写死列号; +// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜; +// - **只能 upsert,绝不允许先清空再导入**, +// 否则人工填的 PDD 链接会被洗掉; +// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_data / collect_status。 +func (h *Handler) ShopeeImport(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + fail(c, http.StatusBadRequest, "没有收到文件,请重新选择 Excel 后再提交。") + return + } + + // TODO(骨架): 校验大小和扩展名(只允许 .xlsx), + // 落盘到 data/uploads/ 时用自己生成的文件名, + // 不要直接用 file.Filename 拼路径(路径穿越)。 + _ = file + + // TODO(骨架): 调 service.ImportShopeeExcel(path), + // 返回 {商品数, SKU数, 解析失败行号列表} 并显示出来。 + // 失败行不要静默跳过。 + + fail(c, http.StatusNotImplemented, + "导入功能尚未实现。已保留你选择的文件,未写入数据库。") +} + +// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。 +// +// 这是 PDD 链接唯一的录入口,见 docs/admin/05-ui-specification.md §4.3。 +func (h *Handler) ShopeeSave(c *gin.Context) { + // TODO(骨架): 保存并根据 PDD 链接是否为空更新 collect_status + // (空 -> no_link,非空且未采集 -> pending) + fail(c, http.StatusNotImplemented, "保存功能尚未实现。") +} + +// ShopeeDelete 批量删除勾选的行。 +func (h *Handler) ShopeeDelete(c *gin.Context) { + // TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删 + fail(c, http.StatusNotImplemented, "删除功能尚未实现。") +} + +// ShopeeCollect 发起采集任务。 +// +// 入口在编辑弹窗里,紧挨 PDD 链接输入框——不要放到表格每一行, +// 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事。 +// +// 规则: +// - PDD 链接为空不允许发起; +// - collect_status 已经是 collecting 的跳过并说明跳过了几个; +// - 批量采集要**按商品去重**后再建任务。 +func (h *Handler) ShopeeCollect(c *gin.Context) { + // TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID) + fail(c, http.StatusNotImplemented, "采集功能尚未实现。") +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go new file mode 100644 index 0000000..dbf3a02 --- /dev/null +++ b/admin/handler/web/web.go @@ -0,0 +1,80 @@ +// Package web 是给浏览器用的页面处理器。 +// +// 和 handler/api 分开是有意的:页面出错要渲染错误页、要 CSRF 防护, +// 接口出错要返回 JSON、要认证。混在一起迟早写错。 +// +// 改动本文件前必读 admin/AGENTS.md。两条最容易踩: +// - 本层**不写业务逻辑、不拼 SQL**,只做取参数 → 调 service → 渲染; +// - 删除、导入这类破坏性操作用 POST,**不得用 GET**, +// 浏览器和插件会预取 GET 链接。 +package web + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Handler 持有各页面共用的依赖。 +type Handler struct { + db *sql.DB +} + +// Register 把四个模块的页面路由挂上去。 +// +// 四个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条), +// 这是有意的,见 docs/admin/05-ui-specification.md §2。 +func Register(r *gin.Engine, db *sql.DB) { + h := &Handler{db: db} + + // 打开根路径直接进第一个模块 + r.GET("/", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/shopee") + }) + + // 1. 蝦皮数据 + r.GET("/shopee", h.ShopeeList) + r.POST("/shopee/import", h.ShopeeImport) + r.POST("/shopee/save", h.ShopeeSave) + r.POST("/shopee/delete", h.ShopeeDelete) + r.POST("/shopee/collect", h.ShopeeCollect) + + // 2. 顺运宝数据 + r.GET("/syb", h.SybList) + r.POST("/syb/sync", h.SybSync) + r.POST("/syb/match", h.SybMatch) + r.POST("/syb/create-task", h.SybCreateTask) + r.POST("/syb/delete", h.SybDelete) + + // 3. 采购任务 + r.GET("/tasks", h.TaskList) + r.POST("/tasks/delete", h.TaskDelete) + + // 4. 客户端列表 + r.GET("/clients", h.ClientList) + r.POST("/clients/delete", h.ClientDelete) +} + +// page 组装每个页面都要的公共数据(导航高亮、标题)。 +func page(active, title string, extra gin.H) gin.H { + data := gin.H{ + "Active": active, + "Title": title, + } + for k, v := range extra { + data[k] = v + } + return data +} + +// fail 渲染一个错误页。 +// +// 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。 +// Go 的错误堆栈只写日志,不要贴到页面上,见 docs/admin/06-quality-security.md §5。 +func fail(c *gin.Context, status int, message string) { + c.HTML(status, "partials/error", gin.H{ + "Title": "出错了", + "Message": message, + }) +} diff --git a/admin/main.go b/admin/main.go new file mode 100644 index 0000000..3c72fc3 --- /dev/null +++ b/admin/main.go @@ -0,0 +1,85 @@ +// Admin 是本地运行的 Web 管理端:管理蝦皮商品、顺运宝货运单、 +// 采购任务和客户端,并给 Client 提供三个接口。 +// +// 启动:go run . 然后打开 http://127.0.0.1:8080 +// 这个命令是安全的——Admin 只管理数据,不会连手机、不会下单。 +// +// 改动前必读 admin/AGENTS.md 和根目录 AGENTS.md 的红线。 +package main + +import ( + "embed" + "flag" + "html/template" + "io/fs" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "cmautobuy/admin/config" + "cmautobuy/admin/handler/api" + "cmautobuy/admin/handler/web" + "cmautobuy/admin/repository" +) + +// 模板和静态文件打进二进制,这样打包后只有一个 exe, +// 用户不会误删或改坏,见 docs/admin/06-quality-security.md §9。 +// +//go:embed templates +var templateFS embed.FS + +//go:embed static +var staticFS embed.FS + +func main() { + addr := flag.String("addr", "127.0.0.1:8080", + "监听地址。默认只听本机,要给内网用需单独评估安全性") + flag.Parse() + + // 1. 数据目录 + dataDir, err := config.DataDir() + if err != nil { + log.Fatalf("无法确定 data 目录: %v", err) + } + log.Printf("数据目录: %s", dataDir) + + // 2. 数据库 + db, err := repository.Open(dataDir) + if err != nil { + log.Fatalf("打开数据库失败: %v", err) + } + defer db.Close() + + if err := repository.Migrate(db); err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + log.Printf("数据库已就绪") + + // 3. Web 引擎 + r := gin.Default() + + // 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字, + // 所以一次全部解析进来不会冲突。 + tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") + if err != nil { + log.Fatalf("解析模板失败: %v", err) + } + r.SetHTMLTemplate(tmpl) + + // 静态文件 + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatalf("准备静态文件失败: %v", err) + } + r.StaticFS("/static", http.FS(staticSub)) + + // 4. 路由 + web.Register(r, db) // 给浏览器的页面 + api.Register(r, db) // 给 Client 的接口 + + log.Printf("Admin 已启动: http://%s", *addr) + if err := r.Run(*addr); err != nil { + log.Fatalf("启动失败: %v", err) + } +} diff --git a/admin/model/model.go b/admin/model/model.go new file mode 100644 index 0000000..7bd9ae2 --- /dev/null +++ b/admin/model/model.go @@ -0,0 +1,170 @@ +// Package model 只放数据结构,不导入 Gin,也不导入数据库驱动。 +// +// 这样领域概念才能被单元测试直接使用,不用起服务器、不用连数据库。 +// 字段含义的权威定义在 docs/admin/03-data-model.md。 +package model + +// ---------- 蝦皮 ---------- + +// CollectStatus 是某个蝦皮商品对应的 PDD 商品数据采到没有。 +// 取值见 docs/admin/01-requirements.md §6.1。 +type CollectStatus string + +const ( + CollectNoLink CollectStatus = "no_link" // 未填 PDD 链接 + CollectPending CollectStatus = "pending" // 已填链接,未发起采集 + CollectCollecting CollectStatus = "collecting" // 采集中,不允许再建任务 + CollectCollected CollectStatus = "collected" // 已采集 + CollectFailed CollectStatus = "failed" // 采集失败,可重新采集 +) + +// ShopeeProduct 是蝦皮商品(商品级)。 +// +// PddGoodsURL 和 PddData 是我们自己维护的,蝦皮报表里没有, +// Excel 导入时**绝不能覆盖**,见 docs/admin/03-data-model.md §3.3。 +type ShopeeProduct struct { + GoodsID string + Title string + ShopeeStatus string + MainSKUCode string + PddGoodsURL string + PddGoodsID string + PddData string // 采集回来的 PDD 商品 JSON + CollectStatus CollectStatus + CollectError string + CollectedAt string + CreatedAt string + UpdatedAt string +} + +// ShopeeSKU 是蝦皮的一个规格(SKU 级)。 +// +// SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」, +// **永远原样保留**。Color/Size/Advice 是尽力解析的结果, +// 解析失败时留空并把 ParseOK 置 false,不要瞎猜。 +type ShopeeSKU struct { + SKUID string + GoodsID string + SpecRaw string + Color string + Size string + Advice string // 建议体重,如「40-50公斤」 + ParseOK bool + SKUCode string + IsManual bool // 人工新增的,后续导入不得删除 + CreatedAt string + UpdatedAt string +} + +// ---------- 顺运宝 ---------- + +// SybOrder 是一张顺运宝货运单。 +// +// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系, +// 不要互相赋值,见 docs/admin/01-requirements.md §7。 +type SybOrder struct { + SybID string + OrderNo string + Title string + ShopeeGoodsID string + ShopeeSKUID string + Quantity int + PriceTwdCent int64 + ImageURL string + SybData string // 完整货运单 JSON,原样保留 + CreatedAt string + UpdatedAt string +} + +// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。 +// +// 匹配一次以后可以复用:下次遇到同一个蝦皮 SKU 自动带出, +// 操作员只需确认。这是省人工的关键。 +type SKUMapping struct { + ShopeeSKUID string + GoodsID string + PddOptions string // JSON,如 {"color":"黑色","size":"M码"} + MappedAt string + MappedBy string +} + +// ---------- 任务 ---------- + +// TaskType 区分采集任务和采购任务。 +type TaskType string + +const ( + TaskCollect TaskType = "collect" + TaskPurchase TaskType = "purchase" +) + +// TaskStatus 是 **Admin 侧**的任务状态。 +// +// 注意它和 Client 本地的 8 个状态是两套,不要混。 +// Admin 看不到客户端执行到哪一步(没有心跳,是有意的), +// claimed 之后就只能等结果。 +type TaskStatus string + +const ( + TaskPending TaskStatus = "pending" // 待分配 + TaskAssigned TaskStatus = "assigned" // 待领取 + TaskClaimed TaskStatus = "claimed" // 已领取,正在执行 + TaskSucceeded TaskStatus = "succeeded" // 成功 + TaskManualReview TaskStatus = "manual_review" // 需人工 + TaskFailed TaskStatus = "failed" // 失败 + TaskCancelled TaskStatus = "cancelled" // 已取消 +) + +// Task 是发给 Client 执行的一个任务。 +// +// PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。 +// 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护, +// 见 docs/client/04-admin-api-contract.md §4。 +type Task struct { + TaskID string + TaskType TaskType + Status TaskStatus + Version int + Priority int + + AssignedClient string + ClaimedAt string + + SybID string + OrderNo string + GoodsID string + ShopeeSKUID string + + PddGoodsURL string + PddGoodsID string + PddOptions string // JSON,采购任务的目标规格 + Quantity int + MaxPriceCent int64 // 人民币分 + + ResultData string + ErrorCode string + ErrorMessage string + FinishedAt string + + CreatedAt string + UpdatedAt string +} + +// ---------- 客户端 ---------- + +// Client 是一台执行任务的客户端。 +// +// 注意**没有 Status 字段**:在线状态是算出来的, +// LastSeenAt 在 N 分钟内算在线,否则离线。 +// 存成字段会和真实情况不同步。 +type Client struct { + ClientID string + Name string + DeviceAddress string + Platform string + PddPackage string + Capabilities string + LastSeenAt string + CreatedAt string + UpdatedAt string +} diff --git a/admin/repository/db.go b/admin/repository/db.go new file mode 100644 index 0000000..fba2d26 --- /dev/null +++ b/admin/repository/db.go @@ -0,0 +1,214 @@ +// Package repository 封装 SQLite 读写。 +// +// 改动本文件前必读 admin/AGENTS.md。三条硬规则: +// - 只有本包能写 SQL,handler 和 service 都不许拼 SQL; +// - SQL 一律参数化查询(用 ? 占位),禁止字符串拼接; +// - 迁移只能往前加,不许在启动时删库重建——data/ 在升级时是保留的, +// 里面有人工填了几个月的 PDD 链接和 SKU 映射。 +// +// 表结构的权威定义在 docs/admin/03-data-model.md,改表要先改文档。 +package repository + +import ( + "database/sql" + "fmt" + "path/filepath" + + // 纯 Go 的 SQLite 驱动,注册的驱动名是 "sqlite"(不是 "sqlite3")。 + // 不得换成 github.com/mattn/go-sqlite3,那个需要 cgo, + // Windows 上要装 gcc,打包 exe 会变麻烦。理由见 admin/AGENTS.md。 + _ "modernc.org/sqlite" +) + +// Open 打开 data/admin.db,并设置必要的 PRAGMA。 +func Open(dataDir string) (*sql.DB, error) { + path := filepath.Join(dataDir, "admin.db") + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("打开数据库 %s 失败: %w", path, err) + } + + // 这三条见 docs/admin/03-data-model.md §2 + pragmas := []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA busy_timeout = 5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("执行 %s 失败: %w", p, err) + } + } + return db, nil +} + +// migrations 按顺序存放每一版的迁移语句。 +// +// 外层一个元素 = 一个版本;内层是该版本要执行的语句,**一条一执行**。 +// 不把多条语句塞进一个字符串,是因为 database/sql 的 Exec 对 +// "一次执行多条语句"的支持因驱动而异,拆开最稳妥。 +// +// 加新版本时**只能往末尾追加**,不许改动已有元素—— +// 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。 +var migrations = [][]string{ + // v1: 初始表结构,对应 docs/admin/03-data-model.md + { + `CREATE TABLE shopee_products ( + goods_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + shopee_status TEXT, + main_sku_code TEXT, + + -- 下面三个是人工维护的,报表里没有,导入时绝不能覆盖 + pdd_goods_url TEXT, + pdd_goods_id TEXT, + pdd_data TEXT, + + collect_status TEXT NOT NULL DEFAULT 'no_link' + CHECK (collect_status IN ( + 'no_link', 'pending', 'collecting', + 'collected', 'failed' + )), + collect_error TEXT, + collected_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX idx_shopee_products_status ON shopee_products(collect_status);`, + `CREATE TABLE shopee_skus ( + sku_id TEXT PRIMARY KEY, + goods_id TEXT NOT NULL, + spec_raw TEXT NOT NULL, + color TEXT, + size TEXT, + advice TEXT, + parse_ok INTEGER NOT NULL DEFAULT 0, + sku_code TEXT, + is_manual INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (goods_id) REFERENCES shopee_products(goods_id) ON DELETE CASCADE + );`, + `CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);`, + `CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);`, + `CREATE TABLE syb_orders ( + syb_id TEXT PRIMARY KEY, + order_no TEXT NOT NULL, + title TEXT, + shopee_goods_id TEXT, + shopee_sku_id TEXT, + quantity INTEGER NOT NULL CHECK (quantity > 0), + price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0), + image_url TEXT, + syb_data TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX idx_syb_orders_order ON syb_orders(order_no);`, + `CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);`, + `CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);`, + `CREATE TABLE sku_mappings ( + shopee_sku_id TEXT PRIMARY KEY, + goods_id TEXT NOT NULL, + pdd_options TEXT NOT NULL, + mapped_at TEXT NOT NULL, + mapped_by TEXT, + FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE + );`, + `CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`, + `CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY, + task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'assigned', 'claimed', + 'succeeded', 'manual_review', + 'failed', 'cancelled')), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + priority INTEGER NOT NULL DEFAULT 0, + + assigned_client TEXT, + claimed_at TEXT, + + syb_id TEXT, + order_no TEXT, + goods_id TEXT, + shopee_sku_id TEXT, + + -- Client 契约要求:pdd_goods_url 必填; + -- 采购任务的 quantity 和 max_price_cent 也必填(价格保护) + pdd_goods_url TEXT NOT NULL, + pdd_goods_id TEXT, + pdd_options TEXT, + quantity INTEGER CHECK (quantity IS NULL OR quantity > 0), + max_price_cent INTEGER CHECK (max_price_cent IS NULL OR max_price_cent > 0), + + result_data TEXT, + error_code TEXT, + error_message TEXT, + finished_at TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX idx_tasks_claim ON tasks(assigned_client, status, priority DESC, created_at);`, + `CREATE INDEX idx_tasks_list ON tasks(updated_at DESC, task_id DESC);`, + `CREATE INDEX idx_tasks_order ON tasks(order_no);`, + `CREATE TABLE clients ( + client_id TEXT PRIMARY KEY, + name TEXT, + device_address TEXT, + platform TEXT, + pdd_package TEXT, + capabilities TEXT, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE TABLE idempotency_keys ( + key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL + );`, + }, +} + +// Migrate 把数据库升到最新版本。 +// 已经是最新的就什么都不做,可以重复调用。 +func Migrate(db *sql.DB) error { + var current int + if err := db.QueryRow("PRAGMA user_version").Scan(¤t); err != nil { + return fmt.Errorf("读取 user_version 失败: %w", err) + } + + if current > len(migrations) { + return fmt.Errorf( + "数据库版本 %d 高于本程序支持的 %d,"+ + "说明这个库是更新版本的程序建的,请升级程序而不是降级", + current, len(migrations)) + } + + for v := current; v < len(migrations); v++ { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始迁移 v%d 失败: %w", v+1, err) + } + for i, stmt := range migrations[v] { + if _, err := tx.Exec(stmt); err != nil { + tx.Rollback() + return fmt.Errorf("执行迁移 v%d 第 %d 条语句失败: %w", v+1, i+1, err) + } + } + // PRAGMA 不支持参数化,这里的值来自循环变量而非外部输入,安全。 + if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", v+1)); err != nil { + tx.Rollback() + return fmt.Errorf("更新 user_version 到 %d 失败: %w", v+1, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交迁移 v%d 失败: %w", v+1, err) + } + } + return nil +} diff --git a/admin/service/service.go b/admin/service/service.go new file mode 100644 index 0000000..b43919c --- /dev/null +++ b/admin/service/service.go @@ -0,0 +1,134 @@ +// Package service 放业务逻辑。 +// +// 本包**不认识 *gin.Context**——这样才能不起服务器就写单元测试。 +// handler 负责取参数,service 负责判断和编排,repository 负责读写数据库。 +// +// 骨架阶段这里只有函数签名和 TODO,实现按工单逐个补。 +package service + +import ( + "database/sql" + "errors" +) + +// ErrNotImplemented 表示该功能还没实现。 +// 补完实现后要把对应的返回删掉,不要留着假装能用。 +var ErrNotImplemented = errors.New("功能尚未实现") + +// ---------- 蝦皮 Excel 导入 ---------- + +// ImportResult 是一次导入的统计结果,要显示给操作员看。 +type ImportResult struct { + ProductCount int // 写入的商品数,样本应为 5195 + SKUCount int // 写入的 SKU 数,样本应为 6092 + FailedRows []int // 解析失败的行号,**不要静默跳过** +} + +// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。 +// +// 实现要点见 docs/admin/03-data-model.md §3.3,五步缺一不可: +// 1. 分行:商品規格ID 为 "-" 的是商品汇总行,其余是 SKU 行; +// 2. 按列名找索引,不要写死列号(报表 40 列,蝦皮改格式列号就变); +// 3. 解析规格原文:先按第一个逗号切开,右边再提取建议。 +// 实测两种格式各占 53.4% / 46.6%,只认【】会漏掉一半。 +// **任何一步失败都不要猜**,parse_ok 置 0,颜色尺码留空,原文照存; +// 4. upsert,**绝不清空**。DO UPDATE SET 里不得出现 +// pdd_goods_url / pdd_data / collect_status——报表里没这些列, +// 写进去会把人工填的洗成空; +// 5. 返回统计。 +func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) { + // TODO(骨架): 用 github.com/xuri/excelize/v2 流式读行 + return nil, ErrNotImplemented +} + +// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。 +// +// 输入样例(两种格式各占一半): +// +// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true +// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true +// "莫名其妙的格式" -> "" / "" / "" / false +// +// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**。 +// 调用方要把 parse_ok 存进库,界面上把这些行标出来让人工补。 +func ParseSpec(raw string) (color, size, advice string, ok bool) { + // TODO(骨架): 按第一个逗号切开;右边先找【建議...】,再找空格后的 建議... + return "", "", "", false +} + +// ---------- 采集 ---------- + +// CreateCollectTasks 为若干蝦皮商品创建采集任务。 +// +// 规则: +// - 按 goods_id **去重**(一个商品有多个 SKU 行,别建重复任务); +// - PDD 链接为空的跳过; +// - collect_status 已是 collecting 的跳过,并在结果里说明跳过了几个; +// - 建任务成功后把 collect_status 置为 collecting。 +func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created, skipped int, err error) { + // TODO(骨架) + return 0, 0, ErrNotImplemented +} + +// ---------- 规格匹配 ---------- + +// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。 +// +// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。 +// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。 +func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error { + // TODO(骨架): upsert sku_mappings + return ErrNotImplemented +} + +// ---------- 采购任务 ---------- + +// TaskCreateError 说明某一条为什么建不了任务。 +// **不要静默跳过**,要把这些列出来告诉操作员缺什么。 +type TaskCreateError struct { + SybID string + Reason string // 例如「该商品未填写 PDD 链接」 +} + +// CreatePurchaseTasks 由货运单创建采购任务。 +// +// 六条校验缺一不可(docs/admin/01-requirements.md §5): +// 1. 已填 PDD 链接 +// 2. 已采集成功(pdd_data 非空) +// 3. 已有 SKU 映射 +// 4. 数量 > 0 +// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出, +// 操作员可改但不允许为空。没有它 Client 会拒绝执行。 +// 6. 已选择分配的客户端 +// +// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。 +func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) { + // TODO(骨架) + return 0, nil, ErrNotImplemented +} + +// ---------- 客户端 ---------- + +// RegisterClient 在客户端领取任务时登记或更新它。 +// +// **没有单独的注册接口,也没有心跳**——注册就在 claim 里做, +// 理由见 docs/admin/04-client-api.md §3。 +// +// 规则: +// - 新 client_id 就新增,已有就更新 device/capabilities/last_seen_at; +// - name 若已被人工改过,**不要用客户端上报的覆盖**; +// - 客户端没上报 name 时,用 client_id 当显示名。 +func RegisterClient(db *sql.DB, clientID, name, address, platform, pddPackage, capabilitiesJSON string) error { + // TODO(骨架): upsert clients + return ErrNotImplemented +} + +// TouchClient 刷新 last_seen_at。 +// +// claim / result / failure **三个接口都要调**。 +// 只在 claim 里调的话,客户端执行长任务期间不调 claim, +// 会被误判成离线。 +func TouchClient(db *sql.DB, clientID string) error { + // TODO(骨架) + return ErrNotImplemented +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css new file mode 100644 index 0000000..22217ee --- /dev/null +++ b/admin/static/css/app.css @@ -0,0 +1,147 @@ +/* Admin 样式表。手写 CSS,不引入框架、不引入构建流程。 + 见 admin/AGENTS.md 前端约束。 + + 四个模块共用同一套三段式布局的样式: + .toolbar(顶部工具条)/ .table-wrap(中间表格)/ .statusbar(底部状态条)。 + 改这里会同时影响四个页面,这是有意的——不要为某个页面复制一份。 */ + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; + font-size: 14px; + color: #222; + background: #f5f6f8; + /* 底部状态条是固定的,给它留出空间 */ + padding-bottom: 40px; +} + +/* ── 导航 ─────────────────────────────── */ +.nav { + display: flex; + align-items: center; + gap: 4px; + background: #2b3440; + padding: 0 16px; +} +.nav-brand { + color: #fff; + font-weight: 600; + margin-right: 24px; + padding: 12px 0; +} +.nav a { + color: #c8cdd4; + text-decoration: none; + padding: 12px 16px; +} +.nav a:hover { background: #3a4552; color: #fff; } +.nav a.active { background: #1f6feb; color: #fff; } + +/* ── 页面主体 ─────────────────────────── */ +.page { padding: 16px; } + +/* ── 第一段:顶部工具条 ───────────────── */ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + background: #fff; + border: 1px solid #e1e4e8; + border-radius: 4px; + padding: 10px 12px; + margin-bottom: 12px; +} +.toolbar .inline { + display: flex; + align-items: center; + gap: 6px; + margin: 0; +} +/* 搜索表单占据剩余空间,把删除按钮挤到右边 */ +.toolbar .grow { flex: 1; } +.toolbar label { color: #555; white-space: nowrap; } +.toolbar input[type="text"] { + flex: 1; + min-width: 160px; + padding: 5px 8px; + border: 1px solid #ccd1d6; + border-radius: 3px; +} + +button { + padding: 5px 14px; + border: 1px solid #ccd1d6; + border-radius: 3px; + background: #fff; + cursor: pointer; + white-space: nowrap; +} +button:hover { background: #f0f2f4; } +button.danger { color: #b42318; border-color: #f0b4ae; } +button.danger:hover { background: #fdf1f0; } +button:disabled { opacity: .5; cursor: not-allowed; } + +/* ── 第二段:表格 ─────────────────────── */ +/* 列多了横向滚动,不要压缩列宽把字挤成两行 */ +.table-wrap { + overflow-x: auto; + background: #fff; + border: 1px solid #e1e4e8; + border-radius: 4px; +} +table { width: 100%; border-collapse: collapse; } +th, td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #eef0f2; + white-space: nowrap; +} +th { background: #fafbfc; font-weight: 600; color: #444; } +tbody tr:hover { background: #f7f9fb; } +.col-check { width: 36px; } + +/* 解析失败的行标黄,提示需要人工补 */ +tr.row-warn { background: #fffbe6; } +tr.row-warn:hover { background: #fff6d0; } + +/* PDD 链接未填写,标红提醒 */ +.missing { color: #b42318; font-weight: 600; } + +/* 空状态:文案要分情况,不要都写"暂无数据" */ +tr.empty td { + text-align: center; + color: #888; + padding: 40px 10px; + white-space: normal; +} +tr.empty small { color: #aaa; } + +.hint { + color: #777; + font-size: 13px; + margin: 12px 2px; +} + +/* ── 第三段:底部状态条 ───────────────── */ +.statusbar { + position: fixed; + left: 0; right: 0; bottom: 0; + background: #2b3440; + color: #c8cdd4; + padding: 10px 16px; + font-size: 13px; +} + +/* ── 错误页 ───────────────────────────── */ +.error-box { + background: #fff; + border: 1px solid #f0b4ae; + border-left: 4px solid #b42318; + border-radius: 4px; + padding: 16px 20px; + max-width: 720px; +} +.error-box h2 { margin-top: 0; color: #b42318; font-size: 16px; } diff --git a/admin/static/js/app.js b/admin/static/js/app.js new file mode 100644 index 0000000..17418ef --- /dev/null +++ b/admin/static/js/app.js @@ -0,0 +1,87 @@ +/* Admin 的全部 JavaScript。 + * + * 原生 JS,没有框架、没有构建步骤——见 admin/AGENTS.md 前端约束。 + * 只做三件纯前端的事:全选、删除前二次确认、按钮禁用。 + * 搜索、删除、导入本身都是普通表单 POST,服务端渲染,不需要 JS。 + * + * 不要往这里加业务逻辑。业务逻辑在服务端。 + */ +(function () { + "use strict"; + + /* ── 全选 / 反选 ────────────────────────────── + 表头的勾选框控制本表格所有行的勾选框。 */ + function setupCheckAll(root) { + var master = root.querySelector("[data-check-all]"); + if (!master) return; + + var table = master.closest("table"); + if (!table) return; + + function rowBoxes() { + return table.querySelectorAll("tbody input[type=checkbox]"); + } + + master.addEventListener("change", function () { + rowBoxes().forEach(function (box) { + box.checked = master.checked; + }); + syncButtons(); + }); + + table.addEventListener("change", function (e) { + if (e.target.type !== "checkbox" || e.target === master) return; + var boxes = Array.prototype.slice.call(rowBoxes()); + master.checked = boxes.length > 0 && boxes.every(function (b) { + return b.checked; + }); + syncButtons(); + }); + } + + /* ── 选中数量 ────────────────────────────── */ + function checkedCount() { + return document.querySelectorAll( + "tbody input[type=checkbox]:checked" + ).length; + } + + /* ── 需要勾选才能点的按钮 ────────────────── + 标了 data-need-checked 的按钮,没勾选任何行时禁用。 */ + function syncButtons() { + var n = checkedCount(); + document.querySelectorAll("[data-need-checked]").forEach(function (btn) { + btn.disabled = n === 0; + }); + } + + /* ── 删除前二次确认 ──────────────────────── + 标了 data-confirm-delete 的表单,提交前必须确认, + 并明确告诉用户会删掉几条、不可恢复。 */ + function setupConfirmDelete() { + document.querySelectorAll("[data-confirm-delete]").forEach(function (form) { + form.addEventListener("submit", function (e) { + var n = checkedCount(); + if (n === 0) { + e.preventDefault(); + return; + } + if (!window.confirm("将删除 " + n + " 条记录,不可恢复。确定继续?")) { + e.preventDefault(); + } + }); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("table").forEach(setupCheckAll); + setupConfirmDelete(); + syncButtons(); + }); + + /* TODO(骨架): 双击行打开弹窗。 + 蝦皮数据页 -> 编辑弹窗(填 PDD 链接 + 采集按钮) + 顺运宝页 -> 规格匹配弹窗 + 实现时注意:弹窗内容由服务端渲染,这里只负责显示/隐藏, + 不要在前端拼业务数据。 */ +})(); diff --git a/admin/templates/client/list.html b/admin/templates/client/list.html new file mode 100644 index 0000000..f06a571 --- /dev/null +++ b/admin/templates/client/list.html @@ -0,0 +1,52 @@ +{{define "client/list"}} +{{template "header" .}} + +
+
+ + + +
+ +
+ +
+
+ +
+ + + + + + + + + + + + + {{range .Rows}} + {{/* TODO(骨架): 行渲染。 + 「状态」是算出来的:最近活动在 N 分钟内为在线,否则离线。 + 数据库里没有 status 字段,存成字段会和真实情况不同步。 + 名称可以人工改成好记的,改过之后客户端上报的名称不再覆盖它。 */}} + {{else}} + + + + {{end}} + +
名称序列号状态最近活动更新时间
+ 还没有客户端。
+ 客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。 +
+
+ +

+ 客户端执行长任务期间不会调领取接口,可能显示为「离线」,属正常现象—— + 本项目有意不做心跳,见 docs/admin/04-client-api.md §3。 +

+ +{{template "footer" .}} +{{end}} diff --git a/admin/templates/partials/error.html b/admin/templates/partials/error.html new file mode 100644 index 0000000..f692892 --- /dev/null +++ b/admin/templates/partials/error.html @@ -0,0 +1,21 @@ +{{define "partials/error"}} + + + +{{.Title}} · 采集采购管理端 + + + + +
+
+

出错了

+ {{/* 错误信息要说清:发生了什么、保住了什么、下一步做什么。 + Go 的错误堆栈只写日志,不要贴到这里。 */}} +

{{.Message}}

+

返回上一页

+
+
+ + +{{end}} diff --git a/admin/templates/partials/footer.html b/admin/templates/partials/footer.html new file mode 100644 index 0000000..0ed6ff8 --- /dev/null +++ b/admin/templates/partials/footer.html @@ -0,0 +1,10 @@ +{{define "footer"}} + + +{{/* 底部状态条:三段式布局的第三段,四个页面都有 */}} +
{{.Status}}
+ + + + +{{end}} diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html new file mode 100644 index 0000000..a8b515d --- /dev/null +++ b/admin/templates/partials/header.html @@ -0,0 +1,20 @@ +{{define "header"}} + + + + +{{.Title}} · 采集采购管理端 + + + + + + +
+{{end}} diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html new file mode 100644 index 0000000..8768f95 --- /dev/null +++ b/admin/templates/shopee/list.html @@ -0,0 +1,65 @@ +{{define "shopee/list"}} +{{template "header" .}} + +{{/* ── 第一段:顶部工具条 ────────────────────────────── */}} +
+
+ + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ +{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}} +
+ + + + + + + + + + + + + + + + {{range .Rows}} + {{/* TODO(骨架): 行渲染。解析失败的行整行标黄(class="row-warn"), + PDD 链接为空的显示"未填写"并标红。双击行打开编辑弹窗。 */}} + {{else}} + + + + {{end}} + +
商品 ID商品名称颜色尺码建议PDD 链接采集状态更新时间
+ 还没有数据,点左上角「导入 Excel」开始。
+ 样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。 +
+
+ +{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html + 这是 PDD 链接唯一的录入口,采集按钮也只出现在这里—— + 不要放到表格每一行,一个商品有 N 个 SKU 行, + 放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 */}} + +{{template "footer" .}} +{{end}} diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html new file mode 100644 index 0000000..2a5e6b2 --- /dev/null +++ b/admin/templates/syb/list.html @@ -0,0 +1,67 @@ +{{define "syb/list"}} +{{template "header" .}} + +
+
+ +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + {{range .Rows}} + {{/* TODO(骨架): 行渲染。价格是台币,显示成 NT$xx.xx, + 要和人民币一眼分得清。图片显示小缩略图,存的是 URL。 + 双击行打开匹配弹窗。 */}} + {{else}} + + + + {{end}} + +
货运单 ID订单号商品标题蝦皮商品 ID规格 SKU数量价格图片匹配状态更新时间
+ 还没有货运单。
+ 顺运宝同步方式尚未确定,当前可先手工录入用于联调。 +
+
+ +{{/* TODO(骨架): 匹配弹窗 templates/syb/match_modal.html + 左边蝦皮规格,右边该商品 pdd_data 里的 PDD 规格下拉。 + 三条硬规则: + 1. 右侧下拉选项来自 pdd_data.dimensions,不要写死"颜色/尺码"两个维度; + 2. 打开时先查 sku_mappings,有记录就自动带出并提示"已自动带出"; + 3. 该商品还没采集时,直接提示"请先到蝦皮数据模块采集"并给跳转链接, + 不要显示一个空下拉让人困惑。 */}} + +{{template "footer" .}} +{{end}} diff --git a/admin/templates/task/list.html b/admin/templates/task/list.html new file mode 100644 index 0000000..3ec66ba --- /dev/null +++ b/admin/templates/task/list.html @@ -0,0 +1,53 @@ +{{define "task/list"}} +{{template "header" .}} + +
+
+ + + +
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + {{range .Rows}} + {{/* TODO(骨架): 行渲染。 + 颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。 + 价格上限显示成 ¥42.00,底层存的是整数分。 + 状态不能只靠颜色区分,必须有文字。 + 建议提供「改派」操作——没有心跳,客户端挂了要靠人工改派。 */}} + {{else}} + + + + {{end}} + +
订单号商品标题颜色尺码数量价格上限蝦皮 ID分配客户端状态更新时间
+ 还没有采购任务。
+ 到「顺运宝数据」勾选货运单,点「创建采购任务」。 +
+
+ +{{template "footer" .}} +{{end}} diff --git a/admin/testdata/README.md b/admin/testdata/README.md new file mode 100644 index 0000000..121237b --- /dev/null +++ b/admin/testdata/README.md @@ -0,0 +1,39 @@ +# testdata + +自动化测试用的**脱敏小样本**放这里,**要提交进 Git**。 +否则别人拉下仓库跑 `go test ./...` 会失败。 + +## 需要准备的文件 + +| 文件 | 用途 | 状态 | +|---|---|---| +| `shopee_sample.xlsx` | Excel 导入测试 | **待制作** | + +## 怎么制作 `shopee_sample.xlsx` + +从真实报表(`raw_data/蝦皮数据样本.xlsx`,需向项目负责人索取)里裁出来: + +1. **只保留几十行**,但要覆盖这几种情况: + - 商品汇总行(`商品規格ID` 是 `-`); + - SKU 行,规格原文用**带【】**的格式,如 `黑色,M【建議40-50公斤】`; + - SKU 行,规格原文用**空格分隔**的格式,如 `卡其色拼黑色,L 建議50-57.5kg`; + - 至少一行**故意写成解析不了的格式**,用来验证 `parse_ok=0` 且不瞎猜。 + + 前两种在真实数据里各占 53.4% 和 46.6%,两种都必须有用例。 + +2. **删掉全部商业指标列**——销售额、曝光次数、点击数、转化率、 + 买家数、回购率等等。导入根本用不到这些,留着就是泄露。 + + 只保留:`商品ID`、`商品名稱`、`商品當前狀態`、`商品規格ID`、 + `商品規格`、`規格當前狀態`、`商品選項貨號`、`主商品貨號`。 + +3. **商品名称换成无意义的占位文字**,如 `测试商品A`、`测试商品B`。 + +4. 记下期望结果,写进测试断言(例如 3 商品 + 8 SKU + 1 行解析失败)。 + +## 为什么不直接用真实报表 + +- 1.9MB,每次跑测试都读一遍太慢; +- 含逐商品的台币销售额,属于商业数据,已在 `.gitignore` 里排除。 + +相关规定见 `docs/admin/06-quality-security.md` §2.1。 diff --git a/docs/admin/00-getting-started.md b/docs/admin/00-getting-started.md index f97e66a..cfcecf0 100644 --- a/docs/admin/00-getting-started.md +++ b/docs/admin/00-getting-started.md @@ -29,29 +29,41 @@ go env -w GOPROXY=https://goproxy.cn,direct ``` -**第一次**需要把三个依赖装上(`go.mod` 里只有模块名和 Go 版本,依赖靠 `go get` 添加): +正常情况下 `go.mod` 和 `go.sum` 已经在仓库里了,直接: ```powershell cd D:\chengma\cmautobuy\admin -go get github.com/gin-gonic/gin -go get modernc.org/sqlite -go get github.com/xuri/excelize/v2 -go mod tidy -``` - -跑完 `go.mod` 会多出 `require` 段,还会生成 `go.sum`。 -`[必须]` **这两个文件都要提交 Git**,否则别人拉下来版本对不上。 - -**之后**只需要: - -```powershell go mod download ``` 预期没有输出——Go 的惯例,成功时不打印东西。 -> 三个依赖是定死的,见 [admin/AGENTS.md](../../admin/AGENTS.md) 技术栈一节。 -> 特别注意 SQLite 驱动**不要**换成 `mattn/go-sqlite3`,那个需要 cgo。 +### 万一需要重新拉依赖 + +`[必须]` **命令必须带版本号。** 直接 `go get github.com/gin-gonic/gin` +会拉到最新版,然后报 `requires go >= 1.25.0`——因为这几个库的新版本 +都已经不支持 Go 1.23.0 了。 + +```powershell +go get github.com/gin-gonic/gin@v1.11.0 +go get modernc.org/sqlite@v1.38.0 +go mod tidy +``` + +写 Excel 导入功能时再把 excelize 加进来(现在没有代码 import 它, +`go mod tidy` 会把它去掉,这是 Go 的正常行为): + +```powershell +go get github.com/xuri/excelize/v2@v2.9.1 +``` + +这三个是**在 Go 1.23.0 下实测能编译通过的最高版本**, +为什么不能更高见 [admin/AGENTS.md](../../admin/AGENTS.md) 技术栈一节。 + +`[必须]` `go.mod` 和 `go.sum` 都要提交 Git,否则别人拉下来版本对不上。 + +> SQLite 驱动**不要**换成 `mattn/go-sqlite3`,那个需要 cgo, +> 得装 gcc,还会让 `go build` 出不了单文件 exe。 ## 3. 跑起来