This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View 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
}