yidun送检新增账户下拉和导出优化

This commit is contained in:
2026-05-15 14:01:15 +08:00
parent 307b01c0e3
commit 3dbcf7a8e3
7 changed files with 388 additions and 30 deletions

View File

@@ -5,4 +5,5 @@ const (
TencentImageTable = "tencent_image" // 图片送检表
TencentVideoTable = "tencent_video" // 视频送检表
TencentContentCheckLogTable = "tencent_content_check_log" // 送检日志表
TencentAccountRelationTable = "tencent_account_relation" // 腾讯广告账户关系表
)

View File

@@ -436,6 +436,103 @@ func (c *MaterialVerifyController) BatchVerifyVideo(ctx context.Context, req *Ba
}, nil
}
// =============================================================================
// 账户列表接口
// =============================================================================
// ListAccountsReq 账户列表请求
type ListAccountsReq struct{}
// AccountItem 账户列表项
type AccountItem struct {
AccountID int64 `json:"accountId"`
CorporationName string `json:"corporationName"`
}
// ListAccountsRes 账户列表响应
type ListAccountsRes struct {
List []AccountItem `json:"list"`
}
// ListAccounts 获取所有启用的广告账户列表(用于前端下拉筛选)
func (c *MaterialVerifyController) ListAccounts(ctx context.Context, req *ListAccountsReq) (res *ListAccountsRes, err error) {
accounts, err := dao.TencentAccountRelation.GetAll(ctx)
if err != nil {
return nil, err
}
var items []AccountItem
for _, acc := range accounts {
items = append(items, AccountItem{
AccountID: acc.AccountID,
CorporationName: acc.CorporationName,
})
}
return &ListAccountsRes{
List: items,
}, nil
}
// =============================================================================
// 导出接口 - 不通过数据
// =============================================================================
// ExportRejectedReq 导出不通过数据请求
type ExportRejectedReq struct {
MaterialType string `json:"materialType"` // IMAGE/VIDEO为空则导出全部
}
// ExportRejectedItem 导出的不通过数据项
type ExportRejectedItem struct {
ID int64 `json:"id"`
MaterialID string `json:"materialId"`
AccountID int64 `json:"accountId"`
CorporationName string `json:"corporationName"`
PreviewURL string `json:"previewUrl"`
Description string `json:"description"`
ErrorMsg string `json:"errorMsg"`
MaterialType string `json:"materialType"`
ImageUsage string `json:"imageUsage,omitempty"`
CreatedAt string `json:"createdAt"`
}
// ExportRejectedRes 导出不通过数据响应
type ExportRejectedRes struct {
Items []ExportRejectedItem `json:"items"`
Total int `json:"total"`
}
// ExportRejected 导出不通过的图片/视频数据(含失败原因)
func (c *MaterialVerifyController) ExportRejected(ctx context.Context, req *ExportRejectedReq) (res *ExportRejectedRes, err error) {
items, err := serviceDataengine.MaterialVerify.ExportRejectedData(ctx, req.MaterialType)
if err != nil {
return nil, err
}
// 转换为响应结构
var respItems []ExportRejectedItem
for _, item := range items {
respItems = append(respItems, ExportRejectedItem{
ID: item.ID,
MaterialID: item.MaterialID,
AccountID: item.AccountID,
CorporationName: item.CorporationName,
PreviewURL: item.PreviewURL,
Description: item.Description,
ErrorMsg: item.ErrorMsg,
MaterialType: item.MaterialType,
ImageUsage: item.ImageUsage,
CreatedAt: item.CreatedAt,
})
}
return &ExportRejectedRes{
Items: respItems,
Total: len(respItems),
}, nil
}
// =============================================================================
// 回调处理接口
// =============================================================================

View File

@@ -273,6 +273,26 @@ func (d *MaterialVerifyLogDAO) GetPendingResults(ctx context.Context, limit int)
return result, nil
}
// GetLastRejectedLogByMaterialID 根据素材ID获取最后一条失败的校验日志
func (d *MaterialVerifyLogDAO) GetLastRejectedLogByMaterialID(ctx context.Context, materialID string, verifyStatus string) (*daoEntity.MaterialVerifyLog, error) {
var result daoEntity.MaterialVerifyLog
r, err := g.DB("default").Model(MaterialVerifyLogTable).
Where(daoEntity.MaterialVerifyLogCols.MaterialID, materialID).
Where(daoEntity.MaterialVerifyLogCols.VerifyStatus, verifyStatus).
OrderDesc(daoEntity.MaterialVerifyLogCols.CreatedAt).
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
}
// CountPendingResults 统计待查询结果的数量
func (d *MaterialVerifyLogDAO) CountPendingResults(ctx context.Context) (int, error) {
count, err := g.DB("default").Model(MaterialVerifyLogTable).

View File

@@ -0,0 +1,33 @@
package dataengine
import (
consts "cid/consts/dataengine"
entity "cid/model/entity/dataengine"
"context"
"github.com/gogf/gf/v2/frame/g"
)
// TencentAccountRelationDAO 腾讯广告账户关系数据访问层
type TencentAccountRelationDAO struct{}
// TencentAccountRelation DAO单例
var TencentAccountRelation = new(TencentAccountRelationDAO)
// GetAll 获取所有启用的账户列表
func (d *TencentAccountRelationDAO) GetAll(ctx context.Context) ([]entity.TencentAccountRelation, error) {
var result []entity.TencentAccountRelation
r, err := Model(consts.TencentAccountRelationTable).
WhereNull("deleted_at").
OrderAsc(entity.TencentAccountRelationCols.AccountID).
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
}

View File

@@ -0,0 +1,27 @@
package dataengine
import (
"gitea.com/red-future/common/beans"
)
// TencentAccountRelation 腾讯广告账户关系实体来源data-engine.tencent_account_relation
type TencentAccountRelation struct {
beans.SQLBaseDO `orm:",inherit"`
// 业务字段
AccountID int64 `orm:"account_id" json:"accountId" description:"账户ID"`
CorporationName string `orm:"corporation_name" json:"corporationName" description:"公司名称"`
}
// TencentAccountRelationCol 账户关系表字段定义
type TencentAccountRelationCol struct {
beans.SQLBaseCol
AccountID string
CorporationName string
}
// TencentAccountRelationCols 账户关系表字段常量
var TencentAccountRelationCols = TencentAccountRelationCol{
SQLBaseCol: beans.DefSQLBaseCol,
AccountID: "account_id",
CorporationName: "corporation_name",
}

View File

@@ -255,8 +255,15 @@
</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>
<label>账户:</label>
<el-select v-model="imageFilters.accountId" placeholder="全部账户" clearable filterable style="width: 200px;" @change="searchImage">
<el-option
v-for="item in accounts"
:key="item.accountId"
:label="item.accountId + ' - ' + item.corporationName"
:value="item.accountId">
</el-option>
</el-select>
</div>
<el-button type="primary" @click="searchImage">搜索</el-button>
<el-button @click="resetImageFilter">重置</el-button>
@@ -334,8 +341,15 @@
</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>
<label>账户:</label>
<el-select v-model="videoFilters.accountId" placeholder="全部账户" clearable filterable style="width: 200px;" @change="searchVideo">
<el-option
v-for="item in accounts"
:key="item.accountId"
:label="item.accountId + ' - ' + item.corporationName"
:value="item.accountId">
</el-option>
</el-select>
</div>
<el-button type="primary" @click="searchVideo">搜索</el-button>
<el-button @click="resetVideoFilter">重置</el-button>
@@ -601,11 +615,14 @@
previewUrl: '',
previewType: 'image',
materialLogs: [],
batchLoading: false
batchLoading: false,
// 账户列表(下拉筛选用)
accounts: []
},
mounted() {
this.loadStats();
this.loadImageList();
this.loadAccounts();
},
methods: {
// API 请求
@@ -639,6 +656,21 @@
});
},
// 加载账户列表(下拉筛选用)
loadAccounts() {
this.apiPost('/material/verify/controller/list-accounts', {}).then(res => {
this.accounts = res.data.data?.list || [];
}).catch(err => {
console.error('加载账户列表失败', err);
});
},
// 获取账户名称(显示用)
getAccountName(accountId) {
const account = this.accounts.find(a => a.accountId === accountId);
return account ? account.corporationName : '';
},
// 图片列表
loadImageList() {
this.imageLoading = true;
@@ -813,36 +845,50 @@
});
},
// 导出
// 导出(不通过数据,含失败原因)
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.apiPost('/material/verify/controller/export-rejected', { materialType: 'IMAGE' }).then(res => {
const items = res.data.data?.items || [];
if (!items.length) {
this.$message.warning('没有不通过的图片数据');
return;
}
const rows = [['序号', '账户(名称)', '预览URL', '描述', '失败原因', '检测时间']];
items.forEach((item, index) => {
const accountLabel = item.corporationName ? item.accountId + ' - ' + item.corporationName : item.accountId;
rows.push([
index + 1, accountLabel,
item.previewUrl || '-', item.description || '-',
item.errorMsg || '-', item.createdAt || '-'
]);
});
this.downloadCsv('图片不通过数据.csv', rows);
this.$message.success('导出成功,共 ' + items.length + ' 条');
}).catch(err => {
this.$message.error('导出失败: ' + (err.response?.data?.msg || err.message));
});
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.apiPost('/material/verify/controller/export-rejected', { materialType: 'VIDEO' }).then(res => {
const items = res.data.data?.items || [];
if (!items.length) {
this.$message.warning('没有不通过的视频数据');
return;
}
const rows = [['序号', '账户(名称)', '预览URL', '描述', '失败原因', '检测时间']];
items.forEach((item, index) => {
const accountLabel = item.corporationName ? item.accountId + ' - '+ item.corporationName : item.accountId;
rows.push([
index + 1, accountLabel,
item.previewUrl || '-', item.description || '-',
item.errorMsg || '-', item.createdAt || '-'
]);
});
this.downloadCsv('视频不通过数据.csv', rows);
this.$message.success('导出成功,共 ' + items.length + ' 条');
}).catch(err => {
this.$message.error('导出失败: ' + (err.response?.data?.msg || err.message));
});
this.downloadCsv('视频列表.csv', rows);
},
downloadCsv(filename, rows) {
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');

View File

@@ -656,6 +656,140 @@ func (s *MaterialVerifyService) PollPendingVideoResults(ctx context.Context) (in
return s.PollPendingResultsByType(ctx, consts.SourceTableTencentVideo)
}
// =============================================================================
// 导出服务 - 不通过数据导出
// =============================================================================
// ExportRejectedItem 导出的不通过数据项
type ExportRejectedItem struct {
ID int64 `json:"id"` // 素材表主键ID
MaterialID string `json:"materialId"` // 素材IDimageId/videoId
AccountID int64 `json:"accountId"` // 账户ID
CorporationName string `json:"corporationName"` // 公司名称
PreviewURL string `json:"previewUrl"` // 预览URL
Description string `json:"description"` // 描述
ErrorMsg string `json:"errorMsg"` // 失败原因最后一条失败日志的error_msg
MaterialType string `json:"materialType"` // 素材类型 IMAGE/VIDEO
ImageUsage string `json:"imageUsage"` // 图片用途(仅图片)
CreatedAt string `json:"createdAt"` // 检测时间(日志创建时间)
}
// getFailureReason 获取失败原因
func getFailureReason(log *entity.MaterialVerifyLog) string {
if log == nil {
return "无校验日志"
}
// 优先使用 error_msg
if log.ErrorMsg != "" {
return log.ErrorMsg
}
// 根据 suggestion 和 label 生成原因
reasonMap := map[int]string{
0: "内容检测通过",
1: "内容嫌疑(需人工审核)",
2: "内容不通过",
}
suggestionText := reasonMap[log.Suggestion]
if suggestionText == "" {
suggestionText = fmt.Sprintf("未知(suggestion=%d)", log.Suggestion)
}
// 如果有 response_result尝试提取更多信息
if log.ResponseResult != "" {
var resultMap map[string]interface{}
if err := json.Unmarshal([]byte(log.ResponseResult), &resultMap); err == nil {
if labels, ok := resultMap["labels"]; ok {
return fmt.Sprintf("%s (labels: %v)", suggestionText, labels)
}
}
return suggestionText
}
return suggestionText
}
// ExportRejectedData 导出不通过数据
func (s *MaterialVerifyService) ExportRejectedData(ctx context.Context, materialType string) ([]ExportRejectedItem, error) {
var items []ExportRejectedItem
// 加载账户名称映射
accountMap := make(map[int64]string)
if accounts, err := dao.TencentAccountRelation.GetAll(ctx); err == nil {
for _, acc := range accounts {
if acc.CorporationName != "" {
accountMap[acc.AccountID] = acc.CorporationName
}
}
}
// 处理图片
if materialType == "" || materialType == entity.MaterialTypeImage {
condition := map[string]interface{}{
entity.TencentImageCols.VerifyStatus: entity.VerifyStatusRejected,
}
images, total, err := dao.TencentImage.GetByCondition(ctx, condition, 1, 100000)
if err != nil {
g.Log().Errorf(ctx, "查询不通过图片失败: %v", err)
return nil, fmt.Errorf("查询不通过图片失败: %w", err)
}
g.Log().Infof(ctx, "导出不通过图片: total=%d, got=%d", total, len(images))
for _, img := range images {
// 查询最后一条失败的校验日志
log, _ := dao.MaterialVerifyLog.GetLastRejectedLogByMaterialID(ctx, img.ImageID, entity.VerifyStatusRejected)
var createdAtStr string
if log != nil && log.CreatedAt != nil {
createdAtStr = log.CreatedAt.Format("Y-m-d H:i:s")
}
items = append(items, ExportRejectedItem{
ID: img.Id,
MaterialID: img.ImageID,
AccountID: img.AccountID,
CorporationName: accountMap[img.AccountID],
PreviewURL: img.PreviewURL,
Description: img.Description,
ErrorMsg: getFailureReason(log),
MaterialType: entity.MaterialTypeImage,
ImageUsage: img.ImageUsage,
CreatedAt: createdAtStr,
})
}
}
// 处理视频
if materialType == "" || materialType == entity.MaterialTypeVideo {
condition := map[string]interface{}{
entity.TencentVideoCols.VerifyStatus: entity.VerifyStatusRejected,
}
videos, total, err := dao.TencentVideo.GetByCondition(ctx, condition, 1, 100000)
if err != nil {
g.Log().Errorf(ctx, "查询不通过视频失败: %v", err)
return nil, fmt.Errorf("查询不通过视频失败: %w", err)
}
g.Log().Infof(ctx, "导出不通过视频: total=%d, got=%d", total, len(videos))
for _, vid := range videos {
// 查询最后一条失败的校验日志
log, _ := dao.MaterialVerifyLog.GetLastRejectedLogByMaterialID(ctx, vid.VideoID, entity.VerifyStatusRejected)
var createdAtStr string
if log != nil && log.CreatedAt != nil {
createdAtStr = log.CreatedAt.Format("Y-m-d H:i:s")
}
items = append(items, ExportRejectedItem{
ID: vid.Id,
MaterialID: vid.VideoID,
AccountID: vid.AccountID,
CorporationName: accountMap[vid.AccountID],
PreviewURL: vid.PreviewURL,
Description: vid.Description,
ErrorMsg: getFailureReason(log),
MaterialType: entity.MaterialTypeVideo,
CreatedAt: createdAtStr,
})
}
}
return items, nil
}
// GetPendingResultsCount 获取待查询结果的数量
func (s *MaterialVerifyService) GetPendingResultsCount(ctx context.Context) (int, error) {
return dao.MaterialVerifyLog.CountPendingResults(ctx)