update
This commit is contained in:
@ -75,6 +75,8 @@ func main() {
|
|||||||
userRepo = repository.NewUserRepository(db)
|
userRepo = repository.NewUserRepository(db)
|
||||||
snapRepo = repository.NewStreetSnapRepository(db)
|
snapRepo = repository.NewStreetSnapRepository(db)
|
||||||
refreshRepo = repository.NewRefreshTokenRepository(db)
|
refreshRepo = repository.NewRefreshTokenRepository(db)
|
||||||
|
favRepo = repository.NewFavoriteRepository(db)
|
||||||
|
histRepo = repository.NewHistoryRepository(db)
|
||||||
|
|
||||||
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||||
|
|
||||||
@ -83,6 +85,8 @@ func main() {
|
|||||||
indexSvc = service.NewIndexService(brandRepo)
|
indexSvc = service.NewIndexService(brandRepo)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo)
|
snapSvc = service.NewStreetSnapService(snapRepo)
|
||||||
authSvc = service.NewAuthService(userRepo, refreshRepo, jwtManager, cfg.JWT.ExpireHours, cfg.JWT.RefreshExpireHours)
|
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{
|
engine := router.New(router.Options{
|
||||||
@ -92,6 +96,8 @@ func main() {
|
|||||||
Brand: handler.NewBrandHandler(brandSvc),
|
Brand: handler.NewBrandHandler(brandSvc),
|
||||||
StreetSnap: handler.NewStreetSnapHandler(snapSvc),
|
StreetSnap: handler.NewStreetSnapHandler(snapSvc),
|
||||||
Auth: handler.NewAuthHandler(authSvc),
|
Auth: handler.NewAuthHandler(authSvc),
|
||||||
|
Favorite: handler.NewFavoriteHandler(favSvc),
|
||||||
|
History: handler.NewHistoryHandler(histSvc),
|
||||||
Health: handler.NewHealthHandler(),
|
Health: handler.NewHealthHandler(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ database:
|
|||||||
max_idle_conns: 10
|
max_idle_conns: 10
|
||||||
max_open_conns: 100
|
max_open_conns: 100
|
||||||
conn_max_lifetime: 3600 # 秒
|
conn_max_lifetime: 3600 # 秒
|
||||||
|
conn_max_idle_time: 60 # 秒,空闲连接回收时间;须 < MySQL wait_timeout,防止池子持有被回收的死连接导致 invalid connection
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
# 生产环境务必用环境变量覆盖为随机长字符串
|
# 生产环境务必用环境变量覆盖为随机长字符串
|
||||||
|
|||||||
@ -60,9 +60,12 @@ type DatabaseConfig struct {
|
|||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Charset string `yaml:"charset"`
|
Charset string `yaml:"charset"`
|
||||||
LogLevel string `yaml:"log_level"`
|
LogLevel string `yaml:"log_level"`
|
||||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||||
MaxOpenConns int `yaml:"max_open_conns"`
|
MaxOpenConns int `yaml:"max_open_conns"`
|
||||||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||||||
|
// ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲
|
||||||
|
// 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。
|
||||||
|
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DSN 组装 MySQL 连接串。
|
// DSN 组装 MySQL 连接串。
|
||||||
@ -144,6 +147,7 @@ func defaultConfig() *Config {
|
|||||||
MaxIdleConns: 10,
|
MaxIdleConns: 10,
|
||||||
MaxOpenConns: 100,
|
MaxOpenConns: 100,
|
||||||
ConnMaxLifetime: 3600,
|
ConnMaxLifetime: 3600,
|
||||||
|
ConnMaxIdleTime: 60,
|
||||||
},
|
},
|
||||||
JWT: JWTConfig{
|
JWT: JWTConfig{
|
||||||
Secret: "dev-secret-change-me-fashion-2026",
|
Secret: "dev-secret-change-me-fashion-2026",
|
||||||
|
|||||||
@ -33,6 +33,11 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
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 {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
|||||||
27
internal/dto/favorite.go
Normal file
27
internal/dto/favorite.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
15
internal/dto/history.go
Normal file
15
internal/dto/history.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
119
internal/handler/favorite_handler.go
Normal file
119
internal/handler/favorite_handler.go
Normal file
@ -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: <FavoriteItem> }
|
||||||
|
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)
|
||||||
|
}
|
||||||
111
internal/handler/history_handler.go
Normal file
111
internal/handler/history_handler.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
20
internal/model/favorite.go
Normal file
20
internal/model/favorite.go
Normal file
@ -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" }
|
||||||
19
internal/model/history.go
Normal file
19
internal/model/history.go
Normal file
@ -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" }
|
||||||
84
internal/repository/favorite_repository.go
Normal file
84
internal/repository/favorite_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
97
internal/repository/history_repository.go
Normal file
97
internal/repository/history_repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -20,6 +20,8 @@ type Options struct {
|
|||||||
Brand *handler.BrandHandler
|
Brand *handler.BrandHandler
|
||||||
StreetSnap *handler.StreetSnapHandler
|
StreetSnap *handler.StreetSnapHandler
|
||||||
Auth *handler.AuthHandler
|
Auth *handler.AuthHandler
|
||||||
|
Favorite *handler.FavoriteHandler
|
||||||
|
History *handler.HistoryHandler
|
||||||
Health *handler.HealthHandler
|
Health *handler.HealthHandler
|
||||||
SSG *handler.SSGHandler
|
SSG *handler.SSGHandler
|
||||||
}
|
}
|
||||||
@ -81,9 +83,20 @@ func New(opt Options) *gin.Engine {
|
|||||||
auth.POST("/logout", opt.Auth.Logout)
|
auth.POST("/logout", opt.Auth.Logout)
|
||||||
// 全设备登出 / 踢下线:吊销该用户全部 refresh token
|
// 全设备登出 / 踢下线:吊销该用户全部 refresh token
|
||||||
auth.POST("/logout-all", opt.Auth.LogoutAll)
|
auth.POST("/logout-all", opt.Auth.LogoutAll)
|
||||||
// 取当前用户(需 Bearer)
|
// 取当前用户(需 Bearer)
|
||||||
auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me)
|
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
|
return r
|
||||||
|
|||||||
102
internal/service/favorite_service.go
Normal file
102
internal/service/favorite_service.go
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
79
internal/service/history_service.go
Normal file
79
internal/service/history_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -38,4 +38,61 @@ func main() {
|
|||||||
log.Fatalf("create refresh_tokens: %v", err)
|
log.Fatalf("create refresh_tokens: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("✓ refresh_tokens ready")
|
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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,34 +62,46 @@ func main() {
|
|||||||
log.Println("✓ users 表就绪")
|
log.Println("✓ users 表就绪")
|
||||||
|
|
||||||
// 2. 预置内部账号
|
// 2. 预置内部账号
|
||||||
const username = "admin"
|
// admin: 正式内部账号,密码取环境变量 SEED_ADMIN_PASSWORD(缺省回落强密码常量)。
|
||||||
const email = "admin@studio.local"
|
// root: 测试账号,固定弱密码 root,仅供本地联调使用,勿用于生产。
|
||||||
password := os.Getenv("SEED_ADMIN_PASSWORD")
|
adminPassword := os.Getenv("SEED_ADMIN_PASSWORD")
|
||||||
if password == "" {
|
if adminPassword == "" {
|
||||||
password = "Studio#2026!Admin"
|
adminPassword = "Studio#2026!Admin"
|
||||||
}
|
}
|
||||||
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
accounts := []struct {
|
||||||
if e != nil {
|
username string
|
||||||
log.Fatalf("✗ 生成密码哈希失败: %v", e)
|
email string
|
||||||
|
password string
|
||||||
|
}{
|
||||||
|
{username: "admin", email: "admin@studio.local", password: adminPassword},
|
||||||
|
{username: "root", email: "root@studio.local", password: "root"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing model.User
|
for _, acc := range accounts {
|
||||||
qerr := db.Where("username = ?", username).First(&existing).Error
|
hash, e := bcrypt.GenerateFromPassword([]byte(acc.password), bcrypt.DefaultCost)
|
||||||
switch {
|
if e != nil {
|
||||||
case errors.Is(qerr, gorm.ErrRecordNotFound):
|
log.Fatalf("✗ 生成密码哈希失败(%s): %v", acc.username, e)
|
||||||
user := model.User{Username: username, Email: email, PasswordHash: string(hash)}
|
|
||||||
if cerr := db.Create(&user).Error; cerr != nil {
|
|
||||||
log.Fatalf("✗ 创建内部账号失败: %v", cerr)
|
|
||||||
}
|
}
|
||||||
log.Printf("✓ 已创建内部账号: %s / %s", username, email)
|
|
||||||
case qerr != nil:
|
var existing model.User
|
||||||
log.Fatalf("✗ 查询账号失败: %v", qerr)
|
qerr := db.Where("username = ?", acc.username).First(&existing).Error
|
||||||
default:
|
switch {
|
||||||
existing.PasswordHash = string(hash)
|
case errors.Is(qerr, gorm.ErrRecordNotFound):
|
||||||
if uerr := db.Save(&existing).Error; uerr != nil {
|
user := model.User{Username: acc.username, Email: acc.email, PasswordHash: string(hash)}
|
||||||
log.Fatalf("✗ 更新账号密码失败: %v", uerr)
|
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 为弱密码测试账号,仅本地使用")
|
||||||
}
|
}
|
||||||
|
|||||||
17
scripts/sql/007_create_favorites.sql
Normal file
17
scripts/sql/007_create_favorites.sql
Normal file
@ -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;
|
||||||
21
scripts/sql/008_create_histories.sql
Normal file
21
scripts/sql/008_create_histories.sql
Normal file
@ -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;
|
||||||
BIN
server-bin.exe
Normal file
BIN
server-bin.exe
Normal file
Binary file not shown.
6
server.err
Normal file
6
server.err
Normal file
@ -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
|
||||||
63
server.out
Normal file
63
server.out
Normal file
@ -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"
|
||||||
Reference in New Issue
Block a user