diff --git a/cmd/server/main.go b/cmd/server/main.go index 1e67ad3..32a0610 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -75,6 +75,8 @@ func main() { userRepo = repository.NewUserRepository(db) snapRepo = repository.NewStreetSnapRepository(db) refreshRepo = repository.NewRefreshTokenRepository(db) + favRepo = repository.NewFavoriteRepository(db) + histRepo = repository.NewHistoryRepository(db) jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours) @@ -83,6 +85,8 @@ func main() { indexSvc = service.NewIndexService(brandRepo) snapSvc = service.NewStreetSnapService(snapRepo) authSvc = service.NewAuthService(userRepo, refreshRepo, jwtManager, cfg.JWT.ExpireHours, cfg.JWT.RefreshExpireHours) + favSvc = service.NewFavoriteService(favRepo) + histSvc = service.NewHistoryService(histRepo) ) engine := router.New(router.Options{ @@ -92,6 +96,8 @@ func main() { Brand: handler.NewBrandHandler(brandSvc), StreetSnap: handler.NewStreetSnapHandler(snapSvc), Auth: handler.NewAuthHandler(authSvc), + Favorite: handler.NewFavoriteHandler(favSvc), + History: handler.NewHistoryHandler(histSvc), Health: handler.NewHealthHandler(), }) diff --git a/configs/config.yml b/configs/config.yml index 301e5f9..da29254 100644 --- a/configs/config.yml +++ b/configs/config.yml @@ -32,6 +32,7 @@ database: max_idle_conns: 10 max_open_conns: 100 conn_max_lifetime: 3600 # 秒 + conn_max_idle_time: 60 # 秒,空闲连接回收时间;须 < MySQL wait_timeout,防止池子持有被回收的死连接导致 invalid connection jwt: # 生产环境务必用环境变量覆盖为随机长字符串 diff --git a/internal/config/config.go b/internal/config/config.go index 30e71b5..f53ae29 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,9 +60,12 @@ type DatabaseConfig struct { Name string `yaml:"name"` Charset string `yaml:"charset"` LogLevel string `yaml:"log_level"` - MaxIdleConns int `yaml:"max_idle_conns"` - MaxOpenConns int `yaml:"max_open_conns"` - ConnMaxLifetime int `yaml:"conn_max_lifetime"` + MaxIdleConns int `yaml:"max_idle_conns"` + MaxOpenConns int `yaml:"max_open_conns"` + ConnMaxLifetime int `yaml:"conn_max_lifetime"` + // ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲 + // 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。 + ConnMaxIdleTime int `yaml:"conn_max_idle_time"` } // DSN 组装 MySQL 连接串。 @@ -144,6 +147,7 @@ func defaultConfig() *Config { MaxIdleConns: 10, MaxOpenConns: 100, ConnMaxLifetime: 3600, + ConnMaxIdleTime: 60, }, JWT: JWTConfig{ Secret: "dev-secret-change-me-fashion-2026", diff --git a/internal/database/mysql.go b/internal/database/mysql.go index 33df778..01e4e22 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -33,6 +33,11 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) { sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + // 空闲回收:定期关闭空闲连接,避免持有被 MySQL wait_timeout 回收的死连接。 + // 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。 + if cfg.ConnMaxIdleTime > 0 { + sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) + } // 立刻探活,避免"启动成功但首个请求才报错" if err := sqlDB.Ping(); err != nil { diff --git a/internal/dto/favorite.go b/internal/dto/favorite.go new file mode 100644 index 0000000..1f646a2 --- /dev/null +++ b/internal/dto/favorite.go @@ -0,0 +1,27 @@ +package dto + +// ---------- 收藏 ---------- + +// FavoriteItem 对外返回的收藏项:id 即 target_uid,便于前端直接比对卡片 id。 +type FavoriteItem struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Cover string `json:"cover"` + Brand string `json:"brand"` + AddedAt uint32 `json:"added_at"` +} + +// FavoriteAdd 添加收藏请求体。target_uid 为前端卡片的 it.id(编码串)。 +type FavoriteAdd struct { + TargetType string `json:"target_type" binding:"required"` // runway | street + TargetUID string `json:"target_uid" binding:"required"` + Title string `json:"title"` + Cover string `json:"cover"` + Brand string `json:"brand"` +} + +// FavoriteCheck 批量查询哪些 target_uid 已收藏(列表页打勾用,与收藏总量解耦)。 +type FavoriteCheck struct { + IDs []string `json:"ids" binding:"required"` +} diff --git a/internal/dto/history.go b/internal/dto/history.go new file mode 100644 index 0000000..c954d75 --- /dev/null +++ b/internal/dto/history.go @@ -0,0 +1,15 @@ +package dto + +// ---------- 浏览历史 ---------- + +// HistoryItem 对外返回的浏览历史项:id 即 target_uid(带 r=/s= 前缀的对外编码串)。 +// 前端据此判断类型(s= 街拍,其余走秀)并回查公开详情接口取标题/封面用于渲染。 +type HistoryItem struct { + ID string `json:"id"` + ViewedAt uint32 `json:"viewed_at"` +} + +// HistoryAdd 记录一次浏览请求体:打开某篇文章详情时上报其对外编码串 id。 +type HistoryAdd struct { + TargetUID string `json:"target_uid" binding:"required"` +} diff --git a/internal/handler/favorite_handler.go b/internal/handler/favorite_handler.go new file mode 100644 index 0000000..0db3af4 --- /dev/null +++ b/internal/handler/favorite_handler.go @@ -0,0 +1,119 @@ +package handler + +import ( + "net/http" + "strconv" + + "fashionapi/internal/dto" + "fashionapi/internal/middleware" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// FavoriteHandler 收藏接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。 +type FavoriteHandler struct { + favs service.FavoriteService +} + +// NewFavoriteHandler 创建收藏 handler。 +func NewFavoriteHandler(favs service.FavoriteService) *FavoriteHandler { + return &FavoriteHandler{favs: favs} +} + +// List 当前用户收藏(服务端分页)。 +// +// GET /api/v1/auth/favorites?page=1&per_page=24(per_page 上限 100) +// 成功:200 { data: { items:[...], total, last_page } } +func (h *FavoriteHandler) List(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24")) + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 24 + } + items, total, err := h.favs.ListPaged(c.Request.Context(), userID, page, perPage) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{ + "items": items, + "total": total, + "last_page": response.LastPage(total, perPage), + }) +} + +// Add 新增收藏(幂等:重复添加不报错)。 +// +// POST /api/v1/auth/favorites +// body: { target_type, target_uid, title?, cover?, brand? } +// 成功:200 { data: } +func (h *FavoriteHandler) Add(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + var req dto.FavoriteAdd + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request") + return + } + item, err := h.favs.Add(c.Request.Context(), userID, req) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, item) +} + +// Remove 删除收藏。 +// +// DELETE /api/v1/auth/favorites/:target_uid +// 成功:200 { data: { ok: true } } +func (h *FavoriteHandler) Remove(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + uid := c.Param("target_uid") + if err := h.favs.Remove(c.Request.Context(), userID, uid); err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{"ok": true}) +} + +// Check 批量查询给定 ids 中哪些已收藏(列表页打勾用,与收藏总量解耦)。 +// +// POST /api/v1/auth/favorites/check +// body: { ids: ["r=...","s=..."] } +// 成功:200 { data: ["r=..."] }(仅返回已收藏的 id) +func (h *FavoriteHandler) Check(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + var req dto.FavoriteCheck + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request") + return + } + ids, err := h.favs.Check(c.Request.Context(), userID, req.IDs) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, ids) +} diff --git a/internal/handler/history_handler.go b/internal/handler/history_handler.go new file mode 100644 index 0000000..30b0a6c --- /dev/null +++ b/internal/handler/history_handler.go @@ -0,0 +1,111 @@ +package handler + +import ( + "net/http" + "strconv" + + "fashionapi/internal/dto" + "fashionapi/internal/middleware" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// HistoryHandler 浏览历史接口(需 Bearer 鉴权,挂在 /api/v1/auth 下)。 +type HistoryHandler struct { + hist service.HistoryService +} + +// NewHistoryHandler 创建浏览历史 handler。 +func NewHistoryHandler(hist service.HistoryService) *HistoryHandler { + return &HistoryHandler{hist: hist} +} + +// List 当前用户浏览历史(服务端分页)。 +// +// GET /api/v1/auth/history?page=1&per_page=24 +// 成功:200 { data: { items:[...], total, last_page } } +func (h *HistoryHandler) List(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "24")) + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 24 + } + items, total, err := h.hist.ListPaged(c.Request.Context(), userID, page, perPage) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{ + "items": items, + "total": total, + "last_page": response.LastPage(total, perPage), + }) +} + +// Record 记录一次浏览(点开文章详情;upsert:同文章刷新时间,不新增行)。 +// +// POST /api/v1/auth/history +// body: { target_uid } // 带 r=/s= 前缀的对外编码串 +// 成功:200 { data: { ok: true } } +func (h *HistoryHandler) Record(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + var req dto.HistoryAdd + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request") + return + } + if err := h.hist.Record(c.Request.Context(), userID, req); err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{"ok": true}) +} + +// Remove 删除一条历史。 +// +// DELETE /api/v1/auth/history/:target_uid +// 成功:200 { data: { ok: true } } +func (h *HistoryHandler) Remove(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + uid := c.Param("target_uid") + if err := h.hist.Remove(c.Request.Context(), userID, uid); err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{"ok": true}) +} + +// Clear 清空当前用户全部历史。 +// +// DELETE /api/v1/auth/history +// 成功:200 { data: { ok: true } } +func (h *HistoryHandler) Clear(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + if err := h.hist.Clear(c.Request.Context(), userID); err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/model/favorite.go b/internal/model/favorite.go new file mode 100644 index 0000000..7fabada --- /dev/null +++ b/internal/model/favorite.go @@ -0,0 +1,20 @@ +package model + +// Favorite 用户收藏(服务端持久化,按用户隔离)。 +// +// 存储「对外编码 uid」(如 runway 的 r= 前缀串、street 的 s= 前缀串)而非数字主键, +// 这样无需 decode 即可与前端卡片的 it.id 直接对齐,取消收藏时也只靠该串匹配。 +// title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表。 +type Favorite struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UserID uint32 `gorm:"column:user_id;index:idx_user;not null" json:"user_id"` + TargetType string `gorm:"column:target_type;size:16;not null" json:"target_type"` // runway | street + TargetUID string `gorm:"column:target_uid;size:64;not null;uniqueIndex:uniq_user_target" json:"target_uid"` + Title string `gorm:"column:title;size:255" json:"title"` + Cover string `gorm:"column:cover;size:512" json:"cover"` + Brand string `gorm:"column:brand;size:255" json:"brand"` +} + +// TableName 指定表名。 +func (Favorite) TableName() string { return "favorites" } diff --git a/internal/model/history.go b/internal/model/history.go new file mode 100644 index 0000000..88e33ed --- /dev/null +++ b/internal/model/history.go @@ -0,0 +1,19 @@ +package model + +// History 用户浏览历史(服务端持久化,按用户隔离,跨设备一致)。 +// +// 2026-09-02 收敛为「文章级」记录:点开某篇走秀/街拍详情(/item/xxx)时记一条, +// 进入列表页不再记。仅存三要素:user_id + target_uid + viewed_at。 +// target_uid 为带类型前缀的对外编码串(runway r=xxx / street s=xxx,见 hashid.EncodeTyped), +// 前缀本身携带类型,无需 kind / target_type 列;title/cover/brand 快照也已移除, +// 账户页展示时按 target_uid 回查公开详情接口(保持表最简、数据新鲜)。 +// 唯一键 (user_id, target_uid) 保证同一篇文章反复打开只刷新 viewed_at、不新增行。 +type History struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + ViewedAt uint32 `gorm:"column:viewed_at" json:"viewed_at"` + UserID uint32 `gorm:"column:user_id;not null" json:"user_id"` + TargetUID string `gorm:"column:target_uid;size:64;not null;uniqueIndex:uniq_user_target" json:"target_uid"` +} + +// TableName 指定表名。 +func (History) TableName() string { return "histories" } diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go new file mode 100644 index 0000000..768207a --- /dev/null +++ b/internal/repository/favorite_repository.go @@ -0,0 +1,84 @@ +package repository + +import ( + "context" + + "fashionapi/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// FavoriteRepository 收藏数据访问接口。 +type FavoriteRepository interface { + // ListPaged 分页取某用户收藏,按收藏时间倒序。 + ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.Favorite, error) + // CountByUser 取某用户收藏总数(分页用)。 + CountByUser(ctx context.Context, userID uint32) (int64, error) + // Add 新增收藏;同用户同 target 已存在时幂等忽略(ON DUPLICATE KEY DO NOTHING)。 + Add(ctx context.Context, f *model.Favorite) error + // Remove 删除某用户对某个 target 的收藏。 + Remove(ctx context.Context, userID uint32, targetUID string) error + // FilterExisting 返回在给定 ids 中、且属于该用户已收藏的 target_uid 子集(列表页打勾用)。 + FilterExisting(ctx context.Context, userID uint32, ids []string) ([]string, error) +} + +type favoriteRepository struct { + db *gorm.DB +} + +// NewFavoriteRepository 创建收藏仓储。 +func NewFavoriteRepository(db *gorm.DB) FavoriteRepository { + return &favoriteRepository{db: db} +} + +func (r *favoriteRepository) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.Favorite, error) { + var rows []model.Favorite + offset := (page - 1) * perPage + if offset < 0 { + offset = 0 + } + err := r.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset). + Limit(perPage). + Find(&rows).Error + return rows, err +} + +func (r *favoriteRepository) CountByUser(ctx context.Context, userID uint32) (int64, error) { + var n int64 + err := r.db.WithContext(ctx). + Model(&model.Favorite{}). + Where("user_id = ?", userID). + Count(&n).Error + return n, err +} + +func (r *favoriteRepository) Add(ctx context.Context, f *model.Favorite) error { + // 复合唯一索引 (user_id, target_uid) 保证不重复;冲突时忽略而非报错,使接口天然幂等。 + return r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "target_uid"}}, + DoNothing: true, + }).Create(f).Error +} + +func (r *favoriteRepository) Remove(ctx context.Context, userID uint32, targetUID string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND target_uid = ?", userID, targetUID). + Delete(&model.Favorite{}).Error +} + +func (r *favoriteRepository) FilterExisting(ctx context.Context, userID uint32, ids []string) ([]string, error) { + if len(ids) == 0 { + return []string{}, nil + } + var uids []string + err := r.db.WithContext(ctx). + Model(&model.Favorite{}). + Where("user_id = ? AND target_uid IN ?", userID, ids). + Pluck("target_uid", &uids).Error + return uids, err +} diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go new file mode 100644 index 0000000..b002f10 --- /dev/null +++ b/internal/repository/history_repository.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + + "fashionapi/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// 每个用户浏览历史硬上限:超出后 FIFO 删除最旧条目,避免重度用户把表撑爆。 +const historyCap = 2000 + +// HistoryRepository 浏览历史数据访问接口。 +type HistoryRepository interface { + // Upsert 记录一次浏览:同一 (user_id, target_uid)(一篇文章)已存在时只刷新 viewed_at, + // 不新增行;插入/刷新后若超出用户上限,按 viewed_at 升序 FIFO 删最旧。 + Upsert(ctx context.Context, h *model.History) error + // ListPaged 分页取某用户历史,按 viewed_at 倒序。 + ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.History, error) + // CountByUser 取某用户历史总数(分页用)。 + CountByUser(ctx context.Context, userID uint32) (int64, error) + // Remove 删除某用户对某篇文章(target_uid)的历史。 + Remove(ctx context.Context, userID uint32, targetUID string) error + // Clear 清空某用户全部历史。 + Clear(ctx context.Context, userID uint32) error +} + +type historyRepository struct { + db *gorm.DB +} + +// NewHistoryRepository 创建浏览历史仓储。 +func NewHistoryRepository(db *gorm.DB) HistoryRepository { + return &historyRepository{db: db} +} + +func (r *historyRepository) Upsert(ctx context.Context, h *model.History) error { + // 复合唯一索引 (user_id, target_uid) 保证同一篇文章不重复;冲突时刷新 viewed_at, + // 反复打开同一篇文章只更新最后浏览时间,行数不增长。 + err := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "target_uid"}}, + DoUpdates: clause.AssignmentColumns([]string{"viewed_at"}), + }).Create(h).Error + if err != nil { + return err + } + // 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避 MySQL「同表不可直接子查删除」限制)。 + var n int64 + if err := r.db.WithContext(ctx).Model(&model.History{}).Where("user_id = ?", h.UserID).Count(&n).Error; err != nil { + return err + } + if n > historyCap { + excess := n - historyCap + if err := r.db.WithContext(ctx).Exec( + "DELETE FROM histories WHERE user_id = ? AND id IN (SELECT id FROM (SELECT id FROM histories WHERE user_id = ? ORDER BY viewed_at ASC LIMIT ?) AS t)", + h.UserID, h.UserID, excess, + ).Error; err != nil { + return err + } + } + return nil +} + +func (r *historyRepository) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]model.History, error) { + var rows []model.History + offset := (page - 1) * perPage + if offset < 0 { + offset = 0 + } + err := r.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("viewed_at DESC"). + Offset(offset).Limit(perPage). + Find(&rows).Error + return rows, err +} + +func (r *historyRepository) CountByUser(ctx context.Context, userID uint32) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.History{}).Where("user_id = ?", userID).Count(&n).Error + return n, err +} + +func (r *historyRepository) Remove(ctx context.Context, userID uint32, targetUID string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND target_uid = ?", userID, targetUID). + Delete(&model.History{}).Error +} + +func (r *historyRepository) Clear(ctx context.Context, userID uint32) error { + return r.db.WithContext(ctx). + Where("user_id = ?", userID). + Delete(&model.History{}).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index bdfdcbb..e9b63f1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -20,6 +20,8 @@ type Options struct { Brand *handler.BrandHandler StreetSnap *handler.StreetSnapHandler Auth *handler.AuthHandler + Favorite *handler.FavoriteHandler + History *handler.HistoryHandler Health *handler.HealthHandler SSG *handler.SSGHandler } @@ -81,9 +83,20 @@ func New(opt Options) *gin.Engine { auth.POST("/logout", opt.Auth.Logout) // 全设备登出 / 踢下线:吊销该用户全部 refresh token auth.POST("/logout-all", opt.Auth.LogoutAll) - // 取当前用户(需 Bearer) - auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) - } + // 取当前用户(需 Bearer) + auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) + // 收藏(需 Bearer):列表 / 新增 / 删除 + auth.GET("/favorites", middleware.Auth(opt.JWT), opt.Favorite.List) + auth.POST("/favorites", middleware.Auth(opt.JWT), opt.Favorite.Add) + auth.DELETE("/favorites/:target_uid", middleware.Auth(opt.JWT), opt.Favorite.Remove) + // 批量校验:列表页把当前可见 id 发来,问哪些已收藏(与收藏总量解耦,10万也常数级) + auth.POST("/favorites/check", middleware.Auth(opt.JWT), opt.Favorite.Check) + // 浏览历史(需 Bearer):分页列表(可按 kind 过滤)/ 记录 / 删除 / 清空 + auth.GET("/history", middleware.Auth(opt.JWT), opt.History.List) + auth.POST("/history", middleware.Auth(opt.JWT), opt.History.Record) + auth.DELETE("/history/:target_uid", middleware.Auth(opt.JWT), opt.History.Remove) + auth.DELETE("/history", middleware.Auth(opt.JWT), opt.History.Clear) + } } return r diff --git a/internal/service/favorite_service.go b/internal/service/favorite_service.go new file mode 100644 index 0000000..eac97c1 --- /dev/null +++ b/internal/service/favorite_service.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "time" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/repository" +) + +// FavoriteService 收藏业务接口。所有方法均要求调用方已通过中间件解析出 userID。 +type FavoriteService interface { + // ListPaged 分页取当前用户收藏(服务端分页,与收藏总量解耦)。 + ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.FavoriteItem, int64, error) + // Add 新增收藏(幂等)。 + Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) + // Remove 删除收藏。 + Remove(ctx context.Context, userID uint32, targetUID string) error + // Check 返回给定 ids 中已收藏的子集(列表页打勾用)。 + Check(ctx context.Context, userID uint32, ids []string) ([]string, error) +} + +type favoriteService struct { + favs repository.FavoriteRepository +} + +// NewFavoriteService 创建收藏服务。 +func NewFavoriteService(favs repository.FavoriteRepository) FavoriteService { + return &favoriteService{favs: favs} +} + +func (s *favoriteService) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.FavoriteItem, int64, error) { + rows, err := s.favs.ListPaged(ctx, userID, page, perPage) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + total, err := s.favs.CountByUser(ctx, userID) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + out := make([]dto.FavoriteItem, 0, len(rows)) + for _, r := range rows { + out = append(out, toFavoriteItem(r)) + } + return out, total, nil +} + +func (s *favoriteService) Add(ctx context.Context, userID uint32, req dto.FavoriteAdd) (*dto.FavoriteItem, error) { + if req.TargetType != "runway" && req.TargetType != "street" { + return nil, badRequest("invalid target_type") + } + if req.TargetUID == "" { + return nil, badRequest("target_uid required") + } + row := &model.Favorite{ + UserID: userID, + TargetType: req.TargetType, + TargetUID: req.TargetUID, + Title: req.Title, + Cover: req.Cover, + Brand: req.Brand, + CreatedAt: uint32(time.Now().Unix()), + } + if err := s.favs.Add(ctx, row); err != nil { + return nil, internalErr(err.Error()) + } + item := toFavoriteItem(*row) + return &item, nil +} + +func (s *favoriteService) Remove(ctx context.Context, userID uint32, targetUID string) error { + if targetUID == "" { + return badRequest("target_uid required") + } + if err := s.favs.Remove(ctx, userID, targetUID); err != nil { + return internalErr(err.Error()) + } + return nil +} + +func (s *favoriteService) Check(ctx context.Context, userID uint32, ids []string) ([]string, error) { + if len(ids) == 0 { + return []string{}, nil + } + got, err := s.favs.FilterExisting(ctx, userID, ids) + if err != nil { + return nil, internalErr(err.Error()) + } + return got, nil +} + +func toFavoriteItem(r model.Favorite) dto.FavoriteItem { + return dto.FavoriteItem{ + ID: r.TargetUID, + Type: r.TargetType, + Title: r.Title, + Cover: r.Cover, + Brand: r.Brand, + AddedAt: r.CreatedAt, + } +} diff --git a/internal/service/history_service.go b/internal/service/history_service.go new file mode 100644 index 0000000..7b5e8ce --- /dev/null +++ b/internal/service/history_service.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "time" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/repository" +) + +// HistoryService 浏览历史业务接口。所有方法均要求调用方已通过中间件解析出 userID。 +type HistoryService interface { + // Record 记录一次浏览:点开某篇文章详情时上报其对外编码串 id(upsert:同文章刷新时间)。 + Record(ctx context.Context, userID uint32, req dto.HistoryAdd) error + // ListPaged 分页取当前用户历史(服务端分页)。 + ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.HistoryItem, int64, error) + // Remove 删除某条历史。 + Remove(ctx context.Context, userID uint32, targetUID string) error + // Clear 清空当前用户全部历史。 + Clear(ctx context.Context, userID uint32) error +} + +type historyService struct { + hist repository.HistoryRepository +} + +// NewHistoryService 创建浏览历史服务。 +func NewHistoryService(hist repository.HistoryRepository) HistoryService { + return &historyService{hist: hist} +} + +func (s *historyService) Record(ctx context.Context, userID uint32, req dto.HistoryAdd) error { + if req.TargetUID == "" { + return badRequest("target_uid required") + } + row := &model.History{ + UserID: userID, + TargetUID: req.TargetUID, + ViewedAt: uint32(time.Now().Unix()), + } + if err := s.hist.Upsert(ctx, row); err != nil { + return internalErr(err.Error()) + } + return nil +} + +func (s *historyService) ListPaged(ctx context.Context, userID uint32, page, perPage int) ([]dto.HistoryItem, int64, error) { + rows, err := s.hist.ListPaged(ctx, userID, page, perPage) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + total, err := s.hist.CountByUser(ctx, userID) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + out := make([]dto.HistoryItem, 0, len(rows)) + for _, r := range rows { + out = append(out, dto.HistoryItem{ID: r.TargetUID, ViewedAt: r.ViewedAt}) + } + return out, total, nil +} + +func (s *historyService) Remove(ctx context.Context, userID uint32, targetUID string) error { + if targetUID == "" { + return badRequest("target_uid required") + } + if err := s.hist.Remove(ctx, userID, targetUID); err != nil { + return internalErr(err.Error()) + } + return nil +} + +func (s *historyService) Clear(ctx context.Context, userID uint32) error { + if err := s.hist.Clear(ctx, userID); err != nil { + return internalErr(err.Error()) + } + return nil +} diff --git a/scripts/migrate_refresh/main.go b/scripts/migrate_refresh/main.go index 4bca971..12ad231 100644 --- a/scripts/migrate_refresh/main.go +++ b/scripts/migrate_refresh/main.go @@ -38,4 +38,61 @@ func main() { log.Fatalf("create refresh_tokens: %v", err) } log.Println("✓ refresh_tokens ready") + + stmtFav := `CREATE TABLE IF NOT EXISTS favorites ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + created_at INT UNSIGNED NOT NULL DEFAULT 0, + user_id INT UNSIGNED NOT NULL, + target_type VARCHAR(16) NOT NULL DEFAULT '', + target_uid VARCHAR(64) NOT NULL DEFAULT '', + title VARCHAR(255) NOT NULL DEFAULT '', + cover VARCHAR(512) NOT NULL DEFAULT '', + brand VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uniq_user_target (user_id, target_uid), + KEY idx_user (user_id), + KEY idx_user_created (user_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` + if err := db.Exec(stmtFav).Error; err != nil { + log.Fatalf("create favorites: %v", err) + } + // 复合索引(user_id, created_at):优化收藏分页深翻页(10万收藏场景);已存在则忽略报错 + if err := db.Exec("ALTER TABLE favorites ADD INDEX idx_user_created (user_id, created_at)").Error; err != nil { + log.Printf("ℹ favorites idx_user_created 已存在,可忽略: %v", err) + } + log.Println("✓ favorites ready") + + // histories:文章级浏览历史(2026-09-02 收敛)。 + // 仅存 user_id + target_uid(带 r=/s= 前缀的对外编码串,前缀即类型)+ viewed_at; + // kind/target_type/title/cover/brand 快照已弃用(账户页展示时回查公开详情接口)。 + stmtHist := `CREATE TABLE IF NOT EXISTS histories ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + viewed_at INT UNSIGNED NOT NULL DEFAULT 0, + user_id INT UNSIGNED NOT NULL, + target_uid VARCHAR(64) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uniq_user_target (user_id, target_uid), + KEY idx_user_viewed (user_id, viewed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` + if err := db.Exec(stmtHist).Error; err != nil { + log.Fatalf("create histories: %v", err) + } + // —— 存量库收敛(幂等):清掉旧固定串行(runway-looks/street-snaps),卸掉旧列与旧唯一键,换上新的 —— + // 旧结构列/键可能不存在(新库)或已收敛(二次执行),每条失败仅提示、不中断。 + trimHist := []string{ + "DELETE FROM histories WHERE target_uid NOT LIKE 'r=%' AND target_uid NOT LIKE 's=%'", + "ALTER TABLE histories DROP KEY uniq_user_kind_target", + "ALTER TABLE histories DROP COLUMN kind", + "ALTER TABLE histories DROP COLUMN target_type", + "ALTER TABLE histories DROP COLUMN title", + "ALTER TABLE histories DROP COLUMN cover", + "ALTER TABLE histories DROP COLUMN brand", + "ALTER TABLE histories ADD UNIQUE KEY uniq_user_target (user_id, target_uid)", + } + for _, stmt := range trimHist { + if err := db.Exec(stmt).Error; err != nil { + log.Printf("ℹ histories 收敛步骤可忽略(可能已收敛/新库): %v", err) + } + } + log.Println("✓ histories ready (article-level: user_id+target_uid+viewed_at)") } diff --git a/scripts/seed_users/main.go b/scripts/seed_users/main.go index f7229e2..5b46c07 100644 --- a/scripts/seed_users/main.go +++ b/scripts/seed_users/main.go @@ -62,34 +62,46 @@ func main() { log.Println("✓ users 表就绪") // 2. 预置内部账号 - const username = "admin" - const email = "admin@studio.local" - password := os.Getenv("SEED_ADMIN_PASSWORD") - if password == "" { - password = "Studio#2026!Admin" + // admin: 正式内部账号,密码取环境变量 SEED_ADMIN_PASSWORD(缺省回落强密码常量)。 + // root: 测试账号,固定弱密码 root,仅供本地联调使用,勿用于生产。 + adminPassword := os.Getenv("SEED_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "Studio#2026!Admin" } - hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if e != nil { - log.Fatalf("✗ 生成密码哈希失败: %v", e) + accounts := []struct { + username string + email string + password string + }{ + {username: "admin", email: "admin@studio.local", password: adminPassword}, + {username: "root", email: "root@studio.local", password: "root"}, } - var existing model.User - qerr := db.Where("username = ?", username).First(&existing).Error - switch { - case errors.Is(qerr, gorm.ErrRecordNotFound): - user := model.User{Username: username, Email: email, PasswordHash: string(hash)} - if cerr := db.Create(&user).Error; cerr != nil { - log.Fatalf("✗ 创建内部账号失败: %v", cerr) + for _, acc := range accounts { + hash, e := bcrypt.GenerateFromPassword([]byte(acc.password), bcrypt.DefaultCost) + if e != nil { + log.Fatalf("✗ 生成密码哈希失败(%s): %v", acc.username, e) } - log.Printf("✓ 已创建内部账号: %s / %s", username, email) - case qerr != nil: - log.Fatalf("✗ 查询账号失败: %v", qerr) - default: - existing.PasswordHash = string(hash) - if uerr := db.Save(&existing).Error; uerr != nil { - log.Fatalf("✗ 更新账号密码失败: %v", uerr) + + var existing model.User + qerr := db.Where("username = ?", acc.username).First(&existing).Error + switch { + case errors.Is(qerr, gorm.ErrRecordNotFound): + user := model.User{Username: acc.username, Email: acc.email, PasswordHash: string(hash)} + if cerr := db.Create(&user).Error; cerr != nil { + log.Fatalf("✗ 创建内部账号失败(%s): %v", acc.username, cerr) + } + log.Printf("✓ 已创建内部账号: %s / %s", acc.username, acc.email) + case qerr != nil: + log.Fatalf("✗ 查询账号失败(%s): %v", acc.username, qerr) + default: + existing.PasswordHash = string(hash) + if uerr := db.Save(&existing).Error; uerr != nil { + log.Fatalf("✗ 更新账号密码失败(%s): %v", acc.username, uerr) + } + log.Printf("✓ 内部账号已存在,已刷新密码哈希: %s", acc.username) } - log.Printf("✓ 内部账号已存在,已刷新密码哈希: %s", username) + log.Printf("ℹ 账号 %s 登录方式: %s / 密码: %s", acc.username, acc.username, acc.password) } - log.Printf("ℹ 登录账号: %s 密码: %s(请尽快修改默认密码)", username, password) + log.Println("⚠ admin 为强密码账号请尽快改默认密码;root 为弱密码测试账号,仅本地使用") } diff --git a/scripts/sql/007_create_favorites.sql b/scripts/sql/007_create_favorites.sql new file mode 100644 index 0000000..b471ff6 --- /dev/null +++ b/scripts/sql/007_create_favorites.sql @@ -0,0 +1,17 @@ +-- 007: favorites —— 用户收藏(服务端持久化,按用户隔离) +-- 与项目约定一致:表结构由 SQL 托管,不依赖 GORM AutoMigrate。 +-- target_uid 存前端卡片的 it.id(编码串),与前端直接对齐;title/cover/brand 为收藏时快照。 +CREATE TABLE IF NOT EXISTS `favorites` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `created_at` int unsigned NOT NULL DEFAULT 0, + `user_id` int unsigned NOT NULL, + `target_type` varchar(16) NOT NULL DEFAULT '', + `target_uid` varchar(64) NOT NULL DEFAULT '', + `title` varchar(255) NOT NULL DEFAULT '', + `cover` varchar(512) NOT NULL DEFAULT '', + `brand` varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`), + KEY `idx_user` (`user_id`), + KEY `idx_user_created` (`user_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/sql/008_create_histories.sql b/scripts/sql/008_create_histories.sql new file mode 100644 index 0000000..4d12d52 --- /dev/null +++ b/scripts/sql/008_create_histories.sql @@ -0,0 +1,21 @@ +-- 008: histories —— 用户浏览历史(服务端持久化,按用户隔离,跨设备一致) +-- 与 favorites 同理:表结构由 SQL 托管,不依赖 GORM AutoMigrate。 +-- +-- 2026-09-02 收敛为「文章级」浏览历史: +-- 1) 触发点:点开某篇走秀/街拍详情(/item/xxx)时记一条;进入列表页不再记。 +-- 2) 仅存 (user_id, target_uid, viewed_at):target_uid 为带类型前缀的对外编码串 +-- (runway r=xxx / street s=xxx,见后端 hashid.EncodeTyped),前缀本身携带类型, +-- 无需 kind / target_type 列。 +-- 3) title/cover/brand 快照已移除:账户页展示时按 target_uid 回查公开详情接口, +-- 保持表最简、展示数据新鲜。 +-- 唯一键 (user_id, target_uid):同一篇文章反复打开只 upsert 刷新 viewed_at,绝不新增行。 +-- 每用户上限 HISTORY_CAP(应用层维护,默认 2000),超出 FIFO 删最旧。 +CREATE TABLE IF NOT EXISTS `histories` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `viewed_at` int unsigned NOT NULL DEFAULT 0, + `user_id` int unsigned NOT NULL, + `target_uid` varchar(64) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`), + KEY `idx_user_viewed` (`user_id`, `viewed_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server-bin.exe b/server-bin.exe new file mode 100644 index 0000000..d0d9228 Binary files /dev/null and b/server-bin.exe differ diff --git a/server.err b/server.err new file mode 100644 index 0000000..33ba907 --- /dev/null +++ b/server.err @@ -0,0 +1,6 @@ +2026/09/02 15:39:33 ✓ 配置文件: D:\project\backend_v2\configs\config.yml +2026/09/02 15:39:33 ✓ MySQL 已连接: 127.0.0.1:3306/db_dev +2026/09/02 15:39:33 ✓ 静态资源: /uploads → D:\project\backend_v2\uploads +2026/09/02 15:39:33 🚀 公开服务已启动: http://localhost:8090 +2026/09/02 15:39:33 🔒 SSG 内部服务已启动: http://localhost:8091 (仅构建期/回环可达) +[mysql] 2026/09/02 16:01:06 connection.go:49: bad connection diff --git a/server.out b/server.out new file mode 100644 index 0000000..3a363da --- /dev/null +++ b/server.out @@ -0,0 +1,63 @@ +[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. + +[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + - using env: export GIN_MODE=release + - using code: gin.SetMode(gin.ReleaseMode) + +[GIN-debug] GET /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers) +[GIN-debug] HEAD /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers) +[GIN-debug] GET /api/v1/health --> fashionapi/internal/handler.(*HealthHandler).Check-fm (4 handlers) +[GIN-debug] GET /api/v1/public/runway-looks --> fashionapi/internal/handler.(*ArticleHandler).List-fm (6 handlers) +[GIN-debug] GET /api/v1/public/brands --> fashionapi/internal/handler.(*BrandHandler).List-fm (6 handlers) +[GIN-debug] GET /api/v1/public/street-snaps --> fashionapi/internal/handler.(*StreetSnapHandler).List-fm (6 handlers) +[GIN-debug] GET /api/v1/public/runway-looks/:id --> fashionapi/internal/handler.(*ArticleHandler).Detail-fm (4 handlers) +[GIN-debug] GET /api/v1/public/street-snaps/:id --> fashionapi/internal/handler.(*StreetSnapHandler).Detail-fm (4 handlers) +[GIN-debug] POST /api/v1/auth/login --> fashionapi/internal/handler.(*AuthHandler).Login-fm (4 handlers) +[GIN-debug] POST /api/v1/auth/refresh --> fashionapi/internal/handler.(*AuthHandler).Refresh-fm (4 handlers) +[GIN-debug] POST /api/v1/auth/logout --> fashionapi/internal/handler.(*AuthHandler).Logout-fm (4 handlers) +[GIN-debug] POST /api/v1/auth/logout-all --> fashionapi/internal/handler.(*AuthHandler).LogoutAll-fm (4 handlers) +[GIN-debug] GET /api/v1/auth/me --> fashionapi/internal/handler.(*AuthHandler).Me-fm (5 handlers) +[GIN-debug] GET /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).List-fm (5 handlers) +[GIN-debug] POST /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).Add-fm (5 handlers) +[GIN-debug] DELETE /api/v1/auth/favorites/:target_uid --> fashionapi/internal/handler.(*FavoriteHandler).Remove-fm (5 handlers) +[GIN-debug] POST /api/v1/auth/favorites/check --> fashionapi/internal/handler.(*FavoriteHandler).Check-fm (5 handlers) +[GIN-debug] GET /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).List-fm (5 handlers) +[GIN-debug] POST /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Record-fm (5 handlers) +[GIN-debug] DELETE /api/v1/auth/history/:target_uid --> fashionapi/internal/handler.(*HistoryHandler).Remove-fm (5 handlers) +[GIN-debug] DELETE /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Clear-fm (5 handlers) +[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + - using env: export GIN_MODE=release + - using code: gin.SetMode(gin.ReleaseMode) + +[GIN-debug] GET /api/v1/ssg/brands/hot --> fashionapi/internal/handler.(*SSGHandler).HotBrands-fm (4 handlers) +[GIN-debug] GET /api/v1/ssg/index/runway --> fashionapi/internal/handler.(*SSGHandler).IndexRunway-fm (4 handlers) +[GIN-debug] GET /api/v1/ssg/street-snaps/popular --> fashionapi/internal/handler.(*SSGHandler).StreetSnapPopular-fm (4 handlers) +[GIN] 2026/09/02 - 15:40:07 | 200 | 0s | ::1 | GET "/api/v1/health" +[GIN] 2026/09/02 - 15:40:07 | 200 | 22.41ms | ::1 | GET "/api/v1/public/brands" +[GIN] 2026/09/02 - 15:41:59 | 200 | 220.55ms | ::1 | POST "/api/v1/auth/login" +[GIN] 2026/09/02 - 15:41:59 | 200 | 22.64ms | ::1 | POST "/api/v1/auth/history" +[GIN] 2026/09/02 - 15:41:59 | 200 | 11.67ms | ::1 | POST "/api/v1/auth/history" +[GIN] 2026/09/02 - 15:41:59 | 200 | 1.03ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" +[GIN] 2026/09/02 - 15:41:59 | 200 | 2.82ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery" +[GIN] 2026/09/02 - 15:41:59 | 200 | 1.6ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image" +[GIN] 2026/09/02 - 15:41:59 | 200 | 17.42ms | ::1 | DELETE "/api/v1/auth/history/rTEST123" +[GIN] 2026/09/02 - 15:41:59 | 200 | 13.48ms | ::1 | DELETE "/api/v1/auth/history/runway-looks" +[GIN] 2026/09/02 - 15:43:41 | 200 | 129.48ms | ::1 | POST "/api/v1/auth/login" +[GIN] 2026/09/02 - 15:43:41 | 200 | 7.37ms | ::1 | POST "/api/v1/auth/history" +[GIN] 2026/09/02 - 15:43:41 | 200 | 10.92ms | ::1 | POST "/api/v1/auth/history" +[GIN] 2026/09/02 - 15:43:41 | 200 | 2.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" +[GIN] 2026/09/02 - 15:43:41 | 200 | 1.22ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery" +[GIN] 2026/09/02 - 15:43:41 | 200 | 1.57ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image" +[GIN] 2026/09/02 - 15:43:41 | 200 | 16.02ms | ::1 | POST "/api/v1/auth/history" +[GIN] 2026/09/02 - 15:43:41 | 200 | 1.88ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" +[GIN] 2026/09/02 - 15:43:41 | 200 | 10.24ms | ::1 | DELETE "/api/v1/auth/history/rTEST123" +[GIN] 2026/09/02 - 15:43:41 | 200 | 5.76ms | ::1 | DELETE "/api/v1/auth/history/runway-looks" +[GIN] 2026/09/02 - 15:43:41 | 200 | 1.15ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" +[GIN] 2026/09/02 - 16:01:06 | 200 | 14.3ms | ::1 | GET "/api/v1/auth/me?locale=en" +[GIN] 2026/09/02 - 16:01:06 | 200 | 14.8ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24" +[GIN] 2026/09/02 - 16:01:06 | 200 | 19.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all" +[GIN] 2026/09/02 - 16:01:09 | 200 | 13.48ms | ::1 | GET "/api/v1/public/runway-looks/r000AhByP?locale=cn" +[GIN] 2026/09/02 - 16:01:09 | 200 | 3.19ms | ::1 | GET "/api/v1/auth/me?locale=en" +[GIN] 2026/09/02 - 16:01:12 | 200 | 627.5µs | ::1 | GET "/api/v1/auth/me?locale=en" +[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24" +[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all"