update backend

This commit is contained in:
toom1996
2026-09-20 01:06:13 +08:00
parent f7ae917603
commit b99dc3dca1
115 changed files with 24732 additions and 10842 deletions

340
README.md
View File

@ -1,224 +1,160 @@
# Fashion API
秀场 / 品牌档案对外只读 API 服务(Go + Gin + GORM/PostgreSQL)。
秀场 / 街拍 / 品牌档案后端服务(Go + Gin + GORM / PostgreSQL + pgvector)。
本项目是从原 `admin/backend`(后台管理接口)中**剥离对外只读接口**后独立出的服务,
专门服务于前端项目 `test/test`(`D:\project\test\test`)。它只保留前端实际调用的公开接口,
并按 Gin 推荐的分层结构重写,配置统一收敛到单个 yml 文件。
一个服务同时承载三块职责,通过**三个物理隔离的端口**对外提供:
> 原 `admin/backend` 目录保持不变,本目录是全新独立工程 `module fashionapi`。
| 端口 | 用途 | 暴露面 |
| --- | --- | --- |
| `8090` | 对外公开 API(走秀 / 街拍 / 品牌 / 账号) | 公网(nginx 反代) |
| `8091` | SSG 构建期内部接口(全量 / 热门数据) | 仅 `127.0.0.1`(回环)+ 可选 token |
| `8092` | 管理后台(后台 UI + 爬虫 ingest 上报) | 内网,独立绑定 / 限流 |
---
## 1. 迁移范围
## 1. 架构分层
仅迁移 `test/test` 前端真实调用的接口(经源码 `src/lib/api.ts`、`src/lib/auth.ts`、`src/lib/data.ts`、
`src/pages/*.astro` 全量扫描确认):
| 接口 | 方法 | 前端调用位置 | 说明 |
| --- | --- | --- | --- |
| `/api/health` | GET | — | 健康检查 |
| `/api/public/articles` | GET | `data.ts`、`articles.astro`、`index.astro` | 文章列表(分页 + 搜索 + 筛选 + 附图) |
| `/api/public/articles/:id` | GET | `data.ts`、`article.astro` | 文章详情 |
| `/api/public/brands` | GET | `brands.astro`、`data.ts` | 品牌列表(A-Z 索引 + featured 精选 + 搜索) |
| `/api/auth/register` | POST | `auth.ts` | 注册(注册成功直接签发 token) |
| `/api/auth/login` | POST | `auth.ts` | 登录(请求体 `account` 可为邮箱或用户名) |
| `/api/auth/me` | GET | `auth.ts` | 当前用户(需 `Authorization: Bearer <token>`) |
| `/api/auth/logout` | POST | `auth.ts` | 登出(无状态 JWT,服务端直接返回 ok) |
| `/uploads/*` | GET/HEAD | 静态资源 | 上传图片静态文件服务 |
**已确认未使用、未迁移的接口**(原 `admin/backend` 有,但 `test/test` 前端不曾调用):
- `/api/public/brands/letters`(A-Z 字母计数)—— 前端直接走 `/api/public/brands` 列表,未用该聚合端点。
- `/api/public/brands/featured`(独立精选集合端点)—— 前端改用 `/api/public/brands?featured=1` 列表参数,
该参数已在 `/api/public/brands` 中实现,因此无需独立端点。
- `/api/public/colors` —— 原后端**本就没有**此接口;前端 `data.ts` 探测它失败后会从封面图派生占位色,属预期降级,无需实现。
- `/api/runways`、`/api/brands` 等后台管理接口 —— 属 admin 后台,不在前端消费范围内。
---
## 2. 架构选型说明(满足“符合框架推荐写法”要求)
原 `admin/backend` 把所有 handler 平铺在一个 `handlers` 包里,直接读写全局单例 `config.DB`,
路由在 `main.go` 中手工注册。对纯 JSON API 而言存在两个问题:
1. **“MVC” 在 JSON API 下是伪命题**:传统 MVC 的 View 层负责渲染 HTML,而本项目只输出 JSON,
没有模板/视图。若强行套 MVC(`models/`、`views/`、`controllers/`),`views/` 会空置,
controller 又夹带了本应属于 service 的业务逻辑。Gin 官方示例与社区主流实践也不推荐在纯 API 中套 MVC。
2. **全局单例导致不可测**:`config.DB` 包级全局变量使 handler 无法在无真实数据库时单元测试,
且 GORM v2 复用 `*gorm.DB` 会残留 `Statement` 状态(需用 Scope 规避)。
因此本项目采用 **分层架构(Layered / 主动式 Repository)**,这也是 Gin 生态中最贴合“可维护、可测试”目标的写法:
采用 **Layered / 主动式 Repository** 架构,`cmd/` 是唯一依赖装配点(组合根),业务代码全部在 `internal/`,不对外暴露。
```
cmd/ 组合根(Composition Root):只做依赖装配,不含业务逻辑
server/ 启动 HTTP 服务
dbtool/ 跨环境数据搬运(dump/import)
internal/ 业务代码(不对外暴露)
config/ 配置加载(yml + 环境变量覆盖 + 默认值)
cmd/ 组合根:只做依赖装配,不含业务逻辑
server/ 启动 HTTP 服务(三端口)+ 入库 worker + 优雅关闭
dbtool/ 跨环境数据搬运(dump / import)
dbdiag/ 数据库诊断
internal/
config/ 配置加载(yml + 环境变量覆盖 + 默认值兜底)
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
database/ PostgreSQL 连接池 / 自动迁移(AutoMigrate)
database/ PostgreSQL 连接池 / 自动迁移 / 去重 schema
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
service/ 业务逻辑(JWT 签发、密码哈希、参数归一化、聚合)
dto/ 请求/响应结构体与边界常量
service/ 业务逻辑(JWT 签发、图片治理、去重、审核、入库管线)
dto/ 请求 / 响应结构体与边界常量
handler/ HTTP 层(仅做参数解析与响应封装)
middleware/ 鉴权 / CORS
router/ 路由注册
pkg/ 可复用工具(response / textutil / jwt)
middleware/ 鉴权 / CORS / 前端签名 / ingest 验签 / SSG token
router/ 路由注册(公开 / SSG / 后台三个引擎)
pkg/ 可复用工具(hashid / jwt / phash / imgurl / storage / ...)
```
**关键改进**
**核心约束**:
- **依赖注入取代全局单例**:`*gorm.DB`、`*jwt.Manager` 全部通过构造函数显式注入,
消除 `config.DB` 全局变量,handler/service 可单独单测。
- **GORM Scope 规避 Statement 复用**:所有查询条件封装为 `func(*gorm.DB) *gorm.DB` Scope,
避免原项目“复用同一个 `*gorm.DB` 实例导致 WHERE 串味”的隐患。
- **依赖注入取代全局单例**:`*gorm.DB`、`*jwt.Manager` 全部通过构造函数显式注入,handler / service 可单独单测。
- **GORM Scope 规避 Statement 复用**:查询条件封装为 `func(*gorm.DB) *gorm.DB`,避免复用同一实例导致 WHERE 串味。
- **排序字段白名单**:`orderBy` 只允许白名单内的值,杜绝 SQL 注入。
- **配置统一收敛到 yml**:见第 3 节。
- **表结构由 GORM AutoMigrate 托管**:服务启动时幂等创建/更新全部表(替代原 `scripts/sql`、`db/migrations` 下的手写 MySQL 迁移脚本),
跨环境数据搬运经 `cmd/dbtool`(dump/import)。如需避免大表启动期加列,可在部署流程中先行 `dbtool import` 建好结构。
- **修复原项目配置隐患**:原 `admin/backend` 代码只读 `DB_PASS`,而 docker-compose 注入的是
`DB_PASSWORD`,二者不一致;本项目同时兼容 `DB_PASSWORD` 与 `DB_PASS`,`DB_PASSWORD` 优先。
**接口功能一致性**:响应字段、分页元结构(`data/total/current_page/last_page/per_page`)、
默认页大小(文章 12 / 上限 100、品牌 200 / 上限 500)、摘要截断字符(`…` U+2026)、
软删除过滤(`is_deleted = 0`)、登录字段名(`account`)、鉴权中间件行为均与原后端逐一核对一致。
- **对外只暴露编码 ID**:自增主键经 HashID(Feistel 混淆)编码后才出参,防爬虫顺序枚举。
---
## 3. 配置管理(满足“统一使用 yml 文件”要求)
## 2. 接口清单
### 2.1 对外公开 API(8090,前缀 `/api/v1`)
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/api/health` | GET | 健康检查(不随版本演进) |
| `/api/v1/public/runway-looks` | GET | 走秀列表(分页 / 筛选 / 排序,每篇附带前 6 张缩略图) |
| `/api/v1/public/runway-looks/:id` | GET | 走秀详情(含完整图片集,未登录仅预览前 5 张) |
| `/api/v1/public/street-snaps` | GET | 街拍列表 |
| `/api/v1/public/street-snaps/:id` | GET | 街拍详情 |
| `/api/v1/public/brands` | GET | 品牌列表(A-Z 索引 / 搜索 / featured 精选) |
| `/api/v1/auth/login` | POST | 登录(`account` 可为用户名或邮箱,签发双令牌) |
| `/api/v1/auth/refresh` | POST | 用 refresh token 换新 access token |
| `/api/v1/auth/sessions/current` | DELETE | 单设备登出(吊销当前 refresh) |
| `/api/v1/auth/sessions` | DELETE | 全设备登出 / 踢下线 |
| `/api/v1/me` | GET | 当前用户(需 Bearer) |
| `/api/v1/me/favorites` | GET / POST / DELETE | 收藏列表 / 新增 / 删除 |
| `/api/v1/me/favorites/checks` | POST | 批量校验哪些 id 已收藏(与收藏总量解耦) |
| `/api/v1/me/history` | GET / POST / DELETE | 浏览历史列表 / 记录 / 清空 |
| `/api/v1/me/history/:uid` | DELETE | 删除单条历史 |
> 列表接口「首页公开、翻页需登录」:`page=1`(或缺省)无需 token,`page>1` 要求 Bearer,防爬虫全量 dump。详情接口对未登录请求截断到 5 张并打 `preview=true`。
> 旧路径(`/api/v1/auth/me`、`/api/v1/auth/favorites*`、`/api/v1/auth/logout` 等)保留为兼容别名,待旧前端下线后删除。
### 2.2 SSG 内部接口(8091,前缀 `/api/internal/ssg`)
仅供 astro 构建期调用,回环绑定 + 可选 `SSG_TOKEN` 双重防护:
| 接口 | 说明 |
| --- | --- |
| `/api/internal/ssg/home/runway` | 首页 runway 区块 |
| `/api/internal/ssg/brands/popular` | 热门品牌(前 30) |
| `/api/internal/ssg/street-snaps/popular` | 热门街拍(按图片数前 20) |
### 2.3 管理后台(8092)
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
- `/admin/*`:品牌 / 走秀 / 街拍管理、草稿审核(通过晋升正式表 / 驳回)、入库任务监控与重试、用户管理(提级 VIP)。
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
---
## 3. 关键设计
- **双令牌认证**:access token 短命无状态(JWT)+ refresh token 长命落库(可吊销、可踢下线)。refresh 只存 SHA256 哈希。
- **HashID 混淆**:32-bit 平衡 Feistel 网络 + 类型化子密钥,相邻 id 编码无规律,且同数字主键在不同类型下编码完全不同。
- **图片治理**:
- 库里只存对象 key(`runway/<sha1>.jpg`)或本地相对路径,完整 URL 由 `imgurl.Composer` 渲染时拼装(换域名只改配置)。
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、写草稿表;后台人工审核通过后晋升正式表。失败按指数退避重试,单图失败整任务回滚。
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
---
## 4. 配置
唯一配置源:`configs/config.yml`。加载优先级 **环境变量 > yml > 代码内置默认值**。
- 本地开发:直接改 `configs/config.yml`,无需任何环境变量。
- 容器 / 生产部署:保留 yml 为默认值,用环境变量覆盖敏感项(见 yml 中每项 `env:` 注释)。
**支持的环境变量**
| 变量 | 覆盖项 |
| 环境变量 | 覆盖项 |
| --- | --- |
| `SERVER_PORT` | server.port |
| `GIN_MODE` | server.mode |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_NAME` | 数据库连接 |
| `DB_PASSWORD`(或 `DB_PASS`) | database.password |
| `DB_LOG_LEVEL` | GORM 日志级别 |
| `JWT_SECRET` / `JWT_EXPIRE_HOURS` | 令牌密钥 / 有效期 |
| `UPLOAD_DIR` / `UPLOAD_URL_PREFIX` | 静态资源目录 / 前缀 |
| `CONFIG_PATH` | 显式指定配置文件路径(优先级高于 `-config` 参数) |
| `SERVER_PORT` / `GIN_MODE` | 公开端口 / 运行模式 |
| `SSG_PORT` / `SSG_TOKEN` | SSG 内部端口 / 访问令牌 |
| `BACKSTAGE_PORT` | 后台端口 |
| `HASHID_SECRET` | 公开 ID 混淆盐值(生产必填) |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 数据库连接 |
| `JWT_SECRET` / `JWT_EXPIRE_HOURS` / `JWT_REFRESH_EXPIRE_HOURS` | 令牌密钥 / 有效期 |
| `CLIENT_SIGN_ENABLED` / `CLIENT_SIGN_SECRET` / `CLIENT_SIGN_TTL` | 公开接口前端签名 |
| `UPLOAD_DIR` / `UPLOAD_URL_PREFIX` | 本地静态资源目录 / 前缀 |
| `INGEST_SECRET` / `INGEST_TTL` | 爬虫上报 HMAC 密钥 / 时间戳容忍窗口 |
| `S4_ENABLED` / `S4_AK` / `S4_SK` / `S4_BUCKET` / `S4_ENDPOINT` / `S4_BASE_URL` / `S4_STYLE_DISPLAY` / `S4_STYLE_THUMB` | 缤纷云 S4 对象存储 |
**路径查找顺序**:`-config` 参数 → `CONFIG_PATH` 环境变量 → `./configs/config.yml` → `./config.yml`
→ `../../configs/config.yml`。找不到配置文件不报错,直接使用默认值 + 环境变量,便于纯环境变量部署。
> 生产环境务必通过 `JWT_SECRET` 覆盖默认密钥,并通过 `DB_*` 覆盖数据库口令。
> 生产环境务必通过环境变量覆盖 `JWT_SECRET`、`HASHID_SECRET`、`INGEST_SECRET`、`S4_AK/SK` 与数据库口令,切勿在 yml 中提交真实密钥。
---
## 4. 运行
## 5. 运行
### 前置要求
- Go 1.26+
- PostgreSQL 16(+ pgvector,见 `scripts/pgvector` 的 Docker 一键起库):`127.0.0.1:5432`,库名 `fashion`,账号 `fashion/fashion_dev_2026`
- PostgreSQL 16(含 pgvector 扩展,见 `scripts/pgvector` 的 Docker 一键起库)
### 构建
### 构建与启动
```bash
# 主服务
go build -o bin/server ./cmd/server
go build -o bin/server ./cmd/server
# 跨环境数据搬运工具(dump / import)
go build -o bin/dbtool ./cmd/dbtool
```
go build -o bin/dbtool ./cmd/dbtool
### 数据库迁移(结构自动创建)
表结构**随服务启动自动执行** GORM AutoMigrate(幂等,仅增量变更,不重建已有表)。
跨环境搬运全库数据(结构 + 数据)用 dbtool:
```bash
go run ./cmd/dbtool dump -out db_dump.json # 导出当前库
go run ./cmd/dbtool import -in db_dump.json # 在目标环境重建并回灌
go run ./cmd/dbtool import -in db_dump.json -data-only # 仅导数据(结构已由 AutoMigrate 建好)
```
### 启动服务
```bash
./bin/server # 读取 configs/config.yml,监听 :8090
# 启动(默认读取 configs/config.yml)
./bin/server
./bin/server -config /path/to/config.yml
```
启动后访问 `http://localhost:8090/api/health` 应返回 `{"status":"ok"}`。
启动后:公开服务 `http://localhost:8090/api/health` 应返回 `{"status":"ok"}`。
### 数据库
表结构随服务启动由 GORM AutoMigrate 幂等创建(含 pgvector 扩展、去重索引与语义 embedding 表)。跨环境搬运全库数据用 dbtool:
```bash
go run ./cmd/dbtool dump -out db_dump.json # 导出当前库
go run ./cmd/dbtool import -in db_dump.json # 在目标环境重建并回灌
```
### 优雅关闭
服务监听 `SIGINT` / `SIGTERM`,收到信号后等待在途请求完成(最多 `shutdown_timeout` 秒)再退出。
---
## 5. 接口详细
### 5.1 健康检查
`GET /api/health` → `200 {"status":"ok"}`
### 5.2 文章列表
`GET /api/public/articles`
| 参数 | 说明 | 默认 / 上限 |
| --- | --- | --- |
| `page` | 页码 | 1 |
| `size` | 每页条数 | 12 / 100 |
| `keyword` | 标题模糊搜索 | — |
| `brand_id` | 单个品牌 id | — |
| `brand_ids` | 多品牌 id,逗号分隔 | — |
| `collection_type` | 多选:rtw/menswear/couture/resort/pre_fall | — |
| `season` | 多选:spring/fall | — |
| `season_code` | 精确匹配:SS26/FW25… | — |
| `year` | 多选年份 | — |
| `sort` | newest / year_desc / year_asc / image_count | newest |
| `with_images` | 每篇附带前 N 张图(避免逐篇拉详情) | 0 / 12 |
响应:`{ data:[...], total, current_page, last_page, per_page }`。
列表项字段:`id, brand_id, title, summary, cover, brand_name, year, image_count, collection_type, season, season_code, published_at, images`。
### 5.3 文章详情
`GET /api/public/articles/:id` → `200 { data:{...} }` / `404 {"error":"文章不存在"}`。
详情字段:`id, title, summary, description, cover, brand_name, year, image_count, published_at, images[]`。
### 5.4 品牌列表
`GET /api/public/brands`
| 参数 | 说明 | 默认 / 上限 |
| --- | --- | --- |
| `letter` | A-Z 字母索引;`OTHER` 中文分桶;缺省=全部拉丁字母 | — |
| `keyword` | 品牌名 / 展示名模糊搜索 | — |
| `page` / `size` | 分页 | 1 / 200 / 500 |
| `only_with_articles` | 只返回有走秀档案的品牌(默认 1) | 1 |
| `featured` | 传 1 时只返回“代表品牌”精选集合(按指标排名前 N) | 0 |
| `metric` | 精选排名指标:images(默认)/ shows | images |
| `limit` | 精选集合大小 | 200 / 500 |
响应:`{ data:[{id,name,show_name,article_count}], total, current_page, last_page, per_page }`。
`featured=1` 且取不到集合时返回空结果(不会退化成全部品牌,避免前端索引膨胀)。
### 5.5 账号体系
| 接口 | 方法 | 请求 | 成功响应 |
| --- | --- | --- | --- |
| 注册 | `POST /api/auth/register` | `{username,email,password(>=6)}` | `201 {token,user}` |
| 登录 | `POST /api/auth/login` | `{account,password}`(account=邮箱或用户名) | `200 {token,user}` |
| 当前用户 | `GET /api/auth/me` | Header `Authorization: Bearer <token>` | `200 {user}`;无 token → `401` |
| 登出 | `POST /api/auth/logout` | — | `200 {ok:true}` |
`user` 结构:`{id, username, email}`(绝不含密码哈希)。无状态 JWT,token 有效期 7 天。
### 5.6 静态资源
`GET /uploads/*filepath` → 返回 `uploads/` 目录下文件。封面/图片在数据库中以相对路径
(如 `/uploads/2026/08/11/xxx.jpg`)存储,前端拼接前缀即得访问地址。
监听 `SIGINT` / `SIGTERM`,等待在途请求完成(最长 `shutdown_timeout` 秒)后退出,并通知入库 worker 停止领取新任务。
---
@ -226,49 +162,25 @@ go run ./cmd/dbtool import -in db_dump.json -data-only # 仅导数据(结
```
backend/
├── configs/
│ └── config.yml # 唯一配置源
├── configs/config.yml # 唯一配置源
├── cmd/
│ ├── server/main.go # 组合根:启动 HTTP 服务 + 优雅关闭
│ └── dbtool/main.go # 组合根:跨环境数据搬运(dump/import)
│ ├── server/ # 组合根:三端口 HTTP + 入库 worker
│ ├── dbtool/ # 跨环境数据搬运
│ └── dbdiag/ # 数据库诊断
├── internal/
│ ├── config/ # 配置加载(yml + env + 默认值)
│ ├── model/ # 实体(brand / runway / runway_image / user)
│ ├── database/ # PostgreSQL 连接池 + 自动迁移
│ ├── repository/ # 数据访问(接口 + GORM 实现)
│ ├── config/ # 配置加载
│ ├── model/ # 实体(brand / runway / street_snap / user / 草稿 / ingest)
│ ├── database/ # 连接池 + AutoMigrate + 去重 schema
│ ├── repository/ # 数据访问接口 + GORM 实现
│ ├── service/ # 业务逻辑
│ ├── dto/ # 请求/响应结构体与边界常量
│ ├── dto/ # 请求 / 响应结构体
│ ├── handler/ # HTTP 层
│ ├── middleware/ # 鉴权 / CORS
│ ├── router/ # 路由注册
│ └── pkg/ # response / textutil / jwt 工具
├── uploads/ # 上传图片(运行时生成,gitignore)
│ ├── middleware/ # 鉴权 / CORS / 签名 / 验签
│ ├── router/ # 公开 / SSG / 后台三引擎路由
│ └── pkg/ # hashid / jwt / phash / imgurl / storage / ...
├── scripts/ # pgvector 起库脚本等
├── go.mod / go.sum
├── Dockerfile
├── Makefile
└── README.md
```
---
## 7. 容器化(参考)
`Dockerfile` 为多阶段构建:构建阶段编译 `server` / `migrate`,运行阶段基于 `alpine` 仅携带二进制、
`ca-certificates` 与 `configs/`。容器内需用环境变量覆盖数据库与 JWT 密钥:
```bash
docker build -t fashionapi .
docker run -d -p 8090:8090 \
-e DB_HOST=db -e DB_NAME=fashionadmin \
-e JWT_SECRET=<随机长字符串> \
-v $(pwd)/uploads:/app/uploads \
fashionapi
```
---
## 8. 后续可扩展
- 若需后台管理接口(`/api/runways` 等),可沿用本分层结构在 `internal/` 内新增模块,
并在 `cmd/server` 组合根装配,不影响现有公开接口。
- 若前端真正需要 `/api/public/colors`,在 `repository`/`service`/`handler` 各加一层并在 router 注册即可。