285 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|