diff --git a/cmd/server/main.go b/cmd/server/main.go index 599f6f2..9f7e8dc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -73,22 +73,25 @@ func main() { articleRepo = repository.NewArticleRepository(db) brandRepo = repository.NewBrandRepository(db) userRepo = repository.NewUserRepository(db) + snapRepo = repository.NewStreetSnapRepository(db) jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours) articleSvc = service.NewArticleService(articleRepo) brandSvc = service.NewBrandService(brandRepo) indexSvc = service.NewIndexService(brandRepo) + snapSvc = service.NewStreetSnapService(snapRepo) authSvc = service.NewAuthService(userRepo, jwtManager) ) engine := router.New(router.Options{ - Config: cfg, - JWT: jwtManager, - Article: handler.NewArticleHandler(articleSvc), - Brand: handler.NewBrandHandler(brandSvc), - Auth: handler.NewAuthHandler(authSvc), - Health: handler.NewHealthHandler(), + Config: cfg, + JWT: jwtManager, + Article: handler.NewArticleHandler(articleSvc), + Brand: handler.NewBrandHandler(brandSvc), + StreetSnap: handler.NewStreetSnapHandler(snapSvc), + Auth: handler.NewAuthHandler(authSvc), + Health: handler.NewHealthHandler(), }) // 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭 @@ -109,7 +112,7 @@ func main() { // 即便部署层误绑了 0.0.0.0,SSGToken 中间件也能作为最后一道闸。 ssgEngine := router.NewSSG(router.Options{ Config: cfg, - SSG: handler.NewSSGHandler(brandSvc, indexSvc), + SSG: handler.NewSSGHandler(brandSvc, indexSvc, snapSvc), }) srvSSG := &http.Server{ Addr: ":" + cfg.Server.SSGPort, diff --git a/configs/config.yml b/configs/config.yml index ba085af..301e5f9 100644 --- a/configs/config.yml +++ b/configs/config.yml @@ -23,8 +23,8 @@ database: port: 3306 # env: DB_PORT user: root # env: DB_USER password: root # env: DB_PASSWORD(兼容 DB_PASS) - # 本地开发库名为 db;docker-compose 部署时用环境变量覆盖为 fashionadmin - name: db # env: DB_NAME + # 本地开发库名为 db_dev;docker-compose 部署时用环境变量覆盖为 fashionadmin + name: db_dev # env: DB_NAME charset: utf8mb4 # GORM 日志级别:silent | error | warn | info log_level: warn @@ -60,4 +60,18 @@ cors: - Content-Type - Authorization - X-Requested-With + - X-Sign + - X-Sign-Ts + - X-Sign-Nonce max_age: 86400 # 预检请求缓存秒数 + +# 公开接口「前端 JS 签名」:给 /runway-looks(列表)与 /brands 两个批量接口加请求签名校验, +# 识别「请求大概率由前端 JS 构造」、抬高 casual 爬虫的批量抓取成本。 +# 安全定位:这是「提高成本」而非「加密」——密钥必须出现在前端 bundle,对浏览器可见; +# 有决心的爬虫可反编译 JS 复刻签名。与按 IP 限流配合才有效。 +# - enabled: false 时不拦截(默认,灰度安全);true 时要求每个请求带 X-Sign 签名。 +# - secret 必须与前端的 CLIENT_SIGN_SECRET 完全一致;生产经环境变量 CLIENT_SIGN_SECRET 注入随机长串。 +client_sign: + enabled: false # env: CLIENT_SIGN_ENABLED(容器部署用环境变量开) + secret: "" # env: CLIENT_SIGN_SECRET + ttl_seconds: 30 # 签名时间戳容忍窗口(秒),防重放;env: CLIENT_SIGN_TTL diff --git a/internal/config/config.go b/internal/config/config.go index c3c4886..3b6bd9e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,11 +16,12 @@ import ( // Config 应用总配置,结构与 configs/config.yml 一一对应。 type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - JWT JWTConfig `yaml:"jwt"` - Upload UploadConfig `yaml:"upload"` - CORS CORSConfig `yaml:"cors"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + JWT JWTConfig `yaml:"jwt"` + Upload UploadConfig `yaml:"upload"` + CORS CORSConfig `yaml:"cors"` + ClientSign ClientSignConfig `yaml:"client_sign"` // loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。 // 小写不导出,yml 无法覆盖它。 @@ -101,6 +102,25 @@ type CORSConfig struct { MaxAge int `yaml:"max_age"` } +// ClientSignConfig 公开接口「前端 JS 签名」配置。 +// +// 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验, +// 用于识别「请求大概率由前端 JS 构造」、抬高 casual 爬虫的批量抓取成本。 +// +// 安全定位(务必知悉): +// - 这是「提高成本」而非「加密」——Secret 必须出现在前端 bundle 才能签名,对浏览器可见; +// 有决心的爬虫可反编译 JS 复刻签名逻辑。它用于拖慢 casual 爬虫,不能作为唯一防线。 +// - 真正有效的组合是:本签名(识别大概率真前端)+ 按 IP 限流(兜住总量)。 +// - Enabled=false 或 Secret 为空时中间件为 noop(不拦截),便于灰度与回滚。 +type ClientSignConfig struct { + // Enabled 开关:false 时中间件不拦截,便于灰度上线与紧急回滚。 + Enabled bool `yaml:"enabled"` + // Secret 签名密钥,必须与前端的 CLIENT_SIGN_SECRET 完全一致。生产经环境变量 CLIENT_SIGN_SECRET 注入随机长串。 + Secret string `yaml:"secret"` + // TTLSeconds 签名时间戳容忍窗口(秒),用于防重放。默认 30。 + TTLSeconds int `yaml:"ttl_seconds"` +} + // defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。 func defaultConfig() *Config { return &Config{ @@ -117,7 +137,7 @@ func defaultConfig() *Config { Port: "3306", User: "root", Password: "root", - Name: "db", + Name: "db_dev", Charset: "utf8mb4", LogLevel: "warn", MaxIdleConns: 10, @@ -135,9 +155,14 @@ func defaultConfig() *Config { CORS: CORSConfig{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With"}, + AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "X-Sign", "X-Sign-Ts", "X-Sign-Nonce"}, MaxAge: 86400, }, + ClientSign: ClientSignConfig{ + Enabled: false, + Secret: "", + TTLSeconds: 30, + }, } } @@ -214,6 +239,10 @@ func (c *Config) applyEnv() { envStr("JWT_SECRET", &c.JWT.Secret) envInt("JWT_EXPIRE_HOURS", &c.JWT.ExpireHours) + envBool("CLIENT_SIGN_ENABLED", &c.ClientSign.Enabled) + envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret) + envInt("CLIENT_SIGN_TTL", &c.ClientSign.TTLSeconds) + envStr("UPLOAD_DIR", &c.Upload.Dir) envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) } diff --git a/internal/dto/article.go b/internal/dto/article.go index eed8eb8..164c970 100644 --- a/internal/dto/article.go +++ b/internal/dto/article.go @@ -2,32 +2,36 @@ package dto // ---------- 请求 ---------- -// ArticleQuery 文章列表查询条件。 +// ArticleQuery 走秀列表查询条件(GET /public/runways)。 // -// 全部筛选项均支持多值(HBX 风格复选框),由 handler 从 query string 解析后填充。 +// 设计原则:参数尽可能少、不复用。 +// - 所有筛选均为单值(前端已全改为单选),不再支持多选数组; +// - 按品牌过滤走查询参数 brand_id(hashid 编码串),不再使用独立嵌套路由; +// - 关键词搜索已移除(无前端调用);season_code 冗余(season+year 已覆盖)也已移除。 +// - 分页 size 与附图 images 由服务端固定(ArticleFixedSize / ArticleFixedImages),前端不传、不可调。 type ArticleQuery struct { - Page int - Size int - Keyword string // 标题模糊搜索 - BrandID string // 单个品牌 id - BrandIDs []uint32 // 多个品牌 id(brand_ids 逗号分隔) - CollectionTypes []string // rtw / menswear / couture / resort / pre_fall - Seasons []string // spring / fall - SeasonCode string // SS26 / FW25,精确匹配 - Years []int // 年份 - Sort string // newest / year_desc / year_asc / image_count - WithImages int // 列表附带每篇前 N 张图,0 表示不带 + Page int + Size int // 服务端固定为 ArticleFixedSize,不从 query 读取 + Collection string // 单值:rtw / menswear / couture / resort / pre_fall + Season string // 单值:spring / fall + Year int // 单值年份,0 表示不过滤 + Sort string // newest(默认)/ year_desc / year_asc + Images int // 服务端固定为 ArticleFixedImages,不从 query 读取 // Locale 语言:cn | en。空时由 service 回落默认 en。 // 决定 title / description / brand_name 出参选取 *_en 还是 *_cn。 Locale string + + // BrandIDs 由列表接口从查询参数 brand_id(hashid 编码串)解码注入;为空表示不过滤。 + BrandIDs []uint32 } -// 列表分页与附图数量的边界,与原项目保持一致。 +// 列表分页与附图数量由服务端固定(前端无调整入口),不再从 query 读取。 const ( - articleDefaultSize = 12 + ArticleFixedSize = 24 // 每页条数(runway-looks 分页固定值) articleMaxSize = 100 - withImagesMax = 12 + ArticleFixedImages = 6 // 列表每篇附带前 N 张缩略图 + imagesMax = 12 ) // Normalize 校正分页与附图参数,防止非法值打穿数据库。 @@ -36,13 +40,13 @@ func (q *ArticleQuery) Normalize() { q.Page = 1 } if q.Size < 1 || q.Size > articleMaxSize { - q.Size = articleDefaultSize + q.Size = ArticleFixedSize } - if q.WithImages < 0 { - q.WithImages = 0 + if q.Images < 0 { + q.Images = 0 } - if q.WithImages > withImagesMax { - q.WithImages = withImagesMax + if q.Images > imagesMax { + q.Images = imagesMax } if q.Sort == "" { q.Sort = "newest" @@ -60,39 +64,31 @@ type PublicArticleImage struct { Name string `json:"name"` } -// PublicArticle 对外展示用的精简文章结构。 +// PublicArticle 对外展示用的精简文章结构(走秀列表)。 // -// 刻意剔除后台管理字段(is_deleted / updated_at / description 全文)。 -// 对外只暴露编码后的 UID(非自增主键),防止爬虫顺序枚举; -// 同时把 brand_id 也编码为 brand_uid,品牌跳转同样不可枚举。 +// 只返回前端真正渲染的字段:id / brand_id / title / cover / brand_name / image_count / images。 +// year / season / season_code / collection_type / published_at / summary 前端从不展示, +// 且排序已由后端按真实走秀新旧(year + season 优先级)完成,故响应不再携带这些元数据。 +// 对外只暴露编码后的 UID(非自增主键),防止爬虫顺序枚举;brand_id 同样编码为 brand_uid。 type PublicArticle struct { - UID string `json:"id"` // 编码后的文章 id(无序串) - BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) - Title string `json:"title"` - Summary string `json:"summary"` // description 截断后的对外摘要 - Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg - BrandName string `json:"brand_name"` // JOIN brand 得到 - Year uint16 `json:"year"` - ImageCount uint16 `json:"image_count"` - CollectionType string `json:"collection_type"` // rtw/menswear/couture/resort/pre_fall - Season string `json:"season"` // spring/fall/空 - SeasonCode string `json:"season_code"` // SS26/FW25/RES26/PF25 - PublishedAt uint32 `json:"published_at"` // = created_at,Unix 秒 - Images []PublicArticleImage `json:"images"` // with_images 参数控制,未请求时为 null + UID string `json:"id"` // 编码后的文章 id(无序串) + BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) + Title string `json:"title"` + Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg + BrandName string `json:"brand_name"` // JOIN brand 得到 + ImageCount uint16 `json:"image_count"` // 该走秀图片总数(列表卡片 "N+" 角标用) + Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 } // PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。 +// +// 与列表一致,只返回前端渲染所需的字段;image_count / source_url / 各类时间季节元数据前端均不展示,已移除。 type PublicArticleDetail struct { UID string `json:"id"` // 编码后的文章 id(无序串) BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) Title string `json:"title"` - Summary string `json:"summary"` Description string `json:"description"` Cover string `json:"cover"` BrandName string `json:"brand_name"` - Year uint16 `json:"year"` - ImageCount uint16 `json:"image_count"` - SourceURL string `json:"source_url"` - PublishedAt uint32 `json:"published_at"` Images []PublicArticleImage `json:"images"` } diff --git a/internal/dto/auth.go b/internal/dto/auth.go index bce5cb8..7edb520 100644 --- a/internal/dto/auth.go +++ b/internal/dto/auth.go @@ -1,18 +1,5 @@ package dto -// ---------- 请求 ---------- - -// AuthRequest 注册 / 登录的统一请求体。 -// -// 沿用原项目的单结构设计:注册用 username + email + password, -// 登录用 account(可填邮箱或用户名)+ password。 -type AuthRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Account string `json:"account"` -} - // ---------- 响应 ---------- // UserPayload 对外返回的用户信息,绝不包含密码哈希。 @@ -21,10 +8,3 @@ type UserPayload struct { Username string `json:"username"` Email string `json:"email"` } - -// AuthResult 注册 / 登录成功后的返回:令牌 + 用户信息。 -// 前端 src/lib/auth.ts 依赖 token 与 user 两个顶层字段。 -type AuthResult struct { - Token string `json:"token"` - User UserPayload `json:"user"` -} diff --git a/internal/dto/brand.go b/internal/dto/brand.go index 537c74c..62394e8 100644 --- a/internal/dto/brand.go +++ b/internal/dto/brand.go @@ -7,11 +7,7 @@ type BrandQuery struct { Letter string // A-Z 字母索引;OTHER 表示中文分桶;空表示全部拉丁字母 Keyword string Page int - Size int - - // OnlyWithArticles 只返回"拥有走秀档案"的品牌,默认 true。 - // 库中有 2 万+ 无档案的品牌,若不过滤会污染 A-Z 索引。 - OnlyWithArticles bool + Size int // 每页条数,已写死 brandDefaultSize(30),前端不传 // Featured 仅保留"代表品牌"精选集合(按指标排名的前 N 个)。 Featured bool @@ -25,7 +21,7 @@ type BrandQuery struct { // 品牌列表分页与精选集合的边界,与原项目保持一致。 const ( - brandDefaultSize = 200 + brandDefaultSize = 30 brandMaxSize = 500 featuredDefaultLimit = 200 featuredMaxLimit = 500 @@ -54,13 +50,12 @@ func (q BrandQuery) Offset() int { return (q.Page - 1) * q.Size } // PublicBrand 对外展示用的精简品牌结构,剔除 created_at / updated_at / is_deleted。 // 注意:对外只暴露编码后的 UID(非自增主键),防止顺序枚举。 +// article_count(各品牌档案数)前端从不展示,已移除,故不再额外查一次分组计数。 type PublicBrand struct { - UID string `json:"id"` // 编码后的品牌 id(无序串),对外唯一标识 - Name string `json:"name"` // 按 locale 选列(cn→name_cn,en→name_en) - NameEn string `json:"name_en"` // 英文规范名(始终返回,供前端组合双语展示) - NameCn string `json:"name_cn"` // 中文名(可能为空,供前端组合双语展示) - ShowName string `json:"show_name"` - ArticleCount int `json:"article_count"` // 该品牌下的走秀档案数,用于索引计数 + UID string `json:"id"` // 编码后的品牌 id(无序串),对外唯一标识 + Name string `json:"name"` // 按 locale 选列(cn→name_cn,en→name_en) + NameEn string `json:"name_en"` // 英文规范名(始终返回,供前端组合双语展示) + NameCn string `json:"name_cn"` // 中文名(可能为空,供前端组合双语展示) } // PopularBrand 首页"热门品牌"区块所需结构。 diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go index e575435..e23852c 100644 --- a/internal/handler/article_handler.go +++ b/internal/handler/article_handler.go @@ -23,49 +23,59 @@ func NewArticleHandler(articles service.ArticleService) *ArticleHandler { return &ArticleHandler{articles: articles} } -// List 文章列表(分页 + 多条件筛选 + 排序)。 +// List 走秀列表(分页 + 单值筛选 + 排序)。 // -// GET /api/public/articles +// GET /api/v1/public/runway-looks // -// 查询参数: -// - page / size 分页,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 -// - with_images 附带每篇前 N 张图,上限 12 +// 查询参数(均为单值,size / images 由服务端固定,前端不传): +// - page 页码,默认 1(服务端固定每页 ArticleFixedSize=24 条) +// - collection rtw / menswear / couture / resort / pre_fall +// - season spring / fall +// - year 年份(整数),0 表示不过滤 +// - sort newest(默认)/ year_desc / year_asc / image_count +// - brand_id hashid 编码串;传入则只返回该品牌走秀(查询参数,取代原嵌套路由 /brands/:brandId/runway-looks) +// - locale cn / en +// +// 每篇附带前 ArticleFixedImages=6 张缩略图(服务端固定,不进参数)。 func (h *ArticleHandler) List(c *gin.Context) { - q := dto.ArticleQuery{ - Page: queryInt(c, "page", 1), - Size: queryInt(c, "size", 12), - Keyword: c.Query("keyword"), - BrandID: decodeBrandID(c.Query("brand_id")), - BrandIDs: decodeBrandIDs(c.Query("brand_ids")), - CollectionTypes: c.QueryArray("collection_type"), - Seasons: c.QueryArray("season"), - SeasonCode: c.Query("season_code"), - Years: parseIntSlice(c.QueryArray("year")), - Sort: c.DefaultQuery("sort", "newest"), - WithImages: queryInt(c, "with_images", 0), - Locale: parseLocale(c), - } - q.Normalize() + h.respondRunways(c, h.parseListQuery(c)) +} +// parseListQuery 从 query string 解析走秀列表的筛选条件。 +// brand_id 为 hashid 编码串;非法串忽略(当作不过滤,返回空结果集而非 404)。 +func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery { + q := dto.ArticleQuery{ + Page: queryInt(c, "page", 1), + Size: dto.ArticleFixedSize, // 服务端固定,前端不传 + Collection: c.Query("collection"), + Season: c.Query("season"), + Year: queryInt(c, "year", 0), + Sort: c.DefaultQuery("sort", "newest"), + Images: dto.ArticleFixedImages, // 服务端固定,前端不传 + Locale: parseLocale(c), + } + if raw := strings.TrimSpace(c.Query("brand_id")); raw != "" { + if n, err := hashid.Decode(raw); err == nil { + q.BrandIDs = []uint32{n} + } + } + return q +} + +// respondRunways 统一执行列表查询并回包(分页)。 +func (h *ArticleHandler) respondRunways(c *gin.Context, q dto.ArticleQuery) { + q.Normalize() list, total, err := h.articles.List(c.Request.Context(), q) if err != nil { fail(c, err) return } - response.Page(c, http.StatusOK, list, total, q.Page, q.Size) + response.Page(c, http.StatusOK, list, total, q.Size) } -// Detail 文章详情(含完整图片集)。 +// Detail 走秀详情(含完整图片集)。 // -// GET /api/public/articles/:id +// GET /api/public/runways/:id // :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。 func (h *ArticleHandler) Detail(c *gin.Context) { raw := strings.TrimSpace(c.Param("id")) @@ -86,35 +96,3 @@ func (h *ArticleHandler) Detail(c *gin.Context) { } response.Data(c, http.StatusOK, detail) } - -// decodeBrandID 把对外编码串还原为数字主键;非法或为空返回 ""(表示不过滤)。 -func decodeBrandID(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "" - } - n, err := hashid.Decode(s) - if err != nil { - return "" - } - return strconv.FormatUint(uint64(n), 10) -} - -// decodeBrandIDs 解析逗号分隔的编码串列表,逐个解码为数字主键,跳过非法项。 -func decodeBrandIDs(s string) []uint32 { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - var out []uint32 - for _, p := range strings.Split(s, ",") { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if n, err := hashid.Decode(p); err == nil { - out = append(out, n) - } - } - return out -} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index f0619ba..e6898f1 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -3,7 +3,6 @@ package handler import ( "net/http" - "fashionapi/internal/dto" "fashionapi/internal/middleware" "fashionapi/internal/pkg/response" "fashionapi/internal/service" @@ -21,46 +20,6 @@ func NewAuthHandler(auth service.AuthService) *AuthHandler { return &AuthHandler{auth: auth} } -// Register 注册。 -// -// POST /api/auth/register -// 请求体:{ username, email, password } -// 成功:201 { token, user } -func (h *AuthHandler) Register(c *gin.Context) { - var req dto.AuthRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "invalid request") - return - } - - result, err := h.auth.Register(c.Request.Context(), req) - if err != nil { - fail(c, err) - return - } - c.JSON(http.StatusCreated, result) -} - -// Login 登录。 -// -// POST /api/auth/login -// 请求体:{ account, password },account 可填邮箱或用户名 -// 成功:200 { token, user } -func (h *AuthHandler) Login(c *gin.Context) { - var req dto.AuthRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "invalid request") - return - } - - result, err := h.auth.Login(c.Request.Context(), req) - if err != nil { - fail(c, err) - return - } - c.JSON(http.StatusOK, result) -} - // Me 当前登录用户,需 Authorization: Bearer 。 // // GET /api/auth/me @@ -79,5 +38,3 @@ func (h *AuthHandler) Me(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"user": user}) } - - diff --git a/internal/handler/brand_handler.go b/internal/handler/brand_handler.go index 6219d20..bb1c285 100644 --- a/internal/handler/brand_handler.go +++ b/internal/handler/brand_handler.go @@ -27,23 +27,23 @@ func NewBrandHandler(brands service.BrandService) *BrandHandler { // // 查询参数: // - letter A-Z 字母;other 表示中文分桶;缺省返回全部拉丁字母开头的品牌 -// - keyword 品牌名 / 展示名模糊搜索 -// - page / size 分页,size 默认 200、上限 500 -// - only_with_articles 默认 1,只返回有走秀档案的品牌;传 0 返回全部 +// - keyword 品牌名 / 展示名模糊搜索(不过滤"必须有走秀档案") +// - page 页码(size 已写死 30,前端不传) // - featured 传 1 时只返回"代表品牌"精选集合 // - metric 精选排名指标:images(默认)/ shows // - limit 精选集合大小,默认 200、上限 500 +// +// 注:原 only_with_articles 参数已删除——字母索引始终只返回有走秀档案的品牌, +// 该过滤不再可由调用方关闭。 func (h *BrandHandler) List(c *gin.Context) { q := dto.BrandQuery{ - Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))), - Keyword: strings.TrimSpace(c.Query("keyword")), - Page: queryInt(c, "page", 1), - Size: queryInt(c, "size", 200), - OnlyWithArticles: queryBool(c, "only_with_articles", true), - Featured: queryBool(c, "featured", false), - Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))), - Limit: queryInt(c, "limit", 200), - Locale: parseLocale(c), + Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))), + Keyword: strings.TrimSpace(c.Query("keyword")), + Page: queryInt(c, "page", 1), + Featured: queryBool(c, "featured", false), + Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))), + Limit: queryInt(c, "limit", 200), + Locale: parseLocale(c), } q.Normalize() @@ -52,5 +52,5 @@ func (h *BrandHandler) List(c *gin.Context) { fail(c, err) return } - response.Page(c, http.StatusOK, list, total, q.Page, q.Size) + response.Page(c, http.StatusOK, list, total, q.Size) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 738dae5..13d00b7 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -45,10 +45,8 @@ func queryInt(c *gin.Context, key string, def int) int { // queryBool 读取布尔查询参数。 // // def 为该参数缺失时的取值: -// - def=true(如 only_with_articles):只有显式传 0 / false 才关闭; +// - def=true(如某开关默认开启):只有显式传 0 / false 才关闭; // - def=false(如 featured):只有显式传 1 / true 才开启。 -// -// 这两种语义都来自原项目,必须保持,否则前端 A-Z 索引的数据量会突变。 func queryBool(c *gin.Context, key string, def bool) bool { v := strings.ToLower(strings.TrimSpace(c.Query(key))) if v == "" { diff --git a/internal/handler/ssg_handler.go b/internal/handler/ssg_handler.go index 6434ced..c6c8b8d 100644 --- a/internal/handler/ssg_handler.go +++ b/internal/handler/ssg_handler.go @@ -19,11 +19,12 @@ import ( type SSGHandler struct { brands service.BrandService index service.IndexService + snaps service.StreetSnapService } // NewSSGHandler 创建 SSG handler。 -func NewSSGHandler(brands service.BrandService, index service.IndexService) *SSGHandler { - return &SSGHandler{brands: brands, index: index} +func NewSSGHandler(brands service.BrandService, index service.IndexService, snaps service.StreetSnapService) *SSGHandler { + return &SSGHandler{brands: brands, index: index, snaps: snaps} } // IndexRunway 首页(/index/)runway 区块数据。 @@ -58,4 +59,15 @@ func (h *SSGHandler) HotBrands(c *gin.Context) { response.Data(c, http.StatusOK, list) } - +// StreetSnapPopular 首页热门街拍(按图片数前 N,返回完整对象含封面)。 +// +// GET /api/v1/ssg/street-snaps/popular +func (h *SSGHandler) StreetSnapPopular(c *gin.Context) { + const limit = 20 + list, err := h.snaps.Popular(c.Request.Context(), limit) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, list) +} diff --git a/internal/model/brand.go b/internal/model/brand.go index df3985a..cc99284 100644 --- a/internal/model/brand.go +++ b/internal/model/brand.go @@ -8,10 +8,9 @@ type Brand struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` - Name string `gorm:"column:name" json:"name"` - ShowName string `gorm:"column:show_name" json:"show_name"` // i18n 列(方案 A):name_en 为规范拉丁名,A-Z 排序/搜索统一基于它; // name_cn 为中文名;二者皆为空的服务端按 locale 回落。 + // (原通用 name / show_name 列已删除,统一由 *_en/*_cn 取代。) NameEn string `gorm:"column:name_en" json:"name_en"` NameCn string `gorm:"column:name_cn" json:"name_cn"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` diff --git a/internal/model/runway.go b/internal/model/runway.go index 4f95490..3217c24 100644 --- a/internal/model/runway.go +++ b/internal/model/runway.go @@ -2,11 +2,10 @@ package model // BrandRunway 走秀档案(对外称"文章")。 type BrandRunway struct { - ID uint32 `gorm:"primaryKey;column:id" json:"id"` - Title string `gorm:"column:title" json:"title"` - Description string `gorm:"column:description" json:"description"` + ID uint32 `gorm:"primaryKey;column:id" json:"id"` // i18n 列(方案 A):*_en 为规范英文(当前数据全英文),*_cn 为中文; // 服务端按 locale 选列,缺翻译回落另一语言。 + // (原通用 title / description 列已删除,统一由 *_en/*_cn 取代。) TitleEn string `gorm:"column:title_en" json:"title_en"` TitleCn string `gorm:"column:title_cn" json:"title_cn"` DescriptionEn string `gorm:"column:description_en" json:"description_en"` @@ -31,15 +30,12 @@ func (BrandRunway) TableName() string { return "brand_runway" } // RunwayRow 走秀档案的查询投影行。 // -// 独立于 BrandRunway 定义,是为了承载 JOIN brand 得到的 brand_name —— -// 该字段不属于 brand_runway 表,混进实体会污染模型语义。 +// 独立于 BrandRunway 定义,是为了承载 JOIN brand 得到的品牌名(en/cn 两列)。 type RunwayRow struct { ID uint32 `gorm:"column:id"` BrandID uint32 `gorm:"column:brand_id"` - Title string `gorm:"column:title"` TitleEn string `gorm:"column:title_en"` TitleCn string `gorm:"column:title_cn"` - Description string `gorm:"column:description"` DescriptionEn string `gorm:"column:description_en"` DescriptionCn string `gorm:"column:description_cn"` Cover string `gorm:"column:cover"` @@ -50,7 +46,6 @@ type RunwayRow struct { SeasonCode string `gorm:"column:season_code"` CreatedAt uint32 `gorm:"column:created_at"` SourceURL string `gorm:"column:source_url"` - BrandName string `gorm:"column:brand_name"` BrandNameEn string `gorm:"column:brand_name_en"` BrandNameCn string `gorm:"column:brand_name_cn"` } diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go index fe01a87..ab7349c 100644 --- a/internal/pkg/jwt/jwt.go +++ b/internal/pkg/jwt/jwt.go @@ -39,16 +39,10 @@ func NewManager(secret string, expireHours int) *Manager { } } -// Generate 签发令牌。 -func (m *Manager) Generate(userID uint32, username, email string) (string, error) { - claims := jwtlib.MapClaims{ - "uid": userID, - "username": username, - "email": email, - "exp": time.Now().Add(m.expire).Unix(), - } - return jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) -} +// 注:Generate(签发令牌)已随注册 / 登录接口一并移除——当前服务没有任何签发入口, +// 只保留 Parse 供 middleware.Auth 校验既有令牌。 +// 将来接入登录时恢复:构造 jwtlib.MapClaims{"uid","username","email","exp"}, +// 再用 jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) 签名即可。 // Parse 校验并解析令牌。 func (m *Manager) Parse(tokenStr string) (*Claims, error) { diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index c4c591f..00aafdd 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -1,7 +1,7 @@ // Package response 统一 HTTP 响应结构。 // // 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名: -// - 列表:{ data, total, current_page, last_page, per_page } +// - 列表:{ data, last_page } // last_page 供前端真分页(页码导航)使用;total/per_page/current_page 前端无消费点,已移除以精简 // - 详情:{ data } // - 错误:{ error } // @@ -15,14 +15,14 @@ func Data(c *gin.Context, status int, v any) { c.JSON(status, gin.H{"data": v}) } -// Page 返回分页列表:{ data, total, current_page, last_page, per_page }。 -func Page(c *gin.Context, status int, data any, total int64, page, size int) { +// Page 返回分页列表:{ data, last_page }。 +// +// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。 +// total / current_page / per_page 前端从无读取,移除以精简协议;last_page 仍由后端用 total 与固定 size 计算。 +func Page(c *gin.Context, status int, data any, total int64, size int) { c.JSON(status, gin.H{ - "data": data, - "total": total, - "current_page": page, - "last_page": LastPage(total, size), - "per_page": size, + "data": data, + "last_page": LastPage(total, size), }) } diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index 9fd3ba5..8f3c9a1 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -20,9 +20,6 @@ type ArticleRepository interface { ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) // ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。 ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) - // IDs 返回全部未删除文章的 id(升序),供 SSG 的 getStaticPaths 枚举路径使用。 - // 只回主键,不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省得多。 - IDs(ctx context.Context) ([]uint32, error) } type articleRepository struct { @@ -35,17 +32,16 @@ func NewArticleRepository(db *gorm.DB) ArticleRepository { } // selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。 -const articleListColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title, +// 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里 +// (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。 +const articleListColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title_en, brand_runway.title_cn, - brand_runway.description, brand_runway.description_en, brand_runway.description_cn, - brand_runway.cover, brand_runway.year, brand_runway.image_count, - brand_runway.created_at, brand_runway.collection_type, brand_runway.season, - brand_runway.season_code, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` + brand_runway.cover, brand_runway.image_count, + b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` -const articleDetailColumns = `brand_runway.id, brand_runway.title, brand_runway.title_en, brand_runway.title_cn, - brand_runway.description, brand_runway.description_en, brand_runway.description_cn, - brand_runway.cover, brand_runway.year, brand_runway.image_count, brand_runway.created_at, - brand_runway.source_url, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` +const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runway.title_cn, + brand_runway.description_en, brand_runway.description_cn, + brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0" @@ -58,21 +54,16 @@ func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { db = db.Where("brand_runway.is_deleted = 0") - if q.Keyword != "" { - kw := "%" + q.Keyword + "%" - db = db.Where("brand_runway.title_en LIKE ? OR brand_runway.title_cn LIKE ?", kw, kw) - } - if q.BrandID != "" && q.BrandID != "0" { - db = db.Where("brand_runway.brand_id = ?", q.BrandID) - } - // 多值筛选统一策略:单值用 = (命中索引更精准),多值用 IN,空集合不加条件。 + // 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。 db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs)) - db = whereMulti(db, "brand_runway.collection_type", toAnySlice(q.CollectionTypes)) - db = whereMulti(db, "brand_runway.season", toAnySlice(q.Seasons)) - db = whereMulti(db, "brand_runway.year", toAnySlice(q.Years)) - - if q.SeasonCode != "" { - db = db.Where("brand_runway.season_code = ?", q.SeasonCode) + if q.Collection != "" { + db = db.Where("brand_runway.collection_type = ?", q.Collection) + } + if q.Season != "" { + db = db.Where("brand_runway.season = ?", q.Season) + } + if q.Year != 0 { + db = db.Where("brand_runway.year = ?", q.Year) } return db } @@ -104,17 +95,19 @@ func toAnySlice[T any](in []T) []any { // orderBy 把 sort 参数映射为 ORDER BY 子句。 // // 白名单映射而非直接拼接用户输入,从根上排除 SQL 注入; -// 每个分支都以 created_at + id 兜底,保证分页结果稳定不跳行。 +// 每个分支都以 id 兜底,保证分页结果稳定不跳行。 +// +// newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧, +// 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。 func orderBy(sort string) string { + seasonPrio := `CASE brand_runway.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END` switch sort { - case "year_desc": - return "brand_runway.year DESC, brand_runway.created_at DESC, brand_runway.id DESC" case "year_asc": - return "brand_runway.year ASC, brand_runway.created_at DESC, brand_runway.id DESC" - case "image_count": - return "brand_runway.image_count DESC, brand_runway.created_at DESC, brand_runway.id DESC" + return "brand_runway.year ASC, brand_runway.id ASC" + case "year_desc": + return "brand_runway.year DESC, brand_runway.id DESC" default: // newest - return "brand_runway.created_at DESC, brand_runway.id DESC" + return "brand_runway.year DESC, " + seasonPrio + " DESC, brand_runway.id DESC" } } @@ -195,14 +188,3 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) } // IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。 -func (r *articleRepository) IDs(ctx context.Context) ([]uint32, error) { - var ids []uint32 - if err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). - Where("is_deleted = 0"). - Order("id ASC"). - Pluck("id", &ids).Error; err != nil { - return nil, err - } - return ids, nil -} diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go index c241493..f452b6a 100644 --- a/internal/repository/brand_repository.go +++ b/internal/repository/brand_repository.go @@ -25,9 +25,6 @@ type BrandRepository interface { // 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。 ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) - // ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。 - ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) - // PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。 // 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。 // 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。 @@ -84,10 +81,12 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3 db = db.Where("name_en REGEXP ?", latinPrefixRegexp) } - // 中文分桶不限档案,直接列出全部中文名品牌,便于检索 - // 关键词搜索时不限制"必须有走秀档案":用户按名字找品牌, - // 即使该品牌暂无走秀档案也应被命中(如 A Bathing Ape / 猿人头)。 - if q.OnlyWithArticles && q.Keyword == "" && q.Letter != "OTHER" { + // 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌, + // 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。 + // 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌, + // 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头); + // 中文分桶(OTHER)同样列出全部中文名品牌便于检索。 + if q.Keyword == "" && q.Letter != "OTHER" { db = db.Where("id IN (?)", r.hasArticlesSubQuery()) } @@ -177,31 +176,6 @@ func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model. return items, nil } -func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) { - counts := make(map[uint32]int, len(ids)) - if len(ids) == 0 { - return counts, nil - } - - var grp []struct { - BrandID uint32 `gorm:"column:brand_id"` - Cnt int `gorm:"column:cnt"` - } - err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). - Select("brand_id, COUNT(*) AS cnt"). - Where("brand_id IN ? AND is_deleted = 0", ids). - Group("brand_id"). - Scan(&grp).Error - if err != nil { - return nil, err - } - for _, g := range grp { - counts[g.BrandID] = g.Cnt - } - return counts, nil -} - // PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。 // // 规则: diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index ccdfb3d..17e1454 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -10,13 +10,10 @@ import ( ) // UserRepository 用户数据访问接口。 +// +// 当前只保留 FindByID(供 /auth/me 取当前用户)。 +// ExistsByUsernameOrEmail / Create / FindByAccount 是注册登录专用,已随路由下线一并删除。 type UserRepository interface { - // ExistsByUsernameOrEmail 判断用户名或邮箱是否已被占用。 - ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error) - // Create 创建用户。 - Create(ctx context.Context, u *model.User) error - // FindByAccount 按邮箱或用户名查找未删除的用户。不存在时返回 ErrNotFound。 - FindByAccount(ctx context.Context, account string) (*model.User, error) // FindByID 按主键查找用户。不存在时返回 ErrNotFound。 FindByID(ctx context.Context, id uint32) (*model.User, error) } @@ -30,30 +27,6 @@ func NewUserRepository(db *gorm.DB) UserRepository { return &userRepository{db: db} } -func (r *userRepository) ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error) { - var cnt int64 - err := r.db.WithContext(ctx). - Model(&model.User{}). - Where("username = ? OR email = ?", username, email). - Count(&cnt).Error - if err != nil { - return false, err - } - return cnt > 0, nil -} - -func (r *userRepository) Create(ctx context.Context, u *model.User) error { - return r.db.WithContext(ctx).Create(u).Error -} - -func (r *userRepository) FindByAccount(ctx context.Context, account string) (*model.User, error) { - var u model.User - err := r.db.WithContext(ctx). - Where("is_deleted = 0 AND (email = ? OR username = ?)", account, account). - First(&u).Error - return wrapUser(&u, err) -} - func (r *userRepository) FindByID(ctx context.Context, id uint32) (*model.User, error) { var u model.User err := r.db.WithContext(ctx).First(&u, id).Error diff --git a/internal/router/router.go b/internal/router/router.go index 55cb995..d68b64e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -14,13 +14,14 @@ import ( // Options 路由注册所需的全部依赖,由 main 装配后传入。 type Options struct { - Config *config.Config - JWT *jwt.Manager - Article *handler.ArticleHandler - Brand *handler.BrandHandler - Auth *handler.AuthHandler - Health *handler.HealthHandler - SSG *handler.SSGHandler + Config *config.Config + JWT *jwt.Manager + Article *handler.ArticleHandler + Brand *handler.BrandHandler + StreetSnap *handler.StreetSnapHandler + Auth *handler.AuthHandler + Health *handler.HealthHandler + SSG *handler.SSGHandler } // New 构建 Gin 引擎并注册全部对外路由。 @@ -43,18 +44,31 @@ func New(opt Options) *gin.Engine { // 对外公开接口:只读查询,不暴露任何后台管理字段 public := api.Group("/public") + // ClientSign 仅挂在「列表 / 品牌」两个批量查询接口上: + // 这俩是爬虫批量 dump 的主要目标,要求前端 JS 签名可抬高抓取成本。 + public.Use(middleware.ClientSign(opt.Config.ClientSign)) { - public.GET("/articles", opt.Article.List) - public.GET("/articles/:id", opt.Article.Detail) + // 走秀档案列表(按品牌过滤走 ?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) - // 账号体系:注册 / 登录 / 登出 / 当前用户(JWT 无状态) + // 账号体系:当前只保留「取当前用户」(JWT 无状态,无服务端登出路由)。 + // + // 注册 / 登录接口已从路由移除:前端没有登录注册入口,对外开放即是垃圾账号与撞库入口。 + // handler / service / repository / dto 中对应实现一并删除。 + // 将来要接入时:补回 POST /auth/register、POST /auth/login,并同步前端 src/lib/api.ts 与登录页。 auth := api.Group("/auth") { - auth.POST("/register", opt.Auth.Register) - auth.POST("/login", opt.Auth.Login) - auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) + auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) } } diff --git a/internal/router/ssg.go b/internal/router/ssg.go index 15ec757..007ca33 100644 --- a/internal/router/ssg.go +++ b/internal/router/ssg.go @@ -29,8 +29,11 @@ func NewSSG(opt Options) *gin.Engine { ssg := r.Group("/api/v1/ssg") { - ssg.GET("/brands/hot", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序) - ssg.GET("/index/runway", opt.SSG.IndexRunway) // 首页(/index/)runway 区块:多模块,当前含 hotBrand + ssg.GET("/brands/hot", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序) + ssg.GET("/index/runway", opt.SSG.IndexRunway) // 首页(/index/)runway 区块:多模块,当前含 hotBrand + ssg.GET("/street-snaps/popular", opt.SSG.StreetSnapPopular) // 首页热门街拍(按图片数前 20) + // 注:/street-snaps/ids(全部街拍编码 id 枚举)已移除——街拍详情页走 /item/[id] 按需 SSR, + // 无需构建期枚举 id。handler/service/repository 对应实现已一并删除。 } return r diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 391064c..7bce26e 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -8,19 +8,13 @@ import ( "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/hashid" - "fashionapi/internal/pkg/textutil" "fashionapi/internal/repository" ) -// summaryLength 对外摘要的截断长度(rune 数),与原项目一致。 -const summaryLength = 140 - // ArticleService 文章业务接口。 type ArticleService interface { List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) - // IDs 返回全部文章的编码 uid,供 SSG 路径枚举(getStaticPaths)。 - IDs(ctx context.Context) ([]string, error) } type articleService struct { @@ -46,25 +40,17 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicArticle{ - UID: hashid.Encode(r.ID), - BrandUID: hashid.Encode(r.BrandID), - Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), - Summary: textutil.Summarize(pickLocale(r.DescriptionEn, r.DescriptionCn, q.Locale), summaryLength), - Cover: r.Cover, - // 品牌名强制优先英文("en"),仅在英文缺失时回落中文,避免留空; - // 与 title/description 不同,品牌名不随 locale 切换,保持英文观感。 - BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), - Year: r.Year, - ImageCount: r.ImageCount, - CollectionType: r.CollectionType, - Season: r.Season, - SeasonCode: r.SeasonCode, - PublishedAt: r.CreatedAt, + UID: hashid.Encode(r.ID), + BrandUID: hashid.Encode(r.BrandID), + Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), + Cover: r.Cover, + BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感 + ImageCount: r.ImageCount, }) } - if q.WithImages > 0 && len(list) > 0 { - if err := s.attachImages(ctx, list, ids, q.WithImages); err != nil { + if q.Images > 0 && len(list) > 0 { + if err := s.attachImages(ctx, list, ids, q.Images); err != nil { return nil, 0, internalErr(err.Error()) } } @@ -110,32 +96,15 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) ( UID: hashid.Encode(row.ID), BrandUID: hashid.Encode(row.BrandID), Title: pickLocale(row.TitleEn, row.TitleCn, locale), - Summary: textutil.Summarize(pickLocale(row.DescriptionEn, row.DescriptionCn, locale), summaryLength), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), Cover: row.Cover, // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 - BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), - Year: row.Year, - ImageCount: row.ImageCount, - SourceURL: row.SourceURL, - PublishedAt: row.CreatedAt, - Images: toPublicImages(imgs), + BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), + Images: toPublicImages(imgs), }, nil } // IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 -func (s *articleService) IDs(ctx context.Context) ([]string, error) { - ids, err := s.articles.IDs(ctx) - if err != nil { - return nil, internalErr(err.Error()) - } - out := make([]string, 0, len(ids)) - for _, id := range ids { - out = append(out, hashid.Encode(id)) - } - return out, nil -} - // toPublicImages 把图片实体映射为对外结构(只保留 url 与名称)。 func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage { if len(imgs) == 0 { diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 9def948..62fb94c 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -2,28 +2,19 @@ package service import ( "context" - "errors" - "regexp" - "strings" "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/jwt" "fashionapi/internal/repository" - - "golang.org/x/crypto/bcrypt" ) -// emailRegex 邮箱格式校验,与原项目一致。 -var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) - -// minPasswordLength 密码最小长度。 -const minPasswordLength = 6 - // AuthService 账号体系业务接口。 +// +// 当前只保留 Me(取当前用户)。注册 / 登录已随路由一并下线:前端没有登录注册入口, +// 对外开放即是垃圾账号与撞库入口。将来接入时补回 Register / Login 与 issue 令牌签发逻辑, +// 并同步前端 src/lib/api.ts 与登录页。 type AuthService interface { - Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) - Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) } @@ -37,61 +28,6 @@ func NewAuthService(users repository.UserRepository, jwtManager *jwt.Manager) Au return &authService{users: users, jwt: jwtManager} } -// Register 注册新用户,成功后直接签发令牌(免去前端再调一次登录)。 -func (s *authService) Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) { - username := strings.TrimSpace(req.Username) - email := strings.TrimSpace(req.Email) - password := req.Password - - if username == "" || email == "" || len(password) < minPasswordLength { - return nil, badRequest("username、email 与 password(>=6 位) 均为必填") - } - if !emailRegex.MatchString(email) { - return nil, badRequest("email 格式不正确") - } - - exists, err := s.users.ExistsByUsernameOrEmail(ctx, username, email) - if err != nil { - return nil, internalErr("server error") - } - if exists { - return nil, conflict("该用户名或邮箱已被注册") - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, internalErr("server error") - } - - user := model.User{Username: username, Email: email, PasswordHash: string(hash)} - if err := s.users.Create(ctx, &user); err != nil { - return nil, internalErr("创建用户失败") - } - return s.issue(user) -} - -// Login 登录:account 可以是邮箱或用户名。 -func (s *authService) Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) { - account := strings.TrimSpace(req.Account) - password := req.Password - if account == "" || password == "" { - return nil, badRequest("账号与密码均为必填") - } - - user, err := s.users.FindByAccount(ctx, account) - if err != nil { - if errors.Is(err, repository.ErrNotFound) { - // 账号不存在与密码错误返回同一文案,避免账号枚举 - return nil, unauthorized("账号或密码错误") - } - return nil, internalErr("server error") - } - if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { - return nil, unauthorized("账号或密码错误") - } - return s.issue(*user) -} - // Me 用令牌中的 uid 取回最新用户信息。 func (s *authService) Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) { user, err := s.users.FindByID(ctx, userID) @@ -103,15 +39,6 @@ func (s *authService) Me(ctx context.Context, userID uint32) (*dto.UserPayload, return &p, nil } -// issue 签发令牌并组装返回体。 -func (s *authService) issue(u model.User) (*dto.AuthResult, error) { - token, err := s.jwt.Generate(u.ID, u.Username, u.Email) - if err != nil { - return nil, internalErr("server error") - } - return &dto.AuthResult{Token: token, User: toUserPayload(u)}, nil -} - func toUserPayload(u model.User) dto.UserPayload { return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email} } diff --git a/internal/service/brand_service.go b/internal/service/brand_service.go index 5d9182e..f42c49f 100644 --- a/internal/service/brand_service.go +++ b/internal/service/brand_service.go @@ -28,7 +28,7 @@ func NewBrandService(brands repository.BrandRepository) BrandService { return &brandService{brands: brands} } -// List 查询品牌列表并附带每个品牌的档案数。 +// List 查询品牌列表。 // // featured=1 时先按指标算出"代表品牌"集合,再把列表收敛到该集合内 —— // 全库 1600+ 有档案品牌对 A-Z 索引来说过多,精选前 200 更符合导航场景。 @@ -52,17 +52,7 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public return nil, 0, internalErr(err.Error()) } - ids := make([]uint32, 0, len(items)) - for _, b := range items { - ids = append(ids, b.ID) - } - counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids) - if err != nil { - return nil, 0, internalErr(err.Error()) - } - - list := s.toPublicBrands(items, counts, q.Locale) - return list, total, nil + return s.toPublicBrands(items, q.Locale), total, nil } // Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。 @@ -82,10 +72,6 @@ func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto if err != nil { return nil, internalErr(err.Error()) } - counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids) - if err != nil { - return nil, internalErr(err.Error()) - } // IN 查询不保证顺序,这里重排回 FeaturedIDs 的热度排名顺序。 pos := make(map[uint32]int, len(ids)) @@ -94,20 +80,18 @@ func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto } sort.Slice(items, func(i, j int) bool { return pos[items[i].ID] < pos[items[j].ID] }) - return s.toPublicBrands(items, counts, locale), nil + return s.toPublicBrands(items, locale), nil } -// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名 + 档案数)。 -func (s *brandService) toPublicBrands(items []model.Brand, counts map[uint32]int, locale string) []dto.PublicBrand { +// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名)。 +func (s *brandService) toPublicBrands(items []model.Brand, locale string) []dto.PublicBrand { list := make([]dto.PublicBrand, 0, len(items)) for _, b := range items { list = append(list, dto.PublicBrand{ - UID: hashid.Encode(b.ID), - Name: pickLocale(b.NameEn, b.NameCn, locale), - NameEn: b.NameEn, - NameCn: b.NameCn, - ShowName: b.NameCn, - ArticleCount: counts[b.ID], + UID: hashid.Encode(b.ID), + Name: pickLocale(b.NameEn, b.NameCn, locale), + NameEn: b.NameEn, + NameCn: b.NameCn, }) } return list