package service import ( "bytes" "context" "encoding/json" "fmt" "image" "image/jpeg" "io" "math" "math/rand" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/image/draw" "observer-server/biz/consts" "observer-server/biz/dao" "observer-server/biz/model/dto" "observer-server/biz/model/entity" "observer-server/common" ) // datasetService 数据集业务:管理端「数据集管理」模块。 // 图片文件在 app.datasetDir/datasets//(平铺、文件名唯一),DB 只存元数据; // AI 生成为付费资产,删除类接口由前端带确认文案(后端不额外拦截)。 type datasetService struct{} var Dataset = &datasetService{} // imgExts 允许上传/保存的图片扩展名 var imgExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true} // datasetNameRe 数据集名称规则(与新建接口 DTO 校验一致:中文/字母/数字/下划线/短横线,1-50) // 更新接口的名称为可选字段,空值合法,正则不能进 DTO 校验链,放 service 侧判定。 var datasetNameRe = regexp.MustCompile(`^[a-zA-Z0-9_一-龥-]{1,50}$`) // AdminListDatasets 数据集分页列表(卡片展示:封面/描述/训练配置标记 + 最新训练状态聚合) func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDatasetListReq) (*dto.AdminDatasetListRes, error) { page, size := common.NormalizePage(req.Page, req.Size) var list []*entity.Dataset var total int64 var err error if req.Keyword != "" { list, total, err = dao.Dataset.PageByKeyword(ctx, req.Keyword, page, size) } else { list, total, err = dao.Dataset.Page(ctx, page, size) } if err != nil { return nil, err } ids := make([]int64, 0, len(list)) for _, v := range list { ids = append(ids, v.Id) } // 图片数/已标注数实时统计(冗余计数列 image_count/labeled_count 曾因生成任务部分失败 // 未回写等路径漂移为负数,展示一律以 dataset_image 实际统计为准) stats, err := dao.DatasetImage.CountStatByDatasets(ctx, ids) if err != nil { return nil, err } latest, err := dao.Training.LatestByDatasets(ctx, ids) if err != nil { return nil, err } // 已发布过的训练不再返回发布按钮(model_version.training_id 反查) trainingIds := make([]int64, 0, len(latest)) for _, t := range latest { trainingIds = append(trainingIds, t.Id) } published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds) if err != nil { return nil, err } items := make([]*dto.AdminDatasetItem, 0, len(list)) for _, v := range list { st := stats[v.Id] // 无图片行时为零值(计数 0),不落冗余列 item := &dto.AdminDatasetItem{ Id: v.Id, Name: v.Name, Source: v.Source, ImageCount: st.Count, LabeledCount: st.Labeled, Status: v.Status, Cover: v.Cover, Description: v.Description, NamePrefix: v.NamePrefix, GenSpecies: v.GenSpecies, GenTone: v.GenTone, GenHeights: v.GenHeights, GenScenes: v.GenScenes, GenActions: v.GenActions, GenOcclusions: v.GenOcclusions, GenClasses: v.GenClasses, SortOrder: v.SortOrder, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, } if t, ok := latest[v.Id]; ok { item.TrainingId = t.Id item.TrainingStatus = t.Status item.TrainingError = t.Error item.TrainingPublished = published[t.Id] item.TrainingCurrentEpoch = t.CurrentEpoch item.TrainingTotalEpochs = t.TotalEpochs } items = append(items, item) } return &dto.AdminDatasetListRes{Total: total, List: items}, nil } // AdminUpdateDataset 更新数据集展示配置(空值字段不覆盖;封面/描述。 // AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh)) func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminDatasetUpdateReq) (*dto.AdminDatasetUpdateRes, error) { existing, err := dao.Dataset.GetById(ctx, req.Id) if err != nil { return nil, err } if existing == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } newName := strings.TrimSpace(req.Name) prefix := strings.TrimSpace(req.NamePrefix) if prefix != "" && !datasetNameRe.MatchString(prefix) { return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50") } if newName != "" && newName != existing.Name { // 改名(可同时改前缀):图片目录 + 模型文件随命名迁移(模型文件名 = 前缀,空回退数据集名) if err := s.renameDataset(ctx, existing.Id, existing.Name, newName, existing.NamePrefix, prefix); err != nil { return nil, err } existing.Name = newName } else if prefix != existing.NamePrefix { // 仅前缀变更:模型文件名随前缀迁移(改名场景已由 renameDataset 处理) migrateModelFile(ctx, existing.Name, existing.NamePrefix, existing.Name, prefix) } // 封面须属于该数据集图片(防伪造文件名指向任意文件) if req.Cover != "" { img, err := dao.DatasetImage.GetByFilename(ctx, req.Id, req.Cover) if err != nil { return nil, err } if img == nil { return nil, gerror.NewCode(common.CodeImageNotFound) } } if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{ Name: newName, NamePrefix: prefix, Cover: req.Cover, }); err != nil { return nil, err } // 描述允许清空(UpdateConfigs 空值不覆盖),单独更新 if err := dao.Dataset.UpdateDescription(ctx, req.Id, strings.TrimSpace(req.Description)); err != nil { return nil, err } // 序号独立更新(指针区分未传与显式 0;0 时按创建时间倒序排) if req.SortOrder != nil { if err := dao.Dataset.UpdateSortOrder(ctx, req.Id, *req.SortOrder); err != nil { return nil, err } } // gen_* 7 列不在此维护:创建数据集时 VLM 生成,重新生成走 gen-pools 接口 return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil } // renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录; // 模型文件按文件名前缀命名(空回退数据集名),随改名/改前缀迁移(失败仅记日志不阻断——旧文件保留)。 // 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。 func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName, oldPrefix, newPrefix string) error { err := common.Serial().Submit(ctx, func() error { if !datasetNameRe.MatchString(newName) { return gerror.New("数据集名称仅支持中文/字母/数字/下划线/短横线,长度 1-50") } // 预标注任务进行中(RF-DETR 正在按旧目录读图) if t, err := dao.LabelTask.GetRunningByDataset(ctx, id); err != nil { return err } else if t != nil { return gerror.NewCode(common.CodeLabelTaskRunning) } // 训练进行中(同步目录与产物路径以旧名构建) if t, err := dao.Training.RunningByDataset(ctx, id); err != nil { return err } else if t != nil { return gerror.New("该数据集有训练任务进行中,无法重命名") } // 同名占用检查(UNIQUE 约束兜底) exists, err := dao.Dataset.GetByName(ctx, newName) if err != nil { return err } if exists != nil { return gerror.NewCode(common.CodeDatasetNameDuplicate) } // 图片目录迁移:失败回滚已迁移项(图片目录可能不存在,缺省跳过) oldImgDir := common.DatasetImagesDir(ctx, oldName) newImgDir := common.DatasetImagesDir(ctx, newName) movedImgDir := false if _, err := os.Stat(oldImgDir); err == nil { if err := os.Rename(oldImgDir, newImgDir); err != nil { return gerror.Wrap(err, "迁移图片目录失败") } movedImgDir = true } // 模型文件随命名迁移(prefix 空回退数据集名;改名不改前缀时路径不变,内部跳过) migrateModelFile(ctx, oldName, oldPrefix, newName, newPrefix) if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil { // DB 失败回滚已迁移项(模型文件反向迁移失败仅记日志,旧文件可能留新名) migrateModelFile(ctx, newName, newPrefix, oldName, oldPrefix) if movedImgDir { if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil { g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr) } } return err } g.Log().Infof(ctx, "数据集 %d 改名完成: %s -> %s", id, oldName, newName) return nil }) return err } // migrateModelFile 模型文件随命名变更迁移(基名 = 文件名前缀,空回退数据集名): // 旧文件不存在或新旧路径相同(前缀未变且非空)直接跳过;失败仅记日志不阻断业务—— // 模型文件为付费训练产物,保留旧名总比删除好(下次训练直写新名覆盖)。 func migrateModelFile(ctx context.Context, oldName, oldPrefix, newName, newPrefix string) { oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix)) newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix)) if oldPath == newPath { return } if _, err := os.Stat(oldPath); err != nil { return } if err := os.Rename(oldPath, newPath); err != nil { g.Log().Errorf(ctx, "迁移模型文件 %s → %s 失败: %+v", oldPath, newPath, err) } } // AdminUploadCover 上传数据集封面:解码校验图片 → 转 jpg(Quality 92)→ UUID 命名落盘, // 删除旧封面文件(UUID 互不覆盖,但 DB 指向切换)→ 更新 cover 字段。 func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } if req.File == nil { return nil, gerror.New("缺少封面文件") } ext := strings.ToLower(filepath.Ext(req.File.Filename)) if !imgExts[ext] { return nil, gerror.New("封面仅支持 jpg/jpeg/png") } if req.File.Size > 10*1024*1024 { return nil, gerror.New("封面不能超过 10MB") } raw, err := req.File.Open() if err != nil { return nil, gerror.Wrap(err, "读取封面文件失败") } defer func() { _ = raw.Close() }() data, err := io.ReadAll(raw) if err != nil { return nil, gerror.Wrap(err, "读取封面文件失败") } // 统一压缩为 1248x704(任意尺寸源图 → 目标尺寸 jpg) data, err = resizeCover(data) if err != nil { return nil, gerror.Wrap(err, "封面文件不是有效图片") } dir := common.DatasetImagesDir(ctx, dataset.Name) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, gerror.Wrap(err, "创建图片目录失败") } if dataset.Cover != "" { _ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover))) } name := common.UuidV4() + ".jpg" if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil { return nil, gerror.Wrap(err, "保存封面失败") } if err := dao.Dataset.UpdateConfigs(ctx, dataset.Id, &entity.Dataset{Cover: name}); err != nil { _ = os.Remove(filepath.Join(dir, name)) return nil, err } return &dto.AdminDatasetCoverUploadRes{}, nil } // coverNameRe 封面命名规范:UUIDv4 + .jpg(固定命名 cover* 为历史遗留,迁移见 MigrateLegacyCovers) var coverNameRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.jpg$`) // isCoverName 校验 DB cover 值是否符合 UUID jpg 规范(防御库内被写入任意文件名) func isCoverName(name string) bool { return coverNameRe.MatchString(filepath.Base(name)) } // CoverFile 封面文件定位(校验归属;controller 直写响应体输出)。 // datasetId>0 从库取封面名;datasetId=0 时按 name+filename 直读(新建对话框预生成封面回显, // 数据集未创建无库记录;filename 须符合 UUID jpg 规范防路径穿越)。 func (s *datasetService) CoverFile(ctx context.Context, datasetId int64, name, filename string) (string, error) { if datasetId > 0 { dataset, err := dao.Dataset.GetById(ctx, datasetId) if err != nil { return "", err } if dataset == nil { return "", gerror.NewCode(common.CodeDatasetNotFound) } if dataset.Cover == "" { return "", gerror.NewCode(common.CodeImageNotFound) } name = dataset.Name filename = dataset.Cover } name = filepath.Base(name) if !datasetNameRe.MatchString(name) { return "", gerror.NewCode(common.CodeImageNotFound) } filename = filepath.Base(filename) if !isCoverName(filename) { return "", gerror.NewCode(common.CodeImageNotFound) } path := filepath.Join(common.DatasetImagesDir(ctx, name), filename) if _, err := os.Stat(path); err != nil { return "", gerror.NewCode(common.CodeImageNotFound) } return path, nil } // AppCoverFile 公开封面定位(App 模型清单缩略图):按训练文件名前缀查数据集, // 复用 CoverFile 的归属校验(数据集不存在/无封面/文件缺失 → 404 错误码)。 func (s *datasetService) AppCoverFile(ctx context.Context, namePrefix string) (string, error) { d, err := dao.Dataset.GetByNamePrefix(ctx, strings.TrimSpace(namePrefix)) if err != nil { return "", err } if d == nil { return "", gerror.NewCode(common.CodeDatasetNotFound) } return s.CoverFile(ctx, d.Id, "", "") } // AdminDeleteCover 删除数据集封面:删文件 + 清 cover 字段(列表卡片恢复占位图) func (s *datasetService) AdminDeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDeleteReq) (*dto.AdminDatasetCoverDeleteRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } if dataset.Cover != "" && isCoverName(dataset.Cover) { _ = os.Remove(filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filepath.Base(dataset.Cover))) } if err := dao.Dataset.ClearCover(ctx, dataset.Id); err != nil { return nil, err } return &dto.AdminDatasetCoverDeleteRes{}, nil } // AdminGenCover 生成数据集封面(对话框「AI 生成封面」按钮入口): // datasetId>0 走完整链路(16:9、1 雄 1 雌,物种取 gen_species 空回退数据集名,写库+删旧封面); // datasetId=0 新建预生成(物种=表单数据集名,仅落盘,创建请求带 cover 回传写库); // 成功返回新 cover 文件名供前端回显;失败报错不覆盖旧封面。 func (s *datasetService) AdminGenCover(ctx context.Context, req *dto.AdminGenCoverReq) (*dto.AdminGenCoverRes, error) { datasetName := strings.TrimSpace(req.Name) species := "" if req.DatasetId > 0 { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } datasetName = dataset.Name species = dataset.GenSpecies } if !datasetNameRe.MatchString(datasetName) { return nil, gerror.New("缺少有效的数据集名称(新建时请先填写名称再生成封面)") } cover, err := s.genCoverWithImageGen(ctx, req.DatasetId, datasetName, species) if err != nil { return nil, err } return &dto.AdminGenCoverRes{Cover: cover}, nil } // MigrateLegacyCovers 存量封面迁移:cover 非空且不符合 UUID jpg 规范(历史 cover.jpg/cover.png 等) // → 重命名为 .jpg(保留原图字节)并更新 cover 字段。幂等:已符合规范的行跳过。 func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error { list, err := dao.Dataset.ListAll(ctx) if err != nil { return err } migrated := 0 for _, d := range list { if d.Cover == "" || isCoverName(d.Cover) { continue } oldName := filepath.Base(d.Cover) src := filepath.Join(common.DatasetImagesDir(ctx, d.Name), oldName) data, err := os.ReadFile(src) if err != nil { g.Log().Warningf(ctx, "数据集 %s 封面迁移跳过(文件不存在: %s): %v", d.Name, src, err) continue } newName := common.UuidV4() + ".jpg" if err := common.WriteFileAtomic(filepath.Join(common.DatasetImagesDir(ctx, d.Name), newName), data); err != nil { g.Log().Errorf(ctx, "数据集 %s 封面迁移写新文件失败: %+v", d.Name, err) continue } if err := dao.Dataset.UpdateConfigs(ctx, d.Id, &entity.Dataset{Cover: newName}); err != nil { g.Log().Errorf(ctx, "数据集 %s 封面迁移更新字段失败: %+v", d.Name, err) continue } _ = os.Remove(src) migrated++ g.Log().Infof(ctx, "数据集 %s 封面迁移: %s → %s", d.Name, oldName, newName) } if migrated > 0 { g.Log().Infof(ctx, "封面存量迁移完成: 共 %d 个数据集", migrated) } return nil } // CompressExistingCovers 存量封面尺寸统一迁移:cover 非空且尺寸不是 1248x704 的 // → resizeCover 压缩覆盖写(原子写)。幂等:已是目标尺寸的行跳过,新库空跑。 func (s *datasetService) CompressExistingCovers(ctx context.Context) error { list, err := dao.Dataset.ListAll(ctx) if err != nil { return err } compressed := 0 for _, d := range list { if d.Cover == "" || !isCoverName(d.Cover) { continue } path := filepath.Join(common.DatasetImagesDir(ctx, d.Name), d.Cover) data, err := os.ReadFile(path) if err != nil { continue } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { g.Log().Warningf(ctx, "数据集 %s 封面解码失败,跳过压缩: %v", d.Name, err) continue } if img.Bounds().Dx() == coverWidth && img.Bounds().Dy() == coverHeight { continue } out, err := resizeCover(data) if err != nil { g.Log().Errorf(ctx, "数据集 %s 封面压缩失败: %+v", d.Name, err) continue } if err := common.WriteFileAtomic(path, out); err != nil { g.Log().Errorf(ctx, "数据集 %s 封面压缩写回失败: %+v", d.Name, err) continue } compressed++ g.Log().Infof(ctx, "数据集 %s 封面压缩: %dx%d → %dx%d", d.Name, img.Bounds().Dx(), img.Bounds().Dy(), coverWidth, coverHeight) } if compressed > 0 { g.Log().Infof(ctx, "封面存量压缩完成: 共 %d 个数据集", compressed) } return nil } // AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 + // VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。 func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) { now := gtime.Now() var id int64 err := common.Serial().Submit(ctx, func() error { exists, err := dao.Dataset.GetByName(ctx, req.Name) if err != nil { return err } if exists != nil { return gerror.NewCode(common.CodeDatasetNameDuplicate) } prefix := strings.TrimSpace(req.NamePrefix) if prefix != "" && !datasetNameRe.MatchString(prefix) { return gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50") } sortOrder := int64(0) if req.SortOrder != nil { sortOrder = *req.SortOrder } id, err = dao.Dataset.Insert(ctx, &entity.Dataset{ Name: req.Name, Source: req.Source, NamePrefix: prefix, SortOrder: sortOrder, Status: consts.DatasetStatusBuilding, CreatedAt: now, UpdatedAt: now, }) return err }) if err != nil { return nil, err } if err := os.MkdirAll(common.DatasetImagesDir(ctx, req.Name), 0o755); err != nil { return nil, gerror.Wrap(err, "创建数据集目录失败") } res := &dto.AdminDatasetCreateRes{Id: id} // VLM 生成参数池:失败不阻断创建(返回原因,可用 gen-pools 补生成/手填提示词) var species string if pools, pErr := s.genPoolsWithVLM(ctx, req.Name); pErr != nil { res.PoolError = pErr.Error() } else if pools != nil { if uErr := common.Serial().Submit(ctx, func() error { return dao.Dataset.UpdatePools(ctx, id, pools) }); uErr == nil { res.PoolsGenerated = true species = pools.GenSpecies } else { res.PoolError = "参数写入失败: " + uErr.Error() } } // 封面:新建对话框预生成(req.Cover 非空且文件已落盘)优先——跳过自动生成;否则自动生成 // (16:9、1雄1雌),失败不阻断创建(前端提示可编辑模式重新生成) if cover := strings.TrimSpace(req.Cover); cover != "" && isCoverName(cover) { if _, statErr := os.Stat(filepath.Join(common.DatasetImagesDir(ctx, req.Name), filepath.Base(cover))); statErr == nil { if uErr := common.Serial().Submit(ctx, func() error { return dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Cover: filepath.Base(cover)}) }); uErr == nil { res.CoverGenerated = true return res, nil } else { res.CoverError = "封面写入失败: " + uErr.Error() } } } if _, coverErr := s.genCoverWithImageGen(ctx, id, req.Name, species); coverErr != nil { res.CoverError = coverErr.Error() } else { res.CoverGenerated = true } return res, nil } // ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpeg(png/webp 等)转 jpeg(Quality 92)。 // 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。 func ensureJpeg(data []byte) ([]byte, error) { img, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, gerror.Wrap(err, "生成图片解码失败") } if format == "jpeg" { return data, nil } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil { return nil, gerror.Wrap(err, "生成图片转 jpg 失败") } return buf.Bytes(), nil } // 封面统一尺寸(2026-08-31 用户定案):生成/上传/存量压缩一律 1248x704(16:9 横幅,App 卡片展示规格) const ( coverWidth = 1248 coverHeight = 704 ) // resizeCover 封面图片统一压缩为 1248x704:解码校验 → 按目标比例中心裁剪 → CatmullRom 缩放 → jpg(Quality 92)。 // 透明通道(png)先合成白底再缩放(jpg 无 alpha,白底比黑底自然)。 func resizeCover(data []byte) ([]byte, error) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, gerror.Wrap(err, "封面图片解码失败") } src := image.NewRGBA(img.Bounds()) draw.Draw(src, src.Bounds(), image.White, image.Point{}, draw.Src) draw.Draw(src, src.Bounds(), img, image.Point{}, draw.Over) // 中心裁剪到 1248:704 比例(源图过宽裁左右,过窄裁上下) sw, sh := src.Bounds().Dx(), src.Bounds().Dy() targetRatio := float64(coverWidth) / float64(coverHeight) if ratio := float64(sw) / float64(sh); ratio > targetRatio { cw := int(float64(sh) * targetRatio) src = cropCenter(src, cw, sh) } else if ratio < targetRatio { ch := int(float64(sw) / targetRatio) src = cropCenter(src, sw, ch) } dst := image.NewRGBA(image.Rect(0, 0, coverWidth, coverHeight)) draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) var buf bytes.Buffer if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 92}); err != nil { return nil, gerror.Wrap(err, "封面转 jpg 失败") } return buf.Bytes(), nil } // cropCenter 取源图中心 w×h 区域(src 为 RGBA,SubImage 返回同类型) func cropCenter(src *image.RGBA, w, h int) *image.RGBA { b := src.Bounds() x0 := b.Min.X + (b.Dx()-w)/2 y0 := b.Min.Y + (b.Dy()-h)/2 return src.SubImage(image.Rect(x0, y0, x0+w, y0+h)).(*image.RGBA) } // genCoverWithImageGen 用 imageGen provider 生成数据集封面:16:9(1248x704)、画面 1 雄 1 雌, // 同一物种雄性+雌性各一只;UUID 命名落盘,返回新文件名。 // datasetId>0:删旧封面文件 + 更新库 cover 字段(与手动上传同路径); // datasetId<=0:仅落盘(新建对话框预生成,数据集未创建,创建请求带 cover 回传写库)。 // 封面仅供展示,不入 dataset_image 表(不参与训练);失败保留旧封面。 func (s *datasetService) genCoverWithImageGen(ctx context.Context, datasetId int64, datasetName, species string) (string, error) { provider := common.ImageGen(ctx) if provider == nil { return "", gerror.New("图像生成服务未配置,未生成封面") } if species == "" { species = datasetName } // 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实) running, err := dao.GenTask.ListRunning(ctx) if err != nil { return "", err } if len(running) > 0 { return "", gerror.New("生成任务进行中(z-image 占用显存),未生成封面") } prompt := fmt.Sprintf("野外实拍照片:一只雄性%s和一只雌性%s并排站立在开阔的自然栖息地中,"+ "雄雌各一只清晰可见,16:9 横幅构图,真实照片质感,光线自然,画面清晰美观", species, species) genCtx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() // 直接指定封面统一尺寸 1248x704(z-image 接受 16 整除尺寸);resizeCover 兜底统一规格 data, err := provider.Generate(genCtx, prompt, "1248x704") if err != nil { return "", gerror.Wrap(err, "封面生成失败") } if data, err = resizeCover(data); err != nil { return "", err } dir := common.DatasetImagesDir(ctx, datasetName) if err := os.MkdirAll(dir, 0o755); err != nil { return "", gerror.Wrap(err, "创建图片目录失败") } if datasetId > 0 { if dataset, dErr := dao.Dataset.GetById(ctx, datasetId); dErr == nil && dataset != nil && dataset.Cover != "" { _ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover))) } } name := common.UuidV4() + ".jpg" if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil { return "", gerror.Wrap(err, "保存封面失败") } if datasetId > 0 { if err := dao.Dataset.UpdateConfigs(ctx, datasetId, &entity.Dataset{Cover: name}); err != nil { _ = os.Remove(filepath.Join(dir, name)) return "", err } } return name, nil } // AdminGenPools 重新生成生成参数池(VLM 覆盖写;失败报错保留旧值——编辑对话框按钮入口) func (s *datasetService) AdminGenPools(ctx context.Context, req *dto.AdminGenPoolsReq) (*dto.AdminGenPoolsRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } pools, err := s.genPoolsWithVLM(ctx, dataset.Name) if err != nil { return nil, err } if pools == nil { return nil, gerror.New("生成参数生成失败,请稍后重试") } if err := common.Serial().Submit(ctx, func() error { return dao.Dataset.UpdatePools(ctx, dataset.Id, pools) }); err != nil { return nil, err } return &dto.AdminGenPoolsRes{ GenSpecies: pools.GenSpecies, GenTone: pools.GenTone, GenHeights: pools.GenHeights, GenScenes: pools.GenScenes, GenActions: pools.GenActions, GenOcclusions: pools.GenOcclusions, GenClasses: pools.GenClasses, }, nil } // genPoolsWithVLM 调 qwen3.6-35b-a3b 生成数据集生成参数池(单物种规则:物种=数据集名, // VLM 只生成轮廓色/站高/场景/动作/遮挡池)。 // 无 localAi 配置/显存被生成任务占用/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断); // 校验通过返回 entity.Dataset(仅 7 个池字段,其余空)。 // 纯文本场景下 llama.cpp mmproj 需图片输入,传 64x64 纯灰占位图(提示词声明忽略图片)。 func (s *datasetService) genPoolsWithVLM(ctx context.Context, datasetName string) (*entity.Dataset, error) { if common.LocalAiClient(ctx) == nil { return nil, nil // 未配置标注服务:不生成也不报错(池为空,生成图片时提示补参数) } // 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实——生成任务可达小时级) running, err := dao.GenTask.ListRunning(ctx) if err != nil { return nil, err } if len(running) > 0 { return nil, gerror.New("生成任务进行中(z-image 占用显存),暂无法生成参数,稍后重试") } target := datasetName prompt := fmt.Sprintf( "你是野外野生动物监测数据集专家。请为数据集「%s」设计 AI 生成训练图的生成参数池。目标动物:%s。\n"+ "生成图规格:手机远距离拍摄的野外大远景实拍图,每张图有且只有一只目标动物,目标在画面中很小。\n"+ "请输出严格 JSON(只输出 JSON 对象本身,不要任何其他文字或代码块标记):\n"+ "{\n \"tone\": \"深色\",\n \"height_cm\": 30,\n \"scenes\": [\"场景描述\", ...],\n"+ " \"actions\": [\"动作词\", ...],\n \"occlusions\": [\"遮挡描述\", ...]\n}\n"+ "要求:tone 为该物种轮廓色词(绝大多数「深色」,白化/浅色个体配「浅色」);"+ "height_cm 为该物种站高厘米(鸟=脚到头顶、兽=蹲坐/站立肩高,数值 10~200);"+ "scenes 4~6 条野外开阔场景,每条含「延伸向远方/地平线/开阔」等拉开距离的元素,符合该动物栖息习性"+ "(雉鸡→农田灌丛、野鸭→水面芦苇、兔子→草坡荒地);按该动物冬季习性配场景:冬季在本地活跃(留鸟/不南迁,如雉鸡、兔子)须含至少 1 条冬季积雪场景(如雪后田野),冬季南迁物种(如野鸭、鹌鹑)不配雪地;若该物种会落树栖息(如雉鸡、锦鸡、马鸡、鸽子等树上夜栖鸟),场景须含林缘与树上落栖场景;不上树的地栖种(如石鸡、鹌鹑)不配树上场景;actions 4~6 条动作短语(如「觅食」「走动啄食」),不能以「在/于」开头;"+ "occlusions 4~6 条遮挡描述,覆盖轻(身体半掩)到重(只露头背),遮挡物符合该动物栖息环境(草丛/芦苇/树叶/枝条等);具备树上/林缘场景的物种,遮挡池须含树叶/树枝类遮挡(不能只有草丛)。\n"+ "忽略图片内容,只按上述要求输出 JSON。", datasetName, target) // 64x64 纯灰占位图(VLM 输入占位,避免空白无法推理) img := image.NewRGBA(image.Rect(0, 0, 64, 64)) var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 60}); err != nil { return nil, gerror.Wrap(err, "构造 VLM 输入失败") } genCtx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() content, err := common.QwenVL(genCtx, buf.Bytes(), "image/jpeg", prompt) if err != nil { return nil, gerror.Wrap(err, "VLM 生成参数失败") } return s.parseGenPoolsJSON(content, target) } // parseGenPoolsJSON 解析并校验 VLM 输出的参数池 JSON(容忍代码块/前后缀), // 物种固定为 species(单物种规则:每数据集一个物种,第一标注类别名=物种,第二类别名="suspect")。 // 校验失败返回 error(宁可报错也不落不合格池——生成图按池组装,池质量决定训练数据质量)。 func (s *datasetService) parseGenPoolsJSON(content string, species string) (*entity.Dataset, error) { m := regexp.MustCompile(`\{[\s\S]*\}`).FindString(content) if m == "" { return nil, gerror.New("VLM 未输出有效 JSON") } var raw struct { Tone string `json:"tone"` HeightCm float64 `json:"height_cm"` Scenes []string `json:"scenes"` Actions []string `json:"actions"` Occlusions []string `json:"occlusions"` } if err := json.Unmarshal([]byte(m), &raw); err != nil { return nil, gerror.Wrap(err, "VLM 输出 JSON 解析失败") } valid := func(list []string, min int, maxLen int) bool { if len(list) < min { return false } for _, v := range list { if strings.TrimSpace(v) == "" || len([]rune(v)) > maxLen { return false } } return true } if !valid(raw.Scenes, 3, 100) || !valid(raw.Actions, 3, 30) || !valid(raw.Occlusions, 3, 100) { return nil, gerror.New("VLM 场景/动作/遮挡池非法(各需至少 3 条)") } tone := "深色" if strings.TrimSpace(raw.Tone) != "" { tone = strings.TrimSpace(raw.Tone) } // 站高存数据集表 gen_heights(数据库维护,用户可在编辑界面手改真实值),VLM 输出为初始值 height := 35.0 if raw.HeightCm >= 10 && raw.HeightCm <= 200 { height = raw.HeightCm } scenesRaw, _ := json.Marshal(raw.Scenes) actionsRaw, _ := json.Marshal(raw.Actions) occlRaw, _ := json.Marshal(raw.Occlusions) // 单值列:物种 / 轮廓色词 / 站高 / 第二类别名(第一类别名=物种,训练 data.yaml names / 模型 labels) return &entity.Dataset{ GenSpecies: species, GenTone: tone, GenHeights: height, GenScenes: string(scenesRaw), GenActions: string(actionsRaw), GenOcclusions: string(occlRaw), GenClasses: "suspect", }, nil } // AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝 // (训练产物与模型为付费资产,需先删除模型版本再删数据集)。 // 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。 func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) { var name, modelName string err := common.Serial().Submit(ctx, func() error { d, err := dao.Dataset.GetById(ctx, req.Id) if err != nil { return err } if d == nil { return gerror.NewCode(common.CodeDatasetNotFound) } name = d.Name modelName = modelFileName(d.Name, d.NamePrefix) // 预标注任务进行中(RF-DETR 正在扫该数据集图片) if t, err := dao.LabelTask.GetRunningByDataset(ctx, d.Id); err != nil { return err } else if t != nil { return gerror.NewCode(common.CodeLabelTaskRunning) } // 该数据集训练进行中(并发度 1,防文件被删训练中断) if t, err := dao.Training.RunningByDataset(ctx, d.Id); err != nil { return err } else if t != nil { return gerror.New("该数据集有训练任务进行中,无法删除") } // 模型版本记录随数据集级联删除(管理端无模型管理界面,2026-08-26 决策; // 若需保留已下发模型,删除数据集前先确认客户端不再需要) if err := dao.ModelVersion.DeleteByDataset(ctx, d.Id); err != nil { return err } if err := dao.DatasetImage.DeleteByDataset(ctx, d.Id); err != nil { return err } return dao.Dataset.DeleteById(ctx, d.Id) }) if err != nil { return nil, err } // 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删) paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, modelName)} for _, p := range paths { if err := os.RemoveAll(p); err != nil { g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err) } } return &dto.AdminDatasetDeleteRes{}, nil } // AdminUploadImages 上传图片(多文件):重名跳过(文件名唯一约束),逐张落盘 + 批量入库。 func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) { if len(req.Files) == 0 { return nil, gerror.New("请选择图片文件") } dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } dir := common.DatasetImagesDir(ctx, dataset.Name) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, gerror.Wrap(err, "创建图片目录失败") } res := &dto.AdminDatasetUploadRes{} now := gtime.Now() saved := make([]string, 0, len(req.Files)) // 本次已落盘文件名(入库失败时清理) for _, f := range req.Files { name := filepath.Base(f.Filename) if !imgExts[strings.ToLower(filepath.Ext(name))] { continue } exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name) if err != nil { return nil, err } if exists != nil { res.Skipped++ continue } if err := saveImageFile(f, dir); err != nil { return nil, gerror.Wrapf(err, "图片 %s 保存失败", name) } saved = append(saved, name) } if len(saved) == 0 { return res, nil } // 标注强语义:未配置标注服务时不允许产生无标注图(文件已落盘,失败则清掉) if common.LocalAiClient(ctx) == nil { for _, name := range saved { _ = os.Remove(filepath.Join(dir, name)) } return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) } addedIds := make([]int64, 0, len(saved)) err = common.Serial().Submit(ctx, func() error { for _, name := range saved { // 重查重(并发上传兜底)+ 入库 exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name) if err != nil { return err } if exists != nil { continue } id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{ DatasetId: dataset.Id, Filename: name, Source: "manual", CreatedAt: now, }) if err != nil { return err } addedIds = append(addedIds, id) res.Added++ } return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(res.Added), -1, "") }) if err != nil { // 入库失败:清掉已落盘文件,保证「记录存在 ⟺ 文件存在」 for _, name := range saved { _ = os.Remove(filepath.Join(dir, name)) } return nil, err } // 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;其他失败回滚本次入库 newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds) if err != nil { return nil, err } if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil { // 触发失败回滚本次入库(manual 上传文件非付费资产,可删) rollbackIds := make([]int64, 0, len(newImages)) for _, img := range newImages { rollbackIds = append(rollbackIds, img.Id) if rErr := os.Remove(filepath.Join(dir, img.Filename)); rErr != nil { g.Log().Warningf(ctx, "回滚删除图片文件失败: %s: %v", img.Filename, rErr) } } if rErr := common.Serial().Submit(ctx, func() error { if dErr := dao.DatasetImage.DeleteByIds(ctx, rollbackIds); dErr != nil { return dErr } return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(rollbackIds)), 0, "") }); rErr != nil { g.Log().Errorf(ctx, "自动标注触发失败后的入库回滚失败: %v", rErr) } return nil, err } return res, nil } // saveImageFile 上传文件保存(ghttp.UploadFile.Save 已按原始文件名落盘) func saveImageFile(f *ghttp.UploadFile, dir string) error { saved, err := f.Save(dir) if err != nil { return err } if saved != filepath.Base(f.Filename) { _ = os.Remove(filepath.Join(dir, saved)) } return nil } // nextImageIndex 生成图顺序编号起点:数据集内 <前缀>_<数字>.jpg 的已有最大序号 + 1 func (s *datasetService) nextImageIndex(ctx context.Context, datasetId int64, prefix string) (int, error) { list, err := dao.DatasetImage.ListByDataset(ctx, datasetId) if err != nil { return 0, err } re := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `_(\d+)\.jpg$`) max := 0 for _, img := range list { if m := re.FindStringSubmatch(img.Filename); m != nil { if v, err := strconv.Atoi(m[1]); err == nil && v > max { max = v } } } return max + 1, nil } // singleAnimalClause 目标数量约束:每张仅一只动物,手填提示词统一追加(模板已由 {count} 固定 "1只") const singleAnimalClause = "画面中有且只有这一只动物,没有任何其他动物" // buildPromptFromTemplate 按 config 通用模板组装提示词(单物种规则:物种固定取数据集 gen_species, // 空则数据集名本身——生成表单无物种输入,不随机)。 // 物种只写名字不写羽毛细节(模型对正确名称自带外观先验);场景/动作/遮挡从数据集表池随机 // (创建时 VLM 生成,config.yml 不再兜底;池空报错——生成图按池组装,池质量决定训练数据质量); // 光线走 config 通用池 lights。 func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, error) { tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String() if tpl == "" { return "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词") } species := strings.TrimSpace(dataset.GenSpecies) if species == "" { species = dataset.Name } // 场景/动作/遮挡按数据集习性取池(表存储,VLM 生成;池空报错——生成图按池组装,池质量决定训练数据质量) poolOf := func(raw string) []string { var pool []string if json.Unmarshal([]byte(raw), &pool) != nil { return nil } return pool } scene := pickCfgList(poolOf(dataset.GenScenes)) action := pickCfgList(poolOf(dataset.GenActions)) occlusion := pickCfgList(poolOf(dataset.GenOcclusions)) if scene == "" || action == "" || occlusion == "" { return "", gerror.New("数据集「" + dataset.Name + "」未配置生成参数池(场景/动作/遮挡),请在编辑数据集生成或手填提示词") } light := pickCfg(ctx, "imageGen.lights") // 轮廓色词(表 gen_tone,白化个体如白雉鸡配浅色,未配置默认深色剪影) tone := strings.TrimSpace(dataset.GenTone) if tone == "" { tone = "深色" } // 距离描述从表单固定值生成(25 → "25米外") distWord := fmt.Sprintf("%d米外", dist) // 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比 sizeHint, pctVal, sErr := s.buildSizeHint(ctx, dataset, dist, size) if sErr != nil { return "", sErr } // 占比 <2% 时切换远景小目标模板:目标小到动作/遮挡/性别细节无法呈现, // 保留细节描写会迫使模型把目标画大(2026-08-31 实测两次失败后定案) if pctVal < 2.0 { if tiny := g.Cfg().MustGet(ctx, "imageGen.promptTemplateTiny").String(); tiny != "" { tpl = tiny } } // 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪) countWord := "1只" // 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等), // 每张随机让训练数据覆盖两性形态 sexWord := "雄性" if rand.Intn(2) == 1 { sexWord = "雌性" } out := tpl for k, v := range map[string]string{ "{scene}": scene, "{species}": sexWord + species, "{count}": countWord, "{action}": action, "{occlusion}": occlusion, "{light}": light, "{distanceWord}": distWord, "{sizeHint}": sizeHint, "{tone}": tone, } { out = strings.ReplaceAll(out, k, v) } g.Log().Infof(ctx, "模板组装提示词: %s", out) return out, nil } // buildSizeHint 尺寸提示:目标像素高 = 体高 × focalPx ÷ 距离(focalPx = 图高/2 ÷ tan(vfov/2)), // 尺寸词按「像素高÷图高」画面占比分级(量化像素数对文生图模型无意义;固定物体类比词 // 不跨物种/距离通用,2026-08-31 用户否决),输出如「(在画面中很小的身影,约占画面高度1.0%)」; // 返回占比供调用方按 <2% 切换远景小目标模板。占比保留一位小数,避免出现 0% func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, float64, error) { _, imgH, err := parseGenSize(size) if err != nil { return "", 0, err } // 站高 cm 从数据集表 gen_heights 读(数据库维护,编辑界面手改真实值;<=0 物理兜底 35cm),换算成米参与投影公式 heightCM := dataset.GenHeights if heightCM <= 0 { heightCM = 35.0 } heightM := heightCM / 100 // 镜头固定为手机默认主摄(垂直视场角配置值,默认 52°;2026-08-28 曾随机 [15,90]、曾试超广角,用户最终定为默认镜头) vfov := g.Cfg().MustGet(ctx, "imageGen.assumedVfovDeg", 52.0).Float64() * math.Pi / 180 focalPx := float64(imgH) / 2 / math.Tan(vfov/2) pxAt := func(dist int) int { if dist <= 0 { dist = 1 } return int(math.Round(heightM * focalPx / float64(dist))) } pct := func(px int) string { return fmt.Sprintf("%.1f", float64(px)*100/float64(imgH)) } // 尺寸词按画面占比分级(占比=像素高÷图高,与物种/距离无关:同占比同观感; // 固定物体类比词(针尖/米粒/拳头)不跨物种通用,用户否决 2026-08-31; // 站高 0.6m、focalPx≈1280 时:60m≈13px≈1.0%、25m≈31px≈2.5%、100m≈8px≈0.6%) px := pxAt(dist) pctVal := float64(px) * 100 / float64(imgH) word := "中等大小的身影" switch { case pctVal < 1.0: word = "微小到几乎难以辨认的身影" case pctVal < 2.0: word = "很小的身影" case pctVal < 5.0: word = "较小的身影" case pctVal < 10.0: word = "中等大小的身影" default: word = "较大的身影" } return fmt.Sprintf("(在画面中%s,约占画面高度%s%%)", word, pct(px)), pctVal, nil } // parseGenSize 解析 "704x1248" 形式尺寸 func parseGenSize(size string) (int, int, error) { parts := strings.SplitN(size, "x", 2) if len(parts) != 2 { return 0, 0, gerror.Newf("非法图片尺寸 %q", size) } w, errW := strconv.Atoi(parts[0]) h, errH := strconv.Atoi(parts[1]) if errW != nil || errH != nil || w <= 0 || h <= 0 { return 0, 0, gerror.Newf("非法图片尺寸 %q", size) } return w, h, nil } // pickCfg 取字符串数组配置项随机一项(空池返回空串) func pickCfg(ctx context.Context, key string) string { return pickCfgList(g.Cfg().MustGet(ctx, key).Strings()) } // pickCfgList 从字符串数组随机取一项(空池返回空串) func pickCfgList(pool []string) string { if len(pool) == 0 { return "" } return pool[rand.Intn(len(pool))] } // AdminGenerateImages AI 生成图片(异步任务):校验通过后插 gen_task 立即返回 TaskId, // 后台协程逐张生成(不设调用超时,失败由 provider 返回错误决定),进度落库供前端轮询; // 完成(或部分失败)后对本次新增图触发自动标注(付费资产,失败保留已生成图不删除)。 func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) { provider := common.ImageGen(ctx) if provider == nil { return nil, gerror.NewCode(common.CodeImageGenNotConfigured) } dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } // 标注强语义:未配置标注服务时不允许开始生成(避免付费资产生成后无法标注) if common.LocalAiClient(ctx) == nil { return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) } dir := common.DatasetImagesDir(ctx, dataset.Name) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, gerror.Wrap(err, "创建图片目录失败") } // 命名:数据集属性 name_prefix(创建/编辑表单维护)非空时按 <前缀>_<两位序号>.jpg 顺序编号 // (延续数据集内已有最大序号,参考既有图片目录的连续编号惯例);无前缀按时间戳命名。 // 注意:gtime.Time.Format 是 PHP 风格格式符,Go 数字布局须用 .Time.Format,否则原样透传 prefix := dataset.NamePrefix nextIdx := 1 if prefix != "" { if !datasetNameRe.MatchString(prefix) { return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线") } nextIdx, err = s.nextImageIndex(ctx, dataset.Id, prefix) if err != nil { return nil, err } } // Serial 内并发检查 + 插任务:同数据集已有 running 生成任务则拒绝 now := gtime.Now() var taskId int64 if err := common.Serial().Submit(ctx, func() error { running, err := dao.GenTask.GetRunningByDataset(ctx, dataset.Id) if err != nil { return err } if running != nil { return gerror.NewCode(common.CodeGenTaskRunning) } taskId, err = dao.GenTask.Insert(ctx, &entity.GenTask{ DatasetId: dataset.Id, Status: consts.GenTaskRunning, Total: req.Count, CreatedAt: now, }) return err }); err != nil { return nil, err } s.runGenTask(taskId, dataset, req, provider, now, prefix, nextIdx) return &dto.AdminDatasetGenerateRes{TaskId: taskId, Total: req.Count}, nil } // recoverGenTasks 服务重启恢复:孤儿 running 生成任务置 failed + 错误提示 // (生成中断后已生成图为付费资产保留;重新发起即可补齐,startDetection 并发检查不受孤儿影响)。 func (s *datasetService) recoverGenTasks(ctx context.Context) { list, err := dao.GenTask.ListRunning(ctx) if err != nil { g.Log().Errorf(ctx, "恢复生成任务失败: %+v", err) return } for _, t := range list { if err := dao.GenTask.Finish(ctx, t.Id, "服务重启,任务中断,可重新发起"); err != nil { g.Log().Errorf(ctx, "恢复生成任务 %d 失败: %+v", t.Id, err) } } } // runGenTask 生成执行协程(生命周期任务):脱离请求 ctx(请求结束即取消会让 Submit 秒退 + // Finish 静默失败 → 任务悬挂),池内逐张生成(imageGen.poolSize 并发,z-image 显存独占默认 1), // 每张成功后 Serial 更新进度;全部完成 → 更新数据集计数 → 自动标注 → 置 done; // 中途失败 → 已生成图先触发自动标注 → 置 failed(错误信息带已生成张数)。 func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *dto.AdminDatasetGenerateReq, provider common.ImageGenProvider, now *gtime.Time, prefix string, nextIdx int) { bgCtx := context.Background() go func() { dir := common.DatasetImagesDir(bgCtx, dataset.Name) // 异步任务不设调用超时:超时只放弃等待、取消不了 local-ai backend, // 会造成「任务已失败、模型仍在跑」的假失败并占住 poolSize=1 的池;失败判定交给 provider 真实返回 handWritten := strings.TrimSpace(req.Prompt) != "" generated := 0 addedIds := make([]int64, 0, req.Count) failed := "" for i := 0; i < req.Count; i++ { // 提示词:手填优先(统一追加数量约束:仅一只动物,拍摄角度不带, // 交由模型平视先验自由构图);未手填走 config 通用模板逐张组装。距离校验已取消(2026-08-28) prompt := strings.TrimSpace(req.Prompt) if handWritten { prompt = strings.TrimSuffix(prompt, "。") + "。" if !strings.Contains(prompt, "一只") && !strings.Contains(prompt, "1只") && !strings.Contains(prompt, "唯一") { prompt += singleAnimalClause } } else { var tErr error if prompt, tErr = s.buildPromptFromTemplate(bgCtx, dataset, req.Distance, req.Size); tErr != nil { failed = tErr.Error() break } } var data []byte err := common.GenTaskPoolInstance().Submit(bgCtx, func(ctx context.Context) error { d, e := provider.Generate(ctx, prompt, req.Size) data = d return e }) if err != nil { // 付费资产保留原则:已生成的不删除 failed = fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, err) break } filename := "" if prefix != "" { filename = fmt.Sprintf("%s_%02d.jpg", prefix, nextIdx) nextIdx++ } else { filename = fmt.Sprintf("gen_%s_%d.jpg", now.Time.Format("20060102150405"), i) } // 生成图统一转 jpg(模型可能返回 png/webp,扩展名 .jpg 须与内容一致) data, jErr := ensureJpeg(data) if jErr != nil { failed = fmt.Sprintf("第 %d 张转 jpg 失败(已生成 %d 张): %v", i+1, generated, jErr) break } if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil { failed = fmt.Sprintf("第 %d 张保存失败(已生成 %d 张): %v", i+1, generated, err) break } var imgId int64 if insErr := common.Serial().Submit(bgCtx, func() error { id, err := dao.DatasetImage.Insert(bgCtx, &entity.DatasetImage{ DatasetId: dataset.Id, Filename: filename, Source: "ai", Prompt: prompt, AnimalCount: 1, CreatedAt: now, }) imgId = id return err }); insErr != nil { failed = fmt.Sprintf("第 %d 张入库失败(已生成 %d 张): %v", i+1, generated, insErr) break } addedIds = append(addedIds, imgId) generated++ _ = common.Serial().Submit(bgCtx, func() error { return dao.GenTask.UpdateProgress(bgCtx, taskId, generated) }) } if failed != "" { s.genTaskAutoLabel(bgCtx, dataset, addedIds) // 部分失败:已入库图按付费资产保留,计数须计入(曾漏更新致 image_count 漂移为负) if err := common.Serial().Submit(bgCtx, func() error { return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "") }); err != nil { g.Log().Errorf(bgCtx, "生成任务 %d 部分失败后更新数据集计数失败: %+v", taskId, err) } _ = dao.GenTask.Finish(bgCtx, taskId, failed) return } // 全部成功:更新数据集计数 → 自动标注 → 置 done if err := common.Serial().Submit(bgCtx, func() error { return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "") }); err != nil { g.Log().Errorf(bgCtx, "生成任务 %d 更新数据集计数失败: %+v", taskId, err) } s.genTaskAutoLabel(bgCtx, dataset, addedIds) _ = dao.GenTask.Finish(bgCtx, taskId, "") }() } // genTaskAutoLabel 生成任务完成后对本次新增图触发自动标注:忙(已有 running 任务)不报错, // 由标注任务完成后的自动补标轮兜底;其他失败只记日志(付费资产,不可删)。 func (s *datasetService) genTaskAutoLabel(ctx context.Context, dataset *entity.Dataset, addedIds []int64) { if len(addedIds) == 0 { return } newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds) if err != nil { g.Log().Errorf(ctx, "生成任务后读取新增图失败: %+v", err) return } if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil { g.Log().Errorf(ctx, "生成任务后自动标注触发失败: %+v", err) } } // AdminGenTaskQuery 生成任务进度(最近一次;无任务返回 nil) func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGenTaskQueryReq) (*dto.AdminGenTaskQueryRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } t, err := dao.GenTask.LatestByDataset(ctx, dataset.Id) if err != nil { return nil, err } if t == nil { return nil, nil } return &dto.AdminGenTaskQueryRes{ Id: t.Id, DatasetId: t.DatasetId, Status: t.Status, Total: t.Total, Done: t.Done, Error: t.Error, CreatedAt: t.CreatedAt, FinishedAt: t.FinishedAt, }, nil } // AdminListImages 数据集图片列表(创建时间正序,标注工作台/网格预览) func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminDatasetImagesReq) (*dto.AdminDatasetImagesRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } list, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id) if err != nil { return nil, err } items := make([]*dto.AdminImageItem, 0, len(list)) for _, v := range list { items = append(items, &dto.AdminImageItem{ Id: v.Id, Filename: v.Filename, Source: v.Source, Prompt: v.Prompt, Url: datasetImageUrl(ctx, dataset.Id, v.Filename), CreatedAt: v.CreatedAt, }) } return &dto.AdminDatasetImagesRes{List: items}, nil } // AdminDeleteImages 删除图片:删图片文件 + 记录 + 更新计数 // (付费资产,前端带确认文案;标注/候选框 JSON 随图片行删除) func (s *datasetService) AdminDeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDeleteReq) (*dto.AdminDatasetImagesDeleteRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } images, err := dao.DatasetImage.GetByIds(ctx, req.Ids) if err != nil { return nil, err } var del []*entity.DatasetImage for _, img := range images { // 只删本数据集内的记录(防跨数据集误删) if img.DatasetId == dataset.Id { del = append(del, img) } } if len(del) == 0 { return &dto.AdminDatasetImagesDeleteRes{}, nil } // 文件清理(删除失败仅记日志,记录照删):图片文件(标注/候选随行删除) imgDir := common.DatasetImagesDir(ctx, dataset.Name) for _, img := range del { _ = os.Remove(filepath.Join(imgDir, img.Filename)) } // Serial 内:删行 → 重算已标注数 → 同步计数与状态 err = common.Serial().Submit(ctx, func() error { ids := make([]int64, 0, len(del)) for _, img := range del { ids = append(ids, img.Id) } if err := dao.DatasetImage.DeleteByIds(ctx, ids); err != nil { return err } labeled, err := dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id) if err != nil { return err } status := "" if labeled > 0 { status = consts.DatasetStatusLabeled } return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(del)), labeled, status) }) if err != nil { return nil, err } return &dto.AdminDatasetImagesDeleteRes{}, nil } // datasetImageUrl 管理端图片访问地址(controller 直写响应体,见 admin.go Image 方法) func datasetImageUrl(ctx context.Context, datasetId int64, filename string) string { return fmt.Sprintf("/api/v1/admin/datasets/image?%s", url.Values{"datasetId": {fmt.Sprintf("%d", datasetId)}, "filename": {filename}}.Encode()) } // ImageFile 图片文件定位(校验归属后返回绝对路径;controller 直写响应体) func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filename string) (string, error) { if filename != filepath.Base(filename) || !imgExts[strings.ToLower(filepath.Ext(filename))] { return "", gerror.NewCode(common.CodeImageNotFound) } dataset, err := dao.Dataset.GetById(ctx, datasetId) if err != nil { return "", err } if dataset == nil { return "", gerror.NewCode(common.CodeDatasetNotFound) } img, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, filename) if err != nil { return "", err } if img == nil { return "", gerror.NewCode(common.CodeImageNotFound) } path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filename) if _, err := os.Stat(path); err != nil { return "", gerror.NewCode(common.CodeImageNotFound) } return path, nil }