diff --git a/consts/dataengine/table.go b/consts/dataengine/table.go
index 531062f..d7797f3 100644
--- a/consts/dataengine/table.go
+++ b/consts/dataengine/table.go
@@ -5,4 +5,5 @@ const (
TencentImageTable = "tencent_image" // 图片送检表
TencentVideoTable = "tencent_video" // 视频送检表
TencentContentCheckLogTable = "tencent_content_check_log" // 送检日志表
+ TencentAccountRelationTable = "tencent_account_relation" // 腾讯广告账户关系表
)
diff --git a/controller/dataengine/material_verify_controller.go b/controller/dataengine/material_verify_controller.go
index c6b4559..d4333f2 100644
--- a/controller/dataengine/material_verify_controller.go
+++ b/controller/dataengine/material_verify_controller.go
@@ -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
+}
+
// =============================================================================
// 回调处理接口
// =============================================================================
diff --git a/dao/dataengine/material_verify_log_dao.go b/dao/dataengine/material_verify_log_dao.go
index e531360..42cff75 100644
--- a/dao/dataengine/material_verify_log_dao.go
+++ b/dao/dataengine/material_verify_log_dao.go
@@ -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).
diff --git a/dao/dataengine/tencent_account_relation_dao.go b/dao/dataengine/tencent_account_relation_dao.go
new file mode 100644
index 0000000..b32f6c1
--- /dev/null
+++ b/dao/dataengine/tencent_account_relation_dao.go
@@ -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
+}
diff --git a/model/entity/dataengine/tencent_account_relation.go b/model/entity/dataengine/tencent_account_relation.go
new file mode 100644
index 0000000..5524b59
--- /dev/null
+++ b/model/entity/dataengine/tencent_account_relation.go
@@ -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",
+}
diff --git a/resource/frontend/material-verify.html b/resource/frontend/material-verify.html
index f62e8d0..2adfe43 100644
--- a/resource/frontend/material-verify.html
+++ b/resource/frontend/material-verify.html
@@ -255,8 +255,15 @@
-
-
+
+
+
+
+
搜索
重置
@@ -334,8 +341,15 @@
-
-
+
+
+
+
+
搜索
重置
@@ -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');
diff --git a/service/dataengine/material_verify_service.go b/service/dataengine/material_verify_service.go
index 28d4d50..f115c8f 100644
--- a/service/dataengine/material_verify_service.go
+++ b/service/dataengine/material_verify_service.go
@@ -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"` // 素材ID(imageId/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)