This commit is contained in:
toom1996
2026-09-02 21:51:35 +08:00
parent 2562fc4aaa
commit 1fd9c48f58
22 changed files with 908 additions and 30 deletions

View File

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

View File

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

27
internal/dto/favorite.go Normal file
View 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
View 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"`
}

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

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

View 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
View 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" }

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

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

View File

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

View 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,
}
}

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