feat: 搭建 Admin 项目骨架(Go + Gin + html/template)

可运行的骨架:四个页面能打开、数据库自动建表、给 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 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-06 16:33:45 +08:00
co-authored by Claude Opus 5
parent 0d38eedee1
commit 799ee33045
23 changed files with 1910 additions and 17 deletions
+26 -2
View File
@@ -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。
- 新增或升级依赖前先过工单。
### 前端约束
+71
View File
@@ -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") -> <data>/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")
}
+47
View File
@@ -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
)
+124
View File
@@ -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=
+166
View File
@@ -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{},
},
})
}
+120
View File
@@ -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, "删除功能尚未实现。")
}
+85
View File
@@ -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, "采集功能尚未实现。")
}
+80
View File
@@ -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,
})
}
+85
View File
@@ -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)
}
}
+170
View File
@@ -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
}
+214
View File
@@ -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(&current); 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
}
+134
View File
@@ -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
}
+147
View File
@@ -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; }
+87
View File
@@ -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 链接 + 采集按钮)
顺运宝页 -> 规格匹配弹窗
实现时注意:弹窗内容由服务端渲染,这里只负责显示/隐藏,
不要在前端拼业务数据。 */
})();
+52
View File
@@ -0,0 +1,52 @@
{{define "client/list"}}
{{template "header" .}}
<div class="toolbar">
<form class="inline grow" method="get" action="/clients">
<label for="q">名称</label>
<input id="q" type="text" name="name" value="{{.Keyword}}" placeholder="客户端名称">
<button type="submit">搜索</button>
</form>
<form class="inline" method="post" action="/clients/delete" data-confirm-delete>
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>名称</th>
<th>序列号</th>
<th>状态</th>
<th>最近活动</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
{{/* TODO(骨架): 行渲染。
「状态」是算出来的:最近活动在 N 分钟内为在线,否则离线。
数据库里没有 status 字段,存成字段会和真实情况不同步。
名称可以人工改成好记的,改过之后客户端上报的名称不再覆盖它。 */}}
{{else}}
<tr class="empty">
<td colspan="6">
还没有客户端。<br>
<small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint">
客户端执行长任务期间不会调领取接口,可能显示为「离线」,属正常现象——
本项目有意不做心跳,见 docs/admin/04-client-api.md §3。
</p>
{{template "footer" .}}
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "partials/error"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{{.Title}} · 采集采购管理端</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="nav"><span class="nav-brand">采集采购管理端</span></nav>
<main class="page">
<div class="error-box">
<h2>出错了</h2>
{{/* 错误信息要说清:发生了什么、保住了什么、下一步做什么。
Go 的错误堆栈只写日志,不要贴到这里。 */}}
<p>{{.Message}}</p>
<p><a href="javascript:history.back()">返回上一页</a></p>
</div>
</main>
</body>
</html>
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "footer"}}
</main>
{{/* 底部状态条:三段式布局的第三段,四个页面都有 */}}
<footer class="statusbar">{{.Status}}</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "header"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 采集采购管理端</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">采集采购管理端</span>
<a href="/shopee" class="{{if eq .Active "shopee"}}active{{end}}">蝦皮数据</a>
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采购任务</a>
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
</nav>
<main class="page">
{{end}}
+65
View File
@@ -0,0 +1,65 @@
{{define "shopee/list"}}
{{template "header" .}}
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
<div class="toolbar">
<form class="inline" method="post" action="/shopee/import" enctype="multipart/form-data">
<input type="file" name="file" accept=".xlsx" required>
<button type="submit">导入 Excel</button>
</form>
<form class="inline" method="post" action="/shopee/collect">
<button type="submit" data-need-checked>批量采集</button>
</form>
<form class="inline grow" method="get" action="/shopee">
<label for="q">商品 ID</label>
<input id="q" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID">
<button type="submit">搜索</button>
</form>
<form class="inline" method="post" action="/shopee/delete"
data-confirm-delete>
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</div>
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>商品 ID</th>
<th>商品名称</th>
<th>颜色</th>
<th>尺码</th>
<th>建议</th>
<th>PDD 链接</th>
<th>采集状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
{{/* TODO(骨架): 行渲染。解析失败的行整行标黄(class="row-warn"),
PDD 链接为空的显示"未填写"并标红。双击行打开编辑弹窗。 */}}
{{else}}
<tr class="empty">
<td colspan="9">
还没有数据,点左上角「导入 Excel」开始。<br>
<small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
这是 PDD 链接唯一的录入口,采集按钮也只出现在这里——
不要放到表格每一行,一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 */}}
{{template "footer" .}}
{{end}}
+67
View File
@@ -0,0 +1,67 @@
{{define "syb/list"}}
{{template "header" .}}
<div class="toolbar">
<form class="inline" method="post" action="/syb/sync">
<button type="submit">同步</button>
</form>
<form class="inline" method="post" action="/syb/create-task">
<button type="submit" data-need-checked>创建采购任务</button>
</form>
<form class="inline grow" method="get" action="/syb">
<label for="q">订单号</label>
<input id="q" type="text" name="order_no" value="{{.Keyword}}" placeholder="订单号">
<button type="submit">搜索</button>
</form>
<form class="inline" method="post" action="/syb/delete" data-confirm-delete>
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>货运单 ID</th>
<th>订单号</th>
<th>商品标题</th>
<th>蝦皮商品 ID</th>
<th>规格 SKU</th>
<th>数量</th>
<th>价格</th>
<th>图片</th>
<th>匹配状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
{{/* TODO(骨架): 行渲染。价格是台币,显示成 NT$xx.xx,
要和人民币一眼分得清。图片显示小缩略图,存的是 URL。
双击行打开匹配弹窗。 */}}
{{else}}
<tr class="empty">
<td colspan="11">
还没有货运单。<br>
<small>顺运宝同步方式尚未确定,当前可先手工录入用于联调。</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{/* TODO(骨架): 匹配弹窗 templates/syb/match_modal.html
左边蝦皮规格,右边该商品 pdd_data 里的 PDD 规格下拉。
三条硬规则:
1. 右侧下拉选项来自 pdd_data.dimensions,不要写死"颜色/尺码"两个维度;
2. 打开时先查 sku_mappings,有记录就自动带出并提示"已自动带出";
3. 该商品还没采集时,直接提示"请先到蝦皮数据模块采集"并给跳转链接,
不要显示一个空下拉让人困惑。 */}}
{{template "footer" .}}
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "task/list"}}
{{template "header" .}}
<div class="toolbar">
<form class="inline grow" method="get" action="/tasks">
<label for="q">订单号</label>
<input id="q" type="text" name="order_no" value="{{.Keyword}}" placeholder="订单号">
<button type="submit">搜索</button>
</form>
<form class="inline" method="post" action="/tasks/delete" data-confirm-delete>
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>订单号</th>
<th>商品标题</th>
<th>颜色</th>
<th>尺码</th>
<th>数量</th>
<th>价格上限</th>
<th>蝦皮 ID</th>
<th>分配客户端</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
{{/* TODO(骨架): 行渲染。
颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。
价格上限显示成 ¥42.00,底层存的是整数分。
状态不能只靠颜色区分,必须有文字。
建议提供「改派」操作——没有心跳,客户端挂了要靠人工改派。 */}}
{{else}}
<tr class="empty">
<td colspan="11">
还没有采购任务。<br>
<small>到「顺运宝数据」勾选货运单,点「创建采购任务」。</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "footer" .}}
{{end}}
+39
View File
@@ -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。
+27 -15
View File
@@ -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. 跑起来