update
This commit is contained in:
351
internal/service/manual_article_service.go
Normal file
351
internal/service/manual_article_service.go
Normal file
@ -0,0 +1,351 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user