1
This commit is contained in:
@@ -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-04;2026-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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"审核时间"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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` JSON:label/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` 占位
|
||||
|
||||
Reference in New Issue
Block a user