update
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 首页"热门品牌"区块所需结构。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 返回"热门品牌"及其最新走秀的封面与标题。
|
||||
//
|
||||
// 规则:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user