This commit is contained in:
toom1996
2026-09-25 11:31:52 +08:00
parent 4f4c11d508
commit 42f6316125
51 changed files with 4489 additions and 998 deletions

View File

@ -5,6 +5,7 @@ import (
"errors"
"log"
"net/http"
"sort"
"fashionapi/internal/dto"
"fashionapi/internal/model"
@ -19,11 +20,11 @@ type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
@ -70,7 +71,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
for _, r := range rows {
ids = append(ids, r.ID)
}
list := s.toPublicSnaps(rows)
list := s.toPublicSnaps(rows, q.Locale)
if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
@ -91,17 +92,17 @@ func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicSnaps(rows), total, nil
return s.toPublicSnaps(rows, q.Locale), total, nil
}
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
list := make([]dto.PublicStreetSnap, 0, len(rows))
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
@ -149,7 +150,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, s.detailErr(err)
@ -158,14 +159,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
return s.buildSnapDetail(snap, imgs, locale), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
@ -174,7 +175,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
return s.buildSnapDetail(snap, imgs, locale), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
@ -186,10 +187,10 @@ func (s *streetSnapService) detailErr(err error) error {
}
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title,
Title: pickLocale(snap.Title, snap.TitleCn, locale),
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}
@ -198,7 +199,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 {
return nil
@ -237,12 +238,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
continue
}
pub := s.toPublicSnapImage(im)
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
for _, im := range imgs {
root := resolveRoot(im.ID)
if root == im.ID {
@ -254,14 +255,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
}
pub := s.toPublicSnapImage(im)
pub.IsDetail = 1
pub.LookIndex = out[gi].LookIndex
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit)
if err != nil {
return nil, internalErr(err.Error())
@ -270,7 +271,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount,
})
@ -323,7 +324,50 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return s.repo.GetForEdit(ctx, id)
snap, imgs, err := s.repo.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后,
// 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。
// 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。
return snap, orderStreetImagesForEdit(imgs), nil
}
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
detailsByParent := make(map[uint32][]model.StreetSnapImage)
var mains []model.StreetSnapImage
for i := range imgs {
im := &imgs[i]
byID[im.ID] = im
if im.IsDetail == 0 {
mains = append(mains, *im)
} else {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im)
}
}
out := make([]model.StreetSnapImage, 0, len(imgs))
emitted := make(map[uint32]bool, len(imgs))
for _, m := range mains {
out = append(out, m)
emitted[m.ID] = true
ds := detailsByParent[m.ID]
sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder })
for _, d := range ds {
out = append(out, d)
emitted[d.ID] = true
}
}
// 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && !emitted[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {