Files
backend_v2/internal/router/router.go
toom1996 74ba700598 update
2026-09-07 00:01:48 +08:00

155 lines
7.2 KiB
Go
Raw Permalink 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.

// 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.POST("/favorites/check", opt.Favorite.Check)
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
}