Files
cmautobuy/docs/admin/02-architecture.md
T
chengmaandClaude Opus 5 90379347b4 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>
2026-08-06 15:49:04 +08:00

231 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)(对接时以它为准)