// Package router 负责路由注册。 // // 所有 URL 的唯一定义处 —— 想知道服务暴露了哪些接口,只看这个文件即可。 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/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.Group("/api/v1") { // 健康检查,供容器 / 反向代理探活 api.GET("/health", opt.Health.Check) // 对外公开接口:只读查询,不暴露任何后台管理字段 public := api.Group("/public") // ClientSign 仅挂在「列表 / 品牌」两个批量查询接口上: // 这俩是爬虫批量 dump 的主要目标,要求前端 JS 签名可抬高抓取成本。 public.Use(middleware.ClientSign(opt.Config.ClientSign)) // 列表接口「首页公开、翻页需登录」:page=1(或缺省)无需 token 直接放行, // page>1 才要求 Authorization: Bearer ,否则 401。 // 这样既让游客/SEO 看到首屏,又保留翻页 / 批量枚举的登录门槛(防爬虫 dump 全量)。 // 注意:详情接口 /api/v1/public/*/:id 不在此组内,保持公开——详情页走 SSR // 服务端渲染、无用户 token,且分享链接/SEO 需可访问;列表隐藏即可防批量枚举。 public.Use(middleware.PublicFirstPageAuth(opt.JWT)) { // 走秀档案列表(按品牌过滤走 ?brand_id 查询参数) public.GET("/runway-looks", opt.Article.List) // 品牌列表(A-Z 索引 / 搜索) public.GET("/brands", opt.Brand.List) // 街拍列表(批量接口,受 ClientSign 保护,与 runway-looks/brands 同批) public.GET("/street-snaps", opt.StreetSnap.List) } // 走秀详情:被 SSR 服务端按需渲染(getSsrArticle)直接 fetch 调用,属可信内部取数, // 不走前端 JS 签名,单独挂载以免破坏服务端渲染。单篇枚举防护由 HashID 承担。 api.GET("/public/runway-looks/:id", opt.Article.Detail) // 街拍详情:同走秀详情,单条枚举防护由 HashID 承担,不走前端签名。 api.GET("/public/street-snaps/:id", opt.StreetSnap.Detail) // 账号体系:双令牌(access 短命无状态 + refresh 落库可吊销)。 // // 登录/刷新/登出均为公开端点(无需 Bearer,否则拿不到 token 或刷新不了)。 // /me 需 Bearer(middleware.Auth);踢下线(logout-all)由 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.POST("/logout", opt.Auth.Logout) // 全设备登出 / 踢下线:吊销该用户全部 refresh token auth.POST("/logout-all", opt.Auth.LogoutAll) // 取当前用户(需 Bearer) auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) // 收藏(需 Bearer):列表 / 新增 / 删除 auth.GET("/favorites", middleware.Auth(opt.JWT), opt.Favorite.List) auth.POST("/favorites", middleware.Auth(opt.JWT), opt.Favorite.Add) auth.DELETE("/favorites/:target_uid", middleware.Auth(opt.JWT), opt.Favorite.Remove) // 批量校验:列表页把当前可见 id 发来,问哪些已收藏(与收藏总量解耦,10万也常数级) auth.POST("/favorites/check", middleware.Auth(opt.JWT), opt.Favorite.Check) // 浏览历史(需 Bearer):分页列表(可按 kind 过滤)/ 记录 / 删除 / 清空 auth.GET("/history", middleware.Auth(opt.JWT), opt.History.List) auth.POST("/history", middleware.Auth(opt.JWT), opt.History.Record) auth.DELETE("/history/:target_uid", middleware.Auth(opt.JWT), opt.History.Remove) auth.DELETE("/history", middleware.Auth(opt.JWT), opt.History.Clear) } } return r }