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

View File

@ -8,19 +8,13 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/textutil"
"fashionapi/internal/repository"
)
// summaryLength 对外摘要的截断长度(rune 数),与原项目一致。
const summaryLength = 140
// ArticleService 文章业务接口。
type ArticleService interface {
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// IDs 返回全部文章的编码 uid,供 SSG 路径枚举(getStaticPaths)。
IDs(ctx context.Context) ([]string, error)
}
type articleService struct {
@ -46,25 +40,17 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
for _, r := range rows {
ids = append(ids, r.ID)
list = append(list, dto.PublicArticle{
UID: hashid.Encode(r.ID),
BrandUID: hashid.Encode(r.BrandID),
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
Summary: textutil.Summarize(pickLocale(r.DescriptionEn, r.DescriptionCn, q.Locale), summaryLength),
Cover: r.Cover,
// 品牌名强制优先英文("en"),仅在英文缺失时回落中文,避免留空;
// 与 title/description 不同,品牌名不随 locale 切换,保持英文观感。
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"),
Year: r.Year,
ImageCount: r.ImageCount,
CollectionType: r.CollectionType,
Season: r.Season,
SeasonCode: r.SeasonCode,
PublishedAt: r.CreatedAt,
UID: hashid.Encode(r.ID),
BrandUID: hashid.Encode(r.BrandID),
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
Cover: r.Cover,
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
ImageCount: r.ImageCount,
})
}
if q.WithImages > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.WithImages); err != nil {
if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
return nil, 0, internalErr(err.Error())
}
}
@ -110,32 +96,15 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
UID: hashid.Encode(row.ID),
BrandUID: hashid.Encode(row.BrandID),
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
Summary: textutil.Summarize(pickLocale(row.DescriptionEn, row.DescriptionCn, locale), summaryLength),
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
Cover: row.Cover,
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Year: row.Year,
ImageCount: row.ImageCount,
SourceURL: row.SourceURL,
PublishedAt: row.CreatedAt,
Images: toPublicImages(imgs),
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Images: toPublicImages(imgs),
}, nil
}
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
func (s *articleService) IDs(ctx context.Context) ([]string, error) {
ids, err := s.articles.IDs(ctx)
if err != nil {
return nil, internalErr(err.Error())
}
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, hashid.Encode(id))
}
return out, nil
}
// toPublicImages 把图片实体映射为对外结构(只保留 url 与名称)。
func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
if len(imgs) == 0 {

View File

@ -2,28 +2,19 @@ package service
import (
"context"
"errors"
"regexp"
"strings"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"golang.org/x/crypto/bcrypt"
)
// emailRegex 邮箱格式校验,与原项目一致。
var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
// minPasswordLength 密码最小长度。
const minPasswordLength = 6
// AuthService 账号体系业务接口。
//
// 当前只保留 Me(取当前用户)。注册 / 登录已随路由一并下线:前端没有登录注册入口,
// 对外开放即是垃圾账号与撞库入口。将来接入时补回 Register / Login 与 issue 令牌签发逻辑,
// 并同步前端 src/lib/api.ts 与登录页。
type AuthService interface {
Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error)
Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error)
Me(ctx context.Context, userID uint32) (*dto.UserPayload, error)
}
@ -37,61 +28,6 @@ func NewAuthService(users repository.UserRepository, jwtManager *jwt.Manager) Au
return &authService{users: users, jwt: jwtManager}
}
// Register 注册新用户,成功后直接签发令牌(免去前端再调一次登录)。
func (s *authService) Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) {
username := strings.TrimSpace(req.Username)
email := strings.TrimSpace(req.Email)
password := req.Password
if username == "" || email == "" || len(password) < minPasswordLength {
return nil, badRequest("username、email 与 password(>=6 位) 均为必填")
}
if !emailRegex.MatchString(email) {
return nil, badRequest("email 格式不正确")
}
exists, err := s.users.ExistsByUsernameOrEmail(ctx, username, email)
if err != nil {
return nil, internalErr("server error")
}
if exists {
return nil, conflict("该用户名或邮箱已被注册")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, internalErr("server error")
}
user := model.User{Username: username, Email: email, PasswordHash: string(hash)}
if err := s.users.Create(ctx, &user); err != nil {
return nil, internalErr("创建用户失败")
}
return s.issue(user)
}
// Login 登录:account 可以是邮箱或用户名。
func (s *authService) Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) {
account := strings.TrimSpace(req.Account)
password := req.Password
if account == "" || password == "" {
return nil, badRequest("账号与密码均为必填")
}
user, err := s.users.FindByAccount(ctx, account)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// 账号不存在与密码错误返回同一文案,避免账号枚举
return nil, unauthorized("账号或密码错误")
}
return nil, internalErr("server error")
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return nil, unauthorized("账号或密码错误")
}
return s.issue(*user)
}
// Me 用令牌中的 uid 取回最新用户信息。
func (s *authService) Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) {
user, err := s.users.FindByID(ctx, userID)
@ -103,15 +39,6 @@ func (s *authService) Me(ctx context.Context, userID uint32) (*dto.UserPayload,
return &p, nil
}
// issue 签发令牌并组装返回体。
func (s *authService) issue(u model.User) (*dto.AuthResult, error) {
token, err := s.jwt.Generate(u.ID, u.Username, u.Email)
if err != nil {
return nil, internalErr("server error")
}
return &dto.AuthResult{Token: token, User: toUserPayload(u)}, nil
}
func toUserPayload(u model.User) dto.UserPayload {
return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email}
}

View File

@ -28,7 +28,7 @@ func NewBrandService(brands repository.BrandRepository) BrandService {
return &brandService{brands: brands}
}
// List 查询品牌列表并附带每个品牌的档案数。
// List 查询品牌列表。
//
// featured=1 时先按指标算出"代表品牌"集合,再把列表收敛到该集合内 ——
// 全库 1600+ 有档案品牌对 A-Z 索引来说过多,精选前 200 更符合导航场景。
@ -52,17 +52,7 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
return nil, 0, internalErr(err.Error())
}
ids := make([]uint32, 0, len(items))
for _, b := range items {
ids = append(ids, b.ID)
}
counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids)
if err != nil {
return nil, 0, internalErr(err.Error())
}
list := s.toPublicBrands(items, counts, q.Locale)
return list, total, nil
return s.toPublicBrands(items, q.Locale), total, nil
}
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
@ -82,10 +72,6 @@ func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto
if err != nil {
return nil, internalErr(err.Error())
}
counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids)
if err != nil {
return nil, internalErr(err.Error())
}
// IN 查询不保证顺序,这里重排回 FeaturedIDs 的热度排名顺序。
pos := make(map[uint32]int, len(ids))
@ -94,20 +80,18 @@ func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto
}
sort.Slice(items, func(i, j int) bool { return pos[items[i].ID] < pos[items[j].ID] })
return s.toPublicBrands(items, counts, locale), nil
return s.toPublicBrands(items, locale), nil
}
// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名 + 档案数)。
func (s *brandService) toPublicBrands(items []model.Brand, counts map[uint32]int, locale string) []dto.PublicBrand {
// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名)。
func (s *brandService) toPublicBrands(items []model.Brand, locale string) []dto.PublicBrand {
list := make([]dto.PublicBrand, 0, len(items))
for _, b := range items {
list = append(list, dto.PublicBrand{
UID: hashid.Encode(b.ID),
Name: pickLocale(b.NameEn, b.NameCn, locale),
NameEn: b.NameEn,
NameCn: b.NameCn,
ShowName: b.NameCn,
ArticleCount: counts[b.ID],
UID: hashid.Encode(b.ID),
Name: pickLocale(b.NameEn, b.NameCn, locale),
NameEn: b.NameEn,
NameCn: b.NameCn,
})
}
return list