Files
observer/server/biz/service/false_target.go
T
2026-09-08 22:29:00 +08:00

285 lines
9.3 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
"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 记录 ⟺ 文件存在。
type falseTargetService struct{}
var FalseTarget = &falseTargetService{}
// falseTargetMaxBytes 裁剪图上限(客户端按框 ±15% 裁剪、jpg q85,远小于该值)
const falseTargetMaxBytes = 8 << 20
// ---------- App ----------
// AppReport 上报假目标:校验图片 → UUID 命名落盘待审目录 → Serial 内查日限额 + 插记录
// (入库失败删已落盘文件,保证记录存在 ⟺ 文件存在)
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, "读取上传文件失败")
}
_, _, err = image.DecodeConfig(f)
_ = f.Close()
if err != nil {
return nil, gerror.New("图片内容无效")
}
// 检测框快照:仅作审核参考的元数据,解析失败/坐标非法的框直接丢弃,
// 不阻断图片上报(主体是画面本身)
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)
}
if err := os.MkdirAll(common.FalseTargetsDir(ctx), 0o755); err != nil {
return nil, gerror.Wrap(err, "创建上报目录失败")
}
name := common.UuidV4() + ".jpg"
if err := saveUploadAs(req.File, common.FalseTargetsDir(ctx), name); err != nil {
return nil, gerror.Wrap(err, "图片保存失败")
}
snapshot, err := json.Marshal(dto.FalseTargetSnapshot{
SourceW: req.SourceW,
SourceH: req.SourceH,
Boxes: boxes,
})
if err != nil {
_ = os.Remove(filepath.Join(common.FalseTargetsDir(ctx), name))
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")
var id int64
err = common.Serial().Submit(ctx, func() error {
cnt, err := dao.FalseTargetReport.CountTodayByPhone(ctx, phone, dayStart)
if err != nil {
return err
}
if cnt >= consts.FalseTargetDailyLimit {
return gerror.Newf("今日上报已达上限(%d 条/天)", consts.FalseTargetDailyLimit)
}
id, err = dao.FalseTargetReport.Insert(ctx, &entity.FalseTargetReport{
PhoneNum: phone,
File: name,
LabelsJson: string(snapshot),
Species: strings.Join(models, ","),
Status: consts.FalseTargetPending,
CreatedAt: now,
})
return err
})
if err != nil {
_ = os.Remove(filepath.Join(common.FalseTargetsDir(ctx), name))
return nil, err
}
return &dto.FalseTargetReportRes{Id: id}, nil
}
// 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),
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)
}
}
}