Files
backend_v2/internal/repository/review_repository.go
toom1996 d15d2a4701 update
2026-09-28 10:52:50 +08:00

504 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"context"
"errors"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"gorm.io/gorm"
)
// ReviewRepository 审核数据访问接口。
//
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
type ReviewRepository interface {
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
// RecordDetail 查单条走秀记录(不含图片)。
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
// SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
// 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
}
type reviewRepository struct {
db *gorm.DB
}
// NewReviewRepository 创建审核仓储。
func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db}
}
// reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。
//
// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用
// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患
//(与 article_repository.filterScope 同一理由)。
func reviewListScope(status string) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
if status != "" {
db = db.Where("status = ?", status)
}
return db
}
}
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
scope := reviewListScope(status)
var total int64
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.BrandRunway{}, 0, nil
}
var rows []model.BrandRunway
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
var d model.BrandRunway
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &d, nil
}
func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// recordEditable 审核前可微调的字段白名单(键即列名)。
var recordEditable = map[string]bool{
"title_en": true,
"title_cn": true,
"description_en": true,
"description_cn": true,
"year": true,
"season": true,
"collection_type": true,
"season_code": true,
}
// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if recordEditable[k] {
clean[k] = v
}
}
if len(clean) == 0 {
return nil
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(clean).Error
}
// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
// 竞态窗口(记录刚被删除)下会给出假成功 302。
//
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
// 而不是悄悄在这个仓储方法里加限制。
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
var n int64
if err := r.db.WithContext(ctx).
Model(dest).
Where("status = ? AND is_deleted = 0", status).
Count(&n).Error; err != nil {
return 0, err
}
return n, nil
}
// ---- 街拍镜像实现(与 runway 同构) ----
func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
scope := reviewListScope(status)
var total int64
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
var d model.StreetSnap
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &d, nil
}
func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
var streetRecordEditable = map[string]bool{
"title": true,
"year": true,
"city": true,
}
func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if streetRecordEditable[k] {
clean[k] = v
}
}
if len(clean) == 0 {
return nil
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(clean).Error
}
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ── 图片软删 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
//
// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」,
// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行,
// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。
// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建,
// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。
//
// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks),
// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。
func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, recordID, recordID).Error
}
// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。
//
// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图),
// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的,
// 与走秀「is_detail 是入库时的细节图」语义不同。
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.StreetSnapImage{}).
Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, recordID, recordID).Error
}
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
switch kind {
case dto.IngestKindRunway:
var im model.BrandRunwayImage
err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return im.Image, nil
case dto.IngestKindStreet:
var im model.StreetSnapImage
err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return im.Image, nil
default:
return "", ErrNotFound
}
}
// ── 街拍主副图分组 ──────────────────────────────────────────────────────────
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
//
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
// 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。
return ErrInvalidImageGroup
}
if len(ids) == 0 {
// 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.StreetSnapImage
if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
if err := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapImage{}).
Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
//
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
if len(ids) == 0 {
return nil // 集合里只有主图自身:无操作成功
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.BrandRunwayImage
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
// 再把被并入图自身标为副图并指向新主图。
return tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}