This commit is contained in:
2026-09-08 22:29:00 +08:00
parent daf47ca385
commit e519e1d71d
23 changed files with 1261 additions and 6 deletions
+14
View File
@@ -12,6 +12,7 @@
| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 |
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 标注众包赚时长 | App 用户处理管理端下发的标注任务获取使用时长:任务 = 管理端**勾选的具体图片集合**(图片粒度下发,2026-09-07;已下发图即从「未标注」tab 消失,停用任务释放未领取图回池),每次领取 10 张(锁定向、超时自动释放、已处理过的图不再分配),手机上画框提交 → 图片进「待审核」;每有效提交 10 张**即时到账 30 分钟**(license 到期时间分钟级顺延),**每日上限 2 小时**;质量惩罚:审核通过比例 <80%(已审核样本 ≥5)自动冻结领取资格 24 小时(时间戳对比天然自动解冻、统计重新累计),管理端可手动解冻;服务端 `annotate_record` 记录每用户每张图的处理明细(领取/提交快照/审核结果) |
| 假目标上报(负样本回流,2026-09-08) | App 识别页一键「误报上报」:上报瞬间按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,整图,jpg q85 重编码剥离全部元数据)+ 当前全部检测框快照(归一化坐标/类别/置信度/来源模型,`detections` JSON),无任何额外选择步骤;服务端落 `false_target_report` 待审(每用户**每日上限 50 条**防灌水);管理端「数据训练 → 假目标上报」tab 审核(radio 带待审数)——**通过 = 整图迁移入负样本库**(`__negative__` 数据集,训练打包自动混入当背景学习,压制同类误报),拒绝 = 删文件;**合规**:首次使用单独同意弹窗(可拒绝且不影响识别),用户协议含反馈图训练使用授权条款 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
@@ -58,6 +59,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| `annotate_task` | 标注众包任务(2026-09-042026-09-07 图片粒度下发) | `id`(PK)、`dataset_id``name``status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) |
| `annotate_record` | 用户标注记录(2026-09-04 | `id`(PK)、`phone_num``task_id``dataset_id`(领取时冗余,详情页过滤用)、`image_id``labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at``submitted_at``reviewed_at`UNIQUE(phone_num,image_id)partial unique(image_id) WHERE status='pending' |
| `reward_log` | 标注时长发放流水(2026-09-04 | `id`(PK)、`phone_num``minutes``task_id``granted_at`(日上限 = 自然日求和) |
| `false_target_report` | 假目标上报(2026-09-08 | `id`(PK)、`phone_num``file`(uuid.jpg 分析帧整图)、`labels_json`(检测框快照:label/class/score/model/归一化 xywh)、`species`(来源模型名去重拼接)、`status`(pending/approved**拒绝即删记录与文件,无 rejected 存量**)、`created_at``reviewed_at`(文件落 `datasetDir/false_targets/`,审核通过迁移入负样本库图片目录) |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -232,6 +234,18 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
### POST /api/v1/feedback/false-target
假目标上报(需 Bearer token`multipart/form-data`):App 识别页一键上报当前画面。字段:`file`(上报瞬间的分析帧整图 jpg,与推理帧同源同尺寸,客户端重编码已剥离元数据)+ `detections`(当前全部检测框快照 JSON 数组 `[{label,class,score,model,cx,cy,w,h}]`,归一化坐标,可空)+ `sourceW`/`sourceH`(分析帧宽高)。每用户每日上限 50 条,超限报错。响应 `data: {id}`
### 管理端假目标上报(`/api/v1/admin`X-Admin-Token 鉴权;页面入口 = 数据训练页第三个 tab)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/admin/false-targets` | 上报分页列表,筛选 `phoneNum/status`pending/approved),条目含画面预览地址/检测框快照/来源模型名 |
| GET | `/admin/false-targets/image` | 上报画面预览(静态字节流,`id` 定位) |
| POST | `/admin/false-targets/review` | 审核 `{"ids":[1],"approve":true}`:通过 = 画面迁移入负样本库(`__negative__` 数据集当背景图训练)+ 置 approved;**拒绝 = 删除记录与图片文件**;仅 pending 可审 |
### GET /api/v1/models
模型目录(需 Bearer token,客户端模型管理页拉取):返回**全部数据集当前生效模型**。响应 `data`
+10
View File
@@ -17,6 +17,16 @@ const (
TableAnnotateRecord = "annotate_record"
TableRewardLog = "reward_log"
TableFalseTargetReport = "false_target_report"
// 假目标上报状态(技术设计.md「假目标上报」):App 上报 → 管理端审核 →
// 通过 = 迁移入负样本库当背景图;拒绝 = 记录与文件同删(无 rejected 存量状态)
FalseTargetPending = "pending"
FalseTargetApproved = "approved"
// 假目标上报每用户每日上限(防灌水/滥用)
FalseTargetDailyLimit = 50
// dataset_image.review_status 审核三态(技术设计.md「标注审核状态机」):
// 预标/App 提交 → 待审核;人工保存/审核通过 → 已审核;拒绝清标注 → 未标注(回任务池)。
// 不变式:review_status=0 ⟹ labels_json 无框;训练集只收 ReviewApproved
+21
View File
@@ -261,3 +261,24 @@ func (c *cAdmin) ListAnnotateRecords(ctx context.Context, req *dto.AdminAnnotate
func (c *cAdmin) UnfreezeAnnotate(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) {
return service.Annotate.AdminUnfreeze(ctx, req)
}
// ListFalseTargets 假目标上报列表
func (c *cAdmin) ListFalseTargets(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) {
return service.FalseTarget.AdminList(ctx, req)
}
// FalseTargetImage 假目标裁剪图预览(直写响应体)
func (c *cAdmin) FalseTargetImage(ctx context.Context, req *dto.AdminFalseTargetImageReq) (*dto.AdminFalseTargetImageRes, error) {
path, err := service.FalseTarget.ImageFile(ctx, req.Id)
if err != nil {
return nil, err
}
r := ghttp.RequestFromCtx(ctx)
r.Response.ServeFile(path)
return &dto.AdminFalseTargetImageRes{}, nil
}
// ReviewFalseTargets 审核假目标上报(通过=入负样本库;拒绝=删文件)
func (c *cAdmin) ReviewFalseTargets(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) {
return service.FalseTarget.AdminReview(ctx, req)
}
+18
View File
@@ -0,0 +1,18 @@
package controller
import (
"context"
"observer-server/biz/model/dto"
"observer-server/biz/service"
)
// cFalseTarget 假目标上报接口(登录态,手机号取自 token)
type cFalseTarget struct{}
var FalseTarget = &cFalseTarget{}
// Report 上报假目标(multipart:误报框裁剪图 + 检测框快照)
func (c *cFalseTarget) Report(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) {
return service.FalseTarget.AppReport(ctx, req)
}
+99
View File
@@ -0,0 +1,99 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
)
// FalseTargetReport 假目标上报表 DAO(技术设计.md「假目标上报」)。
type falseTargetReportDao struct{}
var FalseTargetReport = &falseTargetReportDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS false_target_report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
file TEXT NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
species TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
created_at TEXT NOT NULL,
reviewed_at TEXT
)`)
if err != nil {
panic(err)
}
}
// Insert 插入上报记录并返回自增 id
func (d *falseTargetReportDao) Insert(ctx context.Context, m *entity.FalseTargetReport) (int64, error) {
res, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Data(g.Map{
"phone_num": m.PhoneNum,
"file": m.File,
"labels_json": m.LabelsJson,
"species": m.Species,
"status": m.Status,
"created_at": m.CreatedAt,
}).Insert()
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CountTodayByPhone 某用户当日(自然日)上报条数(日限额判定)
func (d *falseTargetReportDao) CountTodayByPhone(ctx context.Context, phone string, dayStart *gtime.Time) (int, error) {
return g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
Where("phone_num", phone).
WhereGTE("created_at", dayStart).
Count()
}
// PageByFilter 管理端分页(phoneNum 精确过滤、status 过滤,按上报时间倒序)
func (d *falseTargetReportDao) PageByFilter(ctx context.Context, phone, status string, page, size int) ([]*entity.FalseTargetReport, int64, error) {
base := func() *gdb.Model {
m := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx)
if phone != "" {
m = m.Where("phone_num", phone)
}
if status != "" {
m = m.Where("status", status)
}
return m
}
total, err := base().Count()
if err != nil {
return nil, 0, err
}
var list []*entity.FalseTargetReport
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
return list, int64(total), err
}
// GetByIds 按 id 批量取(审核前校验)
func (d *falseTargetReportDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.FalseTargetReport, error) {
var list []*entity.FalseTargetReport
err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Scan(&list)
return list, err
}
// SetStatus 审核通过落状态(仅 pending 可迁转,重复审核由调用方先查后写保证幂等)
func (d *falseTargetReportDao) SetStatus(ctx context.Context, id int64, status string, reviewedAt *gtime.Time) error {
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
Data(g.Map{"status": status, "reviewed_at": reviewedAt}).Update()
return err
}
// DeleteByIds 删除记录(拒绝即删:记录与文件同删,不留 rejected 存量)
func (d *falseTargetReportDao) DeleteByIds(ctx context.Context, ids []int64) error {
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Delete()
return err
}
+95
View File
@@ -0,0 +1,95 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
// 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流负样本库。
// 合规:客户端 jpg 重编码剥离全部元数据,首用单独同意由 App 承担。
// ---------- App ----------
// FalseTargetReportReq 上报假目标(multipart):file = 上报瞬间的分析帧整图
// (与喂给 YOLO 的帧同源同尺寸,jpg q85 重编码),detections = 当前全部检测框
// 快照 JSON[{label,class,score,model,cx,cy,w,h}],归一化坐标,供审核参考)。
type FalseTargetReportReq struct {
g.Meta `path:"/feedback/false-target" method:"post" summary:"上报假目标" tags:"客户端" mime:"multipart/form-data"`
File *ghttp.UploadFile `json:"file" v:"required" dc:"分析帧整图(jpg,与推理帧同尺寸)"`
Detections string `json:"detections" dc:"检测框快照JSON数组,空=上报时无框"`
SourceW int `json:"sourceW" v:"min:0" dc:"分析帧宽"`
SourceH int `json:"sourceH" v:"min:0" dc:"分析帧高"`
}
type FalseTargetReportRes struct {
Id int64 `json:"id"`
}
// ---------- 管理端 ----------
// AdminFalseTargetListReq 上报分页列表
type AdminFalseTargetListReq struct {
g.Meta `path:"/false-targets" method:"get" summary:"假目标上报列表" tags:"管理端"`
PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号精确过滤"`
Status string `json:"status" v:"in:,pending,approved" dc:"状态过滤,空=全部"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// FalseTargetBox 检测框快照(归一化坐标相对原分析帧)
type FalseTargetBox struct {
Label string `json:"label"`
Class int `json:"class"`
Score float64 `json:"score"`
Model string `json:"model"`
Cx float64 `json:"cx"`
Cy float64 `json:"cy"`
W float64 `json:"w"`
H float64 `json:"h"`
}
// FalseTargetSnapshot labels_json 序列化结构:框快照 + 原分析帧尺寸
type FalseTargetSnapshot struct {
SourceW int `json:"sourceW"`
SourceH int `json:"sourceH"`
Boxes []*FalseTargetBox `json:"boxes"`
}
// AdminFalseTargetItem 上报条目(附裁剪图预览地址与检测框快照)
type AdminFalseTargetItem struct {
Id int64 `json:"id"`
PhoneNum string `json:"phoneNum"`
Species string `json:"species"`
Status string `json:"status"`
ImageUrl string `json:"imageUrl"`
Boxes []*FalseTargetBox `json:"boxes"`
SourceW int `json:"sourceW"`
SourceH int `json:"sourceH"`
CreatedAt *gtime.Time `json:"createdAt"`
ReviewedAt *gtime.Time `json:"reviewedAt"`
}
type AdminFalseTargetListRes struct {
Total int64 `json:"total"`
List []*AdminFalseTargetItem `json:"list"`
}
// AdminFalseTargetImageReq 裁剪图预览(静态字节流)
type AdminFalseTargetImageReq struct {
g.Meta `path:"/false-targets/image" method:"get" summary:"假目标裁剪图" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"上报 id"`
}
type AdminFalseTargetImageRes struct{}
// AdminFalseTargetReviewReq 审核:通过 = 迁移入负样本库当背景图;拒绝 = 删文件
type AdminFalseTargetReviewReq struct {
g.Meta `path:"/false-targets/review" method:"post" summary:"审核假目标上报" tags:"管理端"`
Ids []int64 `json:"ids" v:"required|min-length:1" dc:"上报 id 列表"`
Approve bool `json:"approve" dc:"true=通过 false=拒绝"`
}
type AdminFalseTargetReviewRes struct {
Affected int64 `json:"affected"` // 实际审核条数(过滤掉不存在/非 pending)
}
@@ -0,0 +1,16 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// FalseTargetReport 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流,
// 客户端仅上传误报框裁剪图(剥离元数据);管理端审核通过后迁移入负样本库当背景图训练。
type FalseTargetReport struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"上报用户手机号"`
File string `json:"file" orm:"file" description:"裁剪图文件名(uuid.jpg)"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"检测框快照(label/class/score/归一化xywh)"`
Species string `json:"species" orm:"species" description:"上报时来源模型名(=数据集名)"`
Status string `json:"status" orm:"status" description:"pending|approved"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"上报时间"`
ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"`
}
+284
View File
@@ -0,0 +1,284 @@
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)
}
}
}
+5
View File
@@ -27,6 +27,11 @@ func DatasetImagesDir(ctx context.Context, datasetName string) string {
return filepath.Join(DatasetDir(ctx), "datasets", datasetName)
}
// FalseTargetsDir 假目标上报待审裁剪图目录(审核通过迁移入负样本库图片目录)
func FalseTargetsDir(ctx context.Context) string {
return filepath.Join(DatasetDir(ctx), "false_targets")
}
// TrainingModelPath 当前生效模型文件路径(modelName 为模型文件基名 = 数据集文件名前缀,
// 空则回退数据集名——见 service.modelFileName;训练成功即从训练机直写,客户端固定下载)
func TrainingModelPath(ctx context.Context, modelName string) string {
Binary file not shown.
+1 -1
View File
@@ -41,7 +41,7 @@ func main() {
})
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
group.Middleware(common.UnifiedResponse, common.AuthRequired)
common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate)
common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate, controller.FalseTarget)
})
// 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本)
s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) {
+28
View File
@@ -445,6 +445,34 @@ training:
- 性能:N 模型并行推理墙钟 ≈ 单模型 1.2~1.5 倍(多核并行);用户按需下载控制 N;帧率不足时可降采样
- 版本比较规则同 APK(数字段比较,m 前缀剥离后按 x.y.z)
## 假目标上报(部署反馈负样本回流,2026-09-08)
**定位**:识别端误报 = 模型真实混淆边界,是负样本中价值最高的「难负样本」;一键上报把真实拍摄分布的误报回流进负样本库,与 AI 生成数据管线互补(误报为稀疏信号,做补充不做替代)。
**DDL**
```sql
CREATE TABLE IF NOT EXISTS false_target_report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
file TEXT NOT NULL, -- uuid.jpg(分析帧整图,与推理帧同源同尺寸)
labels_json TEXT NOT NULL DEFAULT '', -- 快照 {sourceW,sourceH,boxes:[{label,class,score,model,cx,cy,w,h}]}
species TEXT NOT NULL DEFAULT '', -- 来源模型名去重逗号拼接(= 数据集名,可多个)
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录与文件,无 rejected 存量)
created_at TEXT NOT NULL,
reviewed_at TEXT
);
```
**数据流**:App 一键上报,无选择步骤——按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,保证所见即所传)+ 当前全部检测框快照(`detections` JSONlabel/class/score/model/归一化 xywh,非法框服务端直接丢弃不拦上报)→ 单独同意(首次)→ isolate 内**整帧 jpg q85 重编码**(天然剥离 EXIF/GPS 等全部元数据)→ multipart 上传 → 服务端落 `datasetDir/false_targets/<uuid>.jpg` + 记录 pending(Serial 内查日限额,每用户每日 50 条)→ 管理端「数据训练 → 假目标上报」tab 审核:**通过 = 文件迁移入 `datasets/__negative__/`os.Rename 同盘直迁)+ dataset_image 入库(source=manual,负样本库无标注/审核流程)+ image_count++****拒绝 = 记录与文件同删(无 rejected 状态)**。保证「记录存在 ⟺ 文件存在」不变式(入库失败回滚删文件)。整帧入负样本库的误判风险(画面中真目标未检出却被当背景)由人工审核借助检测框快照把关。
**合规三约束**PIPL + 单独同意):
1. **客户端重编码 jpg** 剥离全部元数据(GPS/设备信息),服务端不接收原始帧
2. App 首次使用**单独同意弹窗**(写明收集内容/用途/可拒绝且拒绝不影响识别/删除渠道),本地记录同意标记;用户协议含「反馈图授权用于模型训练」条款
3. 功能完全可选,不挂任何奖励(避免为利益灌水),服务端另有每用户每日 50 条限额兜底
**训练用法**:审核通过图入负样本库(`__negative__`,固定保留名),`prepare_yolo` 打包时统一混入全部物种数据集当背景图(无标注),压制同类误报。误报裁剪块含「像目标但不是」的纹理,正是背景图最需要的难例。
## 待办/风险
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位