package service import ( "bytes" "context" "encoding/json" "fmt" "image" _ "image/jpeg" "io" "os" "path/filepath" "strings" "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" ) // falseTargetService 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流, // 上传识别瞬间的分析帧整图(jpg 重编码已剥离全部元数据,最小必要);管理端审核通过 // 迁移入负样本库当背景图训练,拒绝删文件。不变式:pending/approved 记录 ⟺ 文件存在。 // 上报即算整帧 dHash 查重(近重复拒绝),落库后异步跑 RF-DETR 疑似真目标预判(2026-09-09)。 type falseTargetService struct{} var FalseTarget = &falseTargetService{} // falseTargetMaxBytes 裁剪图上限(客户端按框 ±15% 裁剪、jpg q85,远小于该值) const falseTargetMaxBytes = 8 << 20 // ---------- App ---------- // AppReport 上报假目标:校验图片 → Serial 内整帧 dHash 查重 + 日限额 → 通过才 UUID // 命名落盘待审目录 → 插记录(任一步失败删已落盘文件,保证记录存在 ⟺ 文件存在)。 // 重复上报不落盘(技术设计.md「上报查重与疑似真目标预判」)。 func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) { phone := common.PhoneFromCtx(ctx) if req.File == nil { return nil, gerror.New("缺少图片文件") } ext := strings.ToLower(filepath.Ext(req.File.Filename)) if ext != ".jpg" && ext != ".jpeg" { return nil, gerror.New("仅支持 jpg 图片") } if req.File.Size > falseTargetMaxBytes { return nil, gerror.New("图片过大") } f, err := req.File.Open() if err != nil { return nil, gerror.Wrap(err, "读取上传文件失败") } data, err := io.ReadAll(f) _ = f.Close() if err != nil { return nil, gerror.Wrap(err, "读取上传文件失败") } if _, _, err = image.DecodeConfig(bytes.NewReader(data)); err != nil { return nil, gerror.New("图片内容无效") } // 整帧 dHash(上报查重,2026-09-09):同机位连续帧近距极小,重复上报主源 hash, err := common.ImageDHash64(data) if err != nil { return nil, gerror.Wrap(err, "图片解析失败") } // 检测框快照:仅作审核参考的元数据,解析失败/坐标非法的框直接丢弃, // 不阻断图片上报(主体是画面本身) var allBoxes []*dto.FalseTargetBox if strings.TrimSpace(req.Detections) != "" { _ = json.Unmarshal([]byte(req.Detections), &allBoxes) } boxes := make([]*dto.FalseTargetBox, 0, len(allBoxes)) for _, b := range allBoxes { if b == nil || b.Cx < 0 || b.Cy < 0 || b.W <= 0 || b.H <= 0 || b.Cx > 1 || b.Cy > 1 { continue } boxes = append(boxes, b) } snapshot, err := json.Marshal(dto.FalseTargetSnapshot{ SourceW: req.SourceW, SourceH: req.SourceH, Boxes: boxes, }) if err != nil { return nil, gerror.Wrap(err, "快照序列化失败") } // 来源模型(=数据集名):快照内去重后逗号拼接(多模型并行推理时可能多个) modelSet := map[string]bool{} models := make([]string, 0, len(boxes)) for _, b := range boxes { if b.Model != "" && !modelSet[b.Model] { modelSet[b.Model] = true models = append(models, b.Model) } } now := gtime.Now() dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00") dir := common.FalseTargetsDir(ctx) name := common.UuidV4() + ".jpg" var id int64 err = common.Serial().Submit(ctx, func() error { // dHash 查重(技术设计.md「上报查重与疑似真目标预判」):近重复直接拒绝,不落盘不占限额。 // 查重与落盘必须同串行块——分在两处时并发重复上报会各自通过查重、各自落盘入库 existing, err := dao.FalseTargetReport.ListHashes(ctx) if err != nil { return err } hashes := make([]uint64, 0, len(existing)) for _, h := range existing { hashes = append(hashes, uint64(h)) } if minHamming(hash, hashes) <= consts.FalseTargetDupHamming { return gerror.New("相似样本已存在,无需重复上报") } cnt, err := dao.FalseTargetReport.CountTodayByPhone(ctx, phone, dayStart) if err != nil { return err } if cnt >= consts.FalseTargetDailyLimit { return gerror.Newf("今日上报已达上限(%d 条/天)", consts.FalseTargetDailyLimit) } // 查重/限额均通过才落盘(重复上报不产生文件) if err := os.MkdirAll(dir, 0o755); err != nil { return gerror.Wrap(err, "创建上报目录失败") } if err := saveUploadAs(req.File, dir, name); err != nil { return gerror.Wrap(err, "图片保存失败") } id, err = dao.FalseTargetReport.Insert(ctx, &entity.FalseTargetReport{ PhoneNum: phone, File: name, LabelsJson: string(snapshot), Species: strings.Join(models, ","), Status: consts.FalseTargetPending, ImageHash: int64(hash), CreatedAt: now, }) return err }) if err != nil { _ = os.Remove(filepath.Join(dir, name)) return nil, err } s.submitSuspectCheck(ctx, id, name) return &dto.FalseTargetReportRes{Id: id}, nil } // submitSuspectCheck 异步疑似真目标预判(2026-09-09):对整帧跑 RF-DETR 全图检测, // 最高置信度 ≥ confConfirmed(真目标确认线)置 suspect_real=1 供审核把关。 // 未配置 local-ai 直接跳过;检测/更新失败仅记日志不回溯上报本身(fail-open)。 func (s *falseTargetService) submitSuspectCheck(ctx context.Context, id int64, file string) { client := common.LocalAiClient(ctx) if client == nil { return } dir := common.FalseTargetsDir(ctx) // 生命周期协程包池任务(promoteQueued 同模式):LabelTaskPool.Submit 是阻塞语义 go func() { bgCtx := context.Background() err := common.LabelTaskPoolInstance().Submit(bgCtx, func(taskCtx context.Context) error { data, err := os.ReadFile(filepath.Join(dir, file)) if err != nil { return err } cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return err } dets, err := client.Detect(taskCtx, data, "image/jpeg", cfg.Width, cfg.Height) if err != nil { return err } for _, d := range dets { if d.Confidence >= client.ConfConfirmed { if err := common.Serial().Submit(taskCtx, func() error { return dao.FalseTargetReport.SetSuspectReal(taskCtx, id) }); err != nil { return err } break } } return nil }) if err != nil { g.Log().Errorf(bgCtx, "假目标 %d 疑似真目标预判失败: %+v", id, err) } }() } // startupHashBackfill 存量记录整帧 dHash 回填(2026-09-09 一次性,启动后台协程调用): // pending 从待审目录、approved 从负样本库目录(审核迁移文件名不变)读图算 hash 回写; // 文件缺失跳过(留 0 不参与查重)。回填完自退出,新上报均带 hash 无需再跑。 func (s *falseTargetService) startupHashBackfill(ctx context.Context) { negLib, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName) if err != nil { g.Log().Errorf(ctx, "假目标 hash 回填查负样本库失败: %+v", err) return } // 单遍扫描:hash=0 只会出现在存量行(新上报均内联算 hash),无需轮询; // 文件缺失/解码失败跳过(留 0 不参与查重),避免失败行导致死循环 missing, err := dao.FalseTargetReport.ListHashMissing(ctx) if err != nil { g.Log().Errorf(ctx, "假目标 hash 回填查询失败: %+v", err) return } for _, r := range missing { dir := common.FalseTargetsDir(ctx) if r.Status == consts.FalseTargetApproved && negLib != nil { dir = common.DatasetImagesDir(ctx, negLib.Name) } data, err := os.ReadFile(filepath.Join(dir, r.File)) if err != nil { continue } h, err := common.ImageDHash64(data) if err != nil { continue } if err := common.Serial().Submit(ctx, func() error { return dao.FalseTargetReport.UpdateHash(ctx, r.Id, int64(h)) }); err != nil { g.Log().Errorf(ctx, "假目标 %d hash 回写失败: %+v", r.Id, err) } } } // saveUploadAs 上传文件落盘为指定文件名(f.Save 随原文件名,先暂存再改名) func saveUploadAs(f *ghttp.UploadFile, dir, name string) error { tmp, err := f.Save(dir) if err != nil { return err } if tmp == name { return nil } if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil { _ = os.Remove(filepath.Join(dir, tmp)) return err } return nil } // ---------- 管理端 ---------- // AdminList 上报分页(附裁剪图预览地址 + 检测框快照解码) func (s *falseTargetService) AdminList(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) { page, size := common.NormalizePage(req.Page, req.Size) list, total, err := dao.FalseTargetReport.PageByFilter(ctx, req.PhoneNum, req.Status, page, size) if err != nil { return nil, err } res := &dto.AdminFalseTargetListRes{Total: total, List: make([]*dto.AdminFalseTargetItem, 0, len(list))} for _, r := range list { item := &dto.AdminFalseTargetItem{ Id: r.Id, PhoneNum: r.PhoneNum, Species: r.Species, Status: r.Status, ImageUrl: fmt.Sprintf("/api/v1/admin/false-targets/image?id=%d", r.Id), SuspectReal: r.SuspectReal, CreatedAt: r.CreatedAt, ReviewedAt: r.ReviewedAt, } if r.LabelsJson != "" { var snap dto.FalseTargetSnapshot if json.Unmarshal([]byte(r.LabelsJson), &snap) == nil { item.Boxes = snap.Boxes item.SourceW = snap.SourceW item.SourceH = snap.SourceH } } res.List = append(res.List, item) } return res, nil } // ImageFile 上报画面路径(controller 直写响应体;pending/approved 记录文件必然存在) func (s *falseTargetService) ImageFile(ctx context.Context, id int64) (string, error) { reports, err := dao.FalseTargetReport.GetByIds(ctx, []int64{id}) if err != nil { return "", err } if len(reports) == 0 { return "", gerror.New("上报记录不存在") } path := filepath.Join(common.FalseTargetsDir(ctx), reports[0].File) if _, err := os.Stat(path); err != nil { return "", gerror.New("图片文件不存在") } return path, nil } // AdminReview 审核:通过 = 裁剪图迁移入负样本库(__negative__,背景图训练); // 拒绝 = 删文件。仅 pending 可审(重复审核跳过)。Serial + 逐条迁移, // 入库失败把已迁移文件迁回待审目录(回滚),保持记录存在 ⟺ 文件存在不变式。 func (s *falseTargetService) AdminReview(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) { res := &dto.AdminFalseTargetReviewRes{} reports, err := dao.FalseTargetReport.GetByIds(ctx, req.Ids) if err != nil { return nil, err } pending := make([]*entity.FalseTargetReport, 0, len(reports)) for _, r := range reports { if r.Status == consts.FalseTargetPending { pending = append(pending, r) } } if len(pending) == 0 { return res, nil } now := gtime.Now() // 负样本库懒创建内部走 Serial,须在本 Serial 块外先行调用(防串行队列嵌套等待死锁) var lib *entity.Dataset if req.Approve { lib, err = Dataset.ensureNegativeLibrary(ctx) if err != nil { return nil, err } } err = common.Serial().Submit(ctx, func() error { fromDir := common.FalseTargetsDir(ctx) moved := make([]string, 0, len(pending)) // 已迁出文件名(失败回迁) toDir := "" if req.Approve { toDir = common.DatasetImagesDir(ctx, consts.NegativeDatasetName) if err := os.MkdirAll(toDir, 0o755); err != nil { return gerror.Wrap(err, "创建负样本库目录失败") } } for _, r := range pending { if req.Approve { name := r.File if _, err := os.Stat(filepath.Join(toDir, name)); err == nil { // 极端重名兜底:换新 uuid name = common.UuidV4() + ".jpg" } if err := os.Rename(filepath.Join(fromDir, r.File), filepath.Join(toDir, name)); err != nil { s.rollbackMoves(ctx, fromDir, toDir, moved) return gerror.Wrap(err, "迁移图片失败") } moved = append(moved, r.File) if _, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{ DatasetId: lib.Id, Filename: name, Source: "manual", CreatedAt: now, }); err != nil { s.rollbackMoves(ctx, fromDir, toDir, moved) return err } } if req.Approve { if err := dao.FalseTargetReport.SetStatus(ctx, r.Id, consts.FalseTargetApproved, now); err != nil { s.rollbackMoves(ctx, fromDir, toDir, moved) return err } } else { // 拒绝 = 记录与文件同删(无 rejected 存量状态) if err := dao.FalseTargetReport.DeleteByIds(ctx, []int64{r.Id}); err != nil { s.rollbackMoves(ctx, fromDir, toDir, moved) return err } _ = os.Remove(filepath.Join(fromDir, r.File)) } res.Affected++ } if req.Approve && res.Affected > 0 { return dao.Dataset.UpdateCounters(ctx, lib.Id, res.Affected, -1, "") } return nil }) if err != nil { return nil, err } return res, nil } // rollbackMoves 审核中途失败:已迁移入负样本库的文件迁回待审目录 func (s *falseTargetService) rollbackMoves(ctx context.Context, fromDir, toDir string, moved []string) { for _, name := range moved { if err := os.Rename(filepath.Join(toDir, name), filepath.Join(fromDir, name)); err != nil { g.Log().Errorf(ctx, "假目标审核回滚迁回失败: %s: %+v", name, err) } } }