Files
backend_v2/internal/service/manual_article_service.go
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

352 lines
12 KiB
Go
Raw Permalink 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 service
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/season"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
"fashionapi/internal/vision"
)
// ManualImage 一张待上传的图片(后台表单读入的原始字节)。
type ManualImage struct {
Filename string
Data []byte
}
// ManualArticleInput 后台「手工新建文章」的输入:元数据 + 图片字节。
type ManualArticleInput struct {
Kind string // runway | street
// 走秀
BrandName string // 品牌英文名,精确匹配(不区分大小写)
TitleEn string
TitleCn string
Year uint16
Season string // spring | fall | resort | pre_fall
CollectionType string // rtw | menswear | couture | resort | pre_fall
// 街拍
City string
Images []ManualImage
}
const (
manualKindRunway = "runway"
manualKindStreet = "street"
manualMaxImageBytes = 10 << 20 // 单张 10MB
)
// ManualArticleService 后台手工建稿:上传图片到存储 + 写正式表(status=pending)。
//
// 与爬虫入库的唯一区别是「图片来源」:那边拿 URL 由 worker 下载,这里直接拿字节。
// 因此复用同一套存储层 Uploader(sha1 内容寻址)与同一组入库仓储写入方法,
// 保证两条路径产出的对象 key / 行结构完全一致——后台不需要为它们维护第二套口径。
type ManualArticleService struct {
ingest repository.IngestRepository
brands repository.BrandRepository
uploader storage.Uploader
local *storage.LocalUploader // S4 上传失败时的本地兜底(可为 nil)
vision *vision.Client // 可为 nil:未启用视觉服务时跳过向量
embedRep repository.EmbeddingRepository // 可为 nil
}
// NewManualArticleService 创建手工建稿服务。
func NewManualArticleService(ingest repository.IngestRepository, brands repository.BrandRepository, uploader storage.Uploader, local *storage.LocalUploader, vis *vision.Client, embedRep repository.EmbeddingRepository) *ManualArticleService {
return &ManualArticleService{ingest: ingest, brands: brands, uploader: uploader, local: local, vision: vis, embedRep: embedRep}
}
// Create 建稿:校验 → 上传图片(sha1 内容寻址、同内容自动去重)→ 写正式表 → 补向量。
// 返回新建记录的数字主键。
func (s *ManualArticleService) Create(ctx context.Context, in ManualArticleInput) (uint32, error) {
if len(in.Images) == 0 {
return 0, NewError(http.StatusBadRequest, "至少上传 1 张图片")
}
switch in.Kind {
case manualKindRunway:
return s.createRunway(ctx, in)
case manualKindStreet:
return s.createStreet(ctx, in)
default:
return 0, NewError(http.StatusBadRequest, "类型无效(仅支持走秀 / 街拍)")
}
}
func (s *ManualArticleService) createRunway(ctx context.Context, in ManualArticleInput) (uint32, error) {
titleEn := strings.TrimSpace(in.TitleEn)
if titleEn == "" {
return 0, NewError(http.StatusBadRequest, "走秀标题(英文)必填")
}
if in.Year == 0 || strings.TrimSpace(in.Season) == "" || strings.TrimSpace(in.CollectionType) == "" {
return 0, NewError(http.StatusBadRequest, "年份、季节、系列均必填")
}
brand, err := s.findBrand(ctx, in.BrandName)
if err != nil {
return 0, err
}
seasonCode := season.Derive(in.Year, in.CollectionType, in.Season)
if seasonCode == "" {
return 0, NewError(http.StatusBadRequest, "无法推导季节码,请检查年份")
}
// 实体键预检:与库里的唯一索引同口径,先给出人话提示,避免把 23505 甩给运营。
if _, _, found, deleted, e := s.ingest.RunwayEntityState(ctx, brand.ID, seasonCode, in.CollectionType); e != nil {
return 0, internalErr(e.Error())
} else if found && !deleted {
return 0, NewError(http.StatusConflict, "该品牌在同一季节/系列下已有图集,请直接编辑那一条,不要重复新建")
}
uploaded, err := s.uploadAll(ctx, manualKindRunway, in.Images)
if err != nil {
return 0, err
}
now := uint32(time.Now().Unix())
rw := &model.BrandRunway{
JobID: 0, // 手工建稿没有入库任务;该列 NOT NULL,用 0 表示「无任务」
BrandID: brand.ID,
TitleEn: titleEn,
TitleCn: strings.TrimSpace(in.TitleCn),
Year: in.Year,
Season: in.Season,
CollectionType: in.CollectionType,
SeasonCode: seasonCode,
Cover: uploaded[0].key,
ImageCount: uint16(len(uploaded)),
Status: model.StatusPending,
CreatedAt: now,
UpdatedAt: now,
}
imgs := make([]model.BrandRunwayImage, 0, len(uploaded))
for i, u := range uploaded {
imgs = append(imgs, model.BrandRunwayImage{
Image: u.key,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
IsDetail: 0,
ImageSize: u.size,
})
}
id, err := s.ingest.CreateRunwayWithImages(ctx, rw, imgs)
if err != nil {
return 0, s.writeErr(err)
}
s.embedUploaded(ctx, manualKindRunway, runwayImageIDs(imgs), uploaded)
return id, nil
}
func (s *ManualArticleService) createStreet(ctx context.Context, in ManualArticleInput) (uint32, error) {
title := strings.TrimSpace(in.TitleEn)
titleCn := strings.TrimSpace(in.TitleCn)
if title == "" {
title = titleCn
}
if title == "" {
return 0, NewError(http.StatusBadRequest, "街拍标题必填")
}
city := strings.TrimSpace(in.City)
if city == "" {
return 0, NewError(http.StatusBadRequest, "城市必填")
}
if in.Year == 0 {
return 0, NewError(http.StatusBadRequest, "年份必填")
}
if _, _, found, deleted, e := s.ingest.StreetSnapEntityState(ctx, city, in.Year, title); e != nil {
return 0, internalErr(e.Error())
} else if found && !deleted {
return 0, NewError(http.StatusConflict, "同一城市、年份、标题下已有街拍,请直接编辑那一条,不要重复新建")
}
uploaded, err := s.uploadAll(ctx, manualKindStreet, in.Images)
if err != nil {
return 0, err
}
now := uint32(time.Now().Unix())
snap := &model.StreetSnap{
JobID: 0,
Title: title,
TitleCn: titleCn,
City: city,
Year: in.Year,
Cover: uploaded[0].key,
ImageCount: uint16(len(uploaded)),
Status: model.StatusPending,
// source / source_url 不设:模型未映射这两列,由库默认值兜底(爬虫入库同样不设)。
// 唯一索引 uq_ss_source 只对非空 source_url 生效,因此手工建稿天然不会与来源去重冲突。
CreatedAt: now,
UpdatedAt: now,
}
imgs := make([]model.StreetSnapImage, 0, len(uploaded))
for i, u := range uploaded {
imgs = append(imgs, model.StreetSnapImage{
Image: u.key,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
ImageSize: u.size,
})
}
id, err := s.ingest.CreateStreetSnapWithImages(ctx, snap, imgs)
if err != nil {
return 0, s.writeErr(err)
}
s.embedUploaded(ctx, manualKindStreet, streetImageIDs(imgs), uploaded)
return id, nil
}
// uploadedManualImage 一张已上传成功的图片:内容寻址 key + 原始字节数 + 原始字节。
// data 保留下来只为随后补向量,避免再向存储回读一次。
type uploadedManualImage struct {
key string
size int64
data []byte
}
// uploadAll 逐张校验 → 内容寻址 key → 上传(S4 失败回退本地)→ 同内容去重。
//
// 类型用 http.DetectContentType 按真实字节判断,不看扩展名(可伪造);
// key 用 sha1,与爬虫入库完全同一口径,因此「同一张图」无论来自哪条路径都落在同一个对象上。
func (s *ManualArticleService) uploadAll(ctx context.Context, prefix string, images []ManualImage) ([]uploadedManualImage, error) {
out := make([]uploadedManualImage, 0, len(images))
seen := make(map[string]struct{}, len(images))
for _, img := range images {
if len(img.Data) == 0 {
continue
}
if len(img.Data) > manualMaxImageBytes {
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 超过 10MB", img.Filename))
}
ct := http.DetectContentType(img.Data)
ext, ok := manualImageExt(ct)
if !ok {
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 不是 JPG/PNG/WEBP(实际识别为 %s)", img.Filename, ct))
}
sum := sha1.Sum(img.Data)
key := prefix + "/" + hex.EncodeToString(sum[:]) + ext
if _, dup := seen[key]; dup {
continue // 同一次提交里的同内容图片只留一份
}
seen[key] = struct{}{}
if _, err := s.uploader.Upload(ctx, key, img.Data, ct); err != nil {
if s.local == nil {
return nil, internalErr("图片上传失败: " + err.Error())
}
if _, lerr := s.local.Upload(ctx, key, img.Data, ct); lerr != nil {
return nil, internalErr("图片上传失败(本地兜底也失败): " + err.Error())
}
}
out = append(out, uploadedManualImage{key: key, size: int64(len(img.Data)), data: img.Data})
}
if len(out) == 0 {
return nil, NewError(http.StatusBadRequest, "没有可用的图片(全部为空或重复)")
}
return out, nil
}
// manualImageExt 把真实 content-type 映射为扩展名;不支持的格式返回 ok=false。
func manualImageExt(contentType string) (string, bool) {
switch contentType {
case "image/jpeg":
return ".jpg", true
case "image/png":
return ".png", true
case "image/webp":
return ".webp", true
}
return "", false
}
// findBrand 按英文名精确匹配(不区分大小写、忽略首尾空格)。
//
// 用 AdminList 的关键词查询而不是新增仓储方法:关键词路径本身就会跳过「必须有图集」
// 的过滤,因此能命中导入的 2 万+ 无图集品牌。
func (s *ManualArticleService) findBrand(ctx context.Context, name string) (*model.Brand, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, NewError(http.StatusBadRequest, "品牌名必填")
}
items, _, err := s.brands.ListAdmin(ctx, dto.BrandQuery{Keyword: name, Size: 50}, nil)
if err != nil {
return nil, internalErr(err.Error())
}
for i := range items {
if strings.EqualFold(strings.TrimSpace(items[i].NameEn), name) {
return &items[i], nil
}
}
return nil, NewError(http.StatusBadRequest, "找不到品牌「"+name+"」,请先在品牌管理里新建(英文名需完全一致)")
}
// writeErr 把唯一键冲突翻译成人话;其它错误按内部错误处理。
// 预检之外仍有并发窗口,DB 层的兜底错误不该原样甩给运营。
func (s *ManualArticleService) writeErr(err error) error {
msg := err.Error()
if strings.Contains(msg, "uq_br_entity") || strings.Contains(msg, "uq_ss_entity") ||
strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key") {
return NewError(http.StatusConflict, "该实体已存在未删除的记录(走秀=品牌+季节+系列;街拍=城市+年份+标题)")
}
return internalErr(msg)
}
// embedUploaded 给刚写入的图片补向量,供后台「疑似同款」使用。
//
// 任何一步不可用(未注入视觉服务 / 仓储为 nil / 熔断中 / 批量调用失败 / 图片 id 未回填)
// 都静默跳过:建稿成功与否绝不能取决于向量产出。
//
// 只处理「仓储回填了主键」的图片(id == 0 视为未回填),并且只在「返回的向量数与输入一一对应」
// 时才写入——有失败项时映射关系不再可靠,宁可少写向量,也不写错位的向量。
func (s *ManualArticleService) embedUploaded(ctx context.Context, kind string, imageIDs []uint32, uploaded []uploadedManualImage) {
if s.vision == nil || s.embedRep == nil || !s.vision.Enabled() {
return
}
batch := make([][]byte, 0, len(uploaded))
slot := make([]uint32, 0, len(uploaded))
for i, u := range uploaded {
if i >= len(imageIDs) || imageIDs[i] == 0 || len(u.data) == 0 {
continue
}
batch = append(batch, u.data)
slot = append(slot, imageIDs[i])
}
if len(batch) == 0 {
return
}
vecs, idxs, err := s.vision.Embed(ctx, batch)
if err != nil || len(vecs) != len(batch) || len(idxs) != len(batch) {
return
}
for i := range batch {
if len(vecs[i]) == 0 {
continue
}
_ = s.embedRep.UpsertEmbedding(ctx, kind, slot[i], vecs[i])
}
}
// imageIDsOf 取出仓储回填到图片行上的主键(未回填时为 0,调用方据此跳过)。
func runwayImageIDs(imgs []model.BrandRunwayImage) []uint32 {
ids := make([]uint32, len(imgs))
for i := range imgs {
ids[i] = imgs[i].ID
}
return ids
}
// streetImageIDs 同上,街拍版。
func streetImageIDs(imgs []model.StreetSnapImage) []uint32 {
ids := make([]uint32, len(imgs))
for i := range imgs {
ids[i] = imgs[i].ID
}
return ids
}