package service import ( "bytes" "context" "fmt" "image" "image/jpeg" "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" "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) } 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 { item := &dto.AdminDatasetItem{ Id: v.Id, Name: v.Name, Source: v.Source, ImageCount: v.ImageCount, LabeledCount: v.LabeledCount, Status: v.Status, Cover: v.Cover, Description: v.Description, NamePrefix: v.NamePrefix, 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) if newName != "" && newName != existing.Name { if err := s.renameDataset(ctx, existing.Id, existing.Name, newName); err != nil { return nil, err } existing.Name = newName } // 封面须属于该数据集图片(防伪造文件名指向任意文件) 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) } } prefix := strings.TrimSpace(req.NamePrefix) if prefix != "" && !datasetNameRe.MatchString(prefix) { return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50") } 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 } return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil } // renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录与当前生效模型文件。 // 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。 func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName 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) oldModel := common.TrainingModelPath(ctx, oldName) newModel := common.TrainingModelPath(ctx, newName) movedImgDir, movedModel := false, false if _, err := os.Stat(oldImgDir); err == nil { if err := os.Rename(oldImgDir, newImgDir); err != nil { return gerror.Wrap(err, "迁移图片目录失败") } movedImgDir = true } if _, err := os.Stat(oldModel); err == nil { if err := os.Rename(oldModel, newModel); err != nil { if movedImgDir { if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil { g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr) } } return gerror.Wrap(err, "迁移模型文件失败") } movedModel = true } if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil { if movedModel { if rbErr := os.Rename(newModel, oldModel); rbErr != nil { g.Log().Errorf(ctx, "回滚模型文件失败: %+v", rbErr) } } 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 } // 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 > 2*1024*1024 { return nil, gerror.New("封面不能超过 2MB") } raw, err := req.File.Open() if err != nil { return nil, gerror.Wrap(err, "读取封面文件失败") } defer func() { _ = raw.Close() }() img, _, err := image.Decode(raw) if err != nil { return nil, gerror.New("封面文件不是有效图片") } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil { return nil, gerror.Wrap(err, "封面转 jpg 失败") } 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), buf.Bytes()); 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 直写响应体输出) func (s *datasetService) CoverFile(ctx context.Context, datasetId int64) (string, error) { 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 := filepath.Base(dataset.Cover) if !isCoverName(name) { return "", gerror.NewCode(common.CodeImageNotFound) } path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), name) if _, err := os.Stat(path); err != nil { return "", gerror.NewCode(common.CodeImageNotFound) } return path, nil } // 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 } // 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 } // AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 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") } id, err = dao.Dataset.Insert(ctx, &entity.Dataset{ Name: req.Name, Source: req.Source, NamePrefix: prefix, 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, "创建数据集目录失败") } return &dto.AdminDatasetCreateRes{Id: id}, nil } // AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝 // (训练产物与模型为付费资产,需先删除模型版本再删数据集)。 // 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。 func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) { var name 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 // 预标注任务进行中(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, name)} 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 } // AdminGenOptions 生成表单选项:该数据集的物种池 + 场景池(config 维护,前端下拉用) func (s *datasetService) AdminGenOptions(ctx context.Context, req *dto.AdminDatasetGenOptionsReq) (*dto.AdminDatasetGenOptionsRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } return &dto.AdminDatasetGenOptionsRes{ Species: g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+dataset.Name).Strings(), Scenes: g.Cfg().MustGet(ctx, "imageGen.scenes").Strings(), }, nil } // singleAnimalClause 目标数量约束:每张仅一只动物,手填提示词统一追加(模板已由 {count} 固定 "1只") const singleAnimalClause = "画面中有且只有这一只动物,没有任何其他动物" // buildPromptFromTemplate 按 config 通用模板组装提示词:物种/场景优先用表单选择 // (物种只写名字不写羽毛细节——模型对正确名称自带外观先验;场景未选则池内随机), // 物种未填按数据集名取物种池随机(池也未配置则用数据集名本身);动作/光线池内随机。 func (s *datasetService) buildPromptFromTemplate(ctx context.Context, datasetName, species string, dist int, size string) (string, error) { tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String() if tpl == "" { return "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词") } if species == "" { species = datasetName if pool := g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+datasetName).Strings(); len(pool) > 0 { species = pool[rand.Intn(len(pool))] } } // 场景/动作按数据集习性取池(鱼水域、雉鸡灌丛……),未配置该数据集回退通用池 scene := pickCfgByDataset(ctx, datasetName, "imageGen.sceneByDataset", "imageGen.scenes") action := pickCfgByDataset(ctx, datasetName, "imageGen.actionByDataset", "imageGen.actions") light := pickCfg(ctx, "imageGen.lights") // 距离描述从表单固定值生成(25 → "25米外") distWord := fmt.Sprintf("%d米外", dist) // 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比 sizeHint, sErr := s.buildSizeHint(ctx, species, dist, size) if sErr != nil { return "", sErr } // 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪) countWord := "1只" out := tpl for k, v := range map[string]string{ "{scene}": scene, "{species}": species, "{count}": countWord, "{action}": action, "{light}": light, "{distanceWord}": distWord, "{sizeHint}": sizeHint, } { out = strings.ReplaceAll(out, k, v) } g.Log().Infof(ctx, "模板组装提示词: %s", out) return out, nil } // buildSizeHint 尺寸提示:目标像素高 = 体高 × focalPx ÷ 距离(focalPx = 图高/2 ÷ tan(vfov/2)), // 输出如「(在画面中高度仅约15像素,占画面高度约1.2%)」。 // 占比向下取整且至少 1%,避免出现 0% func (s *datasetService) buildSizeHint(ctx context.Context, species string, dist int, size string) (string, error) { _, imgH, err := parseGenSize(size) if err != nil { return "", err } // 配置单位为 cm(实际体型),换算成米参与投影公式;未配置物种兜底 35cm heightCM := g.Cfg().MustGet(ctx, "imageGen.speciesHeights."+species, 35.0).Float64() 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)) } px := pxAt(dist) return fmt.Sprintf("(在画面中高度仅约%d像素,占画面高度约%s%%)", px, pct(px)), 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 { pool := g.Cfg().MustGet(ctx, key).Strings() if len(pool) == 0 { return "" } return pool[rand.Intn(len(pool))] } // pickCfgByDataset 按数据集名取配置池(key 形如 imageGen.sceneByDataset.<数据集名>)随机, // 该数据集未配置时回退通用池 func pickCfgByDataset(ctx context.Context, datasetName, byDatasetKey, genericKey string) string { if pool := g.Cfg().MustGet(ctx, byDatasetKey+"."+datasetName).Strings(); len(pool) > 0 { return pool[rand.Intn(len(pool))] } return pickCfg(ctx, genericKey) } // AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库; // 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。 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, "创建图片目录失败") } now := gtime.Now() generated := 0 addedIds := make([]int64, 0, req.Count) // 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600) genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second handWritten := strings.TrimSpace(req.Prompt) != "" genOnce := func(prompt string) ([]byte, error) { genCtx, cancel := context.WithTimeout(ctx, genTimeout) defer cancel() return provider.Generate(genCtx, prompt, req.Size) } // 命名:数据集属性 name_prefix(创建/编辑表单维护)非空时按 <前缀>_<两位序号>.jpg 顺序编号 // (延续数据集内已有最大序号,参考野鸡目录 pheasant_NN.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 } } rejected := 0 for i := 0; i < req.Count; i++ { // 部分成功也要标注:报错返回前对已入库图片触发自动标注(外部重试驱动依赖此语义攒量) failWith := func(err error) (*dto.AdminDatasetGenerateRes, error) { if generated > 0 && len(addedIds) > 0 { if newImages, lErr := dao.DatasetImage.GetByIds(ctx, addedIds); lErr == nil { if aErr := LabelTask.AutoLabel(ctx, dataset, newImages); aErr != nil { g.Log().Errorf(ctx, "部分成功后自动标注触发失败: %+v", aErr) } } } return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, err } // 提示词:手填优先;未手填走 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 } } if !handWritten { var tErr error if prompt, tErr = s.buildPromptFromTemplate(ctx, dataset.Name, strings.TrimSpace(req.Species), req.Distance, req.Size); tErr != nil { return nil, tErr } } data, genErr := genOnce(prompt) if genErr != nil { // 付费资产保留原则:已生成的不删除 return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr))) } 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) } if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil { return failWith(gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated)) } var imgId int64 insErr := common.Serial().Submit(ctx, func() error { id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{ DatasetId: dataset.Id, Filename: filename, Source: "ai", Prompt: prompt, AnimalCount: 1, CreatedAt: now, }) imgId = id return err }) if insErr != nil { return failWith(gerror.Wrap(insErr, "生成图片入库失败")) } addedIds = append(addedIds, imgId) generated++ } if err := common.Serial().Submit(ctx, func() error { return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(generated), -1, "") }); err != nil { 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 { return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, gerror.Wrap(err, "图片已生成入库,但自动标注触发失败") } return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, 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 }