394 lines
14 KiB
Go
394 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|