Files
cmautobuy/docs/admin/02-architecture.md
T

265 lines
10 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,接口要认证。
混在一起迟早写错。
- `[必须]` Web 登录中间件只挂在 `handler/web` 路由组,不能挂到整个 Gin Engine。
`/api/v1/client/*` 本阶段保持原有接口行为,不能返回登录页或 302 重定向。
### 2.1 登录与路由边界
```text
公开网页
├── GET/POST /setup 仅数据库没有用户时可用
├── GET/POST /login
└── /static/*
需要登录的网页
├── /shopee /pdd /syb /tasks /clients
├── POST /logout
└── /users 仅管理员
保持原样的 Client API
└── /api/v1/client/*
```
- `Session` 是服务器保存的网页登录状态,浏览器只持有随机 Cookie。
- 没有任何用户时,业务网页跳转到 `/setup`;初始化完成后 `/setup` 的服务端处理永久拒绝再次创建管理员。
- 有用户但未登录时,业务网页跳转到 `/login`;API 继续返回原有 JSON 或 `204`。
- Handler 从中间件写入的当前用户读取身份,不信任表单里的 `user_id` 或 `role`。
- 采购员可以使用现有业务页面,但访问 `/users` 必须返回 `403`。
## 3. 目录
```text
admin/
├── main.go 启动、路由注册
├── go.mod
├── config/ 端口、路径、超时
├── handler/
│ ├── web/ 五个模块的页面
│ │ ├── auth.go 初始化、登录、退出
│ │ ├── user.go 管理员维护采购员
│ │ ├── 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 连接、迁移
│ ├── user.go 用户和 Web Session
│ ├── shopee.go
│ ├── syb.go
│ ├── task.go
│ └── client.go
├── model/ 纯结构体
├── templates/ 见 §4
├── static/
│ ├── css/
│ └── js/
├── testdata/ 测试用的小样本
└── data/ 运行时数据,见 §6(不进 Git)
```
`[建议]` 一个模块一个文件,文件名和模块对应。初级程序员找代码时不用猜。
## 4. 模板组织
```text
templates/
├── layout.html 整体骨架:导航 + 内容占位 + 状态条
├── auth/
│ ├── setup.html 首次管理员初始化
│ └── login.html
├── user/list.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 **无条件接受**,哪怕任务已取消或已重派。
- Admin 用户登录与 Client 身份是两套边界。Web Session 不传给 Client,
Web 登录中间件也不覆盖 `/api/v1/client/*`。
- 详见 [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)(对接时以它为准)