docs: 建立 Admin 子项目的文档基线

仓库从单子项目变成两个:client(Python + PyQt5 桌面端)和
admin(Go + Gin + HTML 模板 Web 管理端)。两者技术栈完全不同,
规则各自独立,只通过三个 HTTP 接口交互。

新增 admin/AGENTS.md 和 docs/admin/ 共 9 份文档,结构与 docs/client/ 对齐。

关键设计决策(均已与用户确认)
- 四模块:蝦皮数据 / 顺运宝数据 / 采购任务 / 客户端列表,
  统一三段式布局(工具条 / 带勾选的表格 / 状态条)
- 蝦皮数据拆成商品表 + SKU 表:报表本身就是两层结构,
  且 PDD 链接是商品级的,放 SKU 级会重复维护
- pdd_data 存商品级,一个 PDD 商品采一次,所有订单共用
- SKU 映射独立成表且可复用,同一蝦皮 SKU 只需人工匹配一次
- PDD 链接人工填写,采集按钮就放在填链接的编辑弹窗里
- 任务分配给指定客户端;不加心跳,注册在领取时完成,
  在线状态由 last_seen_at 派生
- SQLite 驱动固定 modernc.org/sqlite(纯 Go 免 cgo,go build 直接出 exe)
- 不引入前端框架和 npm 构建,只允许原生 JS 或 htmx

基于样本文件 raw_data/蝦皮数据样本.xlsx 实测得出的约束
- 11287 行 = 5195 商品汇总行 + 6092 SKU 行,导入必须分开处理
- 商品規格ID 零重复,是天然主键
- 规格原文两种格式各占 53.4% / 46.6%,只解析【】会漏掉一半
- 平均每商品仅 1.17 个 SKU,报表不是全量目录,
  因此必须支持手动新增,且货运单外键不能加硬约束

同步更新
- 根 AGENTS.md 开头的指针改为两个子项目对照表
- docs/README.md 重构为双子项目索引
- .gitignore 加 admin/data/、admin.exe、Excel 锁文件

说明:Gitea 尚未配置,本次无对应工单号。
raw_data/ 未提交,含台币销售额等商业数据,待用户决定。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-06 15:49:04 +08:00
co-authored by Claude Opus 5
parent cbcfaca728
commit 90379347b4
13 changed files with 1963 additions and 29 deletions
+9 -2
View File
@@ -1,12 +1,19 @@
# 本地数据:数据库、日志、截图、控件树 XML # 本地数据:数据库、日志、截图、控件树 XML、上传的 Excel
# 位置和用途见 docs/client/03-data-model.md §2.1 # 位置和用途见 docs/client/03-data-model.md §2.1 和 docs/admin/02-architecture.md §6
client/data/ client/data/
admin/data/
data/ data/
# 打包产物 # 打包产物
build/ build/
dist/ dist/
*.spec *.spec
admin/admin.exe
admin/admin
# Excel 打开时生成的锁文件
~$*.xlsx
~$*.xls
# Python # Python
__pycache__/ __pycache__/
+29 -11
View File
@@ -13,13 +13,17 @@
改动看起来再合理,只要碰到上面任意一条,先停下来告诉用户。 改动看起来再合理,只要碰到上面任意一条,先停下来告诉用户。
> **动 `client/` 下任何东西之前,先读 [client/AGENTS.md](client/AGENTS.md)。** > **动代码之前,先读对应子项目的 AGENTS.md**,那里有技术栈、分层和硬性规则,本文件不重复:
> 那里有技术栈、代码分层、线程、采购安全和界面的硬性规则,本文件不重复。
> **本项目的代码目前全部在 `client/` 下**,所以基本上只要是写代码,就必须先读它。
> >
> 第一次接手本项目,还要先看: > | 你要改的东西在 | 先读 | 技术栈 |
> [上手指南](docs/client/00-getting-started.md)(装环境、跑起来)和 > |---|---|---|
> [术语表](docs/client/00-glossary.md)(Outbox、幂等、不可逆阶段等)。 > | `client/` 桌面客户端 | [client/AGENTS.md](client/AGENTS.md) | Python + PyQt5 |
> | `admin/` 管理端 | [admin/AGENTS.md](admin/AGENTS.md) | Go + Gin + HTML 模板 |
>
> **两个子项目技术栈完全不同,规则不通用,不要拿一边的经验套另一边。**
>
> 第一次接手,先看对应的上手指南和术语表:
> [Client](docs/client/00-getting-started.md) · [Admin](docs/admin/00-getting-started.md)
## 需求、缺陷与任务工作流 ## 需求、缺陷与任务工作流
@@ -235,9 +239,23 @@ Gitea 使用约定(**首次使用前需由项目负责人补全**):
- 报错分析应指出具体位置、直接原因、修复方法和验证方式,不只给出修改后的完整代码。 - 报错分析应指出具体位置、直接原因、修复方法和验证方式,不只给出修改后的完整代码。
- 不假设维护者熟悉 Qt 线程、SQLite 事务、Admin 幂等或 uiautomator2 控件树;涉及这些概念时给出一句话背景。 - 不假设维护者熟悉 Qt 线程、SQLite 事务、Admin 幂等或 uiautomator2 控件树;涉及这些概念时给出一句话背景。
## Client 子项目 ## 子项目
- 处理 `client/` 下的需求、代码、测试或文档前,必须读取并遵循 [client/AGENTS.md](client/AGENTS.md)。 本仓库有两个子项目,**技术栈完全不同,规则各自独立**:
- 从仓库根目录启动时也不能跳过该文件(本文件开头已重复提醒一次)。
- Client 的详细需求和技术基线位于 [docs/client/](docs/client/);按当前任务只读取相关文档,清单见 [文档索引](docs/README.md)。 | 子项目 | 是什么 | 规则文件 | 基线文档 |
- Client 专用技术栈、线程、自动化安全、界面和验证规则不在根文件重复维护。 |---|---|---|---|
| `client/` | Windows 桌面客户端,控制安卓设备执行采集和采购 | [client/AGENTS.md](client/AGENTS.md) | [docs/client/](docs/client/) |
| `admin/` | 本地 Web 管理端,管理商品、订单、任务和客户端 | [admin/AGENTS.md](admin/AGENTS.md) | [docs/admin/](docs/admin/) |
- 处理某个子项目下的需求、代码、测试或文档前,必须读取并遵循它的 `AGENTS.md`。
- 从仓库根目录启动时也不能跳过(本文件开头已重复提醒一次)。
- 按当前任务只读取相关文档,清单见 [文档索引](docs/README.md)。
- 子项目专用的技术栈、分层、安全和验证规则不在根文件重复维护。
### 两者的接口边界
Admin 和 Client 通过三个 HTTP 接口交互,**契约以 Client 侧文档为准**:
[docs/client/04-admin-api-contract.md](docs/client/04-admin-api-contract.md)。
改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。
+99
View File
@@ -0,0 +1,99 @@
# Admin 子项目规则
本文件适用于 `admin/` 下的全部代码、模板和测试,并继承仓库根目录 `AGENTS.md` 的
**红线**、工作流、Git、安全、文档和初级程序员维护规则。
## 开始工作前
- 从仓库根目录执行 Admin 任务时,也必须先读取本文件。
- 第一次接手,先看这两份:
- 环境和运行:[00 上手指南](../docs/admin/00-getting-started.md)
- 看不懂的词:[00 术语表](../docs/admin/00-glossary.md)
- 只读取与当前任务有关的长期基线,不必每次加载全部文档:
- 需求边界:[01 产品需求基线](../docs/admin/01-requirements.md)
- 分层和目录:[02 系统架构](../docs/admin/02-architecture.md)
- 数据库表:[03 数据模型](../docs/admin/03-data-model.md)
- 给 Client 的接口:[04 Client 接口实现](../docs/admin/04-client-api.md)
- 页面和交互:[05 界面规范](../docs/admin/05-ui-specification.md)
- 测试和安全:[06 质量与安全](../docs/admin/06-quality-security.md)
- Admin 提供的接口必须满足 Client 侧契约
[docs/client/04-admin-api-contract.md](../docs/client/04-admin-api-contract.md),
两边不一致时以 Client 契约为准,要改先走工单。
## 技术栈
- 固定使用 **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` 一起提交。
### 前端约束
- **不得引入 React、Vue、Angular 等前端框架,不得引入 npm 构建流程。**
页面一律服务端渲染。
- 搜索、删除、导入这类操作用普通表单提交,**零 JavaScript**。
- 勾选、弹窗、导入进度确实需要 JS,只允许两种做法:
- 原生 JavaScript,直接写在模板里或放 `static/js/` 下的独立文件;
- htmx(单个 js 文件,无构建步骤)。
- **不得引入打包器**(webpack/vite/esbuild)。CSS 手写,放 `static/css/`。
## 代码分层
- `handler` 只负责解析请求、调用 service、渲染模板或返回 JSON,**不写业务逻辑,不拼 SQL**。
- `service` 放业务逻辑(导入解析、创建任务、匹配复用),不认识 Gin 的 `*gin.Context`。
- `repository` 封装 SQLite,**只有这一层能写 SQL**。
- `model` 只放数据结构,不导入 Gin 和数据库驱动。
- 给 Client 的接口和给浏览器的页面**分开放**(`handler/api/` 和 `handler/web/`),
两者的错误格式、认证方式都不一样,混在一起迟早出事。
## 数据与接口
- 表结构以 [03 数据模型](../docs/admin/03-data-model.md) 为准。
- 给 Client 的接口以 [docs/client/04-admin-api-contract.md](../docs/client/04-admin-api-contract.md) 为准。
- `[必须]` **金额一律用整数存**,人民币用分、台币用分,字段名带单位后缀(`_cent`)。
**禁止用 float 存金额。**
- `[必须]` 时间一律带时区 ISO 8601,库里存 UTC,页面上转本地时区显示。
- `[必须]` Excel 导入只做 **upsert(更新或新增)**,
**绝不允许先清空再导入** —— 会把人工填的 PDD 链接全洗掉。
- `[必须]` 蝦皮规格原文(`spec_raw`)永远保留,解析不出来就留空,不要瞎猜。
- `[必须]` Client 提交结果时,**不管任务是否已取消、是否已重派,一律接受**,
理由见 Client 契约 §6.1。这条最容易被顺手违反。
## 界面规则
- 四个模块统一使用三段式布局:**顶部工具条 / 中间带勾选的表格 / 底部状态条**。
第一个页面写完,其余三个照抄结构改字段,不要给某个模块搞特殊。
- 表格行的身份用业务主键,**不得用行号**。
- 批量删除必须二次确认,并显示"将删除 N 条"。
- 破坏性操作(删除、导入覆盖)用 POST,**不得用 GET**。
- 页面必须能在 1366×768 上正常使用,表格横向滚动而不是压缩列宽。
## 安全
- 根目录 `AGENTS.md` 的五条红线同样适用。
- `[必须]` 所有写操作(新增/编辑/删除/导入/建任务)加 CSRF 防护。
- `[必须]` SQL 一律用参数化查询,**禁止字符串拼接 SQL**。
- `[必须]` 模板输出走 `html/template` 的自动转义,
**禁止用 `template.HTML` 包裹用户可控内容**。
- `[必须]` 上传的 Excel 限制大小和扩展名,解析失败要有明确报错,不能让整个进程崩掉。
- `[必须]` 日志、页面、导出里不得出现 token、密码、Cookie。
## 验证
全部命令从 `admin/` 目录执行。
```powershell
go vet ./...
go build ./...
go test ./...
go run .
```
- `go run .` 启动后访问 `http://localhost:8080`,**不会连手机、不会下单**,可随时运行。
- 修改数据库时测试首次建库和从上一版本迁移。
- 修改给 Client 的接口时,跑契约测试,确认仍满足 Client 侧 §6.1 的无条件接受。
- 修改 Excel 导入时用 `raw_data/` 下的样本跑一遍,核对导入条数。
- 交付时说明已运行的命令、结果和未验证的部分。
+12
View File
@@ -0,0 +1,12 @@
module cmautobuy/admin
go 1.23.0
// 依赖用 go get 添加后会自动写到下面,连同 go.sum 一起提交。
// 本项目固定使用(理由见 admin/AGENTS.md 技术栈一节):
// github.com/gin-gonic/gin Web 框架
// modernc.org/sqlite SQLite 驱动,纯 Go,不需要 cgo
// github.com/xuri/excelize/v2 读蝦皮 Excel 报表
//
// 不得改用 github.com/mattn/go-sqlite3(需要 cgo,Windows 上要装 gcc,
// 交叉编译和打包 exe 都会变麻烦)。
+70 -16
View File
@@ -1,18 +1,34 @@
# 项目文档索引 # 项目文档索引
本目录保存项目级文档。长期稳定的 Client 基线文档放在 `docs/client`,实施过程和完成记录放在 `docs/task`,两者不得混用。 本目录保存项目级文档。长期稳定的基线文档按子项目放在 `docs/client` 和 `docs/admin`,
实施过程和完成记录放在 `docs/task`,两者不得混用。
## 项目由两部分组成
| 子项目 | 是什么 | 技术栈 | 规则文件 |
|---|---|---|---|
| **Client** | Windows 桌面客户端,控制安卓设备在拼多多采集和下单 | Python 3.10 + PyQt5 | [client/AGENTS.md](../client/AGENTS.md) |
| **Admin** | 本地 Web 管理端,管理商品、货运单、采购任务和客户端 | Go + Gin + HTML 模板 | [admin/AGENTS.md](../admin/AGENTS.md) |
两者通过三个 HTTP 接口交互,契约以
[Client 侧的接口契约](client/04-admin-api-contract.md) 为准。
> **两个子项目技术栈完全不同,规则不通用。** 别拿 Client 的经验套 Admin,反之亦然。
## 新人从这里开始 ## 新人从这里开始
| 文档 | 用途 | | 我要做 | 先读 |
|---|---| |---|---|
| [00 上手指南](client/00-getting-started.md) | 装环境、装依赖、连手机、把程序跑起来、常见报错 | | 上手 Client | [Client 上手指南](client/00-getting-started.md) + [Client 术语表](client/00-glossary.md) |
| [00 术语表](client/00-glossary.md) | Outbox、幂等、SKU 等专业词的一句话解释 | | 上手 Admin | [Admin 上手指南](admin/00-getting-started.md) + [Admin 术语表](admin/00-glossary.md) |
| 搞清楚整条业务链路 | [Admin 需求](admin/01-requirements.md) §3 |
## 按任务找文档 ## 按任务找文档
**不用通读全部文档**,按你要做的事挑: **不用通读全部文档**,按你要做的事挑:
### Client(桌面客户端)
| 我要做的事 | 主要看 | 顺带看 | | 我要做的事 | 主要看 | 顺带看 |
|---|---|---| |---|---|---|
| 第一次把项目跑起来 | [00 上手指南](client/00-getting-started.md) | — | | 第一次把项目跑起来 | [00 上手指南](client/00-getting-started.md) | — |
@@ -24,20 +40,52 @@
| 写测试 | [06 质量与安全](client/06-quality-security.md) §2 | — | | 写测试 | [06 质量与安全](client/06-quality-security.md) §2 | — |
| 搞不清这功能到底要不要做 | [01 产品需求基线](client/01-requirements.md) | — | | 搞不清这功能到底要不要做 | [01 产品需求基线](client/01-requirements.md) | — |
| 打包成 exe、改文件路径 | [01 需求](client/01-requirements.md) §8.1 | [03 数据模型](client/03-data-model.md) §2 | | 打包成 exe、改文件路径 | [01 需求](client/01-requirements.md) §8.1 | [03 数据模型](client/03-data-model.md) §2 |
| 建工单、写归档 | [模板](templates/task.md) | 根目录 [AGENTS.md](../AGENTS.md) |
无论做哪一样,都必须先看一遍 [client/AGENTS.md](../client/AGENTS.md)(技术栈和红线)。 ### Admin(Web 管理端)
| 我要做的事 | 主要看 | 顺带看 |
|---|---|---|
| 第一次把项目跑起来 | [00 上手指南](admin/00-getting-started.md) | — |
| 改页面、加表格列 | [05 界面规范](admin/05-ui-specification.md) | [02 架构](admin/02-architecture.md) §4 模板 |
| 加字段、改表、写 SQL | [03 数据模型](admin/03-data-model.md) | [02 架构](admin/02-architecture.md) §2 分层 |
| 改 Excel 导入 | [03 数据模型](admin/03-data-model.md) §3.3 | [00 术语表](admin/00-glossary.md) §3 upsert |
| 改给 Client 的接口 | [04 Client 接口实现](admin/04-client-api.md) | [Client 侧契约](client/04-admin-api-contract.md) |
| 写测试 | [06 质量与安全](admin/06-quality-security.md) §2 | — |
| 搞不清这功能到底要不要做 | [01 产品需求基线](admin/01-requirements.md) | — |
### 通用
| 我要做的事 | 看这里 |
|---|---|
| 建工单、写归档 | [模板](templates/task.md) + 根目录 [AGENTS.md](../AGENTS.md) |
无论做哪一样,都必须先看一遍对应子项目的 `AGENTS.md`(技术栈和红线)。
## Client 基线文档 ## Client 基线文档
| 文档 | 用途 | | 文档 | 用途 |
|---|---| |---|---|
| [01 产品需求基线](client/01-requirements.md) | 定义目标、范围、业务流程和验收边界 | | [00 上手指南](client/00-getting-started.md) | 装环境、连手机、跑起来、常见报错 |
| [02 系统架构](client/02-architecture.md) | 定义模块、依赖、线程模型和故障恢复原则 | | [00 术语表](client/00-glossary.md) | Outbox、幂等、不可逆阶段等 |
| [03 数据模型](client/03-data-model.md) | 定义 SQLite 表、状态和 `pdd_data` JSON 结构 | | [01 产品需求基线](client/01-requirements.md) | 目标、范围、任务类型、打包策略 |
| [04 Admin 接口契约](client/04-admin-api-contract.md) | 定义 Client 与 Admin 的版本化接口边界 | | [02 系统架构](client/02-architecture.md) | 分层、线程模型、Worker 模板 |
| [05 界面交互规范](client/05-ui-specification.md) | 定义 PDD 任务页、设置页和表格交互 | | [03 数据模型](client/03-data-model.md) | SQLite 表、状态机、`pdd_data` 结构 |
| [06 质量、安全与测试](client/06-quality-security.md) | 定义测试策略、采购安全、日志和发布门禁 | | [04 Admin 接口契约](client/04-admin-api-contract.md) | **两个子项目的接口边界,改动需同步 Admin** |
| [05 界面交互规范](client/05-ui-specification.md) | PDD 任务页、设置页、表格交互 |
| [06 质量、安全与测试](client/06-quality-security.md) | 测试策略、采购安全、发布门禁 |
## Admin 基线文档
| 文档 | 用途 |
|---|---|
| [00 上手指南](admin/00-getting-started.md) | 装 Go、跑起来、常见报错 |
| [00 术语表](admin/00-glossary.md) | 货运单、upsert、SKU 映射等 |
| [01 产品需求基线](admin/01-requirements.md) | 业务链路、四个模块、状态定义 |
| [02 系统架构](admin/02-architecture.md) | 分层、目录、模板组织、前端约束 |
| [03 数据模型](admin/03-data-model.md) | SQLite 表、Excel 导入规则 |
| [04 Client 接口实现](admin/04-client-api.md) | 服务端怎么实现那三个接口 |
| [05 界面规范](admin/05-ui-specification.md) | 三段式布局、四个页面、弹窗 |
| [06 质量、安全与测试](admin/06-quality-security.md) | 测试、Web 安全、发布门禁 |
## 文档标注说明 ## 文档标注说明
@@ -51,20 +99,23 @@
## 文档生命周期 ## 文档生命周期
- `docs/client` 只记录不随单个任务频繁变化的产品和技术基线。 - `docs/client` 和 `docs/admin` 只记录不随单个任务频繁变化的产品和技术基线。
- 日常需求、缺陷、进度、阻塞和方案变更以 Gitea 工单为事实来源。 - 日常需求、缺陷、进度、阻塞和方案变更以 Gitea 工单为事实来源。
- 单元任务完成后,按 `AGENTS.md` 归档到 `docs/task/<工单号>-<简短名称>.md`。 - 单元任务完成后,按 `AGENTS.md` 归档到 `docs/task/<工单号>-<简短名称>.md`。
- 基线发生实质变化时,必须先更新对应 Gitea 工单并完成评审,再在同一任务中更新这里的相关文档。 - 基线发生实质变化时,必须先更新对应 Gitea 工单并完成评审,再在同一任务中更新这里的相关文档。
- **接口契约改动必须两边同步**:`docs/client/04-*` 和 `docs/admin/04-*` 在同一个工单里一起改。
## 文档和代码对不上怎么办 ## 文档和代码对不上怎么办
现在的代码还没做到文档描述的目标状态,**对不上是正常的**,已知差异列在 [02 架构](client/02-architecture.md) §3.1。 现在的代码还没做到文档描述的目标状态,**对不上是正常的**。
Client 的已知差异列在 [02 架构](client/02-architecture.md) §3.1;
Admin 目前尚未开始编码。
按下面处理,不要一发现不一致就停工: 按下面处理,不要一发现不一致就停工:
| 情况 | 怎么办 | | 情况 | 怎么办 |
|---|---| |---|---|
| 差异已经列在 §3.1 差异清单里 | 按代码现状继续做,不用停 | | 差异已经列在差异清单里 | 按代码现状继续做,不用停 |
| 差异不在清单里,但只影响写法、不影响业务结果 | 按文档做,并在工单里记一句 | | 差异不在清单里,但只影响写法、不影响业务结果 | 按文档做,并在工单里记一句 |
| 差异会影响业务结果(金额、数量、状态、下单与否、数据结构) | **停下来**,在工单里说明,等用户确认哪边是对的 | | 差异会影响业务结果(金额、数量、状态、下单与否、数据结构) | **停下来**,在工单里说明,等用户确认哪边是对的 |
@@ -72,4 +123,7 @@
## 文档状态 ## 文档状态
当前 Client 文档为 **基线草案**。Admin 尚未完成,真实采购的最终下单开关、认证方式和部分字段仍需评审确认;这些事项已在对应文档中标注为 `[待定]`,并给出了临时默认值。 Client 和 Admin 文档均为 **基线草案**。
Admin 尚未开始编码;顺运宝同步方式、认证方式和部分字段仍需确认,
这些事项已在对应文档中标注为 `[待定]`,并给出了临时默认值。
+149
View File
@@ -0,0 +1,149 @@
# 00 Admin 上手指南
- 文档状态:基线草案
- 读者:第一次接手 Admin 的开发者
- 目标:照着做完,能在自己电脑上把 Admin 跑起来,并知道下一步读哪份文档
这份文档只讲"怎么把环境搭好、怎么跑起来"。业务规则不在这里,见 [文档索引](../README.md)。
遇到不认识的词(货运单、upsert、SKU 映射……),先查 [术语表](00-glossary.md)。
## 1. 准备电脑环境
| 需要什么 | 版本 | 怎么确认装好了 |
|---|---|---|
| Go | **1.23.0** | `go version` 输出 `go version go1.23.0 windows/amd64` |
| Git | 任意较新版本 | `git --version` 有输出 |
| DB Browser for SQLite | 任意 | 用来看数据库,可选但强烈建议装 |
版本已定为 **go1.23.0**,写在 `admin/go.mod` 里。低于这个版本可能编译不过。
**不需要装 gcc。** 本项目的 SQLite 驱动是纯 Go 实现的
(`modernc.org/sqlite`),不依赖 cgo,这也是选它的原因——
`go build` 直接出单个 exe,不用配 C 编译器。
## 2. 拉依赖
先配国内代理,否则大概率拉不动:
```powershell
go env -w GOPROXY=https://goproxy.cn,direct
```
**第一次**需要把三个依赖装上(`go.mod` 里只有模块名和 Go 版本,依赖靠 `go get` 添加):
```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。
## 3. 跑起来
```powershell
cd D:\chengma\cmautobuy\admin
go run .
```
然后浏览器打开 <http://localhost:8080>。
**这个命令是安全的**:Admin 只管理数据,不会连手机、不会下单。放心随便跑。
改了代码要重启才生效(Go 不像 Python 那样改完就生效)。按 `Ctrl+C` 停掉再 `go run .`。
## 4. 你应该看到什么
左边(或顶部)是四个模块的导航:
| 模块 | 干什么的 |
|---|---|
| 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 |
| 顺运宝数据 | 同步货运单,匹配规格,生成采购任务 |
| 采购任务 | 看任务执行到哪一步了 |
| 客户端列表 | 看哪些客户端在干活 |
每个页面都是同一个结构:**上面工具条 / 中间表格 / 下面状态条**。
四个页面长得像是故意的,见 [05 界面规范](05-ui-specification.md)。
## 5. 常见报错
| 报错 | 原因 | 怎么办 |
|---|---|---|
| `go: command not found` | Go 没装或不在 PATH | 装 Go,重开 PowerShell |
| `dial tcp ... i/o timeout` | 拉不到模块 | 配 GOPROXY,见第 2 步 |
| `no required module provides package` | 依赖没装 | 回第 2 步跑那三条 `go get` |
| `listen tcp :8080: bind: ...` | 8080 端口被占了 | 换端口:`go run . -port 8081`,或关掉占用的程序 |
| 页面全是没样式的白底黑字 | 静态文件没加载到 | 确认是从 `admin/` 目录启动的,静态文件路径是相对的 |
| `database is locked` | 有别的程序占着数据库 | 关掉 DB Browser 再试;程序运行时只读打开是可以的 |
| 导入 Excel 报错但没说哪一行 | 解析器没带行号 | 这是 bug,报错必须带行号,见 [06](06-quality-security.md) §2 |
## 6. 数据库在哪、怎么看
跟 Client 一样是**便携模式**——数据都在程序旁边的 `data/` 里:
```text
admin/data/
├── admin.db 数据库
├── logs/ 运行日志
└── uploads/ 上传的 Excel 原件
```
跑源码时在 `admin\data\`;将来打包成 exe 后,在 exe 旁边。
用 **DB Browser for SQLite** 打开 `admin.db` 就能看数据。
每张表什么意思见 [03 数据模型](03-data-model.md)。
> 写代码时**不要自己拼这个路径**,用统一的 `DataDir()` 函数,
> 见 [02 架构](02-architecture.md) §6。
## 7. 拿样本数据试一下导入
仓库里有一份真实的蝦皮导出样本:
```text
raw_data/蝦皮数据样本.xlsx
```
11287 行,其中 5195 行是商品汇总行、6092 行是 SKU 行。
导入后应该得到 **5195 条商品 + 6092 条 SKU**。数字对不上就是解析有问题。
解析规则见 [03 数据模型](03-data-model.md) §3.3,那里说明了为什么一个文件要拆成两张表。
## 8. 提交代码前的自检
```powershell
cd D:\chengma\cmautobuy\admin
go vet ./... # 静态检查,有问题会打印出来
go build ./... # 能编译
go test ./... # 测试能过
```
三条都没报错就算通过。完整验证要求见 [admin/AGENTS.md](../../admin/AGENTS.md) §验证。
## 9. 接下来读什么
| 我要做的事 | 读这份 |
|---|---|
| 改页面、加表格列 | [05 界面规范](05-ui-specification.md) |
| 加字段、改表、写 SQL | [03 数据模型](03-data-model.md) |
| 改给 Client 的接口 | [04 Client 接口实现](04-client-api.md) + [Client 侧契约](../client/04-admin-api-contract.md) |
| 改 Excel 导入 | [03 数据模型](03-data-model.md) §3.3 |
| 搞不清这功能要不要做 | [01 产品需求基线](01-requirements.md) |
无论做哪一样,都必须先看一遍 [admin/AGENTS.md](../../admin/AGENTS.md)(技术栈和红线)。
+64
View File
@@ -0,0 +1,64 @@
# 00 Admin 术语表
- 文档状态:基线草案
- 用途:Admin 文档里出现的专业词,在这里查一句话解释
跨 Client 和 Admin 共用的词(幂等、SKU、采集任务、采购任务、便携模式……)
在 [Client 术语表](../client/00-glossary.md) 里,本文不重复。
## 1. 业务名词
| 词 | 一句话解释 | 在本项目里指 |
|---|---|---|
| 蝦皮 / Shopee | 台湾的电商平台,**我们在上面卖货** | 订单的来源。导出的报表是繁体中文、金额是台币 |
| 顺运宝 / SYB | 物流服务商,提供**货运单** | 告诉我们"这单要发什么货",是采购的触发源 |
| 货运单 | 顺运宝那边的一条发货记录 | `syb_orders` 表的一行 |
| 拼多多 / PDD | 大陆的电商平台,**我们在上面进货** | 采购的目标平台 |
| 商品規格ID | 蝦皮给每个 SKU 的唯一编号 | `shopee_skus.sku_id`。实测 6092 条零重复,是天然主键 |
| 规格原文 | 蝦皮报表里没拆开的那一列,例如 `黑色,M【建議40-50公斤】` | `shopee_skus.spec_raw`,**永远原样保留** |
| 建议 | 蝦皮规格里的建议体重,例如 `40-50公斤` | 不是"建议采购链接",别理解错 |
| SKU 映射 | "蝦皮的这个规格 = 拼多多的那个规格"的对应关系 | `sku_mappings` 表。**匹配一次,以后同商品自动带出** |
| 采集状态 | 某个蝦皮商品对应的 PDD 商品数据采到没有 | `shopee_products.collect_status` |
## 2. 技术名词
| 词 | 一句话解释 | 在本项目里指 |
|---|---|---|
| Gin | Go 的一个 Web 框架,负责把 URL 路由到你的函数 | 唯一允许使用的 Web 框架 |
| `html/template` | Go 自带的 HTML 模板引擎,**会自动转义**,防 XSS | 所有页面都用它渲染,不引入前端框架 |
| 服务端渲染 | 页面的 HTML 在服务器上拼好再发给浏览器 | 与之相对的是前端框架在浏览器里拼,本项目**不用** |
| htmx | 一个单文件 JS 库,让 HTML 标签直接发请求换局部内容 | 需要局部刷新时可用,**没有构建步骤** |
| upsert | "有就更新、没有就新增",一次操作搞定 | Excel 导入的唯一正确做法,见 §3 |
| cgo | Go 调用 C 代码的机制。**用了就需要装 C 编译器** | 本项目**避开它**,所以 SQLite 驱动选纯 Go 的 |
| `modernc.org/sqlite` | 纯 Go 实现的 SQLite,不需要 cgo | 固定用它,`go build` 直接出 exe |
| excelize | Go 读写 Excel 的库 | 固定用它读蝦皮报表 |
| CSRF | 攻击者诱导你在已登录状态下发出非本意的请求 | 所有写操作都要防,见 [06](06-quality-security.md) §4 |
| 参数化查询 | SQL 里用 `?` 占位、值单独传,而不是拼字符串 | 防 SQL 注入的唯一正确做法 |
## 3. 为什么导入必须是 upsert
这条单独说,因为**做错了会丢数据**。
蝦皮报表可以反复导入(每次导出的都是"最近有成绩的商品",不是全量)。
而 **PDD 链接是人工一条条填上去的**,只存在我们自己的库里,报表里没有。
所以导入时:
| 做法 | 后果 |
|---|---|
| 先 `DELETE` 再 `INSERT` | **人工填的 PDD 链接、SKU 映射全没了** ✗ |
| 按主键 upsert | 报表里有的字段更新,人工填的字段原样保留 ✓ |
主键:商品用 `商品ID`,SKU 用 `商品規格ID`。
## 4. 蝦皮报表的两层结构
一个文件里混了两种行,别当成一种:
| 行类型 | 判断方法 | 条数(样本) | 导入到 |
|---|---|---|---|
| 商品汇总行 | `商品規格ID` 是 `-` | 5195 | `shopee_products` |
| SKU 行 | `商品規格ID` 是数字 | 6092 | `shopee_skus` |
平均每个商品只有 1.17 个 SKU —— 因为这份报表**只包含有销售成绩的 SKU,不是完整目录**。
所以订单来了查不到 SKU 是正常现象,界面必须支持**手动新增**,见 [01 需求](01-requirements.md) §4.1。
+310
View File
@@ -0,0 +1,310 @@
# 01 Admin 产品需求基线
- 文档状态:基线草案,待需求评审
- 适用范围:`admin/`
- 产品类型:本地运行的 Web 管理端(Go + Gin + HTML 模板)
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 背景与目标
我们在**蝦皮**(台湾)卖货,在**拼多多**(大陆)进货,中间由**顺运宝**提供货运单。
Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行的采购任务。
产品目标:
1. 导入并维护蝦皮商品档案,为每个商品登记对应的拼多多链接。
2. 同步顺运宝货运单,知道"这单该买什么"。
3. 把蝦皮的颜色尺码,对应到拼多多的颜色尺码,**且这个对应关系可复用**。
4. 生成采购任务并分配给指定客户端,跟踪执行结果。
5. 管理客户端清单,知道谁在干活。
## 2. 用户与外部系统
- **操作人员:**导入报表、填 PDD 链接、发起采集、做规格匹配、创建并分配任务、处理异常。
- **Client:**领取任务、在安卓设备上执行、提交结果。契约见 [Client 侧文档](../client/04-admin-api-contract.md)。
- **蝦皮:**只提供 Excel 报表导出,**没有接口对接**。
- **顺运宝:**提供货运单,`[待定]` 同步方式待确认,MVP 先做占位按钮。
- **拼多多:**由 Client 操作,Admin 不直接接触。
## 3. 完整业务链路
这条链路是理解全部四个模块的关键,先看懂它:
```text
蝦皮报表 ──导入──→ 商品表 + SKU 表
│
编辑弹窗:人工填 PDD 链接
│ 点【采集】
↓
创建采集任务 → 分配 Client → 被领取
↓
Client 采回 PDD 的颜色尺码和价格
↓
存入 商品表.pdd_data
│
顺运宝货运单 ─同步─→ 货运单表
│ 双击行
↓
匹配弹窗:蝦皮规格 ←→ PDD 规格
(匹配过的自动带出,只有新规格要手工做)
↓
创建采购任务 → 分配 Client → 被领取
↓
Client 下单 → 提交结果 → 人工付款
```
两个要点:
- **采集是商品级的**,一个 PDD 商品采一次,所有相关订单共用结果。
- **匹配是 SKU 级的且可复用**,同一个蝦皮 SKU 只需人工匹配一次。
## 4. 产品范围
顶级导航固定四个模块,顺序不变:
| # | 模块 | 职责 |
|---|---|---|
| 1 | 蝦皮数据 | 商品档案、PDD 链接、发起采集 |
| 2 | 顺运宝数据 | 货运单、规格匹配、生成采购任务 |
| 3 | 采购任务 | 执行进度跟踪 |
| 4 | 客户端列表 | 客户端注册与状态 |
四个模块**统一使用三段式页面布局**:顶部工具条 / 中间带勾选的表格 / 底部状态条。
详见 [05 界面规范](05-ui-specification.md)。
### 4.1 蝦皮数据模块
**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
| 列 | 说明 |
|---|---|
| 勾选 | 支持批量删除、批量采集 |
| 商品 ID | 蝦皮商品编号 |
| 商品名称 | |
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
| PDD 链接 | 人工填写,空的要显眼 |
| 采集状态 | 见 §6 |
| 更新时间 | |
**双击行打开编辑弹窗**,这是 PDD 链接唯一的录入口:
- 可编辑:PDD 链接、颜色、尺码、建议;
- 弹窗内提供【采集】按钮,紧挨 PDD 链接输入框;
- `[必须]` 采集按钮**只出现在弹窗里**,不要放在每个表格行——
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
**其他要求:**
- `[必须]` 支持**手动新增**一条 SKU。报表只含有销售成绩的 SKU(样本里平均每商品仅 1.17 个),
订单来了查无此 SKU 是常态,必须能补。
- `[必须]` 导入是 **upsert**,绝不允许先清空再导入,否则人工填的 PDD 链接会被洗掉。
- `[必须]` 规格原文永远保留,解析失败留空,不要猜。
**底部状态条:** 最近一次导入的时间、条数、失败行数。
### 4.2 顺运宝数据模块
**顶部工具条:** 同步按钮、订单号搜索框、搜索按钮、创建采购任务按钮、删除按钮。
- `[待定]` 同步按钮 MVP 阶段只做占位:点击后提示"同步功能待接入",不发请求。
**中间表格:**
| 列 | 说明 |
|---|---|
| 勾选 | 支持批量删除、批量建任务 |
| 货运单 ID | |
| 订单号 | |
| 商品标题 | |
| 蝦皮商品 ID | 关联蝦皮数据模块 |
| 规格 SKU | 蝦皮的规格编号 |
| 数量 | |
| 价格 | 蝦皮售价,**台币分**,见 §7 |
| 图片 | 存 URL,表格里显示缩略图 |
| 匹配状态 | 已匹配 / 待匹配 |
| 更新时间 | |
原始的完整货运单 JSON 存 `syb_data` 字段,**不作为表格列显示**,在详情里看。
**双击行打开匹配弹窗:**
- 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码;
- 操作员选好对应关系,点保存;
- `[必须]` 保存的是**可复用的 SKU 映射**,不是这一张订单的临时数据。
下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
- `[必须]` 该商品尚未采集(`pdd_data` 为空)时,弹窗要明确提示"请先到蝦皮数据模块采集",
而不是显示一个空列表让人困惑。
**创建采购任务:** 勾选若干行 → 点按钮 → 校验通过后生成任务。校验规则见 §5。
**底部状态条:** 最近同步时间、待匹配条数。
### 4.3 采购任务模块
**顶部工具条:** 订单号搜索框、搜索按钮、删除按钮。
**中间表格:**
| 列 | 说明 |
|---|---|
| 勾选 | 支持批量删除 |
| 订单号 | |
| 商品标题 | |
| 颜色 / 尺码 | **PDD 侧**的规格,不是蝦皮的 |
| 数量 | |
| 价格上限 | 人民币分,见 §7 |
| 蝦皮 ID | |
| 分配客户端 | 可改派 |
| 状态 | 见 §6 |
| 更新时间 | |
**底部状态条:** 各状态的任务条数统计。
### 4.4 客户端列表模块
**顶部工具条:** 客户端名称搜索框、搜索按钮、删除按钮。
**中间表格:** 勾选、名称、序列号、状态、最近活动时间、更新时间。
**注册方式:**
- `[必须]` 客户端**调用领取接口时自动注册**:新序列号就新增,已有就更新。
**不设单独的注册或心跳接口**,理由见 [04 Client 接口实现](04-client-api.md) §3。
- `[必须]` `状态` 是**派生字段,不存库**:
最近活动时间在 N 分钟内算"在线",否则"离线"。`[建议]` N 默认 10 分钟。
- 名称由客户端上报,操作员可以在 Admin 这边改成好记的名字。
**底部状态条:** 在线 / 离线数量统计。
## 5. 创建采购任务的校验
`[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么:
1. 该蝦皮商品已填 PDD 链接;
2. 该蝦皮商品已采集成功(`pdd_data` 非空);
3. 该蝦皮 SKU 已有 PDD 规格映射;
4. 数量大于 0;
5. **价格上限已填且大于 0** —— 默认从 `pdd_data` 里该 SKU 的价格带出,操作员可改,但不允许为空;
6. 已选择分配的客户端。
第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护
(见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。
## 6. 状态定义
### 6.1 采集状态(蝦皮商品)
| 值 | 中文 | 含义 |
|---|---|---|
| `no_link` | 未填链接 | 还没填 PDD 链接 |
| `pending` | 未采集 | 已填链接,还没发起采集 |
| `collecting` | 采集中 | 采集任务已创建,尚未回结果 |
| `collected` | 已采集 | `pdd_data` 已就绪 |
| `failed` | 采集失败 | Client 报告失败,可重新采集 |
`[必须]` `collecting` 状态的商品不允许再建采集任务,按钮置灰并提示"已在采集中"。
### 6.2 任务状态(采集任务和采购任务通用)
这是 **Admin 侧的状态**,和 Client 本地的 8 个状态是两套,不要混
(Client 侧见 [03 数据模型](../client/03-data-model.md) §7)。
| 值 | 中文 | 什么时候 |
|---|---|---|
| `pending` | 待分配 | 刚创建,还没指定客户端 |
| `assigned` | 待领取 | 已分配给某个客户端 |
| `claimed` | 已领取 | 客户端领走了,正在执行 |
| `succeeded` | 成功 | 收到结果 |
| `manual_review` | 需人工 | 客户端报告需要人处理 |
| `failed` | 失败 | 客户端报告失败 |
| `cancelled` | 已取消 | 人工取消 |
`[必须]` **Admin 看不到客户端执行到哪一步**(没有心跳,是有意的)。
`claimed` 之后就只能等结果。想知道细节看 Client 那边的界面。
客户端提交失败时带的 `status`,按下表落地:
| 客户端报告 | Admin 置为 |
|---|---|
| `retry_wait` | `assigned`(等它再来领) |
| `manual_review` | `manual_review` |
| `failed` | `failed` |
| `cancelled` | `cancelled` |
## 7. 金额与币种
`[必须]` **一律用整数存,禁止浮点。** 字段名带单位后缀。
| 场景 | 币种 | 字段示例 |
|---|---|---|
| 蝦皮售价 | 台币 | `price_twd_cent` |
| PDD 采购价、价格上限 | 人民币 | `max_price_cent` |
`[必须]` **两种币种不得混用,不得互相换算后覆盖原值。**
采购任务里的价格上限是**人民币**,来源是采集回来的 PDD 价格,
和蝦皮的台币售价没有换算关系。
`[待定]` 是否需要展示汇率或利润,待业务确认。MVP 不做。
## 8. MVP 范围
MVP 包含:
- 四模块页面框架和统一的三段式布局;
- SQLite 建库与迁移;
- 蝦皮 Excel 导入(upsert)、搜索、批量删除、手动新增、编辑弹窗;
- PDD 链接录入与发起采集;
- 顺运宝数据的**手工录入或造数**(同步按钮占位);
- 规格匹配弹窗与可复用映射;
- 创建采购任务并分配客户端;
- 给 Client 的三个接口:领取、提交结果、提交失败;
- 客户端自动注册与在线状态。
MVP 之后:
- 接入顺运宝真实同步;
- 打包成 exe;
- 权限与多用户;
- 统计报表。
## 9. 非目标
- 不做面向外部的公网服务,只在内网/本机运行。
- 不直接对接蝦皮和拼多多的接口。
- 不自动决定买哪个商品——PDD 链接和规格匹配都由人确认。
- 不自动付款。
- MVP 不做用户登录和权限体系。
## 10. 非功能需求
- 单机运行,操作人员规模是个位数,不追求高并发。
- 页面在 1366×768 上可正常使用。
- 导入 1 万行 Excel 应在可接受时间内完成,并显示进度或结果统计。
- 所有写操作有 CSRF 防护,所有 SQL 参数化。
- 日志、页面、导出不含 token、密码、Cookie。
- 数据放程序旁边的 `data/`,便携模式,见 [02 架构](02-architecture.md) §6。
## 11. 待确认事项
不许因此停工。先按临时默认值做,定了再按工单改。
| # | 待确认什么 | 临时默认值 |
|---|---|---|
| 1 | 顺运宝同步方式(接口?导出文件?) | 按钮占位,数据靠手工录入或造数 |
| 2 | 蝦皮有无全量规格导出 | 没有。靠反复导入累积 + 手动新增补齐 |
| 3 | Admin 是否需要登录 | MVP 不做,仅本机/内网访问 |
| 4 | 是否需要利润和汇率展示 | 不做 |
**已定案:**
- PDD 链接**人工填写**,入口在蝦皮数据模块的编辑弹窗,采集按钮也在那里。
- 任务**分配给指定客户端**,Client 只领分给自己的。
- **不加心跳接口**,注册在领取时完成,在线状态由最近活动时间派生。
- SKU 映射**独立成表且可复用**,同一蝦皮 SKU 只人工匹配一次。
- 货运单的"规格 SKU"**直接对应蝦皮 `商品規格ID`**,不需要转换。
但**不加外键**——本地查不到该 SKU 是常态,见 [03 数据模型](03-data-model.md) §4。
- Go 版本固定 **1.23.0**。
+230
View File
@@ -0,0 +1,230 @@
# 02 Admin 系统架构
- 文档状态:基线草案,待架构评审
- 适用范围:`admin/`
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 架构目标
- 结构简单到初级程序员能独立加一个模块;
- 页面服务端渲染,不引入前端构建链;
- 业务逻辑和 Web 框架解耦,方便单测;
- 给 Client 的接口和给浏览器的页面互不干扰;
- `go build` 出单个 exe,不依赖 C 编译器。
## 2. 分层
```text
┌──────────────────────────────────────────────┐
│ handler/web 页面:解析表单 → 调 service │
│ → 渲染模板 │
│ handler/api 给 Client 的 JSON 接口 │
└───────────────────────┬──────────────────────┘
│ 只传普通结构体
┌───────────────────────▼──────────────────────┐
│ service 业务逻辑 │
│ 导入解析 / 建任务 / 匹配复用 │
└───────────────────────┬──────────────────────┘
│
┌───────────────────────▼──────────────────────┐
│ repository SQLite 读写 │
│ 只有这一层能写 SQL │
└───────────────────────┬──────────────────────┘
│
┌───────────────────────▼──────────────────────┐
│ model 纯数据结构,不导入 Gin/驱动 │
└──────────────────────────────────────────────┘
```
规则:
- `[必须]` `handler` 不写业务逻辑、不拼 SQL。它只做三件事:取参数、调 service、渲染。
- `[必须]` `service` 不认识 `*gin.Context`。这样才能不起服务器就单测。
- `[必须]` 只有 `repository` 能写 SQL,且**一律参数化查询**。
- `[必须]` `handler/web` 和 `handler/api` **分开**:
页面出错要渲染错误页,接口出错要返回 JSON;页面要 CSRF,接口要认证。
混在一起迟早写错。
## 3. 目录
```text
admin/
├── main.go 启动、路由注册
├── go.mod
├── config/ 端口、路径、超时
├── handler/
│ ├── web/ 四个模块的页面
│ │ ├── shopee.go
│ │ ├── syb.go
│ │ ├── task.go
│ │ └── client.go
│ └── api/ 给 Client 的接口
│ └── client_api.go
├── service/
│ ├── shopee_import.go Excel 解析与 upsert
│ ├── collect.go 创建采集任务
│ ├── mapping.go SKU 映射复用
│ ├── purchase.go 创建采购任务与校验
│ └── clientreg.go 客户端注册与在线判断
├── repository/
│ ├── db.go 连接、迁移
│ ├── shopee.go
│ ├── syb.go
│ ├── task.go
│ └── client.go
├── model/ 纯结构体
├── templates/ 见 §4
├── static/
│ ├── css/
│ └── js/
├── testdata/ 测试用的小样本
└── data/ 运行时数据,见 §6(不进 Git)
```
`[建议]` 一个模块一个文件,文件名和模块对应。初级程序员找代码时不用猜。
## 4. 模板组织
```text
templates/
├── layout.html 整体骨架:导航 + 内容占位 + 状态条
├── shopee/
│ ├── list.html 表格页
│ └── edit_modal.html 编辑弹窗(含 PDD 链接和采集按钮)
├── syb/
│ ├── list.html
│ └── match_modal.html 规格匹配弹窗
├── task/list.html
├── client/list.html
└── partials/
├── toolbar.html 顶部工具条
├── table.html 带勾选的表格
└── statusbar.html 底部状态条
```
`[必须]` 三段式布局做成 `partials/` 里的公共片段,四个模块复用。
**不要每个页面复制一份**——改一次样式要改四个地方,必然改漏。
`[必须]` 模板输出走 `html/template` 的自动转义。
**禁止用 `template.HTML` 包裹用户可控的内容**(商品名、订单号都是外部来的)。
## 5. 前端约束
`[必须]` **不引入 React / Vue / npm / 打包器。**
| 功能 | 怎么实现 |
|---|---|
| 搜索、删除、导入 | 普通表单 POST,服务端渲染,**零 JS** |
| 全选 / 反选 | 原生 JS,十几行 |
| 双击行开弹窗 | 原生 JS 或 htmx |
| 导入进度 | MVP 先做成"提交后显示结果统计",不做实时进度条 |
`[必须]` 破坏性操作(删除、导入)用 **POST**,不得用 GET。
浏览器和爬虫会预取 GET 链接,用 GET 删数据迟早出事故。
## 6. 文件位置
跟 Client 一样是**便携模式**:程序自己产生的东西全在 `data/` 下。
```text
data/
├── admin.db SQLite 数据库
├── logs/ 运行日志
└── uploads/ 上传的 Excel 原件
```
| 情况 | `data/` 在哪 |
|---|---|
| 打包成 exe 后 | exe 旁边 |
| 直接 `go run .` | `admin/data/` |
`[必须]` 路径统一走一个函数,**代码里不许出现第二处拼路径的地方**:
```go
package config
import (
"os"
"path/filepath"
)
// DataDir 返回可写数据目录,不存在就创建。
// 打包后 = exe 旁边的 data/;go run 时 = 当前目录下的 data/。
func DataDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Join(filepath.Dir(exe), "data")
// go run 会把程序编译到临时目录,那种情况下用当前工作目录
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
}
```
| 别这么做 | 为什么 |
|---|---|
| 硬编码 `D:\...\data` | 换台机器就废了 |
| 直接用 `os.Getwd()` | 双击 exe 和命令行启动,当前目录不一样 |
| 往 exe 所在目录写日志但不建 `data/` | 打包后和程序文件混在一起,升级时会被删 |
`[必须]` 升级方式:换掉 exe,**`data/` 原样保留**。所以数据库必须支持迁移,见 [03](03-data-model.md) §2。
## 7. 请求流程示例
以"导入蝦皮 Excel"为例,看清各层职责:
```text
浏览器 POST /shopee/import (multipart 文件)
↓
handler/web/shopee.go
- 校验文件大小和扩展名
- 存到 data/uploads/
- 调 service.ImportShopeeExcel(path)
↓
service/shopee_import.go
- 用 excelize 逐行读
- 按 商品規格ID 是否为 "-" 分成商品行/SKU 行
- 解析规格原文 → 颜色/尺码/建议(失败留空)
- 调 repository 做 upsert
- 返回 {商品数, SKU数, 失败行号列表}
↓
repository/shopee.go
- INSERT ... ON CONFLICT(...) DO UPDATE
- 只更新报表来的字段,人工填的 pdd_goods_url 不动
↓
handler 渲染结果页:导入 5195 商品 / 6092 SKU,失败 0 行
```
`[必须]` 注意最后一步:**upsert 时不得覆盖人工维护的字段**。
这是导入逻辑里最容易写错、后果最严重的一处,见 [03](03-data-model.md) §3.3。
## 8. 与 Client 的边界
- Admin 是任务的创建者和分配者,Client 是执行者。
- Admin **不知道** Client 执行到哪一步(没有心跳,是有意的)。
- Client 提交结果时,Admin **无条件接受**,哪怕任务已取消或已重派。
- 详见 [04 Client 接口实现](04-client-api.md)。
## 9. 相关文档
- [上手指南](00-getting-started.md)
- [术语表](00-glossary.md)
- [产品需求基线](01-requirements.md)
- [数据模型](03-data-model.md)
- [Client 接口实现](04-client-api.md)
- [界面规范](05-ui-specification.md)
- [质量与安全](06-quality-security.md)
- [Client 侧接口契约](../client/04-admin-api-contract.md)(对接时以它为准)
+332
View File
@@ -0,0 +1,332 @@
# 03 Admin 数据模型
- 文档状态:基线草案,待数据评审
- 数据库:SQLite(驱动 `modernc.org/sqlite`,纯 Go 免 cgo)
- 位置:`data/admin.db`,见 [02 架构](02-architecture.md) §6
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 设计原则
- `[必须]` 金额一律整数,字段名带单位后缀(`_cent`),**禁止 float**。
- `[必须]` 台币和人民币**分开存、不互相换算覆盖**。
- `[必须]` 时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。
- `[必须]` 外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)**原样保留**,
规范化字段用于查询和显示。解析失败留空,不要猜。
- `[必须]` **人工维护的字段不得被导入覆盖**(详见 §3.3)。
- `[必须]` SQL 一律参数化查询。
## 2. 建库与迁移
每次打开连接至少设置:
```sql
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
```
用 `PRAGMA user_version` 管理顺序迁移。
`[必须]` 升级必须支持从所有已发布版本迁移,**不得在启动时删库重建**。
理由:`data/` 在升级时是保留的(见 [02](02-architecture.md) §6),
里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。
## 3. 蝦皮数据
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
### 3.1 `shopee_products` 商品级
```sql
CREATE TABLE shopee_products (
goods_id TEXT PRIMARY KEY, -- 蝦皮「商品ID」
title TEXT NOT NULL, -- 蝦皮「商品名稱」
shopee_status TEXT, -- 蝦皮「商品當前狀態」
main_sku_code TEXT, -- 蝦皮「主商品貨號」
-- 下面三个是我们自己维护的,报表里没有,导入时绝不能覆盖
pdd_goods_url TEXT, -- ★ 人工填写
pdd_goods_id TEXT, -- 从 url 解析出来
pdd_data TEXT, -- ★ 采集结果 JSON
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);
```
- `pdd_data` 存 Client 采回来的 PDD 商品数据,结构和
[Client 侧 `pdd_data`](../client/03-data-model.md) §8.1 一致,
里面有 `dimensions` 和 `skus`,匹配弹窗右侧就是从它渲染的。
- `[必须]` `pdd_data` 放在**商品级**,不是订单级。
一个 PDD 商品采一次,所有相关订单共用。
### 3.2 `shopee_skus` SKU 级
```sql
CREATE TABLE shopee_skus (
sku_id TEXT PRIMARY KEY, -- 蝦皮「商品規格ID」,实测零重复
goods_id TEXT NOT NULL,
spec_raw TEXT NOT NULL, -- ★ 规格原文,永远保留
color TEXT, -- 解析结果,失败留空
size TEXT,
advice TEXT, -- 建议体重,如「40-50公斤」
parse_ok INTEGER NOT NULL DEFAULT 0, -- 0=解析失败,界面上要标出来
sku_code TEXT, -- 蝦皮「商品選項貨號」
is_manual INTEGER NOT NULL DEFAULT 0, -- 1=人工新增的,导入不得删
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);
```
### 3.3 Excel 导入规则
`[必须]` 这一节的每一条都要照做,写错会丢数据。
**第一步:分行**
| 行类型 | 判断方法 | 样本条数 | 导入到 |
|---|---|---|---|
| 商品汇总行 | `商品規格ID` 是 `-` 或空 | 5195 | `shopee_products` |
| SKU 行 | `商品規格ID` 是数字 | 6092 | `shopee_skus` |
导入 `raw_data/蝦皮数据样本.xlsx` 应得到 **5195 商品 + 6092 SKU**,数字对不上就是解析有问题。
**第二步:按列名找索引,不要写死列号**
报表有 40 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位:
```go
idx := map[string]int{}
for i, name := range header {
idx[strings.TrimSpace(name)] = i
}
goodsID := row[idx["商品ID"]]
```
找不到必需列时**直接报错停止**,不要用默认值蒙混过去。
**第三步:解析规格原文**
`商品規格` 这一列格式**不统一**,实测两种各占一半:
| 格式 | 占比 | 样例 |
|---|---|---|
| 有【】 | 53.4% | `黑色,M【建議40-50公斤】` |
| 无括号、空格分隔 | 46.6% | `卡其色拼黑色,L 建議50-57.5kg` |
好消息:**逗号数恒为 1**(6092 条无例外),所以"颜色,尺码"这个二分结构是稳的。
规则:
1. 按第一个逗号切开 → 左边是颜色,右边是"尺码 + 可能的建议";
2. 右边尝试提取建议:先找 `【建議...】`,再找 ` 建議...`;
3. 剩下的就是尺码;
4. `[必须]` 任何一步失败都**不要猜**,把 `parse_ok` 置 0,颜色尺码留空,
`spec_raw` 照常保存。界面上把这些行标出来让人工补。
**第四步:upsert,绝不清空**
```sql
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(goods_id) DO UPDATE SET
title = excluded.title,
shopee_status = excluded.shopee_status,
main_sku_code = excluded.main_sku_code,
updated_at = excluded.updated_at;
-- 注意:pdd_goods_url / pdd_data / collect_status 一个都不在这里
```
| 别这么做 | 后果 |
|---|---|
| `DELETE FROM shopee_products` 再导入 | **人工填的 PDD 链接、采集结果全没了** |
| `DO UPDATE SET` 里写 `pdd_goods_url = excluded.pdd_goods_url` | 报表里没这列,会被更新成空 |
| 删掉报表里没出现的 SKU | 人工新增的(`is_manual=1`)会被误删 |
**第五步:返回统计**
导入结束返回 `{商品数, SKU数, 解析失败行号列表}`,页面上显示出来。
**不要静默跳过失败行。**
## 4. `syb_orders` 顺运宝货运单
```sql
CREATE TABLE syb_orders (
syb_id TEXT PRIMARY KEY, -- 货运单 ID
order_no TEXT NOT NULL, -- 订单号
title TEXT, -- 商品标题
shopee_goods_id TEXT, -- 蝦皮商品 ID
shopee_sku_id TEXT, -- 蝦皮规格 ID
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, -- 存 URL,不存图片本身
syb_data TEXT NOT NULL DEFAULT '{}',-- 完整货运单 JSON,原样保留
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);
```
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进 SQLite。
- `shopee_sku_id` **直接对应 `shopee_skus.sku_id`**,编号格式一致,不需要额外转换。
`[必须]` 但**不要加外键约束**。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
货运单来了而本地查不到这个 SKU 是**常态**。加了外键,同步就会直接失败。
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录",
并引导操作员到蝦皮数据模块手动新增(见 [05 界面规范](05-ui-specification.md) §4.4)。
**"编号能对上"和"本地一定查得到"是两回事,别混。**
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
## 5. `sku_mappings` 规格映射
这张表是"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
```sql
CREATE TABLE sku_mappings (
shopee_sku_id TEXT PRIMARY KEY,
goods_id TEXT NOT NULL,
pdd_options TEXT NOT NULL, -- JSON: {"color":"黑色","size":"M码"}
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);
```
- `pdd_options` 用 JSON 而不是固定的"颜色/尺码"两列,
因为 PDD 商品可能有第三个规格维度
(Client 侧 [01](../client/01-requirements.md) §11 已明确要求按任意维度设计)。
- `[必须]` 打开匹配弹窗时**先查这张表**,有记录就自动带出,操作员只需确认。
这是省人工的关键,不要做成每张订单都从头匹配。
## 6. `tasks` 任务
采集任务和采购任务共用一张表,用 `task_type` 区分。
```sql
CREATE TABLE tasks (
task_id TEXT PRIMARY KEY, -- 给 Client 的稳定编号,如 PDD-20260806-0001
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, -- 蝦皮商品 ID
shopee_sku_id TEXT,
-- 发给 Client 的执行参数
pdd_goods_url TEXT NOT NULL, -- ★ Client 契约要求必填
pdd_goods_id TEXT,
pdd_options TEXT, -- JSON,采购任务的目标规格
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, -- Client 提交的 pdd_data
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);
```
`[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。
2. **采购任务的 `quantity` 和 `max_price_cent` 都必须有值**,
这是价格保护,没有它 Client 会拒绝执行。
`max_price_cent` 是**人民币分**,默认从 `pdd_data` 里对应 SKU 的价格带出,操作员可改但不能清空。
状态含义见 [01 需求](01-requirements.md) §6.2。
## 7. `clients` 客户端
```sql
CREATE TABLE clients (
client_id TEXT PRIMARY KEY, -- 序列号,来自 X-Client-Id
name TEXT, -- 客户端上报,可人工改
device_address TEXT,
platform TEXT,
pdd_package TEXT,
capabilities TEXT, -- claim 请求里的 capabilities 原文
last_seen_at TEXT NOT NULL, -- 每次调接口都刷新
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
- `[必须]` **没有 `status` 字段。** 在线状态是**算出来的**:
`last_seen_at` 在 N 分钟内算在线,否则离线。`[建议]` N 默认 10 分钟。
存成字段会和真实情况不同步。
- `[必须]` 注册发生在**领取任务时**,新序列号新增、已有的更新,
**不设单独的注册或心跳接口**,见 [04](04-client-api.md) §3。
## 8. 数据关系总览
```text
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_data │ 1:1
│ (采集结果) ↓
│ sku_mappings
│ ↑
│ │ 查映射
syb_orders ────────────────────┘
│
└──创建──→ tasks ──分配──→ clients
```
## 9. 与 Client 数据模型的关系
Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换数据。
| 概念 | Admin 这边 | Client 那边 |
|---|---|---|
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
| 采集结果 | `shopee_products.pdd_data` | `pdd_tasks.pdd_data` |
`[必须]` **两边的状态是两套,不要试图同步。**
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
+237
View File
@@ -0,0 +1,237 @@
# 04 Admin 侧的 Client 接口实现
- 文档状态:基线草案,待联合评审
- 适用范围:`admin/handler/api/`
> **权威契约在 Client 那边:**[docs/client/04-admin-api-contract.md](../client/04-admin-api-contract.md)。
> 本文只讲**Admin 这边怎么实现**。两边说法不一致时**以 Client 契约为准**,要改先走工单。
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
## 1. 一共只有三个接口
```http
POST /api/v1/client/tasks/claim 领一个任务
POST /api/v1/client/tasks/{task_id}/result 提交成功结果
POST /api/v1/client/tasks/{task_id}/failure 提交失败/需人工
```
`[必须]` **不得新增"让 Client 查询状态"类接口**,也不得加回租约和心跳。
理由见 Client 契约 §1.1:本项目人工付款,重复下单只产生重复的**未付款**订单,
不值得为它引入一整套中断逻辑。
## 2. 领取任务
```http
POST /api/v1/client/tasks/claim
X-Client-Id: client-001
```
请求体里有设备信息和能力声明,还有客户端名称(见 §3)。
**处理步骤:**
1. 先做客户端注册/更新(§3);
2. 查 `tasks`:`assigned_client = X-Client-Id` 且 `status = 'assigned'`,
按 `priority DESC, created_at` 取**一条**;
3. 没有就返回 `204 No Content`;
4. 有就把 `status` 改成 `claimed`、记 `claimed_at`,返回任务。
`[必须]` 第 2 步只返回**分配给这个客户端**的任务。这是已定案的分配模型
(Client 契约 §5.1),不要做成谁都能抢。
`[必须]` 第 4 步的查询和更新要在**同一个事务**里,用条件更新防并发:
```sql
UPDATE tasks SET status = 'claimed', claimed_at = ?, updated_at = ?
WHERE task_id = ? AND status = 'assigned';
-- 检查影响行数,为 0 说明被别人抢先了,重新取下一条
```
**响应体:**
```json
{
"task": {
"id": "PDD-20260806-0001",
"type": "purchase",
"version": 1,
"priority": 10,
"payload": {
"goods_id": "737116531267",
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
"options": { "color": "黑色", "size": "M码" },
"quantity": 2,
"max_price_cent": 4200
},
"created_at": "2026-08-06T07:00:00Z",
"updated_at": "2026-08-06T07:05:00Z"
}
}
```
`[必须]` **响应里不含租约**,也不含 Admin 侧状态。Client 不关心这些。
`[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。
这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5),
不要等到这里才发现发不出去。
## 3. 客户端注册就在领取里做
`[必须]` **不设单独的注册接口,也不设心跳接口。**
`claim` 请求里已经带齐了初始化需要的东西:
```http
X-Client-Id: client-001 ← 序列号,主键
```
```json
{
"client": { "name": "办公室-01" },
"supported_types": ["collect", "purchase"],
"device": { "address": "192.168.0.173:5555",
"platform": "android",
"pdd_package": "com.xunmeng.pinduoduo" },
"capabilities": { "purchase_mode": "dry_run", "schema_versions": [1] }
}
```
> `[待定]` `client.name` 是对 Client 契约 §5 的一处小扩展,需在联合评审时确认。
> 未确认前 Admin 要容忍它缺失:没有就用 `X-Client-Id` 当显示名。
处理:
```text
upsert clients:
没有该 client_id → 新增,name 用上报的(没有就用 client_id)
已有 → 更新 device/capabilities/last_seen_at
name 若已被人工改过,不要覆盖
```
`[必须]` `result` 和 `failure` 接口**也要刷新 `last_seen_at`**。
否则客户端执行长任务期间不调 claim,会被误判成离线。
**新客户端第一次来必然拿不到任务**(还没人给它分配),返回 `204` 是正常的。
它这时已经登记进列表了,操作员就能给它分配任务。
## 4. 提交结果
```http
POST /api/v1/client/tasks/{task_id}/result
Idempotency-Key: <task_id>:<attempt_id>:result-v1
```
处理:
1. 用 `Idempotency-Key` 查是否处理过 → 处理过就返回上次的结果,**不重复落库**;
2. 把 `pdd_data` 写进 `tasks.result_data`;
3. 采集任务:同时写进 `shopee_products.pdd_data`,`collect_status` 置 `collected`;
4. `tasks.status` 置 `succeeded`;
5. 刷新客户端 `last_seen_at`;
6. 返回 `{"accepted": true, "result_id": "...", "accepted_at": "..."}`。
第 2~4 步 `[必须]` 在**同一个事务**里。
### 4.1 无条件接受 —— 最容易写错的一条
`[必须]` 下面每一条都不允许违反:
- **不得**因为任务已取消而拒绝结果;
- **不得**因为任务已重派给别的客户端而拒绝结果;
- **必须**能接受同一任务来自**多个客户端**的多份结果;
- 只有**从未分配给该客户端**的任务才返回 `403`。
原因:Client 中途不查任务状态(契约 §1),所以它**必然**会提交一些
"Admin 这边已经不要了"的结果。而它可能真的已经下单了,这些数据必须能交上来留痕。
`accepted: true` 的意思是"**我收到并存下了**",不代表这个任务还算数。
任务算不算数由人工审核决定。
`[建议]` 已取消任务收到的结果,落库时标记出来,在界面上让操作员能看到"这单已取消但客户端还是买了"。
## 5. 提交失败
```http
POST /api/v1/client/tasks/{task_id}/failure
Idempotency-Key: <task_id>:<attempt_id>:failure-v1
```
`status` 只会是 `retry_wait`、`manual_review`、`failed`、`cancelled` 之一,按下表落地:
| Client 报告 | Admin 置为 | 说明 |
|---|---|---|
| `retry_wait` | `assigned` | 放回去,等它再来领 |
| `manual_review` | `manual_review` | 等人处理 |
| `failed` | `failed` | |
| `cancelled` | `cancelled` | |
采集任务失败时,同步把 `shopee_products.collect_status` 置 `failed`,
并把错误信息写进 `collect_error`,界面上要看得见。
`[必须]` §4.1 的无条件接受**同样适用于本接口**。
## 6. 幂等怎么做
`[必须]` 建一张表记录处理过的键:
```sql
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL, -- 请求体的哈希
response_body TEXT NOT NULL, -- 上次返回了什么
created_at TEXT NOT NULL
);
```
- 键相同、哈希相同 → 直接返回 `response_body`,不再处理;
- 键相同、哈希不同 → 返回 `409 IDEMPOTENCY_CONFLICT`;
- 键不存在 → 正常处理,然后连同响应一起写入,**和业务写入在同一事务**。
不这么做的话,Client 网络超时重发就会产生两条结果。
## 7. 错误格式
页面出错渲染错误页,**接口出错返回 JSON**,两者不要混:
```json
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "相同幂等键提交了不同内容",
"retryable": false,
"request_id": "7a5d...",
"details": {}
}
}
```
状态码用法见 Client 契约 §3。注意 `403` 的含义:**只有从未分配给该客户端的任务**才返回它。
## 8. 认证
`[待定]` 认证方式尚未定案(Client 契约 §10 待确认 #1)。
临时默认:接口读 `Authorization: Bearer <token>` 请求头但**不校验**,
MVP 阶段只在内网/本机运行。
`[必须]` 无论最终怎么做,**token 不得写进日志**。
## 9. Admin 侧实现清单
Client 契约里散落的 Admin 侧硬要求,汇总在这里,**可以直接当验收标准用**:
- [ ] `claim` 一次只返回一个任务,且只返回分配给该客户端的
- [ ] `claim` 原子完成,并发下不会把同一任务发给两个客户端
- [ ] `claim` 无任务时返回 `204`,不是 `200` 加空对象
- [ ] 响应不含租约、不含 Admin 侧状态
- [ ] `payload.goods_url` 必有值
- [ ] 采购任务的 `quantity`、`max_price_cent` 必有值,且是人民币分整数
- [ ] 不向只声明 `dry_run` 的客户端分配需要真实下单的任务
- [ ] `result` / `failure` 幂等:同键同内容返回同结果
- [ ] 同键不同内容返回 `409`
- [ ] **任务已取消,仍接受结果**
- [ ] **任务已重派,仍接受结果**
- [ ] **同一任务接受多个客户端的多份结果**
- [ ] 只有从未分配过的任务才返回 `403`
- [ ] `claim` / `result` / `failure` 都刷新 `last_seen_at`
- [ ] token 不进日志
+233
View File
@@ -0,0 +1,233 @@
# 05 Admin 界面规范
- 文档状态:基线草案,待界面评审
- 技术栈:Go `html/template` 服务端渲染 + 手写 CSS + 原生 JS / htmx
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 设计目标
- 四个模块**长得一样**,写完第一个,其余三个照抄结构改字段;
- 操作员一眼看出"这条数据卡在哪一步、下一步该点什么";
- 不引入前端框架和构建流程;
- 破坏性操作(删除、导入)必须有确认,不能手滑。
## 2. 统一的三段式布局
`[必须]` 四个模块全部用这个结构,**不要给某个模块搞特殊**:
```text
┌────────────────────────────────────────────────────────────┐
│ [导航] 蝦皮数据 │ 顺运宝数据 │ 采购任务 │ 客户端列表 │
├────────────────────────────────────────────────────────────┤
│ 顶部工具条:[操作按钮…] [搜索框] [搜索] [删除] │
├────────────────────────────────────────────────────────────┤
│ ☐ │ 列1 │ 列2 │ 列3 │ … │
│ ☐ │ │ │ │ │
│ 数据表格(占满剩余高度) │
│ │
├────────────────────────────────────────────────────────────┤
│ 底部状态条:最近一次操作的结果和关键统计 │
└────────────────────────────────────────────────────────────┘
```
`[必须]` 三段做成 `templates/partials/` 里的公共片段复用,
**不要每个页面复制一份**——改一次样式要改四处,必然改漏。
`[必须]` 页面在 1366×768 上可用。列多了让表格**横向滚动**,不要压缩列宽把字挤成两行。
## 3. 表格通用规则
- `[必须]` 第一列是勾选框,表头有全选。
- `[必须]` 行的身份用**业务主键**(商品規格ID、货运单ID、任务编号、序列号),
**不得用行号**——排序和筛选一变行号就错位。
- `[必须]` 批量删除要二次确认,弹窗里写清"**将删除 N 条,不可恢复**"。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
- 搜索无结果 → "没有匹配的记录" + 清除条件入口
## 4. 蝦皮数据页
### 4.1 工具条
```text
[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除]
```
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
(导入 5195 商品 / 6092 SKU,失败 N 行)。
`[必须]` 失败行要列出行号和原因,不能静默跳过。
- **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。
`[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。
### 4.2 表格列
| 列 | 显示规则 |
|---|---|
| ☐ | 勾选 |
| 商品 ID | |
| 商品名称 | 截断 + 悬停完整 |
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败 |
| 更新时间 | 本地时区 |
`[必须]` 状态不能只靠颜色区分,必须有文字。
### 4.3 编辑弹窗(双击行打开)
这是 **PDD 链接唯一的录入口**,也是采集的唯一发起点。
```text
┌─────────────────────────────────────────┐
│ 编辑商品 40653075144 │
├─────────────────────────────────────────┤
│ 商品名称 【台灣現貨隔日達】西裝外套… │ ← 只读
│ 规格原文 黑色,M【建議40-50公斤】 │ ← 只读,永远显示
│ │
│ 颜色 [黑色___________] │
│ 尺码 [M_______________] │
│ 建议 [40-50公斤_______] │
│ │
│ PDD 链接 [___________________] [采集] │ ← ★
│ 采集状态 已采集(2026-08-06 15:20) │
├─────────────────────────────────────────┤
│ [取消] [保存] │
└─────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- **采集按钮只在这里出现**,不要放到表格每一行。一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
- 采集状态是"采集中"时按钮置灰,提示"已在采集中"。
- 采集失败时显示错误原因,按钮变成"重新采集"。
### 4.4 手动新增
`[必须]` 必须提供"新增 SKU"入口。
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
订单来了查无此 SKU 是常态。没有这个入口整条链路就断了。
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
## 5. 顺运宝数据页
### 5.1 工具条
```text
[同步] [创建采购任务] 订单号 [________] [搜索] [删除]
```
`[待定]` MVP 阶段**同步按钮只做占位**:点击提示"同步功能待接入",不发请求。
### 5.2 表格列
☐ / 货运单ID / 订单号 / 商品标题 / 蝦皮商品ID / 规格SKU / 数量 /
价格(台币)/ 图片 / **匹配状态** / 更新时间
- 图片显示小缩略图,点击看大图。`[必须]` 存 URL,不要把图片塞进数据库。
- 匹配状态是**算出来的**(`sku_mappings` 里有没有记录),不是存的字段。
- 完整货运单 JSON 不作为列显示,在详情里看。
### 5.3 规格匹配弹窗(双击行打开)
```text
┌──────────────────────────────────────────────────┐
│ 规格匹配 · 订单 SO-20260806-001 │
├────────────────────────┬─────────────────────────┤
│ 蝦皮 │ 拼多多 │
│ │ │
│ 颜色 黑色 │ 颜色 [黑色 ▾] │
│ 尺码 M │ 尺码 [M码 ▾] │
│ 建议 40-50公斤 │ │
│ 原文 黑色,M【建議40…】 │ 单价 ¥39.90 │
├────────────────────────┴─────────────────────────┤
│ ⓘ 已有匹配记录,已自动带出,确认无误后保存 │
│ [取消] [保存] │
└──────────────────────────────────────────────────┘
```
`[必须]` 几条硬规则:
- 右侧下拉的选项来自该商品 `pdd_data` 里的 `dimensions`,
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
- **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。
这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。
- 该商品**还没采集**(`pdd_data` 为空)时,不要显示一个空下拉让人困惑,
直接提示:"该商品尚未采集 PDD 数据,请先到『蝦皮数据』填写链接并采集",
并给一个跳转链接。
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
### 5.4 创建采购任务
勾选若干行 → 点按钮 → 逐条校验(见 [01 需求](01-requirements.md) §5)。
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
```text
成功创建 3 个任务。以下 2 条未创建:
· SO-...-007 该商品未填写 PDD 链接
· SO-...-009 规格未匹配
```
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。
价格上限默认从 `pdd_data` 带出,可改,**不允许为空**。
## 6. 采购任务页
工具条:`订单号 [____] [搜索] [删除]`
表格列:☐ / 订单号 / 商品标题 / 颜色 / 尺码 / 数量 / 价格上限 /
蝦皮ID / **分配客户端** / 状态 / 更新时间
- `[必须]` 颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。
- `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。
- `[建议]` 提供"改派"操作,把任务分给别的客户端——
没有心跳,客户端挂了要靠人工改派。
- 状态含义见 [01 需求](01-requirements.md) §6.2。
底部状态条显示各状态的条数统计。
## 7. 客户端列表页
工具条:`名称 [____] [搜索] [删除]`
表格列:☐ / 名称 / 序列号 / 状态 / 最近活动 / 更新时间
- `[必须]` **状态是算出来的**:`最近活动` 在 N 分钟内为"在线",否则"离线"。
`[建议]` N 默认 10 分钟。
- `[必须]` 名称可以人工改成好记的("办公室-01")。改过之后
**客户端上报的名称不再覆盖它**。
- `[建议]` 界面上说明一句:"客户端执行长任务期间可能显示为离线,属正常现象。"
因为没有心跳,这是已知且接受的取舍(见 [04](04-client-api.md) §3)。
## 8. 反馈方式
| 场景 | 怎么反馈 |
|---|---|
| 搜索、翻页 | 直接刷新表格,不弹任何东西 |
| 导入完成 | 底部状态条 + 结果统计(成功 N / 失败 M + 失败行号) |
| 保存成功 | 关闭弹窗,刷新行,状态条提示一句 |
| 校验不通过 | 保留用户输入,**焦点移到第一个错误字段**,就近显示错误 |
| 批量操作部分失败 | 列出失败项和原因,不要只说"部分失败" |
| 删除 | 二次确认框,写明"将删除 N 条,不可恢复" |
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
堆栈写日志。
## 9. 可访问性与细节
- `[必须]` 表单控件有可见 `<label>`,占位符不能当标签用。
- `[必须]` 状态不能只靠颜色,必须有文字。
- `[建议]` 搜索框支持回车提交。
- `[建议]` 主要操作支持键盘 Tab 到达。
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。
+189
View File
@@ -0,0 +1,189 @@
# 06 Admin 质量、安全与测试基线
- 文档状态:基线草案,待质量评审
- 适用范围:Admin 源码、模板、数据库和发布产物
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 质量目标
- 导入不丢人工维护的数据(PDD 链接、SKU 映射、手动新增的行)。
- 不把校验不过的任务发给 Client——发出去 Client 也执行不了。
- 同一任务不会被两个客户端同时领走。
- Client 提交的结果一定收得下,哪怕任务已取消。
- 页面和日志不泄露 token、密码、Cookie。
## 2. 测试分层
### 2.1 单元测试
`[必须]` 覆盖:
- **规格原文解析**:两种格式各占一半,两种都要有用例,
再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜);
- **Excel 导入分行**:商品汇总行 vs SKU 行的判断;
- **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在;
- 任务创建校验(六条规则逐条);
- 金额换算显示(分 ↔ 元),边界值 0 和大额;
- SKU 映射复用:第二次匹配同一 SKU 应自动带出;
- 在线状态派生:`last_seen_at` 刚好在边界前后。
`[必须]` 导入相关的测试用 `testdata/` 下的**小样本**(几十行),
不要每次跑测试都读 1.9MB 的完整报表。
### 2.2 集成测试
用临时 SQLite 覆盖:
- 完整导入 → 建采集任务 → 模拟 Client 提交结果 → `collect_status` 变 `collected`;
- 并发 `claim`:两个客户端同时领,只有一个拿到;
- 幂等:同键同内容重复提交,只落库一次;
- 同键不同内容 → `409`;
- **任务已取消,Client 提交结果仍被接受**;
- **任务已重派给别人,原客户端提交仍被接受**;
- 同一任务收到两个客户端的两份结果,都存下来;
- 数据库从上一版本迁移,人工数据不丢。
中间三条是 [04](04-client-api.md) §4.1 那条最容易写错的规则,**必须有测试盯着**。
### 2.3 页面测试
- `go vet ./...` 无告警;
- 所有模板能正常渲染(起服务跑一遍四个页面,断言 200);
- 空数据、少量数据、大量数据三种情况;
- 批量删除的二次确认存在;
- 校验失败时输入不丢。
### 2.4 契约测试
`[必须]` 拿 [04 §9 的实现清单](04-client-api.md) 逐条写测试。
那张清单本身就是验收标准,可以直接照着做。
## 3. 数据安全
`[必须]` 下面几条错一条就会丢数据:
| 规则 | 为什么 |
|---|---|
| 导入只能 upsert,**禁止先清空再导入** | 人工填了几个月的 PDD 链接会被洗掉 |
| upsert 的 `DO UPDATE SET` 里**不得出现** `pdd_goods_url` / `pdd_data` / `collect_status` | 报表里没这些列,写进去会被更新成空 |
| **不得删除报表里没出现的行** | 手动新增的(`is_manual=1`)会被误删;报表本身就不是全量 |
| 迁移前备份或用可回滚步骤 | `data/` 在升级时保留,迁移失败会毁掉全部历史 |
| **不得在启动时删库重建** | 同上 |
`[建议]` 导入前自动把 `admin.db` 复制一份到 `data/backup/`,保留最近几份。
## 4. Web 安全
- `[必须]` 所有写操作(新增/编辑/删除/导入/建任务)加 **CSRF 防护**。
- `[必须]` 破坏性操作用 **POST**,不得用 GET。
- `[必须]` SQL **一律参数化查询**,禁止字符串拼接。
搜索框的内容是用户可控的,拼进 SQL 就是注入。
- `[必须]` 模板输出走 `html/template` 的自动转义。
**禁止用 `template.HTML` 包裹用户可控内容**——商品名、订单号都来自外部。
- `[必须]` 上传的 Excel 限制**大小和扩展名**,解析失败要返回明确错误,
不能 panic 把整个进程带崩。
- `[必须]` 文件名不得直接用于拼路径(路径穿越),落盘时用自己生成的名字。
- `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。
## 5. 错误处理
`[必须]` 页面出错渲染错误页,接口出错返回 JSON,**两者不要混**。
`[必须]` 错误信息要说清三件事:**发生了什么、保住了什么、下一步做什么**。
```text
好:导入失败:第 128 行「商品規格ID」为空且不是汇总行。
前 127 行已成功导入,请修正后重新导入。
差:导入失败
差:runtime error: index out of range [40] with length 39
```
`[必须]` Go 的错误堆栈只写日志,不上页面。
`[建议]` 用稳定错误码,方便排查:
| 前缀 | 场景 |
|---|---|
| `IMPORT_*` | Excel 格式、列缺失、行解析失败 |
| `TASK_*` | 建任务校验不通过 |
| `CLIENT_*` | 客户端注册、领取冲突 |
| `DB_*` | 迁移、写入、锁超时 |
## 6. 日志
`[必须]` 日志写 `data/logs/`,至少包含时间、级别、模块、事件名、关键 ID。
`[建议]` 记录这些事件:
- `shopee_import_started/completed/failed`(带条数统计)
- `collect_task_created`
- `purchase_task_created`
- `task_claimed`(带 client_id、task_id)
- `task_result_received`
- `task_result_received_but_cancelled` ← 这条要单独记,方便事后对账
- `client_registered`
`[必须]` **不得记录** token、密码、Cookie,也不要把完整请求体无脑打进日志。
## 7. 性能
规模很小(个位数操作员、单机运行),不要提前优化。但下面几条是基本功:
- `[必须]` 搜索、排序、分页走**数据库查询**,不要一次查全量再在内存里过滤。
- `[必须]` 常用查询有索引(见 [03 数据模型](03-data-model.md) 里的 `CREATE INDEX`)。
- `[必须]` 导入 1 万行要在一个事务里批量写,不要一行一个事务。
- `[建议]` 导入时不要把整个文件读进内存,用 excelize 的流式行读取。
## 8. 发布门禁
| # | 门禁项 | 怎么验证 | 谁负责 |
|---|---|---|---|
| 1 | 依赖版本已固定 | `go.mod` / `go.sum` 已提交,`go mod verify` 通过 | 开发者 |
| 2 | `go vet ./...` 无告警 | | 开发者 |
| 3 | `go test ./...` 全绿 | 不允许有跳过而未说明的用例 | 开发者 |
| 4 | 四个页面能正常打开 | 起服务跑一遍 | 开发者 |
| 5 | 样本导入条数正确 | 导 `raw_data/蝦皮数据样本.xlsx`,应得 5195 商品 + 6092 SKU | 开发者 |
| 6 | 数据库从上一版本迁移成功 | 拿旧 `admin.db` 副本启动新版本,人工数据不丢 | 开发者 |
| 7 | 契约测试通过 | [04 §9 清单](04-client-api.md)逐条 | 开发者 |
| 8 | 干净环境启动 | 没装过本项目的机器上 `go run .` 或跑 exe | 开发者 |
| 9 | 日志和页面无敏感信息 | 翻一遍 `data/logs/` | 开发者 |
| 10 | 开源许可证已确认 | 新增依赖的许可证 | **项目负责人** |
第 10 项开发者**不要自己判断放行**,把包名和许可证类型报给项目负责人。
## 9. 打包
`[待定]` 打包动作属于 MVP 之后,但策略现在定下来:
- `go build` 直接出**单个 exe**,不需要 C 编译器
(这就是选 `modernc.org/sqlite` 的原因);
- 模板和静态文件用 `//go:embed` 打进 exe,**不要散在外面**,
否则用户可能改坏或删掉;
- `data/` 留在 exe 旁边,**升级时保留**,见 [02 架构](02-architecture.md) §6;
- 升级方式:换掉 exe,`data/` 不动,靠 `PRAGMA user_version` 自动迁移。
产出目录:
```text
CMAutoBuyAdmin/
├── admin.exe 单文件,模板和静态资源都在里面
└── data/ 数据,升级时保留
├── admin.db
├── logs/
└── uploads/
```
比 Client 简单——Go 没有 Python 那种依赖收集问题,不需要 `app/` 目录。
## 10. 任务完成定义
单元任务同时满足下面几条才算完成:
1. 工单验收标准逐项通过;
2. 代码、迁移、测试和必要文档同步完成;
3. 没有静默跳过的测试;
4. 变更不泄露敏感信息;
5. Gitea 工单更新最终结果和提交哈希;
6. 完成记录归档到 `docs/task`。