Files
backend_v2/internal/repository/review_repository.go

398 lines
16 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, reason 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, reason 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
// 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}
}
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
}
q := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.BrandRunway{}, 0, nil
}
var rows []model.BrandRunway
if err := q.
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。
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
res := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"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
}
q := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnap
if err := q.
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)。
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
res := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"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
}