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" // 图片送检表 TencentImageTable = "tencent_image" // 图片送检表
TencentVideoTable = "tencent_video" // 视频送检表 TencentVideoTable = "tencent_video" // 视频送检表
TencentContentCheckLogTable = "tencent_content_check_log" // 送检日志表 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 }, 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 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 统计待查询结果的数量 // CountPendingResults 统计待查询结果的数量
func (d *MaterialVerifyLogDAO) CountPendingResults(ctx context.Context) (int, error) { func (d *MaterialVerifyLogDAO) CountPendingResults(ctx context.Context) (int, error) {
count, err := g.DB("default").Model(MaterialVerifyLogTable). 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> </el-select>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>账户ID:</label> <label>账户:</label>
<el-input v-model="imageFilters.accountId" placeholder="账户ID" style="width: 120px;" clearable @keyup.enter.native="searchImage"></el-input> <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> </div>
<el-button type="primary" @click="searchImage">搜索</el-button> <el-button type="primary" @click="searchImage">搜索</el-button>
<el-button @click="resetImageFilter">重置</el-button> <el-button @click="resetImageFilter">重置</el-button>
@@ -334,8 +341,15 @@
</el-select> </el-select>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label>账户ID:</label> <label>账户:</label>
<el-input v-model="videoFilters.accountId" placeholder="账户ID" style="width: 120px;" clearable @keyup.enter.native="searchVideo"></el-input> <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> </div>
<el-button type="primary" @click="searchVideo">搜索</el-button> <el-button type="primary" @click="searchVideo">搜索</el-button>
<el-button @click="resetVideoFilter">重置</el-button> <el-button @click="resetVideoFilter">重置</el-button>
@@ -601,11 +615,14 @@
previewUrl: '', previewUrl: '',
previewType: 'image', previewType: 'image',
materialLogs: [], materialLogs: [],
batchLoading: false batchLoading: false,
// 账户列表(下拉筛选用)
accounts: []
}, },
mounted() { mounted() {
this.loadStats(); this.loadStats();
this.loadImageList(); this.loadImageList();
this.loadAccounts();
}, },
methods: { methods: {
// API 请求 // 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() { loadImageList() {
this.imageLoading = true; this.imageLoading = true;
@@ -813,36 +845,50 @@
}); });
}, },
// 导出 // 导出(不通过数据,含失败原因)
exportImageUrls() { exportImageUrls() {
if (!this.imageList.length) { this.apiPost('/material/verify/controller/export-rejected', { materialType: 'IMAGE' }).then(res => {
this.$message.warning('没有可导出的图片'); const items = res.data.data?.items || [];
return; if (!items.length) {
} this.$message.warning('没有不通过的图片数据');
const rows = [['ID', '图片ID', '账户ID', '用途', '预览URL', '状态', '描述']]; return;
this.imageList.forEach(item => { }
rows.push([ const rows = [['序号', '账户(名称)', '预览URL', '描述', '失败原因', '检测时间']];
item.id, item.imageId, item.accountId, item.imageUsage, items.forEach((item, index) => {
item.previewUrl || '-', const accountLabel = item.corporationName ? item.accountId + ' - ' + item.corporationName : item.accountId;
this.getStatusText(item.verifyStatus), item.description || '-' 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() { exportVideoUrls() {
if (!this.videoList.length) { this.apiPost('/material/verify/controller/export-rejected', { materialType: 'VIDEO' }).then(res => {
this.$message.warning('没有可导出的视频'); const items = res.data.data?.items || [];
return; if (!items.length) {
} this.$message.warning('没有不通过的视频数据');
const rows = [['ID', '视频ID', '账户ID', '预览URL', '状态', '描述']]; return;
this.videoList.forEach(item => { }
rows.push([ const rows = [['序号', '账户(名称)', '预览URL', '描述', '失败原因', '检测时间']];
item.id, item.videoId, item.accountId, items.forEach((item, index) => {
item.previewUrl || '-', const accountLabel = item.corporationName ? item.accountId + ' - '+ item.corporationName : item.accountId;
this.getStatusText(item.verifyStatus), item.description || '-' 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) { downloadCsv(filename, rows) {
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); 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) 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 获取待查询结果的数量 // GetPendingResultsCount 获取待查询结果的数量
func (s *MaterialVerifyService) GetPendingResultsCount(ctx context.Context) (int, error) { func (s *MaterialVerifyService) GetPendingResultsCount(ctx context.Context) (int, error) {
return dao.MaterialVerifyLog.CountPendingResults(ctx) return dao.MaterialVerifyLog.CountPendingResults(ctx)