154 lines
7.1 KiB
Go
154 lines
7.1 KiB
Go
// Package router 负责路由注册。
|
||
//
|
||
// 所有 URL 的唯一定义处 —— 想知道服务暴露了哪些接口,只看这个文件即可。
|
||
//
|
||
// 命名约定(2026-09-04 收敛):
|
||
// 1. 路径只允许名词(kebab-case、资源复数化),动作以「名词化子资源」表达
|
||
// (如 POST /me/favorites/checks 而非 /me/favorites/check)。
|
||
// 2. 版本前缀统一 /api/v1;健康检查不随版本演进,挂 /api/health。
|
||
// 3. /auth/* 只放「认证动作」(login/refresh/session);「我的私有资源」统一 /me/*。
|
||
// 4. 路径参数对外只讲 uid(:uid),不暴露内部字段名(旧 :target_uid 已废弃)。
|
||
// 5. SSG 构建接口属内部契约,挂 /api/internal/ssg,不复用公开 /api/v1 前缀。
|
||
// 6. 所有改名均保留旧路径别名(deprecated),便于线上旧前端平滑过渡;确认无调用后可整段删除。
|
||
package router
|
||
|
||
import (
|
||
"fashionapi/internal/config"
|
||
"fashionapi/internal/handler"
|
||
"fashionapi/internal/middleware"
|
||
"fashionapi/internal/pkg/jwt"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// Options 路由注册所需的全部依赖,由 main 装配后传入。
|
||
type Options struct {
|
||
Config *config.Config
|
||
JWT *jwt.Manager
|
||
Article *handler.ArticleHandler
|
||
Brand *handler.BrandHandler
|
||
StreetSnap *handler.StreetSnapHandler
|
||
Auth *handler.AuthHandler
|
||
Favorite *handler.FavoriteHandler
|
||
History *handler.HistoryHandler
|
||
Health *handler.HealthHandler
|
||
SSG *handler.SSGHandler
|
||
}
|
||
|
||
// New 构建 Gin 引擎并注册全部对外路由。
|
||
//
|
||
// 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上,
|
||
// 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。
|
||
func New(opt Options) *gin.Engine {
|
||
r := gin.Default()
|
||
r.Use(middleware.CORS(opt.Config.CORS))
|
||
|
||
// 上传图片的静态服务。
|
||
// 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg),
|
||
// 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。
|
||
r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir)
|
||
|
||
// 健康检查:不随 API 版本演进,容器 / 反向代理探活固定打这个地址。
|
||
r.GET("/api/health", opt.Health.Check)
|
||
|
||
api := r.Group("/api/v1")
|
||
{
|
||
// deprecated: 旧版探活路径,保留兼容
|
||
api.GET("/health", opt.Health.Check)
|
||
|
||
// 对外公开接口:只读查询,不暴露任何后台管理字段。
|
||
// public = 无需 Bearer 即可访问;是否需要登录/签名由各子组决定。
|
||
public := api.Group("/public")
|
||
{
|
||
// ── 批量列表 ──────────────────────────────────────────────
|
||
// ClientSign 仅挂在「列表 / 品牌」两个批量查询接口上:
|
||
// 这俩是爬虫批量 dump 的主要目标,要求前端 JS 签名可抬高抓取成本。
|
||
// 列表接口「首页公开、翻页需登录」:page=1(或缺省)无需 token 直接放行,
|
||
// page>1 才要求 Authorization: Bearer <token>,否则 401。
|
||
// 这样既让游客/SEO 看到首屏,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。
|
||
list := public.Group("")
|
||
list.Use(middleware.ClientSign(opt.Config.ClientSign))
|
||
list.Use(middleware.PublicFirstPageAuth(opt.JWT))
|
||
{
|
||
// 走秀档案列表(按品牌过滤走 ?brand_id 查询参数)
|
||
list.GET("/runway-looks", opt.Article.List)
|
||
// 品牌列表(A-Z 索引 / 搜索)
|
||
list.GET("/brands", opt.Brand.List)
|
||
// 街拍列表(批量接口,受 ClientSign 保护,与 runway-looks/brands 同批)
|
||
list.GET("/street-snaps", opt.StreetSnap.List)
|
||
}
|
||
|
||
// ── 单条详情 ──────────────────────────────────────────────
|
||
// 详情被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数,
|
||
// 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。
|
||
// 挂载 PublicFirstPageAuth 仅用于「解析 Bearer 写入登录态」:游客(无 token)照常放行,
|
||
// 已登录用户则拿到完整图片集(未登录截断到 PreviewLimit 张)。
|
||
detail := public.Group("")
|
||
detail.Use(middleware.PublicFirstPageAuth(opt.JWT))
|
||
{
|
||
detail.GET("/runway-looks/:id", opt.Article.Detail)
|
||
detail.GET("/street-snaps/:id", opt.StreetSnap.Detail)
|
||
}
|
||
}
|
||
|
||
// 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。
|
||
//
|
||
// 登录/刷新/登出均为公开端点(无需 Bearer,否则拿不到 token 或刷新不了)。
|
||
// /me 需 Bearer(middleware.Auth);踢下线(DELETE /auth/sessions)由 refresh token 反查用户,亦公开。
|
||
auth := api.Group("/auth")
|
||
{
|
||
// 登录:公开端点,无需鉴权中间件(否则永远进不来)
|
||
auth.POST("/login", opt.Auth.Login)
|
||
// 续期:用 refresh token 换新的 access token
|
||
auth.POST("/refresh", opt.Auth.Refresh)
|
||
// 会话即 refresh token:吊销单设备 / 全部设备
|
||
auth.DELETE("/sessions/current", opt.Auth.Logout) // 单设备登出
|
||
auth.DELETE("/sessions", opt.Auth.LogoutAll) // 全设备登出 / 踢下线
|
||
|
||
// deprecated: 旧动词式登出路径,保留兼容(新语义见 DELETE /sessions*)
|
||
auth.POST("/logout", opt.Auth.Logout)
|
||
auth.POST("/logout-all", opt.Auth.LogoutAll)
|
||
// deprecated: 取当前用户请改用 GET /api/v1/me
|
||
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||
}
|
||
|
||
// 我的私有资源(需 Bearer):收藏 / 浏览历史。
|
||
// 与「认证动作 /auth/*」分开:auth 只管进出,资源归属 /me/*,便于以后扩展我的资料、我的设置。
|
||
// 当前用户:GET /api/v1/me
|
||
api.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
||
me := api.Group("/me")
|
||
me.Use(middleware.Auth(opt.JWT))
|
||
{
|
||
// 收藏(列表 / 新增 / 删除 / 批量校验)
|
||
me.GET("/favorites", opt.Favorite.List)
|
||
me.POST("/favorites", opt.Favorite.Add)
|
||
me.DELETE("/favorites/:uid", opt.Favorite.Remove)
|
||
// 批量校验:列表页把当前可见 id 发来,问哪些已收藏(与收藏总量解耦,10万也常数级)
|
||
me.POST("/favorites/checks", opt.Favorite.Check)
|
||
|
||
// 浏览历史(分页列表 / 记录 / 删除单条 / 清空)
|
||
me.GET("/history", opt.History.List)
|
||
me.POST("/history", opt.History.Record)
|
||
me.DELETE("/history/:uid", opt.History.Remove)
|
||
me.DELETE("/history", opt.History.Clear)
|
||
}
|
||
|
||
// deprecated: 旧「我的资源」路径(/auth/favorites*、/auth/history*),保留兼容旧前端。
|
||
// 参数名统一 :uid(旧 :target_uid 仅路径段占位,值不变)。
|
||
legacy := api.Group("/auth")
|
||
legacy.Use(middleware.Auth(opt.JWT))
|
||
{
|
||
legacy.GET("/favorites", opt.Favorite.List)
|
||
legacy.POST("/favorites", opt.Favorite.Add)
|
||
legacy.DELETE("/favorites/:uid", opt.Favorite.Remove)
|
||
|
||
legacy.GET("/history", opt.History.List)
|
||
legacy.POST("/history", opt.History.Record)
|
||
legacy.DELETE("/history/:uid", opt.History.Remove)
|
||
legacy.DELETE("/history", opt.History.Clear)
|
||
}
|
||
}
|
||
|
||
return r
|
||
}
|