Files
observer/server/biz/dao/annotate_record.go
T
2026-09-11 09:50:06 +08:00

265 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"observer-server/common"
)
// AnnotateRecord 用户标注记录表 DAOpending 即领取锁(partial unique index 同图仅一条),
// UNIQUE(phone_num, image_id) 一人一图仅一次;拒绝不删记录(低质统计与审计依据)。
type annotateRecordDao struct{}
var AnnotateRecord = &annotateRecordDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_record (
id BIGSERIAL PRIMARY KEY,
phone_num VARCHAR(20) NOT NULL,
task_id BIGINT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
image_id BIGINT NOT NULL,
labels_json JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL,
submitted_at TIMESTAMP,
reviewed_at TIMESTAMP,
UNIQUE (phone_num, image_id)
)`)
if err != nil {
panic(err)
}
// 存量表补列(领取时冗余数据集 id,管理端详情页按数据集过滤记录)
common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id BIGINT NOT NULL DEFAULT 0")
// 领取锁:同一张图同时只允许一条 pendingpartial unique indexSQLite 原生支持)
if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_annotate_record_image_pending
ON annotate_record (image_id) WHERE status = 'pending'`); err != nil {
panic(err)
}
}
// InsertInTx 事务内插入领取记录(锁竞争由 partial unique index 兜底,冲突即失败回滚)
func (d *annotateRecordDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateRecord) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).TX(tx).Data(g.Map{
"phone_num": m.PhoneNum,
"task_id": m.TaskId,
"dataset_id": m.DatasetId,
"image_id": m.ImageId,
"labels_json": common.NilIfEmpty(m.LabelsJson),
"status": m.Status,
"created_at": m.CreatedAt,
}).Insert()
return err
}
// DeleteExpiredPending 惰性释放过期领取锁(领取时调用;pending 未产生任何标注,直接删除)
func (d *annotateRecordDao) DeleteExpiredPending(ctx context.Context, before *gtime.Time) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("status", consts.AnnotateRecordPending).
WhereLT("created_at", before).
Delete()
return err
}
// ListImageIdsByPhone 某用户全部记录的图片 id(领取时排除自己处理过的图,含全部状态)
func (d *annotateRecordDao) ListImageIdsByPhone(ctx context.Context, phone string) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// ListPendingImageIds 当前全部领取锁的图片 id(领取时排除他人锁定的图)
func (d *annotateRecordDao) ListPendingImageIds(ctx context.Context) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("status", consts.AnnotateRecordPending).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// ListPendingImageIdsByTask 某任务当前领取锁的图片 id(停用任务释放时保留这些图——
// 已领取未提交的可继续提交;过期锁由调用方先经 DeleteExpiredPending 惰性清理)
func (d *annotateRecordDao) ListPendingImageIdsByTask(ctx context.Context, taskId int64) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("task_id", taskId).
Where("status", consts.AnnotateRecordPending).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// GetMyPending 某用户在某图上的领取记录(提交校验:必须存在且为 pending)
func (d *annotateRecordDao) GetMyPending(ctx context.Context, phone string, imageId int64) (*entity.AnnotateRecord, error) {
var one *entity.AnnotateRecord
err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).Where("image_id", imageId).
Where("status", consts.AnnotateRecordPending).Scan(&one)
if err != nil {
return nil, err
}
return one, nil
}
// MarkSubmitted 提交:写快照 + 置 submitted
func (d *annotateRecordDao) MarkSubmitted(ctx context.Context, id int64, labelsJson string, submittedAt *gtime.Time) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("id", id).
Data(g.Map{"labels_json": labelsJson, "status": consts.AnnotateRecordSubmitted, "submitted_at": submittedAt}).
Update()
return err
}
// MarkReviewedByImages 按图片批量审核当前提交中的记录(通过→approved / 拒绝→rejected):
// 只命中 status=submitted 的记录(历史已审核记录不动)
func (d *annotateRecordDao) MarkReviewedByImages(ctx context.Context, imageIds []int64, status string, reviewedAt *gtime.Time) error {
if len(imageIds) == 0 {
return nil
}
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("image_id", imageIds).
Where("status", consts.AnnotateRecordSubmitted).
Data(g.Map{"status": status, "reviewed_at": reviewedAt}).
Update()
return err
}
// ListSubmittedPhonesByImages 被审核图片对应的提交用户(审核后重算通过比例用,去重)
func (d *annotateRecordDao) ListSubmittedPhonesByImages(ctx context.Context, imageIds []int64) ([]string, error) {
if len(imageIds) == 0 {
return []string{}, nil
}
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("image_id", imageIds).
Where("status", consts.AnnotateRecordSubmitted).
Fields("DISTINCT phone_num").Array()
if err != nil {
return nil, err
}
phones := make([]string, 0, len(out))
for _, v := range out {
if s := v.String(); s != "" {
phones = append(phones, s)
}
}
return phones, nil
}
// StatusCount 按 task_id × status 聚合的任务记录数(任务列表进度展示)
type StatusCount struct {
TaskId int64 `orm:"task_id"`
Status string `orm:"status"`
Cnt int64 `orm:"cnt"`
}
// CountByTaskIds 多任务各状态记录数(一次 GROUP BY;IN 按 ≤100 分批由调用方保证)
func (d *annotateRecordDao) CountByTaskIds(ctx context.Context, taskIds []int64) (map[int64]map[string]int64, error) {
out := make(map[int64]map[string]int64)
if len(taskIds) == 0 {
return out, nil
}
var rows []StatusCount
err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("task_id", taskIds).
Fields("task_id, status, COUNT(*) AS cnt").
Group("task_id, status").Scan(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
if out[r.TaskId] == nil {
out[r.TaskId] = make(map[string]int64)
}
out[r.TaskId][r.Status] = r.Cnt
}
return out, nil
}
// UserStats 用户统计:累计提交数 + 审核通过/拒绝数(reviewedSince 非空时只统计其后的审核——
// 冻结重置基线后「重新累计」语义)
type UserStats struct {
SubmittedTotal int64
Approved int64
Rejected int64
}
// CountUserStats 用户统计查询
func (d *annotateRecordDao) CountUserStats(ctx context.Context, phone string, reviewedSince *gtime.Time) (*UserStats, error) {
stats := &UserStats{}
// 累计提交 = 已产生标注的全部记录(submitted/approved/rejectedpending 是未处理的锁不算)
n, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).
WhereIn("status", []string{consts.AnnotateRecordSubmitted, consts.AnnotateRecordApproved, consts.AnnotateRecordRejected}).
Count()
if err != nil {
return nil, err
}
stats.SubmittedTotal = int64(n)
base := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("phone_num", phone)
if reviewedSince != nil {
base = base.WhereGTE("reviewed_at", reviewedSince)
}
approved, err := base.Clone().Where("status", consts.AnnotateRecordApproved).Count()
if err != nil {
return nil, err
}
rejected, err := base.Where("status", consts.AnnotateRecordRejected).Count()
if err != nil {
return nil, err
}
stats.Approved = int64(approved)
stats.Rejected = int64(rejected)
return stats, nil
}
// PageByFilter 管理端记录分页(datasetId>0/phone 精确/taskId/status 过滤,id 倒序)
func (d *annotateRecordDao) PageByFilter(ctx context.Context, phone string, taskId, datasetId int64, status string, page, size int) ([]*entity.AnnotateRecord, int64, error) {
base := func() *gdb.Model {
m := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx)
if phone != "" {
m = m.Where("phone_num", phone)
}
if taskId > 0 {
m = m.Where("task_id", taskId)
}
if datasetId > 0 {
m = m.Where("dataset_id", datasetId)
}
if status != "" {
m = m.Where("status", status)
}
return m
}
total, err := base().Count()
if err != nil {
return nil, 0, err
}
var list []*entity.AnnotateRecord
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AnnotateRecord{}, int64(total), nil
}
return nil, 0, err
}
return list, int64(total), nil
}
func gconvInt64Slice(vals gdb.Array) []int64 {
out := make([]int64, 0, len(vals))
for _, v := range vals {
out = append(out, v.Int64())
}
return out
}