update
This commit is contained in:
146
internal/service/article_service.go
Normal file
146
internal/service/article_service.go
Normal file
@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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 {
|
||||
articles repository.ArticleRepository
|
||||
}
|
||||
|
||||
// NewArticleService 创建文章服务。
|
||||
func NewArticleService(articles repository.ArticleRepository) ArticleService {
|
||||
return &articleService{articles: articles}
|
||||
}
|
||||
|
||||
// List 查询文章列表,并按需附带每篇的前 N 张图。
|
||||
func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) {
|
||||
q.Normalize()
|
||||
|
||||
rows, total, err := s.articles.List(ctx, q)
|
||||
if err != nil {
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
|
||||
list := make([]dto.PublicArticle, 0, len(rows))
|
||||
ids := make([]uint32, 0, len(rows))
|
||||
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,
|
||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, q.Locale),
|
||||
Year: r.Year,
|
||||
ImageCount: r.ImageCount,
|
||||
CollectionType: r.CollectionType,
|
||||
Season: r.Season,
|
||||
SeasonCode: r.SeasonCode,
|
||||
PublishedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if q.WithImages > 0 && len(list) > 0 {
|
||||
if err := s.attachImages(ctx, list, ids, q.WithImages); err != nil {
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。
|
||||
// ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。
|
||||
func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error {
|
||||
grouped, err := s.articles.ImagesByRunwayIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range list {
|
||||
imgs, ok := grouped[ids[i]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(imgs) > limit {
|
||||
imgs = imgs[:limit]
|
||||
}
|
||||
list[i].Images = toPublicImages(imgs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。
|
||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||
row, err := s.articles.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrArticleNotFound
|
||||
}
|
||||
return nil, NewError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
imgs, err := s.articles.ListImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
|
||||
return &dto.PublicArticleDetail{
|
||||
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,
|
||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, locale),
|
||||
Year: row.Year,
|
||||
ImageCount: row.ImageCount,
|
||||
SourceURL: row.SourceURL,
|
||||
PublishedAt: row.CreatedAt,
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{Image: im.Image, Name: im.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
117
internal/service/auth_service.go
Normal file
117
internal/service/auth_service.go
Normal file
@ -0,0 +1,117 @@
|
||||
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 账号体系业务接口。
|
||||
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)
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
users repository.UserRepository
|
||||
jwt *jwt.Manager
|
||||
}
|
||||
|
||||
// NewAuthService 创建账号服务。
|
||||
func NewAuthService(users repository.UserRepository, jwtManager *jwt.Manager) AuthService {
|
||||
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)
|
||||
if err != nil {
|
||||
// 令牌有效但用户已不存在,同样视为未授权
|
||||
return nil, unauthorized("unauthorized")
|
||||
}
|
||||
p := toUserPayload(*user)
|
||||
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}
|
||||
}
|
||||
91
internal/service/brand_service.go
Normal file
91
internal/service/brand_service.go
Normal file
@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// BrandService 品牌业务接口。
|
||||
type BrandService interface {
|
||||
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||
// Popular 首页"热门品牌"区块:按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。
|
||||
// locale 决定品牌名/标题的展示语言(cn/en)。
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error)
|
||||
}
|
||||
|
||||
type brandService struct {
|
||||
brands repository.BrandRepository
|
||||
}
|
||||
|
||||
// NewBrandService 创建品牌服务。
|
||||
func NewBrandService(brands repository.BrandRepository) BrandService {
|
||||
return &brandService{brands: brands}
|
||||
}
|
||||
|
||||
// List 查询品牌列表并附带每个品牌的档案数。
|
||||
//
|
||||
// featured=1 时先按指标算出"代表品牌"集合,再把列表收敛到该集合内 ——
|
||||
// 全库 1600+ 有档案品牌对 A-Z 索引来说过多,精选前 200 更符合导航场景。
|
||||
func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||
q.Normalize()
|
||||
|
||||
var restrictIDs []uint32
|
||||
if q.Featured {
|
||||
ids, err := s.brands.FeaturedIDs(ctx, q.Metric, q.Limit)
|
||||
if err != nil {
|
||||
// 取不到精选集合时交给 repository 收窄为空集,
|
||||
// 绝不静默退化成"返回全部品牌"——那会让前端索引突然膨胀。
|
||||
restrictIDs = nil
|
||||
} else {
|
||||
restrictIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := s.brands.List(ctx, q, restrictIDs)
|
||||
if err != nil {
|
||||
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 := 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, q.Locale),
|
||||
ShowName: b.NameCn,
|
||||
ArticleCount: counts[b.ID],
|
||||
})
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Popular 热门品牌(首页区块用):按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。
|
||||
// 复用仓储层的 FeaturedIDs 排名逻辑,保持与"代表品牌"一致的"热门"定义。locale 选展示语言。
|
||||
func (s *brandService) Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) {
|
||||
rows, err := s.brands.PopularWithCover(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
out := make([]dto.PopularBrand, 0, len(rows))
|
||||
for _, b := range rows {
|
||||
out = append(out, dto.PopularBrand{
|
||||
UID: hashid.Encode(b.ID),
|
||||
ArticleUID: hashid.Encode(b.ArticleID),
|
||||
Brand: pickLocale(b.BrandEn, b.BrandCn, locale),
|
||||
Cover: b.Cover,
|
||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
34
internal/service/errors.go
Normal file
34
internal/service/errors.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package service 业务逻辑层。
|
||||
//
|
||||
// 分层约定:
|
||||
// - 只依赖 repository 接口,不感知 *gorm.DB,也不感知 *gin.Context;
|
||||
// - 业务规则(摘要截断长度、精选品牌排名、密码强度、令牌签发)集中在这里,
|
||||
// handler 只负责 HTTP 参数解析与响应写出。
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Error 携带 HTTP 状态码的业务错误。
|
||||
//
|
||||
// service 层不引入 gin 依赖,但错误的语义(是客户端参数错误还是服务端故障)
|
||||
// 只有业务层清楚。用状态码 + 文案表达,handler 直接透传,
|
||||
// 既保持了分层,又能让错误响应与原项目逐字一致。
|
||||
type Error struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
|
||||
// NewError 构造业务错误。
|
||||
func NewError(status int, msg string) *Error {
|
||||
return &Error{Status: status, Message: msg}
|
||||
}
|
||||
|
||||
func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) }
|
||||
func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) }
|
||||
func conflict(msg string) *Error { return NewError(http.StatusConflict, msg) }
|
||||
func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) }
|
||||
|
||||
// ErrArticleNotFound 文章不存在,文案与原项目一致。
|
||||
var ErrArticleNotFound = NewError(http.StatusNotFound, "文章不存在")
|
||||
33
internal/service/locale.go
Normal file
33
internal/service/locale.go
Normal file
@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
// 默认语言与受支持语言(与前端 i18n/config.ts 的 LOCALES 保持一致)。
|
||||
const (
|
||||
defaultLocale = "en"
|
||||
localeCN = "cn"
|
||||
localeEN = "en"
|
||||
)
|
||||
|
||||
// normalizeLocale 把任意输入收敛为受支持的语言;非法或空回落默认 en。
|
||||
func normalizeLocale(locale string) string {
|
||||
if locale == localeCN || locale == localeEN {
|
||||
return locale
|
||||
}
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
// pickLocale 按 locale 选语言字段,缺翻译时回落另一语言,保证出参永远非空(除非两者皆空)。
|
||||
//
|
||||
// 这是 i18n 选列的唯一入口:repository 返回 en/cn 两列裸数据,service 在此按 locale 收敛成
|
||||
// 对外单字段(name / title / description / brand_name),前端契约因此保持不变。
|
||||
func pickLocale(en, cn, locale string) string {
|
||||
if normalizeLocale(locale) == localeCN {
|
||||
if cn != "" {
|
||||
return cn
|
||||
}
|
||||
return en
|
||||
}
|
||||
if en != "" {
|
||||
return en
|
||||
}
|
||||
return cn
|
||||
}
|
||||
Reference in New Issue
Block a user