Files
backend_v2/internal/repository/review_repository.go

362 lines
14 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 设置状态(通过/驳回)与审核人/驳回理由。
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(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
}
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return 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()),
}).Error
}
// 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
}
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return 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()),
}).Error
}
// ── 图片软删 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并同步记录 image_count。
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
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 = GREATEST(image_count - 1, 0) WHERE id = ?", recordID).Error
}
// SoftDeleteStreetImage 软删单张街拍图片,并同步记录 image_count。
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 = GREATEST(image_count - 1, 0) WHERE id = ?", 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
}