yidun送检功能

This commit is contained in:
2026-05-15 10:28:17 +08:00
parent 51d26aeee7
commit c8cc19e8e7
29 changed files with 5133 additions and 121 deletions

View File

@@ -4,11 +4,14 @@
- [概述](#概述)
- [配置说明](#配置说明)
- [模式切换](#模式切换)
- [API接口列表](#api接口列表)
- [文本检测](#1-文本检测)
- [图片检测](#2-图片检测)
- [视频检测](#3-视频检测)
- [回调与轮询接口](#回调与轮询接口)
- [错误码说明](#错误码说明)
- [常见问题](#常见问题)
---
@@ -27,7 +30,7 @@
### 服务地址
```
http://localhost:3002
http://localhost:3001
```
---
@@ -38,38 +41,100 @@ http://localhost:3002
```yaml
yidun:
# 音视频检测配置
# 回调模式开关: true=使用回调模式(需要公网地址), false=使用轮询模式
callback_mode: false
# 视频检测配置
video:
business_id: "YOUR_VIDEO_BUSINESS_ID"
secret_id: "f58a38341ca6227014df7c3bf0e6f16f"
secret_key: "526aa631ba5d518aedeb70b5a3b67371"
callback_url: "http://your-domain.com:3001/yidun/callback/receiveVideo"
# 图片检测配置
image:
business_id: "YOUR_IMAGE_BUSINESS_ID"
business_id: "your_image_business_id"
secret_id: "9a82f90bfec61eb40d1c95605b894817"
secret_key: "f73a78954417a3713c36ec2d14eb2b5f"
callback_url: "http://your-domain.com:3001/yidun/callback/receiveImage"
# 文本检测配置
text:
business_id: "YOUR_TEXT_BUSINESS_ID"
secret_id: "YOUR_TEXT_SECRET_ID"
secret_key: "YOUR_TEXT_SECRET_KEY"
# 内容送检定时任务配置
content_check:
batch_size: 10 # 每批处理数量
image_enabled: true # 是否启用图片检测
video_enabled: true # 是否启用视频检测
interval_seconds: 30 # 定时任务执行间隔(秒)
```
---
## 模式切换
### 轮询模式(无公网地址)
适用于开发测试环境或没有公网地址的场景。
```yaml
yidun:
callback_mode: false # 使用轮询模式
```
**工作流程**
```
定时任务 → 提交检测 → 保存taskId → 手动轮询 → 获取结果 → 更新状态
```
### 回调模式(有公网地址)
适用于生产环境,需要配置公网可访问的回调地址。
```yaml
yidun:
callback_mode: true # 使用回调模式
image:
callback_url: "http://your-public-domain.com:3001/yidun/callback/receiveImage"
video:
callback_url: "http://your-public-domain.com:3001/yidun/callback/receiveVideo"
```
**工作流程**
```
定时任务 → 提交检测 → 易盾检测完成 → 易盾推送结果 → 自动更新状态
```
---
## API接口列表
### 送检接口
| 接口名称 | 请求方法 | 路径 | 说明 |
|---------|---------|------|------|
| 文本检测提交 | POST | `/yidun/detect-text` | 提交文本进行异步检测 |
| 图片检测提交 | POST | `/yidun/detect-image` | 提交图片进行异步检测 |
| 视频检测提交 | POST | `/yidun/detect-video` | 提交视频进行检测 |
| 图片结果查询 | POST | `/yidun/GetImageResult` | 查询图片检测结果(轮询模式) |
| 图片检测回调 | POST | `/yidun/receive-image-callback` | 接收图片检测结果推送(推送模式) |
| 视频结果查询 | POST | `/yidun/GetVideoResult` | 查询视频检测结果(轮询模式) |
| 视频检测回调 | POST | `/yidun/receive-video-callback` | 接收视频检测结果推送(推送模式) |
### 回调模式接口
| 接口名称 | 请求方法 | 路径 | 说明 |
|---------|---------|------|------|
| 接收图片回调 | POST | `/yidun/callback/receiveImage` | 接收易盾图片检测结果推送 |
| 接收视频回调 | POST | `/yidun/callback/receiveVideo` | 接收易盾视频检测结果推送 |
### 轮询模式接口
| 接口名称 | 请求方法 | 路径 | 说明 |
|---------|---------|------|------|
| 轮询所有结果 | POST | `/yidun/callback/poll` | 轮询所有待处理结果(图片+视频) |
| 轮询图片结果 | POST | `/yidun/callback/pollImage` | 仅轮询图片待处理结果 |
| 轮询视频结果 | POST | `/yidun/callback/pollVideo` | 仅轮询视频待处理结果 |
| 查询单个结果 | POST | `/yidun/callback/pollTask` | 根据taskId查询单个结果 |
| 获取待处理数量 | GET | `/yidun/callback/pendingCount` | 查看待处理结果数量 |
**注意**:检测结果是异步的,提交接口只返回 `task_id`,需要通过回调或轮询获取结果。
@@ -814,6 +879,197 @@ callbackData={"antispam":{...}}&signature=xxx&secretId=xxx
---
## 回调与轮询接口
### 轮询模式接口详解
#### 轮询所有待处理结果
```
POST /yidun/callback/poll
```
**说明**:轮询所有图片和视频的待处理检测结果
**响应示例**
```json
{
"code": 200,
"msg": "轮询完成,成功处理 5 条,失败 1 条",
"data": {
"success_count": 5,
"fail_count": 1,
"pending_count": 10
}
}
```
#### 获取待处理数量
```
GET /yidun/callback/pendingCount
```
**响应示例**
```json
{
"code": 200,
"data": {
"pending_count": 15,
"description": "待查询结果的日志数量状态为pending且有taskID"
}
}
```
#### 查询单个结果
```
POST /yidun/callback/pollTask?taskId=xxx&type=image
```
**参数说明**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| taskId | string | 是 | 易盾任务ID |
| type | string | 否 | 类型:`image`/`video`,不填则自动识别 |
**响应示例**
```json
{
"code": 200,
"msg": "查询并处理成功"
}
```
---
## 使用示例
### 轮询模式使用流程
```bash
# 1. 查看待处理数量
curl http://localhost:3001/yidun/callback/pendingCount
# 2. 执行轮询(所有)
curl -X POST http://localhost:3001/yidun/callback/poll
# 3. 或分别轮询
curl -X POST http://localhost:3001/yidun/callback/pollImage
curl -X POST http://localhost:3001/yidun/callback/pollVideo
# 4. 查询单个结果
curl -X POST "http://localhost:3001/yidun/callback/pollTask?taskId=abc123&type=image"
```
### 配置定时轮询
```bash
# 每分钟轮询一次
*/1 * * * * curl -X POST http://localhost:3001/yidun/callback/poll
# 或更保守的频率每5分钟
*/5 * * * * curl -X POST http://localhost:3001/yidun/callback/poll
```
---
## 状态说明
### 检测状态转换
```
┌─────────────────────────────────────────────────────────────┐
│ 送检前 │
│ tencent_image/tencent_video: status = "pending" │
└─────────────────────────────────────────────────────────────┘
↓ 定时任务/手动送检
┌─────────────────────────────────────────────────────────────┐
│ 提交检测 │
│ material_verify_log: verify_status = "PENDING" │
│ material_verify_log: task_id = "易盾任务ID" │
│ tencent_xxx: status = "submitting" │
└─────────────────────────────────────────────────────────────┘
↓ 等待检测完成
↓ (回调模式)或(轮询查询)
┌─────────────────────────────────────────────────────────────┐
│ 检测完成 │
│ material_verify_log: verify_status = "VERIFIED" (通过) │
│ 或 "REJECTED" (不通过) │
│ 或 "PENDING" (嫌疑,需人工审核) │
│ tencent_xxx: status = "VERIFIED"/"REJECTED"/"PENDING" │
└─────────────────────────────────────────────────────────────┘
```
### 状态值说明
| 状态值 | 说明 | 触发条件 |
|--------|------|----------|
| `pending` / `PENDING` | 待检测 | 素材创建时 |
| `submitting` | 送检中 | 提交至易盾检测 |
| `VERIFIED` | 校验通过 | 易盾建议通过suggestion=0|
| `REJECTED` | 校验不通过 | 易盾建议不通过suggestion=2或检测失败 |
| `PENDING` (人审) | 嫌疑待审 | 易盾建议人工审核suggestion=1|
### 易盾处置建议对照
| suggestion值 | 说明 | 系统状态 |
|-------------|------|----------|
| 0 | 通过 | VERIFIED |
| 1 | 嫌疑,需人工审核 | PENDING |
| 2 | 不通过 | REJECTED |
---
## 常见问题
### Q1: 轮询后待处理数量没有减少?
**可能原因**
1. 检测仍在进行中(易盾尚未返回结果)
2. 上次查询出错,状态已更新为 REJECTED
**排查方法**
```bash
# 查看日志
grep "检测仍在进行中" resource/log/server/cid.log
# 查看数据库状态
SELECT id, task_id, verify_status FROM material_verify_log WHERE task_id = 'xxx';
```
### Q2: 如何判断是图片还是视频?
```bash
# 方法1查询日志
SELECT id, material_type, task_id, verify_status FROM material_verify_log WHERE task_id = 'xxx';
# 方法2调用接口指定类型
curl -X POST "http://localhost:3001/yidun/callback/pollTask?taskId=xxx&type=image"
```
### Q3: 回调模式收不到回调?
**排查步骤**
1. 确认公网地址可访问:`curl http://your-domain.com:3001/yidun/callback/receiveImage`
2. 确认易盾控制台配置了正确的回调地址
3. 检查服务器防火墙/安全组是否开放端口
4. 查看日志确认请求是否到达:`grep "收到易盾" resource/log/server/cid.log`
### Q4: 状态不一致?
如果 `material_verify_log``tencent_image/video` 表状态不一致,检查:
1. 代码执行过程中是否有报错
2. 是否有并发更新导致覆盖
3. 日志表和原表更新是否在同一事务中
---
## 错误码说明
| code | 说明 |
@@ -872,4 +1128,4 @@ func detectVideo() (string, error) {
---
**最后更新**: 2026-05-07
**最后更新**: 2026-05-14

BIN
cid Executable file

Binary file not shown.

View File

@@ -13,10 +13,10 @@ rate:
database:
default:
- type: "pgsql"
host: "localhost"
port: "5432"
host: "116.204.74.41"
port: "15432"
user: "postgres"
pass: "123456"
pass: "Bjang09@686^*^"
name: "cid"
role: "master"
maxIdle: "5"
@@ -29,6 +29,25 @@ database:
updatedAt: "updated_at"
deletedAt: "deleted_at"
timeMaintainDisabled: false
# data-engine 数据库配置(用于存放 tencent_image, tencent_video 等送检表)
dataEngine:
- type: "pgsql"
host: "116.204.74.41"
port: "15432"
user: "postgres"
pass: "Bjang09@686^*^"
name: "dataengine"
role: "master"
maxIdle: "5"
maxOpen: "20"
maxLifetime: "60s"
charset: "utf8mb4"
debug: true
dryRun: false
createdAt: "created_at"
updatedAt: "updated_at"
deletedAt: "deleted_at"
timeMaintainDisabled: false
redis:
# 集群模式配置方法
@@ -49,13 +68,20 @@ jaeger: #链路追踪
addr: 116.204.74.41:4318
yidun:
# 音视频检测配置
# 回调模式开关: true=使用回调模式(需要公网地址), false=使用轮询模式
callback_mode: false
# 视频检测配置
video:
business_id: "YD00256761935486"
secret_id: "f58a38341ca6227014df7c3bf0e6f16f"
secret_key: "526aa631ba5d518aedeb70b5a3b67371"
region: "cn-hangzhou"
protocol: "https"
max_retry_count: 3
# 易盾回调地址(用于接收检测结果推送)
# 替换为实际可访问的地址
callback_url: "http://your-domain.com:3001/yidun/callback/receiveVideo"
# 图片检测配置
image:
@@ -65,6 +91,9 @@ yidun:
region: "cn-hangzhou"
protocol: "https"
max_retry_count: 3
# 易盾回调地址(用于接收检测结果推送)
# 替换为实际可访问的地址
callback_url: "http://your-domain.com:3001/yidun/callback/receiveImage"
# 文本检测配置(如需要请补充)
text:
@@ -73,4 +102,20 @@ yidun:
secret_key: "YOUR_TEXT_SECRET_KEY"
region: "cn-hangzhou"
protocol: "https"
max_retry_count: 3
max_retry_count: 3
# 易盾回调地址(用于接收检测结果推送)
# 替换为实际可访问的地址
callback_url: "http://your-domain.com:3001/yidun/callback/receiveText"
# 内容送检定时任务配置
content_check:
# 是否启动定时送检任务true=启动定时任务自动送检false=不启动仅通过API手动送检
scheduler_enabled: false
# 每批处理数量
batch_size: 10
# 是否启用图片检测
image_enabled: false
# 是否启用视频检测
video_enabled: false
# 定时任务执行间隔(秒)
interval_seconds: 30

View File

@@ -0,0 +1,22 @@
package dataengine
// 送检状态常量
const (
// SourceTable 来源表标识
SourceTableTencentImage = "tencent_image"
SourceTableTencentVideo = "tencent_video"
// CheckStatus 送检状态
CheckStatusPending = "PENDING" // 待送检
CheckStatusSubmitting = "SUBMITTING" // 送检中
CheckStatusSuccess = "SUCCESS" // 送检成功
CheckStatusFailed = "FAILED" // 送检失败
CheckStatusCompleted = "COMPLETED" // 检测完成
)
// Suggestion 处置建议
const (
SuggestionPass = 0 // 通过
SuggestionReview = 1 // 嫌疑,需人工审核
SuggestionBlock = 2 // 不通过
)

View File

@@ -0,0 +1,8 @@
package dataengine
// PostgreSQL表名常量
const (
TencentImageTable = "tencent_image" // 图片送检表
TencentVideoTable = "tencent_video" // 视频送检表
TencentContentCheckLogTable = "tencent_content_check_log" // 送检日志表
)

View File

@@ -0,0 +1,509 @@
package dataengine
import (
consts "cid/consts/dataengine"
dao "cid/dao/dataengine"
entity "cid/model/entity/dataengine"
serviceDataengine "cid/service/dataengine"
"context"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// MaterialVerifyController 素材校验控制器
type MaterialVerifyController struct{}
// MaterialVerify 控制器单例
var MaterialVerify = new(MaterialVerifyController)
// =============================================================================
// 请求/响应结构体
// =============================================================================
// ImageListReq 图片列表请求
type ImageListReq struct {
Status string `json:"status"`
AccountID int64 `json:"accountId"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
}
// ImageListRes 图片列表响应
type ImageListRes struct {
List interface{} `json:"list"`
Total int `json:"total"`
}
// StatsRes 统计响应
type StatsRes struct {
Pending int `json:"pending"`
Verified int `json:"verified"`
Rejected int `json:"rejected"`
}
// VideoListReq 视频列表请求
type VideoListReq struct {
Status string `json:"status"`
AccountID int64 `json:"accountId"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
}
// VideoListRes 视频列表响应
type VideoListRes struct {
List interface{} `json:"list"`
Total int `json:"total"`
}
// LogListReq 日志列表请求
type LogListReq struct {
MaterialType string `json:"materialType"`
MaterialID string `json:"materialId"`
VerifyStatus string `json:"verifyStatus"`
AccountID int64 `json:"accountId"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
}
// ManualVerifyReq 手动校验请求
type ManualVerifyReq struct {
MaterialID string `json:"materialId" v:"required#素材ID不能为空"`
}
// TaskIDReq 任务ID请求
type TaskIDReq struct {
TaskID string `json:"taskId" v:"required#任务ID不能为空"`
}
// ImageCallbackReq 图片回调请求
type ImageCallbackReq struct {
CallbackData string `json:"callbackData"`
}
// VideoCallbackReq 视频回调请求
type VideoCallbackReq struct {
CallbackData string `json:"callbackData"`
}
// BatchVerifyReq 批量校验请求
type BatchVerifyReq struct {
Limit int `json:"limit"`
}
// =============================================================================
// 图片素材接口
// =============================================================================
// ListImage 图片素材列表
func (c *MaterialVerifyController) ListImage(ctx context.Context, req *ImageListReq) (res *ImageListRes, err error) {
if req.Page == 0 {
req.Page = 1
}
if req.PageSize == 0 {
req.PageSize = 20
}
condition := make(map[string]interface{})
if req.Status != "" {
condition[entity.TencentImageCols.VerifyStatus] = req.Status
}
if req.AccountID > 0 {
condition[entity.TencentImageCols.AccountID] = req.AccountID
}
data, total, err := dao.TencentImage.GetByCondition(ctx, condition, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return &ImageListRes{
List: data,
Total: total,
}, nil
}
// StatsImage 图片素材统计
func (c *MaterialVerifyController) StatsImage(ctx context.Context, req *ImageListReq) (res *StatsRes, err error) {
// 使用实体中定义的正确状态值PENDING=待校验, VERIFIED=校验通过, REJECTED=校验不通过
pending, _ := dao.TencentImage.CountByStatus(ctx, entity.VerifyStatusPending)
verified, _ := dao.TencentImage.CountByStatus(ctx, entity.VerifyStatusVerified)
rejected, _ := dao.TencentImage.CountByStatus(ctx, entity.VerifyStatusRejected)
return &StatsRes{
Pending: pending,
Verified: verified,
Rejected: rejected,
}, nil
}
// =============================================================================
// 视频素材接口
// =============================================================================
// ListVideo 视频素材列表
func (c *MaterialVerifyController) ListVideo(ctx context.Context, req *VideoListReq) (res *VideoListRes, err error) {
if req.Page == 0 {
req.Page = 1
}
if req.PageSize == 0 {
req.PageSize = 20
}
condition := make(map[string]interface{})
if req.Status != "" {
condition[entity.TencentVideoCols.VerifyStatus] = req.Status
}
if req.AccountID > 0 {
condition[entity.TencentVideoCols.AccountID] = req.AccountID
}
data, total, err := dao.TencentVideo.GetByCondition(ctx, condition, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return &VideoListRes{
List: data,
Total: total,
}, nil
}
// StatsVideo 视频素材统计
func (c *MaterialVerifyController) StatsVideo(ctx context.Context, req *VideoListReq) (res *StatsRes, err error) {
// 使用实体中定义的正确状态值PENDING=待校验, VERIFIED=校验通过, REJECTED=校验不通过
pending, _ := dao.TencentVideo.CountByStatus(ctx, entity.VerifyStatusPending)
verified, _ := dao.TencentVideo.CountByStatus(ctx, entity.VerifyStatusVerified)
rejected, _ := dao.TencentVideo.CountByStatus(ctx, entity.VerifyStatusRejected)
return &StatsRes{
Pending: pending,
Verified: verified,
Rejected: rejected,
}, nil
}
// =============================================================================
// 校验日志接口
// =============================================================================
// ListLogRes 日志列表响应
type ListLogRes struct {
List interface{} `json:"list"`
Total int `json:"total"`
}
// ListLog 日志列表
func (c *MaterialVerifyController) ListLog(ctx context.Context, req *LogListReq) (res *ListLogRes, err error) {
if req.Page == 0 {
req.Page = 1
}
if req.PageSize == 0 {
req.PageSize = 20
}
condition := make(map[string]interface{})
if req.MaterialType != "" {
condition[entity.MaterialVerifyLogCols.MaterialType] = req.MaterialType
}
if req.MaterialID != "" {
condition[entity.MaterialVerifyLogCols.MaterialID] = req.MaterialID
}
if req.VerifyStatus != "" {
condition[entity.MaterialVerifyLogCols.VerifyStatus] = req.VerifyStatus
}
if req.AccountID > 0 {
condition[entity.MaterialVerifyLogCols.AccountID] = req.AccountID
}
data, total, err := serviceDataengine.MaterialVerify.GetLogsByCondition(ctx, condition, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return &ListLogRes{
List: data,
Total: total,
}, nil
}
// LogDetailRes 日志详情响应
type LogDetailRes struct {
*entity.MaterialVerifyLog
PreviewURL string `json:"previewURL"`
}
// GetLogDetailReq 日志详情请求
type GetLogDetailReq struct {
Id int64 `json:"id" v:"required#日志ID不能为空"`
}
// GetLogDetail 日志详情
func (c *MaterialVerifyController) GetLogDetail(ctx context.Context, req *GetLogDetailReq) (res *LogDetailRes, err error) {
log, err := serviceDataengine.MaterialVerify.GetLogByID(ctx, req.Id)
if err != nil {
return nil, err
}
if log == nil {
return nil, fmt.Errorf("日志不存在")
}
// 获取来源数据预览
res = &LogDetailRes{
MaterialVerifyLog: log,
}
if log.SourceTable == consts.SourceTableTencentImage {
image, _ := dao.TencentImage.GetByID(ctx, log.SourceID)
if image != nil {
res.PreviewURL = image.PreviewURL
}
} else if log.SourceTable == consts.SourceTableTencentVideo {
video, _ := dao.TencentVideo.GetByID(ctx, log.SourceID)
if video != nil {
res.PreviewURL = video.PreviewURL
}
}
return
}
// StatsLogRes 日志统计响应
type StatsLogRes struct {
Total int `json:"total"`
Pending int `json:"pending"`
Verified int `json:"verified"`
Rejected int `json:"rejected"`
}
// StatsLog 日志统计
func (c *MaterialVerifyController) StatsLog(ctx context.Context, req *LogListReq) (res *StatsLogRes, err error) {
stats, err := serviceDataengine.MaterialVerify.GetStats(ctx)
if err != nil {
return nil, err
}
return &StatsLogRes{
Total: stats["total"],
Pending: stats["pending"],
Verified: stats["verified"],
Rejected: stats["rejected"],
}, nil
}
// =============================================================================
// 手动校验接口
// =============================================================================
// ManualVerifyImageRes 手动校验响应
type ManualVerifyImageRes struct {
Id int64 `json:"id"`
TaskID string `json:"taskId"`
SourceID string `json:"sourceId"`
}
// ManualVerifyImage 手动校验图片
func (c *MaterialVerifyController) ManualVerifyImage(ctx context.Context, req *ManualVerifyReq) (res *ManualVerifyImageRes, err error) {
log, err := serviceDataengine.MaterialVerify.VerifyImageByID(ctx, req.MaterialID)
if err != nil {
return nil, err
}
return &ManualVerifyImageRes{
Id: log.Id,
TaskID: log.TaskID,
SourceID: fmt.Sprintf("%d", log.SourceID),
}, nil
}
// ManualVerifyVideo 手动校验视频
func (c *MaterialVerifyController) ManualVerifyVideo(ctx context.Context, req *ManualVerifyReq) (res *ManualVerifyImageRes, err error) {
log, err := serviceDataengine.MaterialVerify.VerifyVideoByID(ctx, req.MaterialID)
if err != nil {
return nil, err
}
return &ManualVerifyImageRes{
Id: log.Id,
TaskID: log.TaskID,
SourceID: fmt.Sprintf("%d", log.SourceID),
}, nil
}
// =============================================================================
// 批量校验接口
// =============================================================================
// BatchVerifyRes 批量校验响应
type BatchVerifyRes struct {
Success int `json:"success"`
Fail int `json:"fail"`
Total int `json:"total"`
Message string `json:"message"`
}
// BatchVerifyImage 批量校验图片
func (c *MaterialVerifyController) BatchVerifyImage(ctx context.Context, req *BatchVerifyReq) (res *BatchVerifyRes, err error) {
if req.Limit <= 0 {
req.Limit = 100
}
images, err := dao.TencentImage.GetPendingList(ctx, req.Limit)
if err != nil {
return nil, err
}
successCount := 0
failCount := 0
for _, image := range images {
log, err := serviceDataengine.MaterialVerify.VerifyImageByID(ctx, image.ImageID)
if err != nil {
failCount++
g.Log().Errorf(ctx, "图片校验失败: %s, error: %v", image.ImageID, err)
} else {
successCount++
g.Log().Infof(ctx, "图片校验已提交: %s, logId: %d", image.ImageID, log.Id)
}
time.Sleep(100 * time.Millisecond)
}
// 等待易盾处理,然后自动查询结果
msg := fmt.Sprintf("批量校验完成,成功: %d失败: %d", successCount, failCount)
if successCount > 0 {
g.Log().Infof(ctx, "提交完成等待2秒后自动查询结果...")
time.Sleep(2 * time.Second)
pollSuccess, pollFail, _ := serviceDataengine.MaterialVerify.PollPendingResults(ctx)
msg = fmt.Sprintf("批量校验完成,提交成功: %d提交失败: %d自动查询成功: %d未就绪: %d",
successCount, failCount, pollSuccess, pollFail)
}
return &BatchVerifyRes{
Success: successCount,
Fail: failCount,
Total: len(images),
Message: msg,
}, nil
}
// BatchVerifyVideo 批量校验视频
func (c *MaterialVerifyController) BatchVerifyVideo(ctx context.Context, req *BatchVerifyReq) (res *BatchVerifyRes, err error) {
if req.Limit <= 0 {
req.Limit = 100
}
videos, err := dao.TencentVideo.GetPendingList(ctx, req.Limit)
if err != nil {
return nil, err
}
successCount := 0
failCount := 0
for _, video := range videos {
log, err := serviceDataengine.MaterialVerify.VerifyVideoByID(ctx, video.VideoID)
if err != nil {
failCount++
g.Log().Errorf(ctx, "视频校验失败: %s, error: %v", video.VideoID, err)
} else {
successCount++
g.Log().Infof(ctx, "视频校验已提交: %s, logId: %d", video.VideoID, log.Id)
}
time.Sleep(100 * time.Millisecond)
}
// 等待易盾处理,然后自动查询结果
msg := fmt.Sprintf("批量校验完成,成功: %d失败: %d", successCount, failCount)
if successCount > 0 {
g.Log().Infof(ctx, "提交完成等待2秒后自动查询结果...")
time.Sleep(2 * time.Second)
pollSuccess, pollFail, _ := serviceDataengine.MaterialVerify.PollPendingResults(ctx)
msg = fmt.Sprintf("批量校验完成,提交成功: %d提交失败: %d自动查询成功: %d未就绪: %d",
successCount, failCount, pollSuccess, pollFail)
}
return &BatchVerifyRes{
Success: successCount,
Fail: failCount,
Total: len(videos),
Message: msg,
}, nil
}
// =============================================================================
// 回调处理接口
// =============================================================================
// CallbackRes 回调响应
type CallbackRes struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// ImageCallback 图片校验回调
func (c *MaterialVerifyController) ImageCallback(ctx context.Context, req *ImageCallbackReq) (res *CallbackRes, err error) {
if req.CallbackData == "" {
return &CallbackRes{Code: 400, Msg: "callbackData不能为空"}, nil
}
err = serviceDataengine.MaterialVerify.ProcessImageCallback(ctx, req.CallbackData)
if err != nil {
return &CallbackRes{Code: 500, Msg: err.Error()}, nil
}
return &CallbackRes{Code: 200, Msg: "处理成功"}, nil
}
// VideoCallback 视频校验回调
func (c *MaterialVerifyController) VideoCallback(ctx context.Context, req *VideoCallbackReq) (res *CallbackRes, err error) {
if req.CallbackData == "" {
return &CallbackRes{Code: 400, Msg: "callbackData不能为空"}, nil
}
err = serviceDataengine.MaterialVerify.ProcessVideoCallback(ctx, req.CallbackData)
if err != nil {
return &CallbackRes{Code: 500, Msg: err.Error()}, nil
}
return &CallbackRes{Code: 200, Msg: "处理成功"}, nil
}
// ResultRes 结果查询响应
type ResultRes struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// ImageResult 图片校验结果查询(轮询模式)
func (c *MaterialVerifyController) ImageResult(ctx context.Context, req *TaskIDReq) (res *ResultRes, err error) {
if req.TaskID == "" {
return &ResultRes{Code: 400, Msg: "taskId不能为空"}, nil
}
err = serviceDataengine.MaterialVerify.ProcessImageResultByTask(ctx, req.TaskID)
if err != nil {
return &ResultRes{Code: 500, Msg: err.Error()}, nil
}
return &ResultRes{Code: 200, Msg: "处理成功"}, nil
}
// VideoResult 视频校验结果查询(轮询模式)
func (c *MaterialVerifyController) VideoResult(ctx context.Context, req *TaskIDReq) (res *ResultRes, err error) {
if req.TaskID == "" {
return &ResultRes{Code: 400, Msg: "taskId不能为空"}, nil
}
err = serviceDataengine.MaterialVerify.ProcessVideoResultByTask(ctx, req.TaskID)
if err != nil {
return &ResultRes{Code: 500, Msg: err.Error()}, nil
}
return &ResultRes{Code: 200, Msg: "处理成功"}, nil
}

View File

@@ -0,0 +1,175 @@
package yidun
import (
dto "cid/model/dto/yidun"
serviceDataengine "cid/service/dataengine"
"context"
"gitea.com/red-future/common/beans"
)
// ContentCheckController 内容送检控制器
type ContentCheckController struct{}
// ContentCheck 内容送检控制器单例
var ContentCheck = new(ContentCheckController)
// StatusRes 状态响应
type StatusRes struct {
Running bool `json:"running"`
Config serviceDataengine.ContentCheckConfig `json:"config"`
PendingStats map[string]int `json:"pending_stats"`
}
// Start 启动送检服务
func (c *ContentCheckController) Start(ctx context.Context, req *dto.StartCheckReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
if serviceDataengine.TencentContentCheck.IsRunning() {
return &beans.ResponseEmpty{}, nil
}
// 如果有配置参数,更新配置
if req.BatchSize > 0 || req.IntervalSeconds > 0 {
config := serviceDataengine.ContentCheckConfig{
BatchSize: req.BatchSize,
ImageEnabled: req.ImageEnabled,
VideoEnabled: req.VideoEnabled,
IntervalSeconds: req.IntervalSeconds,
}
serviceDataengine.TencentContentCheck.SetConfig(config)
}
err = serviceDataengine.TencentContentCheck.Start(ctx)
if err != nil {
return nil, err
}
return &beans.ResponseEmpty{}, nil
}
// Stop 停止送检服务
func (c *ContentCheckController) Stop(ctx context.Context, req *dto.EmptyReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
serviceDataengine.TencentContentCheck.Stop(ctx)
return
}
// Status 获取送检服务状态
func (c *ContentCheckController) Status(ctx context.Context, req *dto.EmptyReq) (res *StatusRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
res = &StatusRes{
Running: serviceDataengine.TencentContentCheck.IsRunning(),
Config: serviceDataengine.TencentContentCheck.GetConfig(),
PendingStats: serviceDataengine.TencentContentCheck.GetPendingStats(ctx),
}
return
}
// ProcessImageCallback 处理图片检测回调
func (c *ContentCheckController) ProcessImageCallback(ctx context.Context, req *dto.ProcessImageCallbackReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
if req.CallbackData == "" {
return nil, err
}
err = serviceDataengine.TencentContentCallback.ProcessImageCallback(ctx, req.CallbackData)
return
}
// ProcessVideoCallback 处理视频检测回调
func (c *ContentCheckController) ProcessVideoCallback(ctx context.Context, req *dto.ProcessVideoCallbackReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
if req.CallbackData == "" {
return nil, err
}
err = serviceDataengine.TencentContentCallback.ProcessVideoCallback(ctx, req.CallbackData)
return
}
// ProcessImageResult 查询并处理图片检测结果(轮询模式)
func (c *ContentCheckController) ProcessImageResult(ctx context.Context, req *dto.ProcessImageResultReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
if req.TaskID == "" {
return nil, err
}
err = serviceDataengine.TencentContentCallback.ProcessImageResult(ctx, req.TaskID)
return
}
// ProcessVideoResult 查询并处理视频检测结果(轮询模式)
func (c *ContentCheckController) ProcessVideoResult(ctx context.Context, req *dto.ProcessVideoResultReq) (res *beans.ResponseEmpty, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
if req.TaskID == "" {
return nil, err
}
err = serviceDataengine.TencentContentCallback.ProcessVideoResult(ctx, req.TaskID)
return
}
// ManualSubmitImageByID 根据图片ID手动提交送检
func (c *ContentCheckController) ManualSubmitImageByID(ctx context.Context, req *dto.ManualSubmitImageByIDReq) (res *dto.ManualSubmitRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
result, err := serviceDataengine.TencentContentCheck.SubmitImageByID(ctx, req.ImageID)
if err != nil {
return nil, err
}
res = &dto.ManualSubmitRes{
TaskID: result.TaskID,
}
return
}
// ManualSubmitVideoByID 根据视频ID手动提交送检
func (c *ContentCheckController) ManualSubmitVideoByID(ctx context.Context, req *dto.ManualSubmitVideoByIDReq) (res *dto.ManualSubmitRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
result, err := serviceDataengine.TencentContentCheck.SubmitVideoByID(ctx, req.VideoID)
if err != nil {
return nil, err
}
res = &dto.ManualSubmitRes{
TaskID: result.TaskID,
}
return
}
// GetImageCheckLogs 获取图片的送检日志
func (c *ContentCheckController) GetImageCheckLogs(ctx context.Context, req *dto.GetImageCheckLogsReq) (res *dto.GetCheckLogsRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
logs, err := serviceDataengine.TencentContentCallback.GetCheckLogsByImageID(ctx, req.ImageID)
if err != nil {
return nil, err
}
res = &dto.GetCheckLogsRes{
List: logs,
}
return
}
// GetVideoCheckLogs 获取视频的送检日志
func (c *ContentCheckController) GetVideoCheckLogs(ctx context.Context, req *dto.GetVideoCheckLogsReq) (res *dto.GetCheckLogsRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
logs, err := serviceDataengine.TencentContentCallback.GetCheckLogsByVideoID(ctx, req.VideoID)
if err != nil {
return nil, err
}
res = &dto.GetCheckLogsRes{
List: logs,
}
return
}

View File

@@ -0,0 +1,340 @@
package yidun
import (
dataengineService "cid/service/dataengine"
"context"
"fmt"
"gitea.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// YidunCallbackController 易盾回调控制器
// 用于接收易盾检测结果的主动推送或手动轮询查询
type YidunCallbackController struct{}
// YidunCallback 易盾回调控制器单例
var YidunCallback = new(YidunCallbackController)
// CallbackResult 通用回调响应
type CallbackResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
// PollResult 轮询结果
type PollResult struct {
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
PendingCount int `json:"pending_count"`
}
// =============================================================================
// 易盾主动推送模式回调接口
// 易盾会在检测完成后主动 POST 数据到这些接口
// =============================================================================
// ReceiveImageCallback 接收易盾图片检测结果推送
// 易盾回调格式: POST /yidun/callback/receiveImage
// Body: callbackData={"antispam":{...}}
func (c *YidunCallbackController) ReceiveImageCallback(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "yidun_callback"})
// 易盾推送的数据在请求体中
var callbackData string
// 尝试从表单数据获取
callbackData = r.GetForm("callbackData", "").String()
if callbackData == "" {
// 尝试从请求体JSON获取
var reqBody map[string]interface{}
if err := r.Parse(&reqBody); err == nil {
if v, ok := reqBody["callbackData"]; ok {
callbackData = toString(v)
}
}
}
// 尝试直接从请求体获取原始数据
if callbackData == "" {
callbackData = string(r.GetBody())
}
if callbackData == "" {
g.Log().Warningf(ctx, "图片回调数据为空")
r.Response.WriteJson(CallbackResult{Code: 400, Msg: "callbackData不能为空"})
return
}
g.Log().Infof(ctx, "收到易盾图片回调, data长度: %d", len(callbackData))
// 处理回调 - 更新 material_verify_log 和 tencent_image 表
err := dataengineService.MaterialVerify.ProcessImageCallback(ctx, callbackData)
if err != nil {
g.Log().Errorf(ctx, "处理易盾图片回调失败: %v", err)
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(CallbackResult{Code: 200, Msg: "success"})
}
// ReceiveVideoCallback 接收易盾视频检测结果推送
// 易盾回调格式: POST /yidun/callback/receiveVideo
// Body: callbackData={"antispam":{...}}
func (c *YidunCallbackController) ReceiveVideoCallback(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "yidun_callback"})
// 易盾推送的数据在请求体中
var callbackData string
// 尝试从表单数据获取
callbackData = r.GetForm("callbackData", "").String()
if callbackData == "" {
// 尝试从请求体JSON获取
var reqBody map[string]interface{}
if err := r.Parse(&reqBody); err == nil {
if v, ok := reqBody["callbackData"]; ok {
callbackData = toString(v)
}
}
}
// 尝试直接从请求体获取原始数据
if callbackData == "" {
callbackData = string(r.GetBody())
}
if callbackData == "" {
g.Log().Warningf(ctx, "视频回调数据为空")
r.Response.WriteJson(CallbackResult{Code: 400, Msg: "callbackData不能为空"})
return
}
g.Log().Infof(ctx, "收到易盾视频回调, data长度: %d", len(callbackData))
// 处理回调 - 更新 material_verify_log 和 tencent_video 表
err := dataengineService.MaterialVerify.ProcessVideoCallback(ctx, callbackData)
if err != nil {
g.Log().Errorf(ctx, "处理易盾视频回调失败: %v", err)
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(CallbackResult{Code: 200, Msg: "success"})
}
// =============================================================================
// 轮询模式 - 手动查询检测结果
// =============================================================================
// PollAllResults 轮询所有待查询的检测结果(图片+视频)
// 格式: POST /yidun/callback/poll
func (c *YidunCallbackController) PollAllResults(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
g.Log().Info(ctx, "开始轮询所有待查询的检测结果...")
// 先获取待处理数量
pendingCount, _ := dataengineService.MaterialVerify.GetPendingResultsCount(ctx)
// 执行轮询
successCount, failCount, err := dataengineService.MaterialVerify.PollPendingResults(ctx)
result := PollResult{
SuccessCount: successCount,
FailCount: failCount,
PendingCount: pendingCount - successCount,
}
if err != nil {
r.Response.WriteJson(CallbackResult{
Code: 500,
Msg: fmt.Sprintf("轮询完成但有错误: %v", err),
Data: result,
})
return
}
r.Response.WriteJson(CallbackResult{
Code: 200,
Msg: fmt.Sprintf("轮询完成,成功处理 %d 条,失败 %d 条", successCount, failCount),
Data: result,
})
}
// PollImageResults 轮询图片待查询的检测结果
// 格式: POST /yidun/callback/pollImage
func (c *YidunCallbackController) PollImageResults(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
g.Log().Info(ctx, "开始轮询图片待查询的检测结果...")
successCount, failCount, err := dataengineService.MaterialVerify.PollPendingImageResults(ctx)
if err != nil {
r.Response.WriteJson(CallbackResult{
Code: 500,
Msg: fmt.Sprintf("轮询失败: %v", err),
Data: PollResult{SuccessCount: successCount, FailCount: failCount},
})
return
}
r.Response.WriteJson(CallbackResult{
Code: 200,
Msg: fmt.Sprintf("轮询完成,成功处理 %d 条,失败 %d 条", successCount, failCount),
Data: PollResult{SuccessCount: successCount, FailCount: failCount},
})
}
// PollVideoResults 轮询视频待查询的检测结果
// 格式: POST /yidun/callback/pollVideo
func (c *YidunCallbackController) PollVideoResults(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
g.Log().Info(ctx, "开始轮询视频待查询的检测结果...")
successCount, failCount, err := dataengineService.MaterialVerify.PollPendingVideoResults(ctx)
if err != nil {
r.Response.WriteJson(CallbackResult{
Code: 500,
Msg: fmt.Sprintf("轮询失败: %v", err),
Data: PollResult{SuccessCount: successCount, FailCount: failCount},
})
return
}
r.Response.WriteJson(CallbackResult{
Code: 200,
Msg: fmt.Sprintf("轮询完成,成功处理 %d 条,失败 %d 条", successCount, failCount),
Data: PollResult{SuccessCount: successCount, FailCount: failCount},
})
}
// PollByTaskID 根据任务ID查询单个检测结果
// 格式: POST /yidun/callback/pollTask
func (c *YidunCallbackController) PollByTaskID(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
taskID := r.Get("taskId", "").String()
taskType := r.Get("type", "").String() // image 或 video
if taskID == "" {
r.Response.WriteJson(CallbackResult{Code: 400, Msg: "taskId不能为空"})
return
}
g.Log().Infof(ctx, "查询单个检测结果, taskId=%s, type=%s", taskID, taskType)
var err error
if taskType == "video" || taskType == "" {
// 尝试视频
err = dataengineService.MaterialVerify.ProcessVideoResultByTask(ctx, taskID)
if err != nil {
// 如果失败且没有指定类型,尝试图片
if taskType == "" {
err = dataengineService.MaterialVerify.ProcessImageResultByTask(ctx, taskID)
}
}
} else if taskType == "image" {
err = dataengineService.MaterialVerify.ProcessImageResultByTask(ctx, taskID)
}
if err != nil {
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(CallbackResult{Code: 200, Msg: "查询并处理成功"})
}
// GetPendingCount 获取待查询结果的数量
// 格式: GET /yidun/callback/pendingCount
func (c *YidunCallbackController) GetPendingCount(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
count, err := dataengineService.MaterialVerify.GetPendingResultsCount(ctx)
if err != nil {
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(g.Map{
"code": 200,
"data": g.Map{
"pending_count": count,
"description": "待查询结果的日志数量状态为pending且有taskID",
},
})
}
// =============================================================================
// 兼容旧接口(手动触发回调处理)
// =============================================================================
// ProcessImageCallback 手动处理图片回调(兼容旧接口)
// 格式: POST /yidun/callback/processImage
func (c *YidunCallbackController) ProcessImageCallback(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
var req struct {
CallbackData string `json:"callbackData" v:"required#回调数据不能为空"`
}
if err := r.Parse(&req); err != nil {
r.Response.WriteJson(CallbackResult{Code: 400, Msg: err.Error()})
return
}
err := dataengineService.MaterialVerify.ProcessImageCallback(ctx, req.CallbackData)
if err != nil {
g.Log().Errorf(ctx, "处理图片回调失败: %v", err)
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(CallbackResult{Code: 200, Msg: "success"})
}
// ProcessVideoCallback 手动处理视频回调(兼容旧接口)
// 格式: POST /yidun/callback/processVideo
func (c *YidunCallbackController) ProcessVideoCallback(r *ghttp.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
var req struct {
CallbackData string `json:"callbackData" v:"required#回调数据不能为空"`
}
if err := r.Parse(&req); err != nil {
r.Response.WriteJson(CallbackResult{Code: 400, Msg: err.Error()})
return
}
err := dataengineService.MaterialVerify.ProcessVideoCallback(ctx, req.CallbackData)
if err != nil {
g.Log().Errorf(ctx, "处理视频回调失败: %v", err)
r.Response.WriteJson(CallbackResult{Code: 500, Msg: err.Error()})
return
}
r.Response.WriteJson(CallbackResult{Code: 200, Msg: "success"})
}
// toString 转换interface{}为string
func toString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}

12
dao/dataengine/db.go Normal file
View File

@@ -0,0 +1,12 @@
package dataengine
import (
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// Model 获取 dataEngine 数据库的 ModelGoFrame ORM
// 配置文件中 dataEngine 对应的实际数据库名是 dataengine
func Model(tableName string) *gdb.Model {
return g.DB("dataEngine").Model(tableName)
}

View File

@@ -0,0 +1,287 @@
package dataengine
import (
consts "cid/consts/dataengine"
daoEntity "cid/model/entity/dataengine"
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// MaterialVerifyLogDAO 素材校验日志数据访问层
type MaterialVerifyLogDAO struct{}
// MaterialVerifyLog DAO单例
var MaterialVerifyLog = new(MaterialVerifyLogDAO)
// TableName 表名
const MaterialVerifyLogTable = "material_verify_log"
// Create 创建校验日志
func (d *MaterialVerifyLogDAO) Create(ctx context.Context, log *daoEntity.MaterialVerifyLog) (int64, error) {
// 构建插入数据排除主键Id让数据库自增
data := g.Map{
"tenant_id": log.TenantID,
"material_type": log.MaterialType,
"material_id": log.MaterialID,
"source_table": log.SourceTable,
"source_id": log.SourceID,
"account_id": log.AccountID,
"verify_status": log.VerifyStatus,
"created_at": gtime.Now(),
}
result, err := g.DB("default").Model(MaterialVerifyLogTable).Data(data).Insert()
if err != nil {
g.Log().Errorf(ctx, "创建校验日志失败: %v", err)
return 0, err
}
id, _ := result.LastInsertId()
return id, nil
}
// GetByID 根据ID获取日志
func (d *MaterialVerifyLogDAO) GetByID(ctx context.Context, id int64) (*daoEntity.MaterialVerifyLog, error) {
var result daoEntity.MaterialVerifyLog
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetByTaskID 根据任务ID获取日志
func (d *MaterialVerifyLogDAO) GetByTaskID(ctx context.Context, taskID string) (*daoEntity.MaterialVerifyLog, error) {
var result daoEntity.MaterialVerifyLog
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.TaskID, taskID).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetByMaterialID 根据素材ID获取日志列表
func (d *MaterialVerifyLogDAO) GetByMaterialID(ctx context.Context, materialID string) ([]daoEntity.MaterialVerifyLog, error) {
var result []daoEntity.MaterialVerifyLog
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.MaterialID, materialID).
OrderDesc(daoEntity.MaterialVerifyLogCols.CreatedAt).
All()
if err != nil {
return nil, err
}
if err = r.Structs(&result); err != nil {
return nil, err
}
return result, nil
}
// GetBySource 根据来源获取日志
func (d *MaterialVerifyLogDAO) GetBySource(ctx context.Context, sourceTable string, sourceID int64) ([]daoEntity.MaterialVerifyLog, error) {
var result []daoEntity.MaterialVerifyLog
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.SourceTable, sourceTable).
Where(daoEntity.MaterialVerifyLogCols.SourceID, sourceID).
OrderDesc(daoEntity.MaterialVerifyLogCols.CreatedAt).
All()
if err != nil {
return nil, err
}
if err = r.Structs(&result); err != nil {
return nil, err
}
return result, nil
}
// UpdateVerifyResult 更新校验结果
func (d *MaterialVerifyLogDAO) UpdateVerifyResult(ctx context.Context, id int64, verifyStatus string, suggestion, label, resultType int, responseResult string, checkTime int64) error {
_, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
Data(map[string]interface{}{
daoEntity.MaterialVerifyLogCols.VerifyStatus: verifyStatus,
daoEntity.MaterialVerifyLogCols.Suggestion: suggestion,
daoEntity.MaterialVerifyLogCols.Label: label,
daoEntity.MaterialVerifyLogCols.ResultType: resultType,
daoEntity.MaterialVerifyLogCols.ResponseResult: responseResult,
daoEntity.MaterialVerifyLogCols.CheckTime: checkTime,
}).Update()
if err != nil {
g.Log().Errorf(ctx, "更新校验日志结果失败: %v", err)
return err
}
return nil
}
// UpdateError 更新错误信息
func (d *MaterialVerifyLogDAO) UpdateError(ctx context.Context, id int64, verifyStatus string, errorMsg string) error {
_, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
Data(map[string]interface{}{
daoEntity.MaterialVerifyLogCols.VerifyStatus: verifyStatus,
daoEntity.MaterialVerifyLogCols.ErrorMsg: errorMsg,
}).Update()
if err != nil {
g.Log().Errorf(ctx, "更新校验日志错误失败: %v", err)
return err
}
return nil
}
// UpdateTaskID 更新任务ID
func (d *MaterialVerifyLogDAO) UpdateTaskID(ctx context.Context, id int64, taskID string) error {
_, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
Data(map[string]interface{}{
daoEntity.MaterialVerifyLogCols.TaskID: taskID,
}).Update()
if err != nil {
return err
}
return nil
}
// UpdateDuration 更新处理耗时
func (d *MaterialVerifyLogDAO) UpdateDuration(ctx context.Context, id int64, durationMs int64) error {
_, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
Data(map[string]interface{}{
daoEntity.MaterialVerifyLogCols.DurationMs: durationMs,
}).Update()
if err != nil {
return err
}
return nil
}
// UpdateRequestParams 更新请求参数
func (d *MaterialVerifyLogDAO) UpdateRequestParams(ctx context.Context, id int64, requestParams string) error {
_, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.Id, id).
Data(map[string]interface{}{
daoEntity.MaterialVerifyLogCols.RequestParams: requestParams,
}).Update()
if err != nil {
return err
}
return nil
}
// GetByCondition 根据条件分页查询
func (d *MaterialVerifyLogDAO) GetByCondition(ctx context.Context, condition map[string]interface{}, page, pageSize int) ([]daoEntity.MaterialVerifyLog, int, error) {
var result []daoEntity.MaterialVerifyLog
model := g.DB("default").Model(MaterialVerifyLogTable)
for k, v := range condition {
model = model.Where(k, v)
}
total, err := model.Count()
if err != nil {
return nil, 0, err
}
r, err := model.
OrderDesc(daoEntity.MaterialVerifyLogCols.CreatedAt).
Page(page, pageSize).
All()
if err != nil {
return nil, 0, err
}
if err = r.Structs(&result); err != nil {
return nil, 0, err
}
return result, int(total), nil
}
// CountByStatus 按状态统计
func (d *MaterialVerifyLogDAO) CountByStatus(ctx context.Context, verifyStatus string) (int, error) {
count, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.VerifyStatus, verifyStatus).
Count()
if err != nil {
return 0, err
}
return int(count), nil
}
// GetStats 获取统计信息
func (d *MaterialVerifyLogDAO) GetStats(ctx context.Context) (map[string]int, error) {
stats := make(map[string]int)
// 使用实体中定义的正确状态值PENDING=待校验, VERIFIED=校验通过, REJECTED=校验不通过
statuses := []struct {
statusKey string
statusVal string
}{
{"pending", daoEntity.VerifyStatusPending},
{"verified", daoEntity.VerifyStatusVerified},
{"rejected", daoEntity.VerifyStatusRejected},
}
var totalCount int
for _, item := range statuses {
count, err := d.CountByStatus(ctx, item.statusVal)
if err != nil {
continue
}
stats[item.statusKey] = count
totalCount += count
}
// 添加总计
stats["total"] = totalCount
return stats, nil
}
// GetPendingResults 获取待查询结果的日志状态为submitting且有taskID
func (d *MaterialVerifyLogDAO) GetPendingResults(ctx context.Context, limit int) ([]daoEntity.MaterialVerifyLog, error) {
var result []daoEntity.MaterialVerifyLog
// 查询状态为 pending 且有 task_id 的记录
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.VerifyStatus, consts.CheckStatusPending).
WhereNotNull(daoEntity.MaterialVerifyLogCols.TaskID).
Where(daoEntity.MaterialVerifyLogCols.TaskID + " != ''").
OrderAsc(daoEntity.MaterialVerifyLogCols.CreatedAt).
Limit(limit).
All()
if err != nil {
g.Log().Errorf(ctx, "查询待处理结果日志失败: %v", err)
return nil, err
}
if err = r.Structs(&result); err != nil {
g.Log().Errorf(ctx, "转换待处理结果日志失败: %v", err)
return nil, err
}
return result, nil
}
// CountPendingResults 统计待查询结果的数量
func (d *MaterialVerifyLogDAO) CountPendingResults(ctx context.Context) (int, error) {
count, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.VerifyStatus, consts.CheckStatusPending).
WhereNotNull(daoEntity.MaterialVerifyLogCols.TaskID).
Where(daoEntity.MaterialVerifyLogCols.TaskID + " != ''").
Count()
if err != nil {
return 0, err
}
return int(count), nil
}

View File

@@ -0,0 +1,180 @@
package dataengine
import (
consts "cid/consts/dataengine"
entity "cid/model/entity/dataengine"
yidunService "cid/service/yidun"
"context"
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// TencentContentCheckLogDAO 送检日志数据访问层
type TencentContentCheckLogDAO struct{}
// TencentContentCheckLog 日志DAO单例
var TencentContentCheckLog = new(TencentContentCheckLogDAO)
// Create 创建送检日志
func (d *TencentContentCheckLogDAO) Create(ctx context.Context, log *entity.TencentContentCheckLog) (int64, error) {
r, err := g.DB("default").Model(consts.TencentContentCheckLogTable).Data(log).Insert()
if err != nil {
g.Log().Errorf(ctx, "创建送检日志失败: %v", err)
return 0, err
}
id, _ := r.LastInsertId()
return id, nil
}
// UpdateStatus 更新送检状态
func (d *TencentContentCheckLogDAO) UpdateStatus(ctx context.Context, id int64, status string, responseData string, failReason string) error {
_, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("id", id).
Data(map[string]interface{}{
"status": status,
"response_data": responseData,
"fail_reason": failReason,
}).Update()
return err
}
// UpdateCheckResult 更新检测结果
func (d *TencentContentCheckLogDAO) UpdateCheckResult(ctx context.Context, id int64, suggestion, label, resultType int, checkTime int64) error {
_, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("id", id).
Data(map[string]interface{}{
"status": consts.CheckStatusCompleted,
"suggestion": suggestion,
"label": label,
"result_type": resultType,
"check_time": checkTime,
}).Update()
return err
}
// GetByID 根据ID获取日志
func (d *TencentContentCheckLogDAO) GetByID(ctx context.Context, id int64) (*entity.TencentContentCheckLog, error) {
var result entity.TencentContentCheckLog
r, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("id", id).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetBySourceID 根据来源ID获取日志
func (d *TencentContentCheckLogDAO) GetBySourceID(ctx context.Context, sourceTable string, sourceID int64) ([]entity.TencentContentCheckLog, error) {
var result []entity.TencentContentCheckLog
r, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("source_table", sourceTable).
Where("source_id", sourceID).
OrderDesc("created_at").
All()
if err != nil {
return nil, err
}
if err = r.Structs(&result); err != nil {
return nil, err
}
return result, nil
}
// GetByTaskID 根据任务ID获取日志
func (d *TencentContentCheckLogDAO) GetByTaskID(ctx context.Context, taskID string) (*entity.TencentContentCheckLog, error) {
var result entity.TencentContentCheckLog
r, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("task_id", taskID).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// ListByStatus 根据状态获取日志列表
func (d *TencentContentCheckLogDAO) ListByStatus(ctx context.Context, status string, page, pageSize int) ([]entity.TencentContentCheckLog, int, error) {
var result []entity.TencentContentCheckLog
model := g.DB("default").Model(consts.TencentContentCheckLogTable)
if status != "" {
model = model.Where("status", status)
}
total, err := model.Count()
if err != nil {
return nil, 0, err
}
r, err := model.
OrderDesc("created_at").
Page(page, pageSize).
All()
if err != nil {
return nil, 0, err
}
if err = r.Structs(&result); err != nil {
return nil, 0, err
}
return result, int(total), nil
}
// UpdateDuration 更新耗时
func (d *TencentContentCheckLogDAO) UpdateDuration(ctx context.Context, id int64, duration int64) error {
_, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("id", id).
Data("duration", duration).
Update()
return err
}
// UpdateTaskID 更新任务ID
func (d *TencentContentCheckLogDAO) UpdateTaskID(ctx context.Context, id int64, taskID string) error {
_, err := g.DB("default").Model(consts.TencentContentCheckLogTable).
Where("id", id).
Data("task_id", taskID).
Update()
return err
}
// GetSubmitResult 获取图片提交结果
func (d *TencentContentCheckLogDAO) GetImageSubmitResult(ctx context.Context, id int64) (*yidunService.ImageSubmitResult, error) {
log, err := d.GetByID(ctx, id)
if err != nil || log == nil {
return nil, err
}
var result yidunService.ImageSubmitResult
if err := json.Unmarshal([]byte(log.ResponseData), &result); err != nil {
return nil, err
}
return &result, nil
}
// GetVideoSubmitResult 获取视频提交结果
func (d *TencentContentCheckLogDAO) GetVideoSubmitResult(ctx context.Context, id int64) (*yidunService.VideoSubmitResult, error) {
log, err := d.GetByID(ctx, id)
if err != nil || log == nil {
return nil, err
}
var result yidunService.VideoSubmitResult
if err := json.Unmarshal([]byte(log.ResponseData), &result); err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -0,0 +1,136 @@
package dataengine
import (
consts "cid/consts/dataengine"
entity "cid/model/entity/dataengine"
"context"
"github.com/gogf/gf/v2/frame/g"
)
// TencentImageDAO 图片素材数据访问层
type TencentImageDAO struct{}
// TencentImage 图片DAO单例
var TencentImage = new(TencentImageDAO)
// GetPendingList 获取待送检数据列表
func (d *TencentImageDAO) GetPendingList(ctx context.Context, limit int) ([]entity.TencentImage, error) {
var result []entity.TencentImage
r, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.VerifyStatus, consts.CheckStatusPending).
WhereNull("deleted_at").
OrderAsc("created_time").
Limit(limit).
All()
if err != nil {
g.Log().Errorf(ctx, "查询待送检图片数据失败: %v", err)
return nil, err
}
if err = r.Structs(&result); err != nil {
g.Log().Errorf(ctx, "转换待送检图片数据失败: %v", err)
return nil, err
}
return result, nil
}
// GetByImageID 根据图片ID获取数据
func (d *TencentImageDAO) GetByImageID(ctx context.Context, imageID string) (*entity.TencentImage, error) {
var result entity.TencentImage
r, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.ImageID, imageID).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetByID 根据ID获取数据
func (d *TencentImageDAO) GetByID(ctx context.Context, id int64) (*entity.TencentImage, error) {
var result entity.TencentImage
r, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.Id, id).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// CountPending 统计待送检数量
func (d *TencentImageDAO) CountPending(ctx context.Context) (int, error) {
count, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.VerifyStatus, consts.CheckStatusPending).
WhereNull("deleted_at").
Count()
if err != nil {
g.Log().Errorf(ctx, "统计待送检图片数量失败: %v", err)
return 0, err
}
return int(count), nil
}
// CountByStatus 根据状态统计数量
func (d *TencentImageDAO) CountByStatus(ctx context.Context, status string) (int, error) {
count, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.VerifyStatus, status).
Count()
if err != nil {
return 0, err
}
return int(count), nil
}
// GetByCondition 根据条件获取数据列表
func (d *TencentImageDAO) GetByCondition(ctx context.Context, condition map[string]interface{}, page, pageSize int) ([]entity.TencentImage, int, error) {
var result []entity.TencentImage
model := Model(consts.TencentImageTable)
for k, v := range condition {
model = model.Where(k, v)
}
total, err := model.Count()
if err != nil {
return nil, 0, err
}
r, err := model.
OrderDesc(entity.TencentImageCols.CreatedTime).
Page(page, pageSize).
All()
if err != nil {
return nil, 0, err
}
if err = r.Structs(&result); err != nil {
return nil, 0, err
}
return result, int(total), nil
}
// UpdateStatus 更新图片校验状态
func (d *TencentImageDAO) UpdateStatus(ctx context.Context, id int64, verifyStatus string) (int64, error) {
result, err := Model(consts.TencentImageTable).
Where(entity.TencentImageCols.Id, id).
Data(entity.TencentImageCols.VerifyStatus, verifyStatus).
Update()
if err != nil {
g.Log().Errorf(ctx, "更新图片校验状态失败: %v", err)
return 0, err
}
affected, _ := result.RowsAffected()
return affected, nil
}

View File

@@ -0,0 +1,136 @@
package dataengine
import (
consts "cid/consts/dataengine"
entity "cid/model/entity/dataengine"
"context"
"github.com/gogf/gf/v2/frame/g"
)
// TencentVideoDAO 视频素材数据访问层
type TencentVideoDAO struct{}
// TencentVideo 视频DAO单例
var TencentVideo = new(TencentVideoDAO)
// GetPendingList 获取待送检数据列表
func (d *TencentVideoDAO) GetPendingList(ctx context.Context, limit int) ([]entity.TencentVideo, error) {
var result []entity.TencentVideo
r, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.VerifyStatus, consts.CheckStatusPending).
WhereNull("deleted_at").
OrderAsc("created_time").
Limit(limit).
All()
if err != nil {
g.Log().Errorf(ctx, "查询待送检视频数据失败: %v", err)
return nil, err
}
if err = r.Structs(&result); err != nil {
g.Log().Errorf(ctx, "转换待送检视频数据失败: %v", err)
return nil, err
}
return result, nil
}
// GetByVideoID 根据视频ID获取数据
func (d *TencentVideoDAO) GetByVideoID(ctx context.Context, videoID string) (*entity.TencentVideo, error) {
var result entity.TencentVideo
r, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.VideoID, videoID).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetByID 根据ID获取数据
func (d *TencentVideoDAO) GetByID(ctx context.Context, id int64) (*entity.TencentVideo, error) {
var result entity.TencentVideo
r, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.Id, id).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
if err = r.Struct(&result); err != nil {
return nil, err
}
return &result, nil
}
// CountPending 统计待送检数量
func (d *TencentVideoDAO) CountPending(ctx context.Context) (int, error) {
count, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.VerifyStatus, consts.CheckStatusPending).
WhereNull("deleted_at").
Count()
if err != nil {
g.Log().Errorf(ctx, "统计待送检视频数量失败: %v", err)
return 0, err
}
return int(count), nil
}
// CountByStatus 根据状态统计数量
func (d *TencentVideoDAO) CountByStatus(ctx context.Context, status string) (int, error) {
count, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.VerifyStatus, status).
Count()
if err != nil {
return 0, err
}
return int(count), nil
}
// GetByCondition 根据条件获取数据列表
func (d *TencentVideoDAO) GetByCondition(ctx context.Context, condition map[string]interface{}, page, pageSize int) ([]entity.TencentVideo, int, error) {
var result []entity.TencentVideo
model := Model(consts.TencentVideoTable)
for k, v := range condition {
model = model.Where(k, v)
}
total, err := model.Count()
if err != nil {
return nil, 0, err
}
r, err := model.
OrderDesc(entity.TencentVideoCols.CreatedTime).
Page(page, pageSize).
All()
if err != nil {
return nil, 0, err
}
if err = r.Structs(&result); err != nil {
return nil, 0, err
}
return result, int(total), nil
}
// UpdateStatus 更新视频校验状态
func (d *TencentVideoDAO) UpdateStatus(ctx context.Context, id int64, verifyStatus string) (int64, error) {
result, err := Model(consts.TencentVideoTable).
Where(entity.TencentVideoCols.Id, id).
Data(entity.TencentVideoCols.VerifyStatus, verifyStatus).
Update()
if err != nil {
g.Log().Errorf(ctx, "更新视频校验状态失败: %v", err)
return 0, err
}
affected, _ := result.RowsAffected()
return affected, nil
}

10
go.mod
View File

@@ -4,11 +4,10 @@ go 1.26.0
require (
gitea.com/red-future/common v0.0.18
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.5
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5
github.com/gogf/gf/v2 v2.9.5
github.com/gogf/gf/v2 v2.10.0
github.com/yidun/yidun-golang-sdk v1.0.38
golang.org/x/net v0.47.0
)
replace gitea.com/red-future/common => ../common
@@ -24,13 +23,12 @@ require (
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-ego/gse v1.0.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -54,6 +52,7 @@ require (
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -83,6 +82,7 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/appengine v1.6.7 // indirect

16
go.sum
View File

@@ -50,8 +50,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -74,19 +74,17 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.5 h1:0+ZBYhi4sqwxXwL+hIBpp06a7G4m5nmjskQ3NNb8qYc=
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.5/go.mod h1:vyB7J/uJcLCrHD5lfFBzxhEEMkePIRzfhd33EcsuLa0=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5 h1:Ku7p3CvGchxC7zPSgArf/tZs2w9Yb8tS/gH5ADN+p9g=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5/go.mod h1:cjy18NsSLZQf5zaLAzuo7B2gr8GGjCTWDTEPY7T+6FI=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88=
github.com/gogf/gf/v2 v2.9.5 h1:1scfOdHbMP854oQaiLejl+eL+c4xfuvtWmmZiDJxbKs=
github.com/gogf/gf/v2 v2.9.5/go.mod h1:VUb5eyJKpvW77O/dXsbbLNO/Kjrg0UycIiq0lRiBjjo=
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -198,6 +196,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=

120
main.go
View File

@@ -3,41 +3,145 @@ package main
import (
"cid/controller/app"
"cid/controller/data"
"cid/controller/dataengine"
"cid/controller/mapping"
"cid/controller/yidun"
controllerYidun "cid/controller/yidun"
serviceDataengine "cid/service/dataengine"
serviceYidun "cid/service/yidun"
"context"
"fmt"
"os"
"path/filepath"
"time"
_ "gitea.com/red-future/common/consul"
"gitea.com/red-future/common/http"
"gitea.com/red-future/common/jaeger"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
"golang.org/x/net/context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
ctx := context.Background()
defer jaeger.ShutDown(ctx)
// 设置时区为东八区
loc, err := time.LoadLocation("Asia/Shanghai")
if err == nil {
time.Local = loc
}
// 关键:设置 PGTZ 环境变量lib/pq 驱动在连接 pg 时会自动设置 session timezone
// 确保从数据库读取 TIMESTAMPTZ 时返回的是东八区时间gtime.Time 序列化输出北京时间
os.Setenv("PGTZ", "Asia/Shanghai")
// 初始化易盾客户端
if err := serviceYidun.InitYidunClients(ctx); err != nil {
panic(fmt.Sprintf("初始化易盾客户端失败: %v", err))
}
g.Log().Info(ctx, "易盾客户端初始化成功")
// 启动内容送检定时任务
startContentCheckService(ctx)
// 获取前端目录
frontendDir := getFrontendDir()
// 注册前端静态文件路由(在使用 http.Httpserver 之前)
registerFrontendRoutes(frontendDir)
// 注册 API 路由并启动服务器
http.RouteRegister([]interface{}{
// 平台管理
data.Platform,
// 接口管理
data.ApiInterface,
// 数据获取
data.DataFetch,
// 数据映射
mapping.DataMapping,
// 应用管理
app.Application,
// 易盾内容安全
controllerYidun.YidunController,
controllerYidun.YidunCallback,
yidun.ContentCheck,
dataengine.MaterialVerify,
})
// 打印前端访问地址
port := g.Cfg().MustGet(ctx, "server.address", ":3001").String()
g.Log().Info(ctx, "============================================")
g.Log().Infof(ctx, "🌐 前端访问地址: http://localhost%s", port)
g.Log().Info(ctx, "============================================")
select {}
}
// getFrontendDir 获取前端目录路径
func getFrontendDir() string {
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
frontendDir := filepath.Join(execDir, "resource", "frontend")
if _, err := os.Stat(frontendDir); os.IsNotExist(err) {
cwd, _ := os.Getwd()
frontendDir = filepath.Join(cwd, "resource", "frontend")
}
return frontendDir
}
// registerFrontendRoutes 注册前端静态文件路由
func registerFrontendRoutes(frontendDir string) {
if _, err := os.Stat(frontendDir); os.IsNotExist(err) {
g.Log().Warningf(context.Background(), "前端目录不存在: %s", frontendDir)
return
}
s := http.Httpserver
// 静态资源路由
s.BindHandler("/frontend/{file}", func(r *ghttp.Request) {
file := r.Get("file").String()
filePath := filepath.Join(frontendDir, file)
if _, err := os.Stat(filePath); err == nil {
r.Response.ServeFile(filePath)
} else {
r.Response.WriteStatus(404)
}
})
// 首页/主入口
s.BindHandler("/", func(r *ghttp.Request) {
indexFile := filepath.Join(frontendDir, "material-verify.html")
if _, err := os.Stat(indexFile); err == nil {
r.Response.ServeFile(indexFile)
} else {
r.Response.Write("<html><body><h1>CID Backend Service</h1><p>前端页面未找到</p></body></html>")
}
})
}
// startContentCheckService 启动内容送检服务
func startContentCheckService(ctx context.Context) {
// 检查是否启用定时送检任务
schedulerEnabled := g.Cfg().MustGet(ctx, "content_check.scheduler_enabled", true).Bool()
if !schedulerEnabled {
g.Log().Info(ctx, "定时送检任务已禁用scheduler_enabled=false仅支持API手动送检")
return
}
// 配置送检服务参数
config := serviceDataengine.ContentCheckConfig{
BatchSize: g.Cfg().MustGet(ctx, "content_check.batch_size", 10).Int(),
ImageEnabled: g.Cfg().MustGet(ctx, "content_check.image_enabled", true).Bool(),
VideoEnabled: g.Cfg().MustGet(ctx, "content_check.video_enabled", true).Bool(),
IntervalSeconds: g.Cfg().MustGet(ctx, "content_check.interval_seconds", 30).Int(),
}
serviceDataengine.TencentContentCheck.SetConfig(config)
// 启动服务
if err := serviceDataengine.TencentContentCheck.Start(ctx); err != nil {
g.Log().Errorf(ctx, "启动内容送检服务失败: %v", err)
} else {
g.Log().Info(ctx, "内容送检服务启动成功")
}
}

View File

@@ -0,0 +1,70 @@
package yidun
// ContentCheckConfig 送检配置
type ContentCheckConfig struct {
BatchSize int `json:"batch_size"`
ImageEnabled bool `json:"image_enabled"`
VideoEnabled bool `json:"video_enabled"`
IntervalSeconds int `json:"interval_seconds"`
}
// StartCheckReq 启动送检服务请求
type StartCheckReq struct {
BatchSize int `json:"batch_size"`
IntervalSeconds int `json:"interval_seconds"`
ImageEnabled bool `json:"image_enabled"`
VideoEnabled bool `json:"video_enabled"`
}
// EmptyReq 空请求
type EmptyReq struct{}
// ProcessImageCallbackReq 处理图片回调请求
type ProcessImageCallbackReq struct {
CallbackData string `json:"callbackData"`
}
// ProcessVideoCallbackReq 处理视频回调请求
type ProcessVideoCallbackReq struct {
CallbackData string `json:"callbackData"`
}
// ProcessImageResultReq 查询图片检测结果请求
type ProcessImageResultReq struct {
TaskID string `json:"taskId"`
}
// ProcessVideoResultReq 查询视频检测结果请求
type ProcessVideoResultReq struct {
TaskID string `json:"taskId"`
}
// ManualSubmitImageByIDReq 手动提交图片送检请求
type ManualSubmitImageByIDReq struct {
ImageID string `json:"image_id" v:"required#图片ID不能为空"`
}
// ManualSubmitVideoByIDReq 手动提交视频送检请求
type ManualSubmitVideoByIDReq struct {
VideoID string `json:"video_id" v:"required#视频ID不能为空"`
}
// ManualSubmitRes 手动提交响应
type ManualSubmitRes struct {
TaskID string `json:"taskId"`
}
// GetImageCheckLogsReq 获取图片送检日志请求
type GetImageCheckLogsReq struct {
ImageID string `json:"image_id" v:"required#图片ID不能为空"`
}
// GetVideoCheckLogsReq 获取视频送检日志请求
type GetVideoCheckLogsReq struct {
VideoID string `json:"video_id" v:"required#视频ID不能为空"`
}
// GetCheckLogsRes 获取送检日志响应
type GetCheckLogsRes struct {
List interface{} `json:"list"`
}

View File

@@ -0,0 +1,85 @@
package dataengine
import (
"gitea.com/red-future/common/beans"
)
// MaterialVerifyLog 素材校验日志实体
type MaterialVerifyLog struct {
beans.SQLBaseDO `orm:",inherit"`
// 业务字段
TenantID int64 `orm:"tenant_id" json:"tenantId" description:"租户ID"`
MaterialType string `orm:"material_type" json:"materialType" description:"素材类型 IMAGE/VIDEO"`
MaterialID string `orm:"material_id" json:"materialId" description:"素材ID"`
SourceTable string `orm:"source_table" json:"sourceTable" description:"来源表"`
SourceID int64 `orm:"source_id" json:"sourceId" description:"原表主键ID"`
AccountID int64 `orm:"account_id" json:"accountId" description:"账户ID"`
TaskID string `orm:"task_id" json:"taskId" description:"易盾任务ID"`
RequestParams string `orm:"request_params" json:"requestParams" description:"请求入参"`
ResponseResult string `orm:"response_result" json:"responseResult" description:"响应出参"`
VerifyStatus string `orm:"verify_status" json:"verifyStatus" description:"校验状态"`
Suggestion int `orm:"suggestion" json:"suggestion" description:"处置建议"`
Label int `orm:"label" json:"label" description:"垃圾类型"`
ResultType int `orm:"result_type" json:"resultType" description:"结果类型"`
ErrorMsg string `orm:"error_msg" json:"errorMsg" description:"错误信息"`
CheckTime int64 `orm:"check_time" json:"checkTime" description:"审核时间戳"`
DurationMs int64 `orm:"duration_ms" json:"durationMs" description:"处理耗时(毫秒)"`
// 扩展字段(用于展示)
PreviewURL string `orm:"-" json:"previewUrl" description:"预览URL"`
}
// MaterialVerifyLogCol 日志表字段定义
type MaterialVerifyLogCol struct {
beans.SQLBaseCol
TenantID string
MaterialType string
MaterialID string
SourceTable string
SourceID string
AccountID string
TaskID string
RequestParams string
ResponseResult string
VerifyStatus string
Suggestion string
Label string
ResultType string
ErrorMsg string
CheckTime string
DurationMs string
}
// MaterialVerifyLogCols 日志表字段常量
var MaterialVerifyLogCols = MaterialVerifyLogCol{
SQLBaseCol: beans.DefSQLBaseCol,
TenantID: "tenant_id",
MaterialType: "material_type",
MaterialID: "material_id",
SourceTable: "source_table",
SourceID: "source_id",
AccountID: "account_id",
TaskID: "task_id",
RequestParams: "request_params",
ResponseResult: "response_result",
VerifyStatus: "verify_status",
Suggestion: "suggestion",
Label: "label",
ResultType: "result_type",
ErrorMsg: "error_msg",
CheckTime: "check_time",
DurationMs: "duration_ms",
}
// 素材类型常量
const (
MaterialTypeImage = "IMAGE"
MaterialTypeVideo = "VIDEO"
)
// 校验状态常量
const (
VerifyStatusPending = "PENDING" // 待校验
VerifyStatusVerified = "VERIFIED" // 校验通过
VerifyStatusRejected = "REJECTED" // 校验不通过
)

View File

@@ -0,0 +1,62 @@
package dataengine
import (
"gitea.com/red-future/common/beans"
)
// TencentContentCheckLog 送检日志实体来源data-engine.tencent_content_check_log
type TencentContentCheckLog struct {
beans.SQLBaseDO `orm:",inherit"`
// 来源标识
SourceTable string `orm:"source_table" json:"sourceTable" description:"来源表标识tencent_image/tencent_video"`
SourceID int64 `orm:"source_id" json:"sourceId" description:"原数据ID关联业务表数据"`
// 送检信息
RequestURL string `orm:"request_url" json:"requestUrl" description:"送检请求路径(接口地址)"`
RequestParam string `orm:"request_param" json:"requestParam" description:"送检入参完整请求参数JSON格式"`
ResponseData string `orm:"response_data" json:"responseData" description:"送检出参完整接口返回结果JSON格式"`
Status string `orm:"status" json:"status" description:"送检状态pending-待送检, submitting-送检中, success-送检成功, failed-送检失败"`
CheckTime int64 `orm:"check_time" json:"checkTime" description:"送检时间(时间戳,毫秒)"`
FailReason string `orm:"fail_reason" json:"failReason" description:"失败原因(可选,记录接口报错信息)"`
TaskID string `orm:"task_id" json:"taskId" description:"易盾返回的任务ID"`
// 检测结果
Suggestion int `orm:"suggestion" json:"suggestion" description:"检测结果建议0-通过1-嫌疑2-不通过"`
Label int `orm:"label" json:"label" description:"检测标签"`
ResultType int `orm:"result_type" json:"resultType" description:"结果类型1-机器结果2-人审结果"`
Duration int64 `orm:"duration" json:"duration" description:"送检耗时(毫秒)"`
}
// TencentContentCheckLogCol 送检日志表字段定义
type TencentContentCheckLogCol struct {
beans.SQLBaseCol
SourceTable string
SourceID string
RequestURL string
RequestParam string
ResponseData string
Status string
CheckTime string
FailReason string
TaskID string
Suggestion string
Label string
ResultType string
Duration string
}
// TencentContentCheckLogCols 送检日志表字段常量
var TencentContentCheckLogCols = TencentContentCheckLogCol{
SQLBaseCol: beans.DefSQLBaseCol,
SourceTable: "source_table",
SourceID: "source_id",
RequestURL: "request_url",
RequestParam: "request_param",
ResponseData: "response_data",
Status: "status",
CheckTime: "check_time",
FailReason: "fail_reason",
TaskID: "task_id",
Suggestion: "suggestion",
Label: "label",
ResultType: "result_type",
Duration: "duration",
}

View File

@@ -0,0 +1,120 @@
package dataengine
import (
"gitea.com/red-future/common/beans"
)
// TencentImage 图片素材实体来源data-engine.tencent_image
type TencentImage struct {
beans.SQLBaseDO `orm:",inherit"`
// 业务字段 - 匹配现有表结构
ImageID string `orm:"image_id" json:"imageId" description:"图片ID"`
AccountID int64 `orm:"account_id" json:"accountId" description:"账户ID"`
Width int `orm:"width" json:"width" description:"宽度"`
Height int `orm:"height" json:"height" description:"高度"`
FileSize int64 `orm:"file_size" json:"fileSize" description:"文件大小"`
Type string `orm:"type" json:"type" description:"图片类型"`
Signature string `orm:"signature" json:"signature" description:"签名"`
Description string `orm:"description" json:"description" description:"描述"`
SourceSignature string `orm:"source_signature" json:"sourceSignature" description:"源签名"`
PreviewURL string `orm:"preview_url" json:"previewUrl" description:"预览URL"`
ThumbPreviewURL string `orm:"thumb_preview_url" json:"thumbPreviewUrl" description:"缩略图URL"`
SourceType string `orm:"source_type" json:"sourceType" description:"来源类型"`
ImageUsage string `orm:"image_usage" json:"imageUsage" description:"图片用途"`
CreatedTime int64 `orm:"created_time" json:"createdTime" description:"创建时间戳"`
LastModifiedTime int64 `orm:"last_modified_time" json:"lastModifiedTime" description:"最后修改时间戳"`
ProductCatalogID int64 `orm:"product_catalog_id" json:"productCatalogId" description:"产品目录ID"`
ProductOuterID string `orm:"product_outer_id" json:"productOuterId" description:"产品外部ID"`
SourceReferenceID string `orm:"source_reference_id" json:"sourceReferenceId" description:"源引用ID"`
OwnerAccountID string `orm:"owner_account_id" json:"ownerAccountId" description:"所有者账户ID"`
VerifyStatus string `orm:"verify_status" json:"verifyStatus" description:"审核状态"`
SampleAspectRatio string `orm:"sample_aspect_ratio" json:"sampleAspectRatio" description:"示例宽高比"`
SourceMaterialID string `orm:"source_material_id" json:"sourceMaterialId" description:"源素材ID"`
NewSourceType string `orm:"new_source_type" json:"newSourceType" description:"新来源类型"`
FirstPublicationStatus string `orm:"first_publication_status" json:"firstPublicationStatus" description:"首次发布状态"`
QualityStatus string `orm:"quality_status" json:"qualityStatus" description:"质量状态"`
SimilarityStatus string `orm:"similarity_status" json:"similarityStatus" description:"相似度状态"`
UserAigcStatus string `orm:"user_aigc_status" json:"userAigcStatus" description:"用户AIGC状态"`
SystemAigcStatus string `orm:"system_aigc_status" json:"systemAigcStatus" description:"系统AIGC状态"`
AigcSource string `orm:"aigc_source" json:"aigcSource" description:"AIGC来源"`
AigcFlag string `orm:"aigc_flag" json:"aigcFlag" description:"AIGC标志"`
MuseAigcVersion int `orm:"muse_aigc_version" json:"museAigcVersion" description:"Muse AIGC版本"`
AigcType int `orm:"aigc_type" json:"aigcType" description:"AIGC类型"`
// 内容检测相关字段(扩展字段,用于存储检测结果)
// 注意:如果表中没有这些字段,需要通过 content_check_log 表来存储检测结果
}
// TencentImageCol 图片素材表字段定义
type TencentImageCol struct {
beans.SQLBaseCol
ImageID string
AccountID string
Width string
Height string
FileSize string
Type string
Signature string
Description string
SourceSignature string
PreviewURL string
ThumbPreviewURL string
SourceType string
ImageUsage string
CreatedTime string
LastModifiedTime string
ProductCatalogID string
ProductOuterID string
SourceReferenceID string
OwnerAccountID string
VerifyStatus string
SampleAspectRatio string
SourceMaterialID string
NewSourceType string
FirstPublicationStatus string
QualityStatus string
SimilarityStatus string
UserAigcStatus string
SystemAigcStatus string
AigcSource string
AigcFlag string
MuseAigcVersion string
AigcType string
}
// TencentImageCols 图片素材表字段常量
var TencentImageCols = TencentImageCol{
SQLBaseCol: beans.DefSQLBaseCol,
ImageID: "image_id",
AccountID: "account_id",
Width: "width",
Height: "height",
FileSize: "file_size",
Type: "type",
Signature: "signature",
Description: "description",
SourceSignature: "source_signature",
PreviewURL: "preview_url",
ThumbPreviewURL: "thumb_preview_url",
SourceType: "source_type",
ImageUsage: "image_usage",
CreatedTime: "created_time",
LastModifiedTime: "last_modified_time",
ProductCatalogID: "product_catalog_id",
ProductOuterID: "product_outer_id",
SourceReferenceID: "source_reference_id",
OwnerAccountID: "owner_account_id",
VerifyStatus: "verify_status",
SampleAspectRatio: "sample_aspect_ratio",
SourceMaterialID: "source_material_id",
NewSourceType: "new_source_type",
FirstPublicationStatus: "first_publication_status",
QualityStatus: "quality_status",
SimilarityStatus: "similarity_status",
UserAigcStatus: "user_aigc_status",
SystemAigcStatus: "system_aigc_status",
AigcSource: "aigc_source",
AigcFlag: "aigc_flag",
MuseAigcVersion: "muse_aigc_version",
AigcType: "aigc_type",
}

View File

@@ -0,0 +1,159 @@
package dataengine
import (
"gitea.com/red-future/common/beans"
)
// TencentVideo 视频素材实体来源data-engine.tencent_video
type TencentVideo struct {
beans.SQLBaseDO `orm:",inherit"`
// 业务字段 - 匹配现有表结构
VideoID string `orm:"video_id" json:"videoId" description:"视频ID"`
AccountID int64 `orm:"account_id" json:"accountId" description:"账户ID"`
Width int `orm:"width" json:"width" description:"宽度"`
Height int `orm:"height" json:"height" description:"高度"`
VideoFrames int `orm:"video_frames" json:"videoFrames" description:"视频帧数"`
VideoFps int `orm:"video_fps" json:"videoFps" description:"帧率"`
VideoCodec string `orm:"video_codec" json:"videoCodec" description:"视频编码"`
VideoBitRate int64 `orm:"video_bit_rate" json:"videoBitRate" description:"视频码率"`
AudioCodec string `orm:"audio_codec" json:"audioCodec" description:"音频编码"`
AudioBitRate int64 `orm:"audio_bit_rate" json:"audioBitRate" description:"音频码率"`
FileSize int64 `orm:"file_size" json:"fileSize" description:"文件大小"`
Type string `orm:"type" json:"type" description:"媒体类型"`
Signature string `orm:"signature" json:"signature" description:"签名"`
SystemStatus string `orm:"system_status" json:"systemStatus" description:"系统状态"`
Description string `orm:"description" json:"description" description:"描述"`
PreviewURL string `orm:"preview_url" json:"previewUrl" description:"预览URL"`
KeyFrameImageURL string `orm:"key_frame_image_url" json:"keyFrameImageUrl" description:"关键帧图片URL"`
CreatedTime int64 `orm:"created_time" json:"createdTime" description:"创建时间戳"`
LastModifiedTime int64 `orm:"last_modified_time" json:"lastModifiedTime" description:"最后修改时间戳"`
VideoProfileName string `orm:"video_profile_name" json:"videoProfileName" description:"视频配置名称"`
AudioSampleRate int `orm:"audio_sample_rate" json:"audioSampleRate" description:"音频采样率"`
MaxKeyframeInterval int `orm:"max_keyframe_interval" json:"maxKeyframeInterval" description:"最大关键帧间隔"`
MinKeyframeInterval int `orm:"min_keyframe_interval" json:"minKeyframeInterval" description:"最小关键帧间隔"`
SampleAspectRatio string `orm:"sample_aspect_ratio" json:"sampleAspectRatio" description:"示例宽高比"`
AudioProfileName string `orm:"audio_profile_name" json:"audioProfileName" description:"音频配置名称"`
ScanType string `orm:"scan_type" json:"scanType" description:"扫描类型"`
ImageDurationMs int64 `orm:"image_duration_millisecond" json:"imageDurationMs" description:"图片时长(毫秒)"`
AudioDurationMs int64 `orm:"audio_duration_millisecond" json:"audioDurationMs" description:"音频时长(毫秒)"`
SourceType string `orm:"source_type" json:"sourceType" description:"来源类型"`
ProductCatalogID string `orm:"product_catalog_id" json:"productCatalogId" description:"产品目录ID"`
ProductOuterID string `orm:"product_outer_id" json:"productOuterId" description:"产品外部ID"`
SourceReferenceID string `orm:"source_reference_id" json:"sourceReferenceId" description:"源引用ID"`
OwnerAccountID string `orm:"owner_account_id" json:"ownerAccountId" description:"所有者账户ID"`
VerifyStatus string `orm:"verify_status" json:"verifyStatus" description:"审核状态"`
SourceMaterialID string `orm:"source_material_id" json:"sourceMaterialId" description:"源素材ID"`
NewSourceType string `orm:"new_source_type" json:"newSourceType" description:"新来源类型"`
AigcType int `orm:"aigc_type" json:"aigcType" description:"AIGC类型"`
FirstPublicationStatus string `orm:"first_publication_status" json:"firstPublicationStatus" description:"首次发布状态"`
QualityStatus string `orm:"quality_status" json:"qualityStatus" description:"质量状态"`
CoverID string `orm:"cover_id" json:"coverId" description:"封面ID"`
SimilarityStatus string `orm:"similarity_status" json:"similarityStatus" description:"相似度状态"`
UserAigcStatus string `orm:"user_aigc_status" json:"userAigcStatus" description:"用户AIGC状态"`
SystemAigcStatus string `orm:"system_aigc_status" json:"systemAigcStatus" description:"系统AIGC状态"`
AigcSource string `orm:"aigc_source" json:"aigcSource" description:"AIGC来源"`
AigcFlag string `orm:"aigc_flag" json:"aigcFlag" description:"AIGC标志"`
MuseAigcVersion int `orm:"muse_aigc_version" json:"museAigcVersion" description:"Muse AIGC版本"`
}
// TencentVideoCol 视频素材表字段定义
type TencentVideoCol struct {
beans.SQLBaseCol
VideoID string
AccountID string
Width string
Height string
VideoFrames string
VideoFps string
VideoCodec string
VideoBitRate string
AudioCodec string
AudioBitRate string
FileSize string
Type string
Signature string
SystemStatus string
Description string
PreviewURL string
KeyFrameImageURL string
CreatedTime string
LastModifiedTime string
VideoProfileName string
AudioSampleRate string
MaxKeyframeInterval string
MinKeyframeInterval string
SampleAspectRatio string
AudioProfileName string
ScanType string
ImageDurationMs string
AudioDurationMs string
SourceType string
ProductCatalogID string
ProductOuterID string
SourceReferenceID string
OwnerAccountID string
VerifyStatus string
SourceMaterialID string
NewSourceType string
AigcType string
FirstPublicationStatus string
QualityStatus string
CoverID string
SimilarityStatus string
UserAigcStatus string
SystemAigcStatus string
AigcSource string
AigcFlag string
MuseAigcVersion string
}
// TencentVideoCols 视频素材表字段常量
var TencentVideoCols = TencentVideoCol{
SQLBaseCol: beans.DefSQLBaseCol,
VideoID: "video_id",
AccountID: "account_id",
Width: "width",
Height: "height",
VideoFrames: "video_frames",
VideoFps: "video_fps",
VideoCodec: "video_codec",
VideoBitRate: "video_bit_rate",
AudioCodec: "audio_codec",
AudioBitRate: "audio_bit_rate",
FileSize: "file_size",
Type: "type",
Signature: "signature",
SystemStatus: "system_status",
Description: "description",
PreviewURL: "preview_url",
KeyFrameImageURL: "key_frame_image_url",
CreatedTime: "created_time",
LastModifiedTime: "last_modified_time",
VideoProfileName: "video_profile_name",
AudioSampleRate: "audio_sample_rate",
MaxKeyframeInterval: "max_keyframe_interval",
MinKeyframeInterval: "min_keyframe_interval",
SampleAspectRatio: "sample_aspect_ratio",
AudioProfileName: "audio_profile_name",
ScanType: "scan_type",
ImageDurationMs: "image_duration_millisecond",
AudioDurationMs: "audio_duration_millisecond",
SourceType: "source_type",
ProductCatalogID: "product_catalog_id",
ProductOuterID: "product_outer_id",
SourceReferenceID: "source_reference_id",
OwnerAccountID: "owner_account_id",
VerifyStatus: "verify_status",
SourceMaterialID: "source_material_id",
NewSourceType: "new_source_type",
AigcType: "aigc_type",
FirstPublicationStatus: "first_publication_status",
QualityStatus: "quality_status",
CoverID: "cover_id",
SimilarityStatus: "similarity_status",
UserAigcStatus: "user_aigc_status",
SystemAigcStatus: "system_aigc_status",
AigcSource: "aigc_source",
AigcFlag: "aigc_flag",
MuseAigcVersion: "muse_aigc_version",
}

View File

@@ -0,0 +1,965 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>素材送检状态管理</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
min-height: 100vh;
}
.app-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.header h1 {
color: #409EFF;
font-size: 24px;
margin-bottom: 10px;
}
.header p {
color: #909399;
font-size: 14px;
}
.stats-card {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.stat-item {
background: #fff;
padding: 20px;
border-radius: 8px;
flex: 1;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.stat-item.pending { border-left: 4px solid #E6A23C; }
.stat-item.verified { border-left: 4px solid #67C23A; }
.stat-item.rejected { border-left: 4px solid #F56C6C; }
.stat-value {
font-size: 32px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.stat-breakdown {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #ebeef5;
}
.stat-breakdown-item {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
font-size: 13px;
line-height: 24px;
}
.stat-breakdown-item .type-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: normal;
min-width: 28px;
}
.stat-breakdown-item .type-tag.image {
background: #ecf5ff;
color: #409EFF;
}
.stat-breakdown-item .type-tag.video {
background: #fdf6ec;
color: #E6A23C;
}
.stat-breakdown-item .num {
font-weight: bold;
font-size: 14px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.filter-bar {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
color: #606266;
font-size: 14px;
}
.action-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.table-status {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
}
.status-pending { background: #FDF6EC; color: #E6A23C; }
.status-submitting { background: #E8F4FD; color: #409EFF; }
.status-verified { background: #F0F9EB; color: #67C23A; }
.status-rejected { background: #FEF0F0; color: #F56C6C; }
.pagination {
margin-top: 20px;
text-align: right;
}
.log-detail {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
margin: 10px 0;
}
.log-detail pre {
white-space: pre-wrap;
word-wrap: break-word;
font-family: Monaco, Consolas, monospace;
font-size: 12px;
}
.media-preview {
max-width: 200px;
max-height: 150px;
border-radius: 4px;
cursor: pointer;
}
.video-preview {
width: 200px;
height: 120px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.tab-container {
margin-top: 20px;
}
.description-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<div id="app">
<div class="app-container">
<!-- 头部 -->
<div class="header">
<h1>素材送检状态管理</h1>
<p>腾讯图片/视频素材自动校验系统 - 基于易盾内容安全检测</p>
</div>
<!-- 统计卡片 -->
<div class="stats-card">
<div class="stat-item pending">
<div class="stat-value">{{ imageStats.pending + videoStats.pending || 0 }}</div>
<div class="stat-label">待校验</div>
<div class="stat-breakdown">
<div class="stat-breakdown-item">
<span class="type-tag image"></span>
<span>图片: </span>
<span class="num">{{ imageStats.pending || 0 }}</span>
</div>
<div class="stat-breakdown-item">
<span class="type-tag video"></span>
<span>视频: </span>
<span class="num">{{ videoStats.pending || 0 }}</span>
</div>
</div>
</div>
<div class="stat-item verified">
<div class="stat-value">{{ imageStats.verified + videoStats.verified || 0 }}</div>
<div class="stat-label">校验通过</div>
<div class="stat-breakdown">
<div class="stat-breakdown-item">
<span class="type-tag image"></span>
<span>图片: </span>
<span class="num">{{ imageStats.verified || 0 }}</span>
</div>
<div class="stat-breakdown-item">
<span class="type-tag video"></span>
<span>视频: </span>
<span class="num">{{ videoStats.verified || 0 }}</span>
</div>
</div>
</div>
<div class="stat-item rejected">
<div class="stat-value">{{ imageStats.rejected + videoStats.rejected || 0 }}</div>
<div class="stat-label">校验不通过</div>
<div class="stat-breakdown">
<div class="stat-breakdown-item">
<span class="type-tag image"></span>
<span>图片: </span>
<span class="num">{{ imageStats.rejected || 0 }}</span>
</div>
<div class="stat-breakdown-item">
<span class="type-tag video"></span>
<span>视频: </span>
<span class="num">{{ videoStats.rejected || 0 }}</span>
</div>
</div>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="图片素材" name="image">
<div class="card">
<!-- 筛选 -->
<div class="filter-bar">
<div class="filter-item">
<label>状态:</label>
<el-select v-model="imageFilters.status" placeholder="全部" clearable style="width: 120px;">
<el-option label="全部" value=""></el-option>
<el-option label="待校验" value="PENDING"></el-option>
<el-option label="送检中" value="SUBMITTING"></el-option>
<el-option label="校验通过" value="VERIFIED"></el-option>
<el-option label="校验不通过" value="REJECTED"></el-option>
</el-select>
</div>
<div class="filter-item">
<label>账户ID:</label>
<el-input v-model="imageFilters.accountId" placeholder="账户ID" style="width: 120px;" clearable @keyup.enter.native="searchImage"></el-input>
</div>
<el-button type="primary" @click="searchImage">搜索</el-button>
<el-button @click="resetImageFilter">重置</el-button>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<el-button type="success" @click="batchVerifyImage" :loading="batchLoading">
批量校验图片
</el-button>
<el-button type="primary" plain @click="pollImageResults" :loading="pollLoading">
<i class="el-icon-refresh"></i> 刷新检测结果
</el-button>
<el-button type="warning" plain @click="exportImageUrls">
<i class="el-icon-download"></i> 导出
</el-button>
</div>
<!-- 表格 -->
<el-table :data="imageList" border style="width: 100%;" v-loading="imageLoading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="imageId" label="图片ID" width="180"></el-table-column>
<el-table-column prop="accountId" label="账户ID" width="100"></el-table-column>
<el-table-column prop="imageUsage" label="用途" width="100"></el-table-column>
<el-table-column label="预览" width="120">
<template slot-scope="scope">
<img v-if="scope.row.previewUrl" :src="scope.row.previewUrl" class="media-preview" @click="previewMedia(scope.row.previewUrl, 'image')">
</template>
</el-table-column>
<el-table-column prop="verifyStatus" label="校验状态" width="110">
<template slot-scope="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="150">
<template slot-scope="scope">
<span class="description-text" :title="scope.row.description">{{ scope.row.description || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="showLogs('IMAGE', scope.row.imageId)">查看日志</el-button>
<el-button size="mini" type="text" @click="verifyImage(scope.row.imageId)">送检</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
@current-change="handleImagePageChange"
:current-page="imagePage"
:page-size="imagePageSize"
layout="total, prev, pager, next"
:total="imageTotal">
</el-pagination>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="视频素材" name="video">
<div class="card">
<!-- 筛选 -->
<div class="filter-bar">
<div class="filter-item">
<label>状态:</label>
<el-select v-model="videoFilters.status" placeholder="全部" clearable style="width: 120px;">
<el-option label="全部" value=""></el-option>
<el-option label="待校验" value="PENDING"></el-option>
<el-option label="送检中" value="SUBMITTING"></el-option>
<el-option label="校验通过" value="VERIFIED"></el-option>
<el-option label="校验不通过" value="REJECTED"></el-option>
</el-select>
</div>
<div class="filter-item">
<label>账户ID:</label>
<el-input v-model="videoFilters.accountId" placeholder="账户ID" style="width: 120px;" clearable @keyup.enter.native="searchVideo"></el-input>
</div>
<el-button type="primary" @click="searchVideo">搜索</el-button>
<el-button @click="resetVideoFilter">重置</el-button>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<el-button type="success" @click="batchVerifyVideo" :loading="batchLoading">
批量校验视频
</el-button>
<el-button type="primary" plain @click="pollVideoResults" :loading="pollLoading">
<i class="el-icon-refresh"></i> 刷新检测结果
</el-button>
<el-button type="warning" plain @click="exportVideoUrls">
<i class="el-icon-download"></i> 导出
</el-button>
</div>
<!-- 表格 -->
<el-table :data="videoList" border style="width: 100%;" v-loading="videoLoading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="videoId" label="视频ID" width="180"></el-table-column>
<el-table-column prop="accountId" label="账户ID" width="100"></el-table-column>
<el-table-column label="预览" width="120">
<template slot-scope="scope">
<div class="video-preview" @click="previewMedia(scope.row.previewUrl, 'video')">
<i class="el-icon-video-play" style="font-size: 32px;"></i>
</div>
</template>
</el-table-column>
<el-table-column prop="verifyStatus" label="校验状态" width="110">
<template slot-scope="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="150">
<template slot-scope="scope">
<span class="description-text" :title="scope.row.description">{{ scope.row.description || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="showLogs('VIDEO', scope.row.videoId)">查看日志</el-button>
<el-button size="mini" type="text" @click="verifyVideo(scope.row.videoId)">送检</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
@current-change="handleVideoPageChange"
:current-page="videoPage"
:page-size="videoPageSize"
layout="total, prev, pager, next"
:total="videoTotal">
</el-pagination>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="校验日志" name="log">
<div class="card">
<!-- 筛选 -->
<div class="filter-bar">
<div class="filter-item">
<label>素材类型:</label>
<el-select v-model="logFilters.materialType" placeholder="全部" clearable style="width: 120px;">
<el-option label="全部" value=""></el-option>
<el-option label="图片" value="IMAGE"></el-option>
<el-option label="视频" value="VIDEO"></el-option>
</el-select>
</div>
<div class="filter-item">
<label>校验状态:</label>
<el-select v-model="logFilters.verifyStatus" placeholder="全部" clearable style="width: 120px;">
<el-option label="全部" value=""></el-option>
<el-option label="待校验" value="PENDING"></el-option>
<el-option label="校验通过" value="VERIFIED"></el-option>
<el-option label="校验不通过" value="REJECTED"></el-option>
</el-select>
</div>
<div class="filter-item">
<label>素材ID:</label>
<el-input v-model="logFilters.materialId" placeholder="素材ID" style="width: 150px;" clearable></el-input>
</div>
<el-button type="primary" @click="searchLog">搜索</el-button>
<el-button @click="resetLogFilter">重置</el-button>
</div>
<!-- 表格 -->
<el-table :data="logList" border style="width: 100%;" v-loading="logLoading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="materialType" label="类型" width="80">
<template slot-scope="scope">
{{ scope.row.materialType === 'IMAGE' ? '图片' : '视频' }}
</template>
</el-table-column>
<el-table-column prop="materialId" label="素材ID" width="180"></el-table-column>
<el-table-column prop="accountId" label="账户ID" width="100"></el-table-column>
<el-table-column prop="taskId" label="任务ID" width="180"></el-table-column>
<el-table-column prop="verifyStatus" label="校验状态" width="110">
<template slot-scope="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" width="80">
<template slot-scope="scope">
{{ getSuggestionText(scope.row.suggestion) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160">
<template slot-scope="scope">
{{ formatTime(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="showLogDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
@current-change="handleLogPageChange"
:current-page="logPage"
:page-size="logPageSize"
layout="total, prev, pager, next"
:total="logTotal">
</el-pagination>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 日志详情对话框 -->
<el-dialog title="校验日志详情" :visible.sync="logDialogVisible" width="800px">
<div v-if="currentLog">
<el-descriptions :column="2" border>
<el-descriptions-item label="日志ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="素材类型">{{ currentLog.materialType === 'IMAGE' ? '图片' : '视频' }}</el-descriptions-item>
<el-descriptions-item label="素材ID">{{ currentLog.materialId }}</el-descriptions-item>
<el-descriptions-item label="账户ID">{{ currentLog.accountId }}</el-descriptions-item>
<el-descriptions-item label="任务ID">{{ currentLog.taskId || '-' }}</el-descriptions-item>
<el-descriptions-item label="校验状态">
<span :class="'table-status status-' + (currentLog.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(currentLog.verifyStatus) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="处置建议">{{ getSuggestionText(currentLog.suggestion) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(currentLog.createdAt) }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top: 20px;">请求参数:</h4>
<div class="log-detail">
<pre>{{ currentLog.requestParams || '无' }}</pre>
</div>
<h4>响应结果:</h4>
<div class="log-detail">
<pre>{{ currentLog.responseResult || '无' }}</pre>
</div>
<h4>错误信息:</h4>
<div class="log-detail" v-if="currentLog.errorMsg">
<pre style="color: #F56C6C;">{{ currentLog.errorMsg }}</pre>
</div>
<div class="log-detail" v-else>
<pre></pre>
</div>
</div>
</el-dialog>
<!-- 预览对话框 -->
<el-dialog title="媒体预览" :visible.sync="previewVisible" width="60%">
<div style="text-align: center;">
<img v-if="previewType === 'image'" :src="previewUrl" style="max-width: 100%;">
<video v-if="previewType === 'video'" :src="previewUrl" controls style="max-width: 100%;"></video>
</div>
</el-dialog>
<!-- 日志列表对话框 -->
<el-dialog title="校验日志" :visible.sync="logsDialogVisible" width="900px">
<el-table :data="materialLogs" border style="width: 100%;" size="small">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="taskId" label="任务ID" width="180"></el-table-column>
<el-table-column prop="verifyStatus" label="状态" width="100">
<template slot-scope="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" width="80">
<template slot-scope="scope">
{{ getSuggestionText(scope.row.suggestion) }}
</template>
</el-table-column>
<el-table-column prop="errorMsg" label="错误信息" show-overflow-tooltip></el-table-column>
<el-table-column prop="createdAt" label="时间" width="160">
<template slot-scope="scope">
{{ formatTime(scope.row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js"></script>
<script>
// API 基础地址
const API_BASE = 'http://localhost:3001';
new Vue({
el: '#app',
data: {
activeTab: 'image',
// 轮询加载
pollLoading: false,
// 图片统计
imageStats: { pending: 0, verified: 0, rejected: 0 },
// 视频统计
videoStats: { pending: 0, verified: 0, rejected: 0 },
// 图片
imageList: [],
imageLoading: false,
imagePage: 1,
imagePageSize: 20,
imageTotal: 0,
imageFilters: { status: '', accountId: '' },
// 视频
videoList: [],
videoLoading: false,
videoPage: 1,
videoPageSize: 20,
videoTotal: 0,
videoFilters: { status: '', accountId: '' },
// 日志
logList: [],
logLoading: false,
logPage: 1,
logPageSize: 20,
logTotal: 0,
logFilters: { materialType: '', verifyStatus: '', materialId: '' },
// 对话框
logDialogVisible: false,
previewVisible: false,
logsDialogVisible: false,
currentLog: null,
previewUrl: '',
previewType: 'image',
materialLogs: [],
batchLoading: false
},
mounted() {
this.loadStats();
this.loadImageList();
},
methods: {
// API 请求
apiGet(url, params = {}) {
return axios.get(API_BASE + url, { params });
},
apiPost(url, data = {}) {
return axios.post(API_BASE + url, data);
},
// 加载统计(图片和视频分开存储)
loadStats() {
Promise.all([
this.apiGet('/material/verify/controller/stats-image'),
this.apiGet('/material/verify/controller/stats-video')
]).then(results => {
const imgStats = results[0].data.data || {};
const vidStats = results[1].data.data || {};
this.imageStats = {
pending: imgStats.pending || 0,
verified: imgStats.verified || 0,
rejected: imgStats.rejected || 0
};
this.videoStats = {
pending: vidStats.pending || 0,
verified: vidStats.verified || 0,
rejected: vidStats.rejected || 0
};
}).catch(err => {
console.error('加载统计失败', err);
});
},
// 图片列表
loadImageList() {
this.imageLoading = true;
const params = {
page: this.imagePage,
pageSize: this.imagePageSize,
status: this.imageFilters.status,
accountId: this.imageFilters.accountId
};
this.apiGet('/material/verify/controller/list-image', params).then(res => {
this.imageList = res.data.data?.list || [];
this.imageTotal = res.data.data?.total || 0;
}).catch(err => {
this.$message.error('加载图片列表失败');
console.error(err);
}).finally(() => {
this.imageLoading = false;
});
},
// 视频列表
loadVideoList() {
this.videoLoading = true;
const params = {
page: this.videoPage,
pageSize: this.videoPageSize,
status: this.videoFilters.status,
accountId: this.videoFilters.accountId
};
this.apiGet('/material/verify/controller/list-video', params).then(res => {
this.videoList = res.data.data?.list || [];
this.videoTotal = res.data.data?.total || 0;
}).catch(err => {
this.$message.error('加载视频列表失败');
console.error(err);
}).finally(() => {
this.videoLoading = false;
});
},
// 日志列表
loadLogList() {
this.logLoading = true;
const params = {
page: this.logPage,
pageSize: this.logPageSize,
materialType: this.logFilters.materialType,
verifyStatus: this.logFilters.verifyStatus,
materialId: this.logFilters.materialId
};
this.apiGet('/material/verify/controller/list-log', params).then(res => {
this.logList = res.data.data?.list || [];
this.logTotal = res.data.data?.total || 0;
}).catch(err => {
this.$message.error('加载日志列表失败');
console.error(err);
}).finally(() => {
this.logLoading = false;
});
},
// Tab 切换
handleTabClick(tab) {
if (tab.name === 'image') {
this.loadImageList();
} else if (tab.name === 'video') {
this.loadVideoList();
} else if (tab.name === 'log') {
this.loadLogList();
}
},
// 搜索
searchImage() {
this.imagePage = 1;
this.loadImageList();
},
searchVideo() {
this.videoPage = 1;
this.loadVideoList();
},
searchLog() {
this.logPage = 1;
this.loadLogList();
},
// 重置筛选
resetImageFilter() {
this.imageFilters = { status: '', accountId: '' };
this.searchImage();
},
resetVideoFilter() {
this.videoFilters = { status: '', accountId: '' };
this.searchVideo();
},
resetLogFilter() {
this.logFilters = { materialType: '', verifyStatus: '', materialId: '' };
this.searchLog();
},
// 分页
handleImagePageChange(page) {
this.imagePage = page;
this.loadImageList();
},
handleVideoPageChange(page) {
this.videoPage = page;
this.loadVideoList();
},
handleLogPageChange(page) {
this.logPage = page;
this.loadLogList();
},
// 手动送检
verifyImage(imageId) {
this.$confirm('确认提交图片 ' + imageId + ' 进行校验?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.apiPost('/material/verify/controller/manual-verify-image', { materialId: imageId }).then(res => {
this.$message.success('提交成功');
this.loadImageList();
this.loadStats();
}).catch(err => {
this.$message.error('提交失败: ' + (err.response?.data?.msg || err.message));
});
}).catch(() => {});
},
verifyVideo(videoId) {
this.$confirm('确认提交视频 ' + videoId + ' 进行校验?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.apiPost('/material/verify/controller/manual-verify-video', { materialId: videoId }).then(res => {
this.$message.success('提交成功');
this.loadVideoList();
this.loadStats();
}).catch(err => {
this.$message.error('提交失败: ' + (err.response?.data?.msg || err.message));
});
}).catch(() => {});
},
// 刷新检测结果(轮询)
pollImageResults() {
this.pollLoading = true;
this.apiPost('/yidun/callback/controller/poll-image-results').then(res => {
const d = res.data;
this.$message.success(d.msg || '刷新完成');
this.loadImageList();
this.loadStats();
}).catch(err => {
this.$message.error('刷新失败: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.pollLoading = false;
});
},
pollVideoResults() {
this.pollLoading = true;
this.apiPost('/yidun/callback/controller/poll-video-results').then(res => {
const d = res.data;
this.$message.success(d.msg || '刷新完成');
this.loadVideoList();
this.loadStats();
}).catch(err => {
this.$message.error('刷新失败: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.pollLoading = false;
});
},
// 导出
exportImageUrls() {
if (!this.imageList.length) {
this.$message.warning('没有可导出的图片');
return;
}
const rows = [['ID', '图片ID', '账户ID', '用途', '预览URL', '状态', '描述']];
this.imageList.forEach(item => {
rows.push([
item.id, item.imageId, item.accountId, item.imageUsage,
item.previewUrl || '-',
this.getStatusText(item.verifyStatus), item.description || '-'
]);
});
this.downloadCsv('图片列表.csv', rows);
},
exportVideoUrls() {
if (!this.videoList.length) {
this.$message.warning('没有可导出的视频');
return;
}
const rows = [['ID', '视频ID', '账户ID', '预览URL', '状态', '描述']];
this.videoList.forEach(item => {
rows.push([
item.id, item.videoId, item.accountId,
item.previewUrl || '-',
this.getStatusText(item.verifyStatus), item.description || '-'
]);
});
this.downloadCsv('视频列表.csv', rows);
},
downloadCsv(filename, rows) {
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
},
// 批量送检
batchVerifyImage() {
this.$confirm('确认批量校验待处理的图片?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.batchLoading = true;
this.apiPost('/material/verify/controller/batch-verify-image', {}).then(res => {
this.$message.success(res.data.msg || '批量校验完成');
this.loadImageList();
this.loadStats();
}).catch(err => {
this.$message.error('批量校验失败: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.batchLoading = false;
});
}).catch(() => {});
},
batchVerifyVideo() {
this.$confirm('确认批量校验待处理的视频?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.batchLoading = true;
this.apiPost('/material/verify/controller/batch-verify-video', {}).then(res => {
this.$message.success(res.data.msg || '批量校验完成');
this.loadVideoList();
this.loadStats();
}).catch(err => {
this.$message.error('批量校验失败: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.batchLoading = false;
});
}).catch(() => {});
},
// 查看日志
showLogs(materialType, materialId) {
this.apiGet('/material/verify/controller/list-log', {
materialType: materialType,
materialId: materialId,
pageSize: 100
}).then(res => {
// 后端返回格式为 { list: [...], total: xxx }
this.materialLogs = (res.data.data && res.data.data.list) || [];
this.logsDialogVisible = true;
}).catch(err => {
this.$message.error('加载日志失败');
});
},
// 日志详情
showLogDetail(log) {
this.currentLog = log;
this.logDialogVisible = true;
},
// 预览
previewMedia(url, type) {
if (!url) {
this.$message.warning('无预览地址');
return;
}
this.previewUrl = url;
this.previewType = type;
this.previewVisible = true;
},
// 工具方法
getStatusText(status) {
const map = {
'PENDING': '待校验',
'SUBMITTING': '送检中',
'VERIFIED': '校验通过',
'REJECTED': '校验不通过'
};
return map[status] || status || '待校验';
},
getSuggestionText(suggestion) {
const map = {
0: '通过',
1: '嫌疑',
2: '不通过'
};
return map[suggestion] || '-';
},
formatTime(timeStr) {
if (!timeStr) return '-';
// 后端 gf gtime.Time 输出北京时间但不含时区(如 "2026-05-15 09:35:07"
// 手动补上 +08:00 时区new Date() 才能正确解析
if (typeof timeStr === 'string' && !/Z|[+-]\d{2}:\d{2}$/i.test(timeStr)) {
timeStr = timeStr.replace(' ', 'T') + '+08:00';
}
const date = new Date(timeStr);
if (isNaN(date.getTime())) return timeStr;
return date.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,662 @@
package dataengine
import (
consts "cid/consts/dataengine"
dao "cid/dao/dataengine"
entity "cid/model/entity/dataengine"
yidunService "cid/service/yidun"
"context"
"encoding/json"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 轮询配置常量
const (
// PollBatchSize 每次轮询处理数量
PollBatchSize = 20
)
// 状态常量
const (
// 原表状态 - 与 tencent_image/tencent_video 表的 status 字段对应
StatusSubmitting = consts.CheckStatusSubmitting // 送检中
)
// MaterialVerifyService 素材校验服务
type MaterialVerifyService struct{}
// MaterialVerify 校验服务单例
var MaterialVerify = new(MaterialVerifyService)
// =============================================================================
// 校验状态转换
// =============================================================================
// SuggestionToVerifyStatus 根据易盾处置建议转换为校验状态
func SuggestionToVerifyStatus(suggestion int) string {
switch suggestion {
case consts.SuggestionPass:
return entity.VerifyStatusVerified // 通过
case consts.SuggestionReview:
return entity.VerifyStatusPending // 嫌疑,需要人工审核,暂不更新状态
case consts.SuggestionBlock:
return entity.VerifyStatusRejected // 不通过
default:
return entity.VerifyStatusPending
}
}
// =============================================================================
// 图片校验
// =============================================================================
// VerifyImageByID 根据图片ID执行校验
func (s *MaterialVerifyService) VerifyImageByID(ctx context.Context, imageID string) (*entity.MaterialVerifyLog, error) {
// 1. 获取图片数据
image, err := dao.TencentImage.GetByImageID(ctx, imageID)
if err != nil {
return nil, fmt.Errorf("查询图片数据失败: %w", err)
}
if image == nil {
return nil, fmt.Errorf("未找到图片数据, imageID=%s", imageID)
}
// 2. 创建校验日志
log := s.createVerifyLog(ctx, entity.MaterialTypeImage, imageID, consts.SourceTableTencentImage, image.Id, image.AccountID)
if log == nil {
return nil, fmt.Errorf("创建校验日志失败")
}
// 3. 执行校验
err = s.submitImageCheck(ctx, image, log)
if err != nil {
return nil, err
}
return log, nil
}
// submitImageCheck 提交图片校验
func (s *MaterialVerifyService) submitImageCheck(ctx context.Context, image *entity.TencentImage, log *entity.MaterialVerifyLog) error {
startTime := time.Now()
// 获取回调模式开关
callbackMode := g.Cfg().MustGet(ctx, "yidun.callback_mode").Bool()
// 构建请求参数
requestParams := map[string]interface{}{
"imageURL": image.PreviewURL,
"dataID": image.ImageID,
}
requestParamsJSON, _ := json.Marshal(requestParams)
var (
taskID string
duration int64
)
if callbackMode {
// 回调模式:使用异步检测,易盾处理完成后会回调
callbackURL := g.Cfg().MustGet(ctx, "yidun.image.callback_url").String()
requestParams["callbackURL"] = callbackURL
result, err := yidunService.ImageDetection.DetectImage(ctx, image.PreviewURL, image.ImageID, callbackURL)
duration = time.Since(startTime).Milliseconds()
if err != nil {
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, err.Error())
dao.MaterialVerifyLog.UpdateDuration(ctx, log.Id, duration)
g.Log().Warningf(ctx, "图片异步检测失败(保持待检验), id=%d, imageId=%s, error=%v", image.Id, image.ImageID, err)
return fmt.Errorf("图片异步检测失败: %w", err)
}
taskID = result.TaskID
// 保存任务ID和请求参数
dao.MaterialVerifyLog.UpdateTaskID(ctx, log.Id, taskID)
dao.MaterialVerifyLog.UpdateRequestParams(ctx, log.Id, string(requestParamsJSON))
// 更新原表状态为 submitting等待回调
s.updateImageStatus(ctx, image.Id, StatusSubmitting)
g.Log().Infof(ctx, "图片异步检测已提交, id=%d, imageId=%s, taskId=%s, duration=%dms",
image.Id, image.ImageID, taskID, duration)
} else {
// 轮询模式:使用同步检测,直接返回结果
syncResult, err := yidunService.ImageDetection.DetectImageSync(ctx, image.PreviewURL, image.ImageID)
duration = time.Since(startTime).Milliseconds()
if err != nil {
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, err.Error())
dao.MaterialVerifyLog.UpdateDuration(ctx, log.Id, duration)
g.Log().Warningf(ctx, "图片同步检测失败(保持待检验), id=%d, imageId=%s, error=%v", image.Id, image.ImageID, err)
return fmt.Errorf("图片同步检测失败: %w", err)
}
taskID = syncResult.TaskID
// 保存任务ID和请求参数
dao.MaterialVerifyLog.UpdateTaskID(ctx, log.Id, taskID)
dao.MaterialVerifyLog.UpdateRequestParams(ctx, log.Id, string(requestParamsJSON))
// 根据同步结果更新状态
verifyStatus := SuggestionToVerifyStatus(syncResult.Suggestion)
responseJSON, _ := json.Marshal(syncResult)
dao.MaterialVerifyLog.UpdateVerifyResult(ctx, log.Id, verifyStatus,
syncResult.Suggestion, syncResult.Label, syncResult.ResultType, string(responseJSON), syncResult.CensorTime)
s.updateImageStatus(ctx, image.Id, verifyStatus)
g.Log().Infof(ctx, "图片同步检测完成, id=%d, imageId=%s, taskId=%s, suggestion=%d, verifyStatus=%s, duration=%dms",
image.Id, image.ImageID, taskID, syncResult.Suggestion, verifyStatus, duration)
}
return nil
}
// =============================================================================
// 视频校验
// =============================================================================
// VerifyVideoByID 根据视频ID执行校验
func (s *MaterialVerifyService) VerifyVideoByID(ctx context.Context, videoID string) (*entity.MaterialVerifyLog, error) {
// 1. 获取视频数据
video, err := dao.TencentVideo.GetByVideoID(ctx, videoID)
if err != nil {
return nil, fmt.Errorf("查询视频数据失败: %w", err)
}
if video == nil {
return nil, fmt.Errorf("未找到视频数据, videoID=%s", videoID)
}
// 2. 创建校验日志
log := s.createVerifyLog(ctx, entity.MaterialTypeVideo, videoID, consts.SourceTableTencentVideo, video.Id, video.AccountID)
if log == nil {
return nil, fmt.Errorf("创建校验日志失败")
}
// 3. 执行校验
err = s.submitVideoCheck(ctx, video, log)
if err != nil {
return nil, err
}
return log, nil
}
// submitVideoCheck 提交视频校验
func (s *MaterialVerifyService) submitVideoCheck(ctx context.Context, video *entity.TencentVideo, log *entity.MaterialVerifyLog) error {
startTime := time.Now()
// 获取回调模式开关
callbackMode := g.Cfg().MustGet(ctx, "yidun.callback_mode").Bool()
// 根据开关决定回调地址
var callbackURL string
if callbackMode {
callbackURL = g.Cfg().MustGet(ctx, "yidun.video.callback_url").String()
}
// 构建请求参数
requestParams := map[string]interface{}{
"videoURL": video.PreviewURL,
"dataID": video.VideoID,
"callbackURL": callbackURL,
}
requestParamsJSON, _ := json.Marshal(requestParams)
// 调用易盾视频检测
result, err := yidunService.VideoDetection.DetectVideo(ctx, video.PreviewURL, video.VideoID, callbackURL)
duration := time.Since(startTime).Milliseconds()
if err != nil {
// 调用易盾接口失败(如额度用光、网络错误、超时等),不更新状态,保持待检验
// 只有易盾明确返回检测结果且suggestion=BLOCK时才标记为失败
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, err.Error())
dao.MaterialVerifyLog.UpdateDuration(ctx, log.Id, duration)
g.Log().Warningf(ctx, "视频校验接口调用失败(保持待检验), id=%d, videoId=%s, error=%v", video.Id, video.VideoID, err)
return fmt.Errorf("视频校验调用失败: %w", err)
}
// 保存任务ID和请求参数
dao.MaterialVerifyLog.UpdateTaskID(ctx, log.Id, result.TaskID)
dao.MaterialVerifyLog.UpdateRequestParams(ctx, log.Id, string(requestParamsJSON))
// 更新原表状态为 submitting
s.updateVideoStatus(ctx, video.Id, StatusSubmitting)
// 轮询模式(无回调):提交后立即尝试查询检测结果
if !callbackMode {
g.Log().Infof(ctx, "轮询模式:提交后立即查询结果, taskId=%s", result.TaskID)
// 等待500ms让易盾有时间处理
time.Sleep(500 * time.Millisecond)
if err := s.ProcessVideoResultByTask(ctx, result.TaskID); err != nil {
g.Log().Warningf(ctx, "提交后立即查询结果失败(不影响状态,后续轮询继续), taskId=%s, error=%v", result.TaskID, err)
}
}
g.Log().Infof(ctx, "视频校验已提交, id=%d, videoId=%s, taskId=%s, duration=%dms",
video.Id, video.VideoID, result.TaskID, duration)
return nil
}
// =============================================================================
// 回调处理
// =============================================================================
// ProcessImageCallback 处理图片校验回调
func (s *MaterialVerifyService) ProcessImageCallback(ctx context.Context, callbackData string) error {
g.Log().Infof(ctx, "处理图片校验回调, data: %s", callbackData)
var callback yidunService.ImageCallbackData
if err := json.Unmarshal([]byte(callbackData), &callback); err != nil {
g.Log().Errorf(ctx, "解析图片回调数据失败: %v", err)
return fmt.Errorf("解析回调数据失败: %w", err)
}
if callback.Antispam == nil {
return fmt.Errorf("回调数据格式错误缺少antispam字段")
}
antispam := callback.Antispam
g.Log().Infof(ctx, "处理图片校验结果 - taskId: %s, suggestion: %d, resultType: %d",
antispam.TaskId, antispam.Suggestion, antispam.ResultType)
// 根据 taskId 查找校验日志
log, err := dao.MaterialVerifyLog.GetByTaskID(ctx, antispam.TaskId)
if err != nil {
return fmt.Errorf("查找校验日志失败: %w", err)
}
if log == nil {
g.Log().Warningf(ctx, "未找到校验日志, taskId=%s", antispam.TaskId)
return nil
}
// 构建响应结果
responseResult := callbackData
// 根据 suggestion 确定校验状态
verifyStatus := SuggestionToVerifyStatus(antispam.Suggestion)
// 更新日志
err = dao.MaterialVerifyLog.UpdateVerifyResult(ctx, log.Id, verifyStatus,
antispam.Suggestion, antispam.Label, antispam.ResultType, responseResult, antispam.CensorTime)
if err != nil {
return fmt.Errorf("更新校验日志失败: %w", err)
}
// 更新原表状态(图片回调只处理图片来源)
if log.SourceTable == consts.SourceTableTencentImage {
s.updateImageStatus(ctx, log.SourceID, verifyStatus)
}
g.Log().Infof(ctx, "图片校验回调处理完成, taskId=%s, verifyStatus=%s, suggestion=%d",
antispam.TaskId, verifyStatus, antispam.Suggestion)
return nil
}
// ProcessVideoCallback 处理视频校验回调
func (s *MaterialVerifyService) ProcessVideoCallback(ctx context.Context, callbackData string) error {
g.Log().Infof(ctx, "处理视频校验回调, data: %s", callbackData)
var callback yidunService.VideoCallbackData
if err := json.Unmarshal([]byte(callbackData), &callback); err != nil {
g.Log().Errorf(ctx, "解析视频回调数据失败: %v", err)
return fmt.Errorf("解析回调数据失败: %w", err)
}
if callback.Antispam == nil {
return fmt.Errorf("视频回调数据格式错误缺少antispam字段")
}
antispam := callback.Antispam
g.Log().Infof(ctx, "处理视频校验结果 - taskId: %s, suggestion: %d, resultType: %d",
antispam.TaskID, antispam.Suggestion, antispam.ResultType)
// 根据 taskId 查找校验日志
log, err := dao.MaterialVerifyLog.GetByTaskID(ctx, antispam.TaskID)
if err != nil {
return fmt.Errorf("查找校验日志失败: %w", err)
}
if log == nil {
g.Log().Warningf(ctx, "未找到校验日志, taskId=%s", antispam.TaskID)
return nil
}
// 构建响应结果
responseResult := callbackData
// 根据 suggestion 确定校验状态
verifyStatus := SuggestionToVerifyStatus(antispam.Suggestion)
// 审核时间
checkTime := antispam.CensorTime
if checkTime == 0 {
checkTime = antispam.CheckTime
}
// 更新日志
err = dao.MaterialVerifyLog.UpdateVerifyResult(ctx, log.Id, verifyStatus,
antispam.Suggestion, antispam.Label, antispam.ResultType, responseResult, checkTime)
if err != nil {
return fmt.Errorf("更新校验日志失败: %w", err)
}
// 更新原表状态(视频回调只处理视频来源)
if log.SourceTable == consts.SourceTableTencentVideo {
s.updateVideoStatus(ctx, log.SourceID, verifyStatus)
}
g.Log().Infof(ctx, "视频校验回调处理完成, taskId=%s, verifyStatus=%s, suggestion=%d",
antispam.TaskID, verifyStatus, antispam.Suggestion)
return nil
}
// =============================================================================
// 轮询模式处理
// =============================================================================
// 易盾检测状态常量
const (
YidunStatusNotStart = 0 // 未开始
YidunStatusProcessing = 1 // 检测中
YidunStatusSuccess = 2 // 检测成功
YidunStatusFailed = 3 // 检测失败
)
// ProcessImageResultByTask 根据任务ID处理图片结果轮询模式
func (s *MaterialVerifyService) ProcessImageResultByTask(ctx context.Context, taskID string) error {
log, err := dao.MaterialVerifyLog.GetByTaskID(ctx, taskID)
if err != nil || log == nil {
return fmt.Errorf("未找到校验日志, taskId=%s", taskID)
}
result, err := yidunService.ImageDetection.GetImageResult(ctx, taskID)
if err != nil {
// 判断是否是未找到结果或仍在检测中的错误
if err == yidunService.ErrImageResultNotFound || err == yidunService.ErrImageStillProcessing {
// 未获取到结果(任务不存在或仍在处理),不更新状态,保持等待下次轮询
g.Log().Infof(ctx, "图片检测结果未就绪, taskId=%s, 保持pending状态, err=%v", taskID, err)
return nil
}
// 其他错误如额度用光、网络错误、API错误等不更新状态保持待检验
// 只有易盾明确返回suggestion=BLOCK时才标记为失败
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, err.Error())
g.Log().Warningf(ctx, "图片检测查询失败(保持待检验), taskId=%s, error=%v", taskID, err)
return nil // 返回nil避免日志被反复处理但保持pending状态
}
// 判断检测状态
if result.Status == YidunStatusProcessing || result.Status == YidunStatusNotStart {
// 检测仍在进行中保持pending状态
g.Log().Infof(ctx, "图片检测仍在进行中, taskId=%s, status=%d, 保持pending状态", taskID, result.Status)
return nil
}
if result.Status == YidunStatusFailed {
// 易盾检测失败(如额度用光、服务端错误等),不更新状态,保持待检验
// 只有易盾明确返回suggestion=BLOCK时才标记为失败
errMsg := fmt.Sprintf("易盾检测失败, status=%d", result.Status)
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, errMsg)
g.Log().Warningf(ctx, "图片检测失败(保持待检验), taskId=%s, status=%d", taskID, result.Status)
return nil
}
// status == YidunStatusSuccess检测成功根据suggestion更新状态
verifyStatus := SuggestionToVerifyStatus(result.Suggestion)
responseJSON, _ := json.Marshal(result)
dao.MaterialVerifyLog.UpdateVerifyResult(ctx, log.Id, verifyStatus,
result.Suggestion, result.Label, result.ResultType, string(responseJSON), result.CensorTime)
if log.SourceTable == consts.SourceTableTencentImage {
s.updateImageStatus(ctx, log.SourceID, verifyStatus)
}
g.Log().Infof(ctx, "图片检测结果更新成功, taskId=%s, status=%d, suggestion=%d, verifyStatus=%s",
taskID, result.Status, result.Suggestion, verifyStatus)
return nil
}
// ProcessVideoResultByTask 根据任务ID处理视频结果轮询模式
func (s *MaterialVerifyService) ProcessVideoResultByTask(ctx context.Context, taskID string) error {
log, err := dao.MaterialVerifyLog.GetByTaskID(ctx, taskID)
if err != nil || log == nil {
return fmt.Errorf("未找到校验日志, taskId=%s", taskID)
}
result, err := yidunService.VideoDetection.GetVideoResult(ctx, taskID)
if err != nil {
// 判断是否是未找到结果或仍在检测中的错误
if err == yidunService.ErrVideoResultNotFound || err == yidunService.ErrVideoStillProcessing {
// 未获取到结果(任务不存在或仍在处理),不更新状态,保持等待下次轮询
g.Log().Infof(ctx, "视频检测结果未就绪, taskId=%s, 保持pending状态, err=%v", taskID, err)
return nil
}
// 其他错误如额度用光、网络错误、API错误等不更新状态保持待检验
// 只有易盾明确返回suggestion=BLOCK时才标记为失败
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, err.Error())
g.Log().Warningf(ctx, "视频检测查询失败(保持待检验), taskId=%s, error=%v", taskID, err)
return nil // 返回nil避免日志被反复处理但保持pending状态
}
// 判断检测状态
if result.Status == YidunStatusProcessing || result.Status == YidunStatusNotStart {
// 检测仍在进行中保持pending状态
g.Log().Infof(ctx, "视频检测仍在进行中, taskId=%s, status=%d, 保持pending状态", taskID, result.Status)
return nil
}
if result.Status == YidunStatusFailed {
// 易盾检测失败(如额度用光、服务端错误等),不更新状态,保持待检验
// 只有易盾明确返回suggestion=BLOCK时才标记为失败
errMsg := fmt.Sprintf("易盾检测失败, status=%d", result.Status)
dao.MaterialVerifyLog.UpdateError(ctx, log.Id, entity.VerifyStatusPending, errMsg)
g.Log().Warningf(ctx, "视频检测失败(保持待检验), taskId=%s, status=%d", taskID, result.Status)
return nil
}
// status == YidunStatusSuccess检测成功根据suggestion更新状态
verifyStatus := SuggestionToVerifyStatus(result.Suggestion)
responseJSON, _ := json.Marshal(result)
dao.MaterialVerifyLog.UpdateVerifyResult(ctx, log.Id, verifyStatus,
result.Suggestion, result.Label, result.ResultType, string(responseJSON), result.CensorTime)
if log.SourceTable == consts.SourceTableTencentVideo {
s.updateVideoStatus(ctx, log.SourceID, verifyStatus)
}
g.Log().Infof(ctx, "视频检测结果更新成功, taskId=%s, status=%d, suggestion=%d, verifyStatus=%s",
taskID, result.Status, result.Suggestion, verifyStatus)
return nil
}
// =============================================================================
// 辅助方法
// =============================================================================
// createVerifyLog 创建校验日志
func (s *MaterialVerifyService) createVerifyLog(ctx context.Context, materialType, materialID, sourceTable string, sourceID, accountID int64) *entity.MaterialVerifyLog {
log := &entity.MaterialVerifyLog{
TenantID: 0,
MaterialType: materialType,
MaterialID: materialID,
SourceTable: sourceTable,
SourceID: sourceID,
AccountID: accountID,
VerifyStatus: entity.VerifyStatusPending,
}
id, err := dao.MaterialVerifyLog.Create(ctx, log)
if err != nil {
g.Log().Errorf(ctx, "创建校验日志失败: %v", err)
return nil
}
log.Id = id
return log
}
// updateImageStatus 更新图片状态
func (s *MaterialVerifyService) updateImageStatus(ctx context.Context, imageID int64, verifyStatus string) {
_, err := dao.TencentImage.UpdateStatus(ctx, imageID, verifyStatus)
if err != nil {
g.Log().Errorf(ctx, "更新图片状态失败: %v", err)
} else {
g.Log().Infof(ctx, "更新图片状态成功, imageID=%d, status=%s", imageID, verifyStatus)
}
}
// updateVideoStatus 更新视频状态
func (s *MaterialVerifyService) updateVideoStatus(ctx context.Context, videoID int64, verifyStatus string) {
_, err := dao.TencentVideo.UpdateStatus(ctx, videoID, verifyStatus)
if err != nil {
g.Log().Errorf(ctx, "更新视频状态失败: %v", err)
} else {
g.Log().Infof(ctx, "更新视频状态成功, videoID=%d, status=%s", videoID, verifyStatus)
}
}
// =============================================================================
// 查询接口
// =============================================================================
// GetLogByID 根据ID获取日志
func (s *MaterialVerifyService) GetLogByID(ctx context.Context, id int64) (*entity.MaterialVerifyLog, error) {
return dao.MaterialVerifyLog.GetByID(ctx, id)
}
// GetLogsByMaterialID 根据素材ID获取日志列表
func (s *MaterialVerifyService) GetLogsByMaterialID(ctx context.Context, materialID string) ([]entity.MaterialVerifyLog, error) {
return dao.MaterialVerifyLog.GetByMaterialID(ctx, materialID)
}
// GetLogsByCondition 条件查询日志
func (s *MaterialVerifyService) GetLogsByCondition(ctx context.Context, condition map[string]interface{}, page, pageSize int) ([]entity.MaterialVerifyLog, int, error) {
return dao.MaterialVerifyLog.GetByCondition(ctx, condition, page, pageSize)
}
// GetStats 获取统计信息
func (s *MaterialVerifyService) GetStats(ctx context.Context) (map[string]int, error) {
return dao.MaterialVerifyLog.GetStats(ctx)
}
// =============================================================================
// 轮询模式 - 批量查询检测结果
// =============================================================================
// PollPendingResults 轮询所有待查询结果的日志(手动触发)
// 返回处理成功的数量和错误信息
func (s *MaterialVerifyService) PollPendingResults(ctx context.Context) (int, int, error) {
// 获取待查询的日志
logs, err := dao.MaterialVerifyLog.GetPendingResults(ctx, PollBatchSize)
if err != nil {
return 0, 0, err
}
if len(logs) == 0 {
g.Log().Infof(ctx, "没有待查询结果的日志")
return 0, 0, nil
}
g.Log().Infof(ctx, "开始轮询 %d 条待处理结果", len(logs))
successCount := 0
failCount := 0
var lastErr error
for _, log := range logs {
var err error
// 根据来源表判断调用哪个接口
if log.SourceTable == consts.SourceTableTencentImage {
err = s.ProcessImageResultByTask(ctx, log.TaskID)
} else if log.SourceTable == consts.SourceTableTencentVideo {
err = s.ProcessVideoResultByTask(ctx, log.TaskID)
} else {
g.Log().Warningf(ctx, "未知的来源表: %s, logId=%d", log.SourceTable, log.Id)
continue
}
if err != nil {
failCount++
lastErr = err
g.Log().Warningf(ctx, "处理结果失败, logId=%d, taskId=%s, error=%v", log.Id, log.TaskID, err)
} else {
successCount++
g.Log().Infof(ctx, "处理结果成功, logId=%d, taskId=%s", log.Id, log.TaskID)
}
// 避免请求过快
time.Sleep(100 * time.Millisecond)
}
g.Log().Infof(ctx, "轮询完成, 成功=%d, 失败=%d", successCount, failCount)
return successCount, failCount, lastErr
}
// PollPendingResultsByType 按类型轮询待查询结果的日志
func (s *MaterialVerifyService) PollPendingResultsByType(ctx context.Context, sourceTable string) (int, int, error) {
// 获取待查询的日志
logs, err := dao.MaterialVerifyLog.GetPendingResults(ctx, PollBatchSize)
if err != nil {
return 0, 0, err
}
// 过滤指定类型
var filteredLogs []entity.MaterialVerifyLog
for _, log := range logs {
if log.SourceTable == sourceTable {
filteredLogs = append(filteredLogs, log)
}
}
if len(filteredLogs) == 0 {
g.Log().Infof(ctx, "没有待查询结果的日志, sourceTable=%s", sourceTable)
return 0, 0, nil
}
g.Log().Infof(ctx, "开始轮询 %d 条待处理结果, sourceTable=%s", len(filteredLogs), sourceTable)
successCount := 0
failCount := 0
var lastErr error
for _, log := range filteredLogs {
var err error
if sourceTable == consts.SourceTableTencentImage {
err = s.ProcessImageResultByTask(ctx, log.TaskID)
} else if sourceTable == consts.SourceTableTencentVideo {
err = s.ProcessVideoResultByTask(ctx, log.TaskID)
}
if err != nil {
failCount++
lastErr = err
} else {
successCount++
}
time.Sleep(100 * time.Millisecond)
}
g.Log().Infof(ctx, "轮询完成, sourceTable=%s, 成功=%d, 失败=%d", sourceTable, successCount, failCount)
return successCount, failCount, lastErr
}
// PollPendingImageResults 轮询图片待查询结果
func (s *MaterialVerifyService) PollPendingImageResults(ctx context.Context) (int, int, error) {
return s.PollPendingResultsByType(ctx, consts.SourceTableTencentImage)
}
// PollPendingVideoResults 轮询视频待查询结果
func (s *MaterialVerifyService) PollPendingVideoResults(ctx context.Context) (int, int, error) {
return s.PollPendingResultsByType(ctx, consts.SourceTableTencentVideo)
}
// GetPendingResultsCount 获取待查询结果的数量
func (s *MaterialVerifyService) GetPendingResultsCount(ctx context.Context) (int, error) {
return dao.MaterialVerifyLog.CountPendingResults(ctx)
}

View File

@@ -0,0 +1,190 @@
package dataengine
import (
consts "cid/consts/dataengine"
dao "cid/dao/dataengine"
entity "cid/model/entity/dataengine"
yidunService "cid/service/yidun"
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// TencentContentCallbackService 腾讯内容检测回调处理服务
type TencentContentCallbackService struct{}
// TencentContentCallback 回调处理服务单例
var TencentContentCallback = new(TencentContentCallbackService)
// ProcessImageCallback 处理图片检测回调
func (s *TencentContentCallbackService) ProcessImageCallback(ctx context.Context, callbackData string) error {
g.Log().Infof(ctx, "处理图片检测回调, data: %s", callbackData)
var callback yidunService.ImageCallbackData
if err := json.Unmarshal([]byte(callbackData), &callback); err != nil {
g.Log().Errorf(ctx, "解析图片回调数据失败: %v", err)
return fmt.Errorf("解析回调数据失败: %w", err)
}
if callback.Antispam == nil {
return fmt.Errorf("回调数据格式错误缺少antispam字段")
}
antispam := callback.Antispam
g.Log().Infof(ctx, "处理图片检测结果 - taskId: %s, suggestion: %d, resultType: %d",
antispam.TaskId, antispam.Suggestion, antispam.ResultType)
// 根据 taskId 查找送检日志
log, err := dao.TencentContentCheckLog.GetByTaskID(ctx, antispam.TaskId)
if err != nil {
g.Log().Errorf(ctx, "查找送检日志失败, taskId=%s: %v", antispam.TaskId, err)
return fmt.Errorf("查找送检日志失败: %w", err)
}
if log == nil {
g.Log().Warningf(ctx, "未找到送检日志, taskId=%s", antispam.TaskId)
return nil
}
// 更新送检日志
checkTime := antispam.CensorTime
err = dao.TencentContentCheckLog.UpdateCheckResult(ctx, log.Id,
antispam.Suggestion, antispam.Label, antispam.ResultType, checkTime)
if err != nil {
g.Log().Errorf(ctx, "更新送检日志检测结果失败: %v", err)
return err
}
g.Log().Infof(ctx, "图片检测回调处理完成, taskId=%s, suggestion=%d", antispam.TaskId, antispam.Suggestion)
return nil
}
// ProcessVideoCallback 处理视频检测回调
func (s *TencentContentCallbackService) ProcessVideoCallback(ctx context.Context, callbackData string) error {
g.Log().Infof(ctx, "处理视频检测回调, data: %s", callbackData)
var callback yidunService.VideoCallbackData
if err := json.Unmarshal([]byte(callbackData), &callback); err != nil {
g.Log().Errorf(ctx, "解析视频回调数据失败: %v", err)
return fmt.Errorf("解析回调数据失败: %w", err)
}
if callback.Antispam == nil {
return fmt.Errorf("回调数据格式错误缺少antispam字段")
}
antispam := callback.Antispam
g.Log().Infof(ctx, "处理视频检测结果 - taskId: %s, suggestion: %d, resultType: %d, censorSource: %d",
antispam.TaskID, antispam.Suggestion, antispam.ResultType, antispam.CensorSource)
// 根据 taskId 查找送检日志
log, err := dao.TencentContentCheckLog.GetByTaskID(ctx, antispam.TaskID)
if err != nil {
g.Log().Errorf(ctx, "查找送检日志失败, taskId=%s: %v", antispam.TaskID, err)
return fmt.Errorf("查找送检日志失败: %w", err)
}
if log == nil {
g.Log().Warningf(ctx, "未找到送检日志, taskId=%s", antispam.TaskID)
return nil
}
// 更新送检日志
checkTime := antispam.CensorTime
if checkTime == 0 {
checkTime = antispam.CheckTime
}
err = dao.TencentContentCheckLog.UpdateCheckResult(ctx, log.Id,
antispam.Suggestion, antispam.Label, antispam.ResultType, checkTime)
if err != nil {
g.Log().Errorf(ctx, "更新送检日志检测结果失败: %v", err)
return err
}
g.Log().Infof(ctx, "视频检测回调处理完成, taskId=%s, suggestion=%d", antispam.TaskID, antispam.Suggestion)
return nil
}
// ProcessImageResult 手动处理图片检测结果(轮询模式)
func (s *TencentContentCallbackService) ProcessImageResult(ctx context.Context, taskID string) error {
g.Log().Infof(ctx, "查询图片检测结果, taskId: %s", taskID)
// 查找送检日志
log, err := dao.TencentContentCheckLog.GetByTaskID(ctx, taskID)
if err != nil || log == nil {
return fmt.Errorf("未找到送检日志, taskId=%s", taskID)
}
// 调用易盾查询结果
result, err := yidunService.ImageDetection.GetImageResult(ctx, taskID)
if err != nil {
g.Log().Errorf(ctx, "查询图片检测结果失败: %v", err)
return err
}
// 更新日志
err = dao.TencentContentCheckLog.UpdateCheckResult(ctx, log.Id,
result.Suggestion, result.Label, result.ResultType, result.CensorTime)
if err != nil {
g.Log().Errorf(ctx, "更新送检日志检测结果失败: %v", err)
return err
}
g.Log().Infof(ctx, "图片检测结果处理完成, taskId=%s, suggestion=%d", taskID, result.Suggestion)
return nil
}
// ProcessVideoResult 手动处理视频检测结果(轮询模式)
func (s *TencentContentCallbackService) ProcessVideoResult(ctx context.Context, taskID string) error {
g.Log().Infof(ctx, "查询视频检测结果, taskId: %s", taskID)
// 查找送检日志
log, err := dao.TencentContentCheckLog.GetByTaskID(ctx, taskID)
if err != nil || log == nil {
return fmt.Errorf("未找到送检日志, taskId=%s", taskID)
}
// 调用易盾查询结果
result, err := yidunService.VideoDetection.GetVideoResult(ctx, taskID)
if err != nil {
g.Log().Errorf(ctx, "查询视频检测结果失败: %v", err)
return err
}
// 更新日志
err = dao.TencentContentCheckLog.UpdateCheckResult(ctx, log.Id,
result.Suggestion, result.Label, result.ResultType, result.CensorTime)
if err != nil {
g.Log().Errorf(ctx, "更新送检日志检测结果失败: %v", err)
return err
}
g.Log().Infof(ctx, "视频检测结果处理完成, taskId=%s, suggestion=%d", taskID, result.Suggestion)
return nil
}
// GetCheckLogsByImageID 根据图片ID获取送检日志
func (s *TencentContentCallbackService) GetCheckLogsByImageID(ctx context.Context, imageID string) ([]entity.TencentContentCheckLog, error) {
// 先获取图片数据
image, err := dao.TencentImage.GetByImageID(ctx, imageID)
if err != nil || image == nil {
return nil, fmt.Errorf("未找到图片数据")
}
return dao.TencentContentCheckLog.GetBySourceID(ctx, consts.SourceTableTencentImage, image.Id)
}
// GetCheckLogsByVideoID 根据视频ID获取送检日志
func (s *TencentContentCallbackService) GetCheckLogsByVideoID(ctx context.Context, videoID string) ([]entity.TencentContentCheckLog, error) {
// 先获取视频数据
video, err := dao.TencentVideo.GetByVideoID(ctx, videoID)
if err != nil || video == nil {
return nil, fmt.Errorf("未找到视频数据")
}
return dao.TencentContentCheckLog.GetBySourceID(ctx, consts.SourceTableTencentVideo, video.Id)
}

View File

@@ -0,0 +1,390 @@
package dataengine
import (
consts "cid/consts/dataengine"
dao "cid/dao/dataengine"
entity "cid/model/entity/dataengine"
yidunService "cid/service/yidun"
"context"
"encoding/json"
"fmt"
"time"
"gitea.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
)
// ContentCheckConfig 送检配置
type ContentCheckConfig struct {
// 每批处理数量
BatchSize int `json:"batch_size"`
// 图片检测启用
ImageEnabled bool `json:"image_enabled"`
// 视频检测启用
VideoEnabled bool `json:"video_enabled"`
// 定时任务间隔(秒)
IntervalSeconds int `json:"interval_seconds"`
}
// DefaultConfig 默认配置
var DefaultConfig = ContentCheckConfig{
BatchSize: 10,
ImageEnabled: true,
VideoEnabled: true,
IntervalSeconds: 30,
}
// TencentContentCheckService 腾讯内容送检服务
type TencentContentCheckService struct {
config ContentCheckConfig
isRunning bool
}
// TencentContentCheck 送检服务单例
var TencentContentCheck = &TencentContentCheckService{
config: DefaultConfig,
}
// SetConfig 设置配置
func (s *TencentContentCheckService) SetConfig(config ContentCheckConfig) {
s.config = config
}
// Start 启动定时任务
func (s *TencentContentCheckService) Start(ctx context.Context) error {
if s.isRunning {
g.Log().Info(ctx, "送检服务已在运行中,跳过启动")
return nil
}
s.isRunning = true
g.Log().Infof(ctx, "启动内容送检服务,配置: batch_size=%d, interval=%ds, image=%v, video=%v",
s.config.BatchSize, s.config.IntervalSeconds, s.config.ImageEnabled, s.config.VideoEnabled)
go s.runScheduler(ctx)
return nil
}
// Stop 停止定时任务
func (s *TencentContentCheckService) Stop(ctx context.Context) {
s.isRunning = false
g.Log().Info(ctx, "停止内容送检服务")
}
// runScheduler 定时调度器
func (s *TencentContentCheckService) runScheduler(ctx context.Context) {
ticker := time.NewTicker(time.Duration(s.config.IntervalSeconds) * time.Second)
defer ticker.Stop()
// 启动时先执行一次
s.processAll(ctx)
for s.isRunning {
select {
case <-ticker.C:
s.processAll(ctx)
case <-ctx.Done():
s.isRunning = false
return
}
}
}
// processAll 处理所有待送检数据
func (s *TencentContentCheckService) processAll(ctx context.Context) {
// 添加系统用户上下文绕过gfdb租户验证
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "system", TenantId: 1})
startTime := time.Now()
g.Log().Info(ctx, "开始处理待送检数据...")
var totalProcessed int
// 处理图片
if s.config.ImageEnabled {
imageCount, _ := dao.TencentImage.CountPending(ctx)
if imageCount > 0 {
count, _ := s.processImages(ctx)
totalProcessed += count
}
}
// 处理视频
if s.config.VideoEnabled {
videoCount, _ := dao.TencentVideo.CountPending(ctx)
if videoCount > 0 {
count, _ := s.processVideos(ctx)
totalProcessed += count
}
}
duration := time.Since(startTime).Milliseconds()
g.Log().Infof(ctx, "处理完成,共处理 %d 条数据,耗时 %dms", totalProcessed, duration)
}
// processImages 处理图片送检
func (s *TencentContentCheckService) processImages(ctx context.Context) (int, error) {
// 获取待送检图片
images, err := dao.TencentImage.GetPendingList(ctx, s.config.BatchSize)
if err != nil {
g.Log().Errorf(ctx, "获取待送检图片失败: %v", err)
return 0, err
}
if len(images) == 0 {
return 0, nil
}
g.Log().Infof(ctx, "开始送检 %d 张图片", len(images))
successCount := 0
failedCount := 0
for _, img := range images {
// 创建送检日志
log := s.createCheckLog(ctx, consts.SourceTableTencentImage, img.Id, img.ImageID, img.PreviewURL)
// 提交送检
err := s.submitImageCheck(ctx, &img, log)
if err != nil {
failedCount++
// 更新日志为失败
if log != nil {
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusFailed, "", err.Error())
}
} else {
successCount++
}
// 避免请求过快
time.Sleep(100 * time.Millisecond)
}
g.Log().Infof(ctx, "图片送检完成,成功: %d失败: %d", successCount, failedCount)
return len(images), nil
}
// processVideos 处理视频送检
func (s *TencentContentCheckService) processVideos(ctx context.Context) (int, error) {
// 获取待送检视频
videos, err := dao.TencentVideo.GetPendingList(ctx, s.config.BatchSize)
if err != nil {
g.Log().Errorf(ctx, "获取待送检视频失败: %v", err)
return 0, err
}
if len(videos) == 0 {
return 0, nil
}
g.Log().Infof(ctx, "开始送检 %d 个视频", len(videos))
successCount := 0
failedCount := 0
for _, video := range videos {
// 创建送检日志
log := s.createCheckLog(ctx, consts.SourceTableTencentVideo, video.Id, video.VideoID, video.PreviewURL)
// 提交送检
err := s.submitVideoCheck(ctx, &video, log)
if err != nil {
failedCount++
// 更新日志为失败
if log != nil {
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusFailed, "", err.Error())
}
} else {
successCount++
}
// 避免请求过快
time.Sleep(100 * time.Millisecond)
}
g.Log().Infof(ctx, "视频送检完成,成功: %d失败: %d", successCount, failedCount)
return len(videos), nil
}
// createCheckLog 创建送检日志
func (s *TencentContentCheckService) createCheckLog(ctx context.Context, sourceTable string, sourceID int64, mediaID string, mediaURL string) *entity.TencentContentCheckLog {
requestParam := map[string]interface{}{
"media_id": mediaID,
"url": mediaURL,
}
requestParamJSON, _ := json.Marshal(requestParam)
log := &entity.TencentContentCheckLog{
SourceTable: sourceTable,
SourceID: sourceID,
RequestURL: "易盾内容安全检测接口",
RequestParam: string(requestParamJSON),
Status: consts.CheckStatusPending,
CheckTime: time.Now().UnixMilli(),
}
id, err := dao.TencentContentCheckLog.Create(ctx, log)
if err != nil {
g.Log().Errorf(ctx, "创建送检日志失败: %v", err)
return nil
}
log.Id = id
g.Log().Debugf(ctx, "创建送检日志成功, id=%d, sourceTable=%s, sourceID=%d", id, sourceTable, sourceID)
return log
}
// submitImageCheck 提交图片送检
func (s *TencentContentCheckService) submitImageCheck(ctx context.Context, image *entity.TencentImage, log *entity.TencentContentCheckLog) error {
startTime := time.Now()
// 更新日志状态为送检中
if log != nil {
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusSubmitting, "", "")
}
// 获取回调地址
callbackURL := g.Cfg().MustGet(ctx, "yidun.image.callback_url").String()
// 调用易盾图片检测
result, err := yidunService.ImageDetection.DetectImage(ctx, image.PreviewURL, image.ImageID, callbackURL)
duration := time.Since(startTime).Milliseconds()
// 更新日志
if log != nil {
if err != nil {
dao.TencentContentCheckLog.UpdateDuration(ctx, log.Id, duration)
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusFailed, "", err.Error())
g.Log().Errorf(ctx, "图片送检失败, id=%d, url=%s, error=%v", image.Id, image.PreviewURL, err)
return err
}
// 更新日志和图片状态
responseData, _ := json.Marshal(result)
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusSuccess, string(responseData), "")
dao.TencentContentCheckLog.UpdateTaskID(ctx, log.Id, result.TaskID)
dao.TencentContentCheckLog.UpdateDuration(ctx, log.Id, duration)
}
g.Log().Infof(ctx, "图片送检成功, id=%d, imageId=%s, taskId=%s", image.Id, image.ImageID, result.TaskID)
return nil
}
// submitVideoCheck 提交视频送检
func (s *TencentContentCheckService) submitVideoCheck(ctx context.Context, video *entity.TencentVideo, log *entity.TencentContentCheckLog) error {
startTime := time.Now()
// 更新日志状态为送检中
if log != nil {
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusSubmitting, "", "")
}
// 获取回调地址
callbackURL := g.Cfg().MustGet(ctx, "yidun.video.callback_url").String()
// 调用易盾视频检测
result, err := yidunService.VideoDetection.DetectVideo(ctx, video.PreviewURL, video.VideoID, callbackURL)
duration := time.Since(startTime).Milliseconds()
// 更新日志
if log != nil {
if err != nil {
dao.TencentContentCheckLog.UpdateDuration(ctx, log.Id, duration)
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusFailed, "", err.Error())
g.Log().Errorf(ctx, "视频送检失败, id=%d, url=%s, error=%v", video.Id, video.PreviewURL, err)
return err
}
// 更新日志和视频状态
responseData, _ := json.Marshal(result)
dao.TencentContentCheckLog.UpdateStatus(ctx, log.Id, consts.CheckStatusSuccess, string(responseData), "")
dao.TencentContentCheckLog.UpdateTaskID(ctx, log.Id, result.TaskID)
dao.TencentContentCheckLog.UpdateDuration(ctx, log.Id, duration)
}
g.Log().Infof(ctx, "视频送检成功, id=%d, videoId=%s, taskId=%s", video.Id, video.VideoID, result.TaskID)
return nil
}
// SubmitImageByID 根据图片ID手动提交送检
func (s *TencentContentCheckService) SubmitImageByID(ctx context.Context, imageID string) (*yidunService.ImageSubmitResult, error) {
// 根据图片ID获取数据
image, err := dao.TencentImage.GetByImageID(ctx, imageID)
if err != nil {
return nil, fmt.Errorf("查询图片数据失败: %w", err)
}
if image == nil {
return nil, fmt.Errorf("未找到图片数据, imageID=%s", imageID)
}
// 创建送检日志
log := s.createCheckLog(ctx, consts.SourceTableTencentImage, image.Id, image.ImageID, image.PreviewURL)
if log == nil {
return nil, fmt.Errorf("创建送检日志失败")
}
// 提交送检
err = s.submitImageCheck(ctx, image, log)
if err != nil {
return nil, err
}
// 获取送检结果
return dao.TencentContentCheckLog.GetImageSubmitResult(ctx, log.Id)
}
// SubmitVideoByID 根据视频ID手动提交送检
func (s *TencentContentCheckService) SubmitVideoByID(ctx context.Context, videoID string) (*yidunService.VideoSubmitResult, error) {
// 根据视频ID获取数据
video, err := dao.TencentVideo.GetByVideoID(ctx, videoID)
if err != nil {
return nil, fmt.Errorf("查询视频数据失败: %w", err)
}
if video == nil {
return nil, fmt.Errorf("未找到视频数据, videoID=%s", videoID)
}
// 创建送检日志
log := s.createCheckLog(ctx, consts.SourceTableTencentVideo, video.Id, video.VideoID, video.PreviewURL)
if log == nil {
return nil, fmt.Errorf("创建送检日志失败")
}
// 提交送检
err = s.submitVideoCheck(ctx, video, log)
if err != nil {
return nil, err
}
// 获取送检结果
return dao.TencentContentCheckLog.GetVideoSubmitResult(ctx, log.Id)
}
// GetPendingStats 获取待送检统计
func (s *TencentContentCheckService) GetPendingStats(ctx context.Context) map[string]int {
stats := make(map[string]int)
if s.config.ImageEnabled {
count, _ := dao.TencentImage.CountPending(ctx)
stats["image_pending"] = count
}
if s.config.VideoEnabled {
count, _ := dao.TencentVideo.CountPending(ctx)
stats["video_pending"] = count
}
return stats
}
// IsRunning 获取运行状态
func (s *TencentContentCheckService) IsRunning() bool {
return s.isRunning
}
// GetConfig 获取当前配置
func (s *TencentContentCheckService) GetConfig() ContentCheckConfig {
return s.config
}

View File

@@ -92,6 +92,79 @@ func (s *ImageDetectionService) DetectImage(ctx context.Context, imageURL, dataI
return result, nil
}
// DetectImageSync 同步检测图片,提交并直接返回检测结果
// 适用于轮询模式(无回调),提交时实时返回结果,无需额外查询
func (s *ImageDetectionService) DetectImageSync(ctx context.Context, imageURL, dataID string) (*ImageResult, error) {
if DefaultClients == nil || DefaultClients.ImageClient == nil {
return nil, fmt.Errorf("易盾图片检测客户端未初始化")
}
if imageURL == "" {
return nil, fmt.Errorf("图片URL不能为空")
}
businessId := g.Cfg().MustGet(ctx, "yidun.image.business_id").String()
g.Log().Infof(ctx, "图片同步检测, url: %s, business_id: %s", imageURL, businessId)
// 创建同步检测请求
request := check.NewImageV5CheckRequest(businessId)
imageBean := check.NewImageBeanRequest()
imageBean.SetData(imageURL)
imageBean.SetName(dataID)
imageBean.SetType(1) // 1: 图片URL
request.SetImages([]check.ImageBeanRequest{*imageBean})
// 调用同步检测API
response, err := DefaultClients.ImageClient.ImageSyncCheck(request)
if err != nil {
g.Log().Errorf(ctx, "图片同步检测失败: %v", err)
return nil, fmt.Errorf("图片同步检测失败: %w", err)
}
if response.GetCode() != 200 {
g.Log().Errorf(ctx, "图片同步检测API错误: code=%d, msg=%s", response.GetCode(), response.GetMsg())
return nil, fmt.Errorf("图片同步检测API错误: code=%d, msg=%s", response.GetCode(), response.GetMsg())
}
if response.Result == nil || len(*response.Result) == 0 {
g.Log().Warningf(ctx, "图片同步检测结果为空, url: %s", imageURL)
return nil, ErrImageResultNotFound
}
// 提取结果
detail := (*response.Result)[0]
result := &ImageResult{
TaskID: dataID,
Name: dataID,
Url: imageURL,
}
if detail.Antispam != nil {
if detail.Antispam.TaskId != nil {
result.TaskID = *detail.Antispam.TaskId
}
if detail.Antispam.Status != nil {
result.Status = *detail.Antispam.Status
}
if detail.Antispam.Suggestion != nil {
result.Suggestion = *detail.Antispam.Suggestion
}
if detail.Antispam.Label != nil {
result.Label = *detail.Antispam.Label
}
if detail.Antispam.ResultType != nil {
result.ResultType = *detail.Antispam.ResultType
}
if detail.Antispam.CensorTime != nil {
result.CensorTime = *detail.Antispam.CensorTime
}
result.Antispam = detail.Antispam
}
g.Log().Infof(ctx, "图片同步检测完成, taskID: %s, suggestion: %d, status: %d",
result.TaskID, result.Suggestion, result.Status)
return result, nil
}
// ImageResult 图片检测完整结果
type ImageResult struct {
TaskID string `json:"taskId"` // 任务ID
@@ -144,7 +217,7 @@ func (s *ImageDetectionService) GetImageResult(ctx context.Context, taskID strin
if response.Result == nil || len(*response.Result) == 0 {
g.Log().Warningf(ctx, "未找到图片检测结果, taskID: %s", taskID)
return nil, ErrImageStillProcessing
return nil, ErrImageResultNotFound
}
// 查找指定taskID的结果

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
audiocallback "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/audio/callback/v4/response"
@@ -13,7 +12,6 @@ import (
callbackrequest "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/videosolution/callback/v2/request"
callbackresponse "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/videosolution/callback/v2/response"
vsrequest "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/videosolution/submit/v2/request"
submitresponse "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/videosolution/submit/v2/response"
)
// VideoDetectionService 视频检测服务
@@ -43,27 +41,47 @@ func (s *VideoDetectionService) DetectVideo(ctx context.Context, videoURL, dataI
return nil, fmt.Errorf("视频URL不能为空")
}
g.Log().Infof(ctx, "视频检测任务提交, url: %s", videoURL)
g.Log().Infof(ctx, "视频检测任务提交, url: %s, dataID: %s", videoURL, dataID)
// 创建请求
req := vsrequest.NewVideoSolutionSubmitV2Req()
req.SetURL(videoURL)
req.SetDataID(dataID)
req.SetUniqueKey(dataID)
// 设置回调地址
if callbackURL != "" {
req.SetCallbackURL(callbackURL)
}
// 设置子产品标识(视频解决方案必须)
req.SetSubProduct("videoStream")
// 可选设置IP用于风险用户识别
// req.SetIP("127.0.0.1")
// 调用API
response, err := DefaultClients.VideoClient.Submit(req)
if err != nil {
g.Log().Errorf(ctx, "视频检测提交失败: %v", err)
return nil, fmt.Errorf("视频检测提交失败: %w", err)
g.Log().Errorf(ctx, "视频检测提交HTTP错误: %v", err)
return nil, fmt.Errorf("视频检测提交HTTP错误: %w", err)
}
if response.GetCode() != 200 {
g.Log().Errorf(ctx, "视频检测API错误: code=%d, msg=%s", response.GetCode(), response.GetMsg())
return nil, fmt.Errorf("视频检测API错误: code=%d, msg=%s", response.GetCode(), response.GetMsg())
// 根据错误码提供更详细的错误信息
errMsg := fmt.Sprintf("视频检测API错误: code=%d, msg=%s", response.GetCode(), response.GetMsg())
// 常见错误码说明
switch response.GetCode() {
case 417:
errMsg += " (可能原因: 视频URL无法访问或业务配置问题)"
case 400:
errMsg += " (可能原因: 请求参数错误)"
case 403:
errMsg += " (可能原因: 鉴权失败检查secretId和secretKey)"
}
return nil, fmt.Errorf(errMsg)
}
result := &VideoSubmitResult{}
@@ -132,7 +150,7 @@ func (s *VideoDetectionService) GetVideoResult(ctx context.Context, taskID strin
if response.Result == nil || len(*response.Result) == 0 {
g.Log().Warningf(ctx, "未找到视频检测结果, taskID: %s", taskID)
return nil, ErrVideoStillProcessing
return nil, ErrVideoResultNotFound
}
// 查找指定taskID的结果
@@ -182,82 +200,6 @@ func (s *VideoDetectionService) GetVideoResult(ctx context.Context, taskID strin
return nil, ErrVideoResultNotFound
}
// PollVideoResult 轮询获取视频检测结果
func (s *VideoDetectionService) PollVideoResult(ctx context.Context, taskID string, interval, maxWait time.Duration) (*VideoResult, error) {
if interval <= 0 {
interval = 1 * time.Second
}
if maxWait <= 0 {
maxWait = 5 * time.Minute
}
startTime := time.Now()
g.Log().Infof(ctx, "开始轮询视频检测结果, taskID: %s, interval: %v, maxWait: %v", taskID, interval, maxWait)
for time.Since(startTime) < maxWait {
select {
case <-ctx.Done():
return nil, fmt.Errorf("轮询取消: %w", ctx.Err())
default:
}
result, err := s.GetVideoResult(ctx, taskID)
if err == nil {
g.Log().Infof(ctx, "轮询成功, taskID: %s, elapsed: %v", taskID, time.Since(startTime))
return result, nil
}
if errors.Is(err, ErrVideoResultNotFound) {
g.Log().Infof(ctx, "视频仍在检测中,继续轮询, taskID: %s, elapsed: %v", taskID, time.Since(startTime))
} else {
g.Log().Warningf(ctx, "查询出错,继续重试, taskID: %s, error: %v", taskID, err)
}
time.Sleep(interval)
}
return nil, fmt.Errorf("轮询超时,已等待 %v", time.Since(startTime))
}
// GetSDKSubmitResponse 获取SDK原始提交响应包含完整易盾返回信息
func (s *VideoDetectionService) GetSDKSubmitResponse(ctx context.Context, videoURL, dataID string, callbackURL string) (*submitresponse.VideoSolutionSubmitV2Response, error) {
if DefaultClients == nil || DefaultClients.VideoClient == nil {
return nil, fmt.Errorf("易盾视频检测客户端未初始化")
}
req := vsrequest.NewVideoSolutionSubmitV2Req()
req.SetURL(videoURL)
req.SetDataID(dataID)
req.SetUniqueKey(dataID)
if callbackURL != "" {
req.SetCallbackURL(callbackURL)
}
response, err := DefaultClients.VideoClient.Submit(req)
if err != nil {
return nil, err
}
return response, nil
}
// GetSDKCallbackResponse 获取SDK原始回调响应包含完整易盾返回信息
func (s *VideoDetectionService) GetSDKCallbackResponse(ctx context.Context, taskID string) (*callbackresponse.VideoSolutionCallbackV2Response, error) {
if DefaultClients == nil || DefaultClients.VideoClient == nil {
return nil, fmt.Errorf("易盾视频检测客户端未初始化")
}
req := callbackrequest.NewVideoSolutionCallbackV2Request()
req.SetYidunRequestId(taskID)
response, err := DefaultClients.VideoClient.Callback(req)
if err != nil {
return nil, err
}
return response, nil
}
// =============================================================================
// 视频检测结果推送模式(易盾主动回调)
// =============================================================================

View File

@@ -0,0 +1,40 @@
-- =============================================
-- 素材校验日志表 (material_verify_log) - cid数据库
-- =============================================
CREATE TABLE IF NOT EXISTS material_verify_log
(
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT DEFAULT 0,
material_type VARCHAR(20) NOT NULL,
material_id VARCHAR(100) NOT NULL,
source_table VARCHAR(50) NOT NULL,
source_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
task_id VARCHAR(100),
request_params TEXT,
response_result TEXT,
verify_status VARCHAR(20) DEFAULT 'PENDING' NOT NULL,
suggestion INT DEFAULT -1,
label INT DEFAULT -1,
result_type INT DEFAULT -1,
error_msg TEXT,
check_time BIGINT,
duration_ms BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100) DEFAULT 'system'
);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_material_id ON material_verify_log(material_id);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_source ON material_verify_log(source_table, source_id);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_status ON material_verify_log(verify_status);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_account ON material_verify_log(account_id);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_task ON material_verify_log(task_id);
CREATE INDEX IF NOT EXISTS idx_material_verify_log_created ON material_verify_log(created_at);
COMMENT ON TABLE material_verify_log IS '素材校验日志表';
COMMENT ON COLUMN material_verify_log.material_type IS '素材类型 IMAGE/VIDEO';
COMMENT ON COLUMN material_verify_log.material_id IS '素材ID(image_id/video_id)';
COMMENT ON COLUMN material_verify_log.verify_status IS '校验状态: PENDING=待校验, VERIFIED=通过, REJECTED=不通过';
COMMENT ON COLUMN material_verify_log.suggestion IS '易盾处置建议: 0=通过, 1=嫌疑, 2=不通过';
COMMENT ON COLUMN material_verify_log.error_msg IS '错误信息';

View File

@@ -0,0 +1,44 @@
-- =============================================
-- 送检日志表 (tencent_content_check_log) - cid数据库
-- =============================================
CREATE TABLE IF NOT EXISTS "tencent_content_check_log" (
"id" BIGSERIAL PRIMARY KEY,
"source_table" VARCHAR(64) NOT NULL DEFAULT '',
"source_id" BIGINT NOT NULL DEFAULT 0,
"request_url" VARCHAR(512) NOT NULL DEFAULT '',
"request_param" TEXT NOT NULL DEFAULT '',
"response_data" TEXT NOT NULL DEFAULT '',
"status" VARCHAR(32) NOT NULL DEFAULT 'pending',
"check_time" BIGINT NOT NULL DEFAULT 0,
"fail_reason" TEXT NOT NULL DEFAULT '',
"task_id" VARCHAR(128) NOT NULL DEFAULT '',
"suggestion" INTEGER NOT NULL DEFAULT 0,
"label" INTEGER NOT NULL DEFAULT 0,
"result_type" INTEGER NOT NULL DEFAULT 0,
"duration" BIGINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "idx_content_check_log_source" ON "tencent_content_check_log" ("source_table", "source_id");
CREATE INDEX IF NOT EXISTS "idx_content_check_log_task_id" ON "tencent_content_check_log" ("task_id");
CREATE INDEX IF NOT EXISTS "idx_content_check_log_status" ON "tencent_content_check_log" ("status");
CREATE INDEX IF NOT EXISTS "idx_content_check_log_check_time" ON "tencent_content_check_log" ("check_time");
CREATE INDEX IF NOT EXISTS "idx_content_check_log_created_at" ON "tencent_content_check_log" ("created_at");
COMMENT ON TABLE "tencent_content_check_log" IS '送检日志表';
COMMENT ON COLUMN "tencent_content_check_log"."source_table" IS '来源表标识tencent_image/tencent_video';
COMMENT ON COLUMN "tencent_content_check_log"."source_id" IS '原数据ID关联业务表数据';
COMMENT ON COLUMN "tencent_content_check_log"."request_url" IS '送检请求路径(接口地址)';
COMMENT ON COLUMN "tencent_content_check_log"."request_param" IS '送检入参完整请求参数JSON格式';
COMMENT ON COLUMN "tencent_content_check_log"."response_data" IS '送检出参完整接口返回结果JSON格式';
COMMENT ON COLUMN "tencent_content_check_log"."status" IS '送检状态pending-待送检, submitting-送检中, success-送检成功, failed-送检失败, completed-检测完成';
COMMENT ON COLUMN "tencent_content_check_log"."check_time" IS '送检时间(时间戳,毫秒)';
COMMENT ON COLUMN "tencent_content_check_log"."fail_reason" IS '失败原因(可选,记录接口报错信息)';
COMMENT ON COLUMN "tencent_content_check_log"."task_id" IS '易盾返回的任务ID';
COMMENT ON COLUMN "tencent_content_check_log"."suggestion" IS '检测结果建议0-通过1-嫌疑2-不通过';
COMMENT ON COLUMN "tencent_content_check_log"."label" IS '检测标签';
COMMENT ON COLUMN "tencent_content_check_log"."result_type" IS '结果类型1-机器结果2-人审结果';
COMMENT ON COLUMN "tencent_content_check_log"."duration" IS '送检耗时(毫秒)';