231 lines
8.7 KiB
Markdown
231 lines
8.7 KiB
Markdown
# 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)(对接时以它为准)
|