This commit is contained in:
toom1996
2026-08-30 10:45:34 +08:00
parent a6724265ad
commit 9c3403a903
23 changed files with 284 additions and 508 deletions

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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 首页"热门品牌"区块所需结构。

View File

@ -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
}

View File

@ -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 <token>。
//
// GET /api/auth/me
@ -79,5 +38,3 @@ func (h *AuthHandler) Me(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"user": user})
}

View File

@ -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)
}

View File

@ -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 == "" {

View File

@ -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)
}

View File

@ -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"`

View File

@ -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"`
}

View File

@ -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) {

View File

@ -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),
})
}

View File

@ -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
}

View File

@ -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 返回"热门品牌"及其最新走秀的封面与标题。
//
// 规则:

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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}
}

View File

@ -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