# 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 MySQL 8 读写 │ │ 只有这一层能写 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/ │ ├── catalog_import.go 商品目录批次校验、幂等与 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/ ├── backup/ 迁移前 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/` | 打包后和程序文件混在一起,升级时会被删 | `[必须]` 升级时保留 `data/`,并先备份 MySQL。数据库结构通过 MySQL `schema_migrations` 只追加升级,见 [03](03-data-model.md) §2。 ## 7. 请求流程示例 以“第三方脚本导入商品目录”为例,看清各层职责: ```text 第三方脚本 POST /api/v1/integrations/catalog/batches(JSON) ↓ handler/integration/catalog_api.go - 独立 Bearer 鉴权 - 限制请求体并解析 JSON - 调 service.ImportCatalogBatch(...) ↓ service/catalog_import.go - 校验批次、商品、SKU、金额和关联 - 开单事务并处理幂等/冲突 - 调 repository 做 upsert - 返回各类新增、更新和关联计数 ↓ repository/catalog_import.go - 按来源观测时间更新 - 人工 pdd_goods_url / pdd_goods_id 和 is_manual 不动 ↓ handler 返回批次 JSON;管理员在统一导入记录页查看摘要 ``` `[必须]` 注意最后一步:**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)(对接时以它为准)