From 90379347b45a15fc7cf91186b700466ded5e93ef Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 6 Aug 2026 15:49:04 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=BB=BA=E7=AB=8B=20Admin=20=E5=AD=90?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=9A=84=E6=96=87=E6=A1=A3=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓库从单子项目变成两个: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 --- .gitignore | 11 +- AGENTS.md | 40 +++- admin/AGENTS.md | 99 +++++++++ admin/go.mod | 12 ++ docs/README.md | 86 ++++++-- docs/admin/00-getting-started.md | 149 ++++++++++++++ docs/admin/00-glossary.md | 64 ++++++ docs/admin/01-requirements.md | 310 ++++++++++++++++++++++++++++ docs/admin/02-architecture.md | 230 +++++++++++++++++++++ docs/admin/03-data-model.md | 332 ++++++++++++++++++++++++++++++ docs/admin/04-client-api.md | 237 +++++++++++++++++++++ docs/admin/05-ui-specification.md | 233 +++++++++++++++++++++ docs/admin/06-quality-security.md | 189 +++++++++++++++++ 13 files changed, 1963 insertions(+), 29 deletions(-) create mode 100644 admin/AGENTS.md create mode 100644 admin/go.mod create mode 100644 docs/admin/00-getting-started.md create mode 100644 docs/admin/00-glossary.md create mode 100644 docs/admin/01-requirements.md create mode 100644 docs/admin/02-architecture.md create mode 100644 docs/admin/03-data-model.md create mode 100644 docs/admin/04-client-api.md create mode 100644 docs/admin/05-ui-specification.md create mode 100644 docs/admin/06-quality-security.md diff --git a/.gitignore b/.gitignore index 47c63f2..16bf8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,19 @@ -# 本地数据:数据库、日志、截图、控件树 XML -# 位置和用途见 docs/client/03-data-model.md §2.1 +# 本地数据:数据库、日志、截图、控件树 XML、上传的 Excel +# 位置和用途见 docs/client/03-data-model.md §2.1 和 docs/admin/02-architecture.md §6 client/data/ +admin/data/ data/ # 打包产物 build/ dist/ *.spec +admin/admin.exe +admin/admin + +# Excel 打开时生成的锁文件 +~$*.xlsx +~$*.xls # Python __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 8918a4a..526546c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,17 @@ 改动看起来再合理,只要碰到上面任意一条,先停下来告诉用户。 -> **动 `client/` 下任何东西之前,先读 [client/AGENTS.md](client/AGENTS.md)。** -> 那里有技术栈、代码分层、线程、采购安全和界面的硬性规则,本文件不重复。 -> **本项目的代码目前全部在 `client/` 下**,所以基本上只要是写代码,就必须先读它。 +> **动代码之前,先读对应子项目的 AGENTS.md**,那里有技术栈、分层和硬性规则,本文件不重复: > -> 第一次接手本项目,还要先看: -> [上手指南](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 控件树;涉及这些概念时给出一句话背景。 -## 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)。 + +改动接口时,两边的文档必须在同一个工单里同步更新,不允许只改一边。 diff --git a/admin/AGENTS.md b/admin/AGENTS.md new file mode 100644 index 0000000..14d051e --- /dev/null +++ b/admin/AGENTS.md @@ -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/` 下的样本跑一遍,核对导入条数。 +- 交付时说明已运行的命令、结果和未验证的部分。 diff --git a/admin/go.mod b/admin/go.mod new file mode 100644 index 0000000..f9dc677 --- /dev/null +++ b/admin/go.mod @@ -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 都会变麻烦)。 diff --git a/docs/README.md b/docs/README.md index b8362c0..4b356a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | 装环境、装依赖、连手机、把程序跑起来、常见报错 | -| [00 术语表](client/00-glossary.md) | Outbox、幂等、SKU 等专业词的一句话解释 | +| 上手 Client | [Client 上手指南](client/00-getting-started.md) + [Client 术语表](client/00-glossary.md) | +| 上手 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) | — | @@ -24,20 +40,52 @@ | 写测试 | [06 质量与安全](client/06-quality-security.md) §2 | — | | 搞不清这功能到底要不要做 | [01 产品需求基线](client/01-requirements.md) | — | | 打包成 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 基线文档 | 文档 | 用途 | |---|---| -| [01 产品需求基线](client/01-requirements.md) | 定义目标、范围、业务流程和验收边界 | -| [02 系统架构](client/02-architecture.md) | 定义模块、依赖、线程模型和故障恢复原则 | -| [03 数据模型](client/03-data-model.md) | 定义 SQLite 表、状态和 `pdd_data` JSON 结构 | -| [04 Admin 接口契约](client/04-admin-api-contract.md) | 定义 Client 与 Admin 的版本化接口边界 | -| [05 界面交互规范](client/05-ui-specification.md) | 定义 PDD 任务页、设置页和表格交互 | -| [06 质量、安全与测试](client/06-quality-security.md) | 定义测试策略、采购安全、日志和发布门禁 | +| [00 上手指南](client/00-getting-started.md) | 装环境、连手机、跑起来、常见报错 | +| [00 术语表](client/00-glossary.md) | Outbox、幂等、不可逆阶段等 | +| [01 产品需求基线](client/01-requirements.md) | 目标、范围、任务类型、打包策略 | +| [02 系统架构](client/02-architecture.md) | 分层、线程模型、Worker 模板 | +| [03 数据模型](client/03-data-model.md) | SQLite 表、状态机、`pdd_data` 结构 | +| [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 工单为事实来源。 - 单元任务完成后,按 `AGENTS.md` 归档到 `docs/task/<工单号>-<简短名称>.md`。 - 基线发生实质变化时,必须先更新对应 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 尚未开始编码;顺运宝同步方式、认证方式和部分字段仍需确认, +这些事项已在对应文档中标注为 `[待定]`,并给出了临时默认值。 diff --git a/docs/admin/00-getting-started.md b/docs/admin/00-getting-started.md new file mode 100644 index 0000000..4ba0577 --- /dev/null +++ b/docs/admin/00-getting-started.md @@ -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 . +``` + +然后浏览器打开 。 + +**这个命令是安全的**: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)(技术栈和红线)。 diff --git a/docs/admin/00-glossary.md b/docs/admin/00-glossary.md new file mode 100644 index 0000000..6e3f5a8 --- /dev/null +++ b/docs/admin/00-glossary.md @@ -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。 diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md new file mode 100644 index 0000000..985c93b --- /dev/null +++ b/docs/admin/01-requirements.md @@ -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**。 diff --git a/docs/admin/02-architecture.md b/docs/admin/02-architecture.md new file mode 100644 index 0000000..b46edb7 --- /dev/null +++ b/docs/admin/02-architecture.md @@ -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)(对接时以它为准) diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md new file mode 100644 index 0000000..3eb7a95 --- /dev/null +++ b/docs/admin/03-data-model.md @@ -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 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。 diff --git a/docs/admin/04-client-api.md b/docs/admin/04-client-api.md new file mode 100644 index 0000000..f392d57 --- /dev/null +++ b/docs/admin/04-client-api.md @@ -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: ::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: ::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 ` 请求头但**不校验**, +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 不进日志 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md new file mode 100644 index 0000000..a2521c7 --- /dev/null +++ b/docs/admin/05-ui-specification.md @@ -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. 可访问性与细节 + +- `[必须]` 表单控件有可见 `