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 }