同步音频和图片

This commit is contained in:
2026-05-06 16:19:22 +08:00
parent 3814c95047
commit 162bab15e6
22 changed files with 5970 additions and 2 deletions

View File

@@ -51,4 +51,11 @@ consul:
# namespace: "default"
# pass: jiahui8888
jaeger: #链路追踪
addr: 116.204.74.41:4318
addr: 116.204.74.41:4318
tencent:
oauth:
client_id: "1112038234"
client_secret: "GxyjXFbZAs5dnsNQ"
refresh_token: "afd77374e4b0a305a1a5c1ce349f7105"
access_token: "55c1371d16c65921b1448b91ce688a49"

View File

@@ -18,5 +18,8 @@ const (
UnitReportDetailTable = "unit_report_detail" // 广告单元数据detail表
CampaignReportSumTable = "campaign_report_sum" // 广告计划数据detail表
CampaignReportDetailTable = "campaign_report_detail" // 广告计划数据detail表
SyncTaskLogTable = "sync_task_log" // 广告计划数据detail
SyncTaskLogTable = "sync_task_log" // 同步任务日志
TencentAccountRelationTable = "tencent_account_relation" // 腾讯广告账户关系表
TencentAudioTable = "tencent_audio" // 腾讯广告音乐素材表
TencentImageTable = "tencent_image" // 腾讯广告图片素材表
)

View File

@@ -0,0 +1,94 @@
package tencent
import (
"context"
dto "dataengine/model/dto/tencent"
entity "dataengine/model/entity/tencent"
service "dataengine/service/tencent"
"gitea.com/red-future/common/beans"
)
type oauthController struct{}
// OauthController OAuth控制器
var OauthController = new(oauthController)
// RefreshToken 刷新腾讯广告Token
func (c *oauthController) RefreshToken(ctx context.Context, req *dto.RefreshTokenReq) (res *dto.RefreshTokenRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.OauthService.RefreshToken(ctx, req)
}
// SyncAccountRelation 同步账户关系(自动分页获取所有数据)
func (c *oauthController) SyncAccountRelation(ctx context.Context, req *dto.SyncAccountRelationReq) (res *dto.SyncAccountRelationRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.AccountRelationService.SyncAll(ctx, req)
}
// ListAccountRelation 获取所有账户关系
func (c *oauthController) ListAccountRelation(ctx context.Context, req *dto.ListAccountRelationReq) (res *dto.ListAccountRelationRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
list, err := service.AccountRelationService.ListAll(ctx)
if err != nil {
return nil, err
}
// 转换为DTO
items := make([]dto.AccountRelationItem, 0, len(list))
for _, item := range list {
items = append(items, dto.AccountRelationItem{
ID: item.Id,
AccountID: item.AccountID,
CorporationName: item.CorporationName,
IsAdx: item.IsAdx,
IsBid: item.IsBid,
IsMp: item.IsMp,
})
}
res = &dto.ListAccountRelationRes{
List: items,
}
return res, nil
}
// SyncAudio 同步音乐素材(自动分页获取所有数据)
func (c *oauthController) SyncAudio(ctx context.Context, req *dto.SyncAudioReq) (res *dto.SyncAudioRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.AudioService.SyncAll(ctx, req)
}
// ListAudio 获取所有音乐素材
func (c *oauthController) ListAudio(ctx context.Context, req *dto.ListAudioReq) (res []entity.Audio, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.AudioService.ListAll(ctx)
}
// SyncImage 同步图片素材(遍历所有账户,自动分页)
func (c *oauthController) SyncImage(ctx context.Context, req *dto.SyncImageReq) (res *dto.SyncImageRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.ImageService.SyncAll(ctx, req)
}
// ListImage 获取所有图片素材(旧接口,保留兼容)
func (c *oauthController) ListImage(ctx context.Context, req *dto.ListImageReq) (res []entity.Image, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
return service.ImageService.ListAll(ctx)
}
// ListImagePage 分页查询图片素材(支持时间过滤)
func (c *oauthController) ListImagePage(ctx context.Context, req *dto.ListImagePageReq) (res *dto.ListImageRes, err error) {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"})
// 转换请求参数为Service层使用的类型
queryReq := &dto.ListImageQueryReq{
Page: req.Page,
PageSize: req.PageSize,
AccountId: req.AccountId,
StartTime: req.StartTime,
EndTime: req.EndTime,
Status: req.Status,
}
return service.ImageService.ListWithPage(ctx, queryReq)
}

View File

@@ -0,0 +1,118 @@
package tencent
import (
"context"
consts "dataengine/consts/public"
entity "dataengine/model/entity/tencent"
"time"
"gitea.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type accountRelationDao struct{}
var AccountRelation = new(accountRelationDao)
// Upsert 插入或更新账户关系根据account_id判断
func (d *accountRelationDao) Upsert(ctx context.Context, item *entity.AccountRelation) error {
now := time.Now()
// 检查是否已存在
var existing entity.AccountRelation
err := gfdb.DB(ctx).Model(ctx, consts.TencentAccountRelationTable).
Where("tenant_id", item.TenantId).
Where(entity.AccountRelationCols.AccountID, item.AccountID).
WhereNull(entity.AccountRelationCols.DeletedAt).
Scan(&existing)
// Scan找不到记录时err不为nil但这是正常情况需要继续执行插入
if err != nil && existing.Id == 0 {
// 记录不存在,执行插入
item.CreatedAt = &now
item.UpdatedAt = &now
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentAccountRelationTable).
Data(item).
Insert()
return err
}
// 记录存在,执行更新
item.UpdatedAt = &now
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentAccountRelationTable).
Where("id", existing.Id).
Data(g.Map{
entity.AccountRelationCols.CorporationName: item.CorporationName,
entity.AccountRelationCols.CommentDataList: item.CommentDataList,
entity.AccountRelationCols.IsAdx: item.IsAdx,
entity.AccountRelationCols.IsBid: item.IsBid,
entity.AccountRelationCols.IsMp: item.IsMp,
entity.AccountRelationCols.UpdatedAt: now,
}).
Update()
return err
}
// BatchUpsert 批量插入或更新(使用 OnConflict 实现 Upsert
func (d *accountRelationDao) BatchUpsert(ctx context.Context, items []*entity.AccountRelation) (successCount int, err error) {
if len(items) == 0 {
return 0, nil
}
logrus.Infof("开始批量Upsert: %d 条记录", len(items))
// 分批处理每批100条
batchSize := 100
successCount = 0
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]
logrus.Infof("处理第 %d-%d 条记录", i+1, end)
// 执行批量插入,使用 OnConflict 实现 Upsert
result, err := gfdb.DB(ctx).Model(ctx, consts.TencentAccountRelationTable).
Data(batch).
OnConflict(entity.AccountRelationCols.AccountID).
Save()
if err != nil {
logrus.Errorf("批量Upsert失败: %v尝试逐条处理", err)
// 批量失败,逐条处理
for _, item := range batch {
if upsertErr := d.Upsert(ctx, item); upsertErr != nil {
logrus.Errorf("逐条Upsert失败: account_id=%d, err=%v", item.AccountID, upsertErr)
continue
}
successCount++
}
} else {
affected, _ := result.RowsAffected()
successCount += int(affected)
logrus.Infof("批量Upsert成功: 影响 %d 条记录", affected)
}
}
logrus.Infof("批量Upsert完成: 成功 %d 条", successCount)
return successCount, nil
}
// ListAll 获取所有账户关系
func (d *accountRelationDao) ListAll(ctx context.Context) ([]entity.AccountRelation, error) {
var list []entity.AccountRelation
err := gfdb.DB(ctx).Model(ctx, consts.TencentAccountRelationTable).
WhereNull(entity.AccountRelationCols.DeletedAt).
OrderAsc(entity.AccountRelationCols.AccountID).
Scan(&list)
return list, err
}

111
dao/tencent/audio_dao.go Normal file
View File

@@ -0,0 +1,111 @@
package tencent
import (
"context"
consts "dataengine/consts/public"
entity "dataengine/model/entity/tencent"
"gitea.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type audioDao struct{}
var Audio = new(audioDao)
// BatchUpsert 批量插入或更新(使用 OnConflict 实现 Upsert
func (d *audioDao) BatchUpsert(ctx context.Context, items []*entity.Audio) (successCount int, err error) {
if len(items) == 0 {
return 0, nil
}
logrus.Infof("开始批量Upsert音乐素材: %d 条记录", len(items))
// 分批处理每批100条
batchSize := 100
successCount = 0
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]
logrus.Infof("处理第 %d-%d 条音乐素材记录", i+1, end)
// 执行批量插入,使用 OnConflict 实现 Upsert
result, err := gfdb.DB(ctx).Model(ctx, consts.TencentAudioTable).
Data(batch).
OnConflict(entity.AudioCols.AudioId).
Save()
if err != nil {
logrus.Errorf("批量Upsert音乐素材失败: %v尝试逐条处理", err)
// 批量失败,逐条处理
for _, item := range batch {
if upsertErr := d.upsertSingle(ctx, item); upsertErr != nil {
logrus.Errorf("逐条Upsert音乐素材失败: audio_id=%s, err=%v", item.AudioId, upsertErr)
continue
}
successCount++
}
} else {
affected, _ := result.RowsAffected()
successCount += int(affected)
logrus.Infof("批量Upsert音乐素材成功: 影响 %d 条记录", affected)
}
}
logrus.Infof("批量Upsert音乐素材完成: 成功 %d 条", successCount)
return successCount, nil
}
// upsertSingle 单条插入或更新
func (d *audioDao) upsertSingle(ctx context.Context, item *entity.Audio) error {
var existing entity.Audio
err := gfdb.DB(ctx).Model(ctx, consts.TencentAudioTable).
Where("tenant_id", item.TenantId).
Where(entity.AudioCols.AudioId, item.AudioId).
WhereNull(entity.AudioCols.DeletedAt).
Scan(&existing)
if err != nil && existing.Id == 0 {
// 记录不存在,执行插入
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentAudioTable).
Data(item).
Insert()
return err
}
// 记录存在,执行更新
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentAudioTable).
Where("id", existing.Id).
Data(g.Map{
entity.AudioCols.CoverImageUrl: item.CoverImageUrl,
entity.AudioCols.AudioName: item.AudioName,
entity.AudioCols.Author: item.Author,
entity.AudioCols.Duration: item.Duration,
entity.AudioCols.ExpireTime: item.ExpireTime,
entity.AudioCols.FeelTags: item.FeelTags,
entity.AudioCols.GenreTags: item.GenreTags,
entity.AudioCols.Updater: item.Updater,
entity.AudioCols.UpdatedAt: item.UpdatedAt,
}).
Update()
return err
}
// ListAll 获取所有音乐素材
func (d *audioDao) ListAll(ctx context.Context) ([]entity.Audio, error) {
var list []entity.Audio
err := gfdb.DB(ctx).Model(ctx, consts.TencentAudioTable).
WhereNull(entity.AudioCols.DeletedAt).
OrderAsc(entity.AudioCols.AudioId).
Scan(&list)
return list, err
}

159
dao/tencent/image_dao.go Normal file
View File

@@ -0,0 +1,159 @@
package tencent
import (
"context"
consts "dataengine/consts/public"
entity "dataengine/model/entity/tencent"
"gitea.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type imageDao struct{}
var Image = new(imageDao)
// BatchUpsert 批量插入或更新(使用 OnConflict 实现 Upsert
func (d *imageDao) BatchUpsert(ctx context.Context, items []*entity.Image) (successCount int, err error) {
if len(items) == 0 {
return 0, nil
}
logrus.Infof("开始批量Upsert图片素材: %d 条记录", len(items))
// 分批处理每批100条
batchSize := 100
successCount = 0
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]
logrus.Infof("处理第 %d-%d 条图片素材记录", i+1, end)
// 执行批量插入,使用 OnConflict 实现 Upsert
result, err := gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
Data(batch).
OnConflict("(image_id, account_id)").
Save()
if err != nil {
logrus.Errorf("批量Upsert图片素材失败: %v尝试逐条处理", err)
// 批量失败,逐条处理
for _, item := range batch {
if upsertErr := d.upsertSingle(ctx, item); upsertErr != nil {
logrus.Errorf("逐条Upsert图片素材失败: image_id=%s, account_id=%d, err=%v", item.ImageId, item.AccountId, upsertErr)
} else {
successCount++
}
}
} else {
affected, _ := result.RowsAffected()
successCount += int(affected)
logrus.Infof("批量Upsert图片素材成功: 影响 %d 条记录", affected)
}
}
logrus.Infof("批量Upsert图片素材完成: 成功 %d 条", successCount)
return successCount, nil
}
// upsertSingle 单条插入或更新
func (d *imageDao) upsertSingle(ctx context.Context, item *entity.Image) error {
var existing entity.Image
err := gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
Where(entity.ImageCols.ImageId, item.ImageId).
Where(entity.ImageCols.AccountId, item.AccountId).
WhereNull("deleted_at").
Scan(&existing)
if err != nil && existing.Id == 0 {
// 记录不存在,执行插入
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
Data(item).
Insert()
return err
}
// 记录存在,执行更新
_, err = gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
Where("id", existing.Id).
Data(g.Map{
entity.ImageCols.Width: item.Width,
entity.ImageCols.Height: item.Height,
entity.ImageCols.FileSize: item.FileSize,
entity.ImageCols.Type: item.Type,
entity.ImageCols.Signature: item.Signature,
entity.ImageCols.Description: item.Description,
entity.ImageCols.PreviewUrl: item.PreviewUrl,
entity.ImageCols.ThumbPreviewUrl: item.ThumbPreviewUrl,
entity.ImageCols.Status: item.Status,
entity.ImageCols.LastModifiedTime: item.LastModifiedTime,
}).
Update()
return err
}
// ListAll 获取所有图片素材
func (d *imageDao) ListAll(ctx context.Context) ([]entity.Image, error) {
var list []entity.Image
err := gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
WhereNull("deleted_at").
OrderAsc(entity.ImageCols.ImageId).
Scan(&list)
return list, err
}
// ListWithPage 分页查询图片素材(支持时间过滤)
func (d *imageDao) ListWithPage(ctx context.Context, page, pageSize int, accountId *int64, startTime, endTime *int64, status string) ([]entity.Image, int, error) {
model := gfdb.DB(ctx).Model(ctx, consts.TencentImageTable).
WhereNull("deleted_at")
// 账户ID过滤
if accountId != nil && *accountId > 0 {
model = model.Where(entity.ImageCols.AccountId, *accountId)
}
// 状态过滤
if status != "" {
model = model.Where(entity.ImageCols.Status, status)
}
// 时间范围过滤(根据 last_modified_time
if startTime != nil && *startTime > 0 {
model = model.WhereGTE(entity.ImageCols.LastModifiedTime, *startTime)
}
if endTime != nil && *endTime > 0 {
model = model.WhereLTE(entity.ImageCols.LastModifiedTime, *endTime)
}
// 设置排序(按最后修改时间降序)
model = model.OrderDesc(entity.ImageCols.LastModifiedTime)
// 获取总数
total, err := model.Count()
if err != nil {
return nil, 0, err
}
// 分页查询
var list []entity.Image
if page > 0 && pageSize > 0 {
err = model.Page(page, pageSize).Scan(&list)
} else {
err = model.Scan(&list)
}
if err != nil {
return nil, 0, err
}
return list, total, nil
}

View File

@@ -3,6 +3,7 @@ package main
import (
"dataengine/controller/copydata"
"dataengine/controller/dict"
"dataengine/controller/tencent"
"gitea.com/red-future/common/http"
"gitea.com/red-future/common/jaeger"
@@ -29,6 +30,8 @@ func main() {
copydata.CreativeReport,
copydata.UnitReport,
copydata.CampaignReport,
// 腾讯广告OAuth
tencent.OauthController,
})
select {}
}

View File

@@ -0,0 +1,48 @@
package tencent
import "github.com/gogf/gf/v2/frame/g"
// GetAccountRelationReq 获取账户关系请求
type GetAccountRelationReq struct {
g.Meta `path:"/getAccountRelation" method:"post" tags:"腾讯广告账户关系" summary:"获取账户关系列表" dc:"从腾讯广告API获取账户关系数据"`
AccessToken string `json:"access_token" dc:"访问令牌"`
Timestamp int64 `json:"timestamp" dc:"时间戳"`
Nonce string `json:"nonce" dc:"随机字符串"`
PaginationMode string `json:"pagination_mode" dc:"分页模式" d:"PAGINATION_MODE_NORMAL"`
Page int `json:"page" dc:"页码" d:"1"`
PageSize int `json:"page_size" dc:"每页数量" d:"100"`
}
// SyncAccountRelationReq 同步账户关系请求
type SyncAccountRelationReq struct {
g.Meta `path:"/syncAccountRelation" method:"post" tags:"腾讯广告账户关系" summary:"同步账户关系" dc:"自动分页获取所有账户关系并保存到数据库"`
AccessToken string `json:"access_token" dc:"访问令牌(可选,不传则从配置读取)"`
}
// SyncAccountRelationRes 同步账户关系响应
type SyncAccountRelationRes struct {
TotalNumber int `json:"total_number" dc:"总记录数"`
TotalPage int `json:"total_page" dc:"总页数"`
SyncedCount int `json:"synced_count" dc:"同步成功数量"`
Message string `json:"message" dc:"消息"`
}
// ListAccountRelationReq 获取账户关系列表请求
type ListAccountRelationReq struct {
g.Meta `path:"/listAccountRelation" method:"post" tags:"腾讯广告账户关系" summary:"获取账户关系列表" dc:"从本地数据库查询账户关系列表"`
}
// ListAccountRelationRes 获取账户关系列表响应
type ListAccountRelationRes struct {
List []AccountRelationItem `json:"list" dc:"账户关系列表"`
}
// AccountRelationItem 账户关系项
type AccountRelationItem struct {
ID int64 `json:"id" dc:"主键ID"`
AccountID int64 `json:"account_id" dc:"账户ID"`
CorporationName string `json:"corporation_name" dc:"公司名称"`
IsAdx bool `json:"is_adx" dc:"是否ADX"`
IsBid bool `json:"is_bid" dc:"是否BID"`
IsMp bool `json:"is_mp" dc:"是否MP"`
}

View File

@@ -0,0 +1,22 @@
package tencent
import "github.com/gogf/gf/v2/frame/g"
// SyncAudioReq 同步音乐素材请求
type SyncAudioReq struct {
g.Meta `path:"/syncAudio" method:"post" tags:"腾讯广告音乐素材" summary:"同步音乐素材" dc:"自动分页获取所有音乐素材并保存到数据库"`
AccessToken string `json:"access_token" dc:"访问令牌(可选,不传则从配置读取)"`
}
// ListAudioReq 获取音乐素材列表请求
type ListAudioReq struct {
g.Meta `path:"/listAudio" method:"post" tags:"腾讯广告音乐素材" summary:"获取音乐素材列表" dc:"从本地数据库查询音乐素材列表"`
}
// SyncAudioRes 同步音乐素材响应
type SyncAudioRes struct {
TotalNumber int `json:"total_number" dc:"总记录数"`
TotalPage int `json:"total_page" dc:"总页数"`
SyncedCount int `json:"synced_count" dc:"同步成功数量"`
Message string `json:"message" dc:"消息"`
}

View File

@@ -0,0 +1,72 @@
package tencent
import "github.com/gogf/gf/v2/frame/g"
// SyncImageReq 同步图片素材请求
type SyncImageReq struct {
g.Meta `path:"/syncImage" method:"post" tags:"腾讯广告图片素材" summary:"同步图片素材" dc:"遍历所有账户,自动分页获取图片素材并保存到数据库"`
AccessToken string `json:"access_token" dc:"访问令牌(可选,不传则从配置读取)"`
}
// SyncImageRes 同步图片素材响应
type SyncImageRes struct {
TotalAccounts int `json:"total_accounts" dc:"处理的账户数"`
TotalImages int `json:"total_images" dc:"总图片数"`
SyncedCount int `json:"synced_count" dc:"同步成功数量"`
Message string `json:"message" dc:"消息"`
}
// ListImageReq 获取图片素材列表请求(旧接口,无分页)
type ListImageReq struct {
g.Meta `path:"/listImage" method:"post" tags:"腾讯广告图片素材" summary:"获取图片素材列表" dc:"从本地数据库查询所有图片素材(无分页)"`
}
// ListImagePageReq 分页查询图片素材请求
type ListImagePageReq struct {
g.Meta `path:"/listImagePage" method:"post" tags:"腾讯广告图片素材" summary:"分页查询图片素材" dc:"支持分页、时间过滤、账户过滤等条件查询"`
Page int `json:"page" dc:"页码" d:"1"`
PageSize int `json:"page_size" dc:"每页数量" d:"20"`
AccountId *int64 `json:"account_id,omitempty" dc:"账户ID可选"`
StartTime *int64 `json:"start_time,omitempty" dc:"开始时间戳(秒,可选)"`
EndTime *int64 `json:"end_time,omitempty" dc:"结束时间戳(秒,可选)"`
Status string `json:"status,omitempty" dc:"状态筛选(可选)"`
}
// ListImageQueryReq 图片素材查询请求Service层使用
type ListImageQueryReq struct {
Page int `json:"page" dc:"页码"`
PageSize int `json:"page_size" dc:"每页数量"`
AccountId *int64 `json:"account_id,omitempty" dc:"账户ID可选"`
StartTime *int64 `json:"start_time,omitempty" dc:"开始时间戳(秒,可选)"`
EndTime *int64 `json:"end_time,omitempty" dc:"结束时间戳(秒,可选)"`
Status string `json:"status,omitempty" dc:"状态筛选(可选)"`
}
// ListImageRes 获取图片素材列表响应
type ListImageRes struct {
List []ImageItem `json:"list" dc:"图片素材列表"`
Total int `json:"total" dc:"总记录数"`
Page int `json:"page" dc:"当前页码"`
PageSize int `json:"page_size" dc:"每页数量"`
TotalPages int `json:"total_pages" dc:"总页数"`
}
// ImageItem 图片素材项
type ImageItem struct {
Id int64 `json:"id" dc:"主键ID"`
ImageId string `json:"image_id" dc:"图片ID"`
AccountId int64 `json:"account_id" dc:"账户ID"`
Width int `json:"width" dc:"宽度"`
Height int `json:"height" dc:"高度"`
FileSize int64 `json:"file_size" dc:"文件大小"`
Type string `json:"type" dc:"图片类型"`
Signature string `json:"signature" dc:"签名"`
Description string `json:"description" dc:"描述"`
PreviewUrl string `json:"preview_url" dc:"预览URL"`
ThumbPreviewUrl string `json:"thumb_preview_url" dc:"缩略图URL"`
Status string `json:"status" dc:"状态"`
CreatedTime int64 `json:"created_time" dc:"创建时间戳"`
LastModifiedTime int64 `json:"last_modified_time" dc:"最后修改时间戳"`
CreatedAt string `json:"created_at" dc:"数据库创建时间"`
UpdatedAt string `json:"updated_at" dc:"数据库更新时间"`
}

View File

@@ -0,0 +1,19 @@
package tencent
import "github.com/gogf/gf/v2/frame/g"
// RefreshTokenReq 刷新Token请求
type RefreshTokenReq struct {
g.Meta `path:"/refreshToken" method:"post" tags:"腾讯广告OAuth" summary:"刷新访问令牌" dc:"使用refresh_token获取新的access_token"`
ClientID string `json:"client_id" dc:"客户端ID"`
ClientSecret string `json:"client_secret" dc:"客户端密钥"`
RefreshToken string `json:"refresh_token" dc:"刷新令牌"`
}
// RefreshTokenRes 刷新Token响应
type RefreshTokenRes struct {
AccessToken string `json:"access_token" dc:"访问令牌"`
RefreshToken string `json:"refresh_token" dc:"新的刷新令牌"`
AccessTokenExpiresIn int64 `json:"access_token_expires_in" dc:"访问令牌过期时间(秒)"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in" dc:"刷新令牌过期时间(秒)"`
}

View File

@@ -0,0 +1,56 @@
package tencent
import (
"time"
)
// AccountRelation 腾讯广告账户关系实体
type AccountRelation struct {
Id int64 `orm:"id" json:"id" description:"主键ID"`
TenantId int64 `orm:"tenant_id" json:"tenantId" description:"租户ID"`
Creator string `orm:"creator" json:"creator" description:"创建人"`
CreatedAt *time.Time `orm:"created_at" json:"createdAt" description:"创建时间"`
Updater string `orm:"updater" json:"updater" description:"更新人"`
UpdatedAt *time.Time `orm:"updated_at" json:"updatedAt" description:"更新时间"`
DeletedAt *time.Time `orm:"deleted_at" json:"deletedAt" description:"软删除时间"`
AccountID int64 `orm:"account_id" json:"accountId" description:"账户ID"`
CorporationName string `orm:"corporation_name" json:"corporationName" description:"公司名称"`
CommentDataList string `orm:"comment_data_list" json:"commentDataList" description:"备注数据列表JSON"`
IsAdx bool `orm:"is_adx" json:"isAdx" description:"是否ADX"`
IsBid bool `orm:"is_bid" json:"isBid" description:"是否BID"`
IsMp bool `orm:"is_mp" json:"isMp" description:"是否MP"`
}
// AccountRelationCol 账户关系表字段定义
type AccountRelationCol struct {
ID string
TenantID string
Creator string
CreatedAt string
Updater string
UpdatedAt string
DeletedAt string
AccountID string
CorporationName string
CommentDataList string
IsAdx string
IsBid string
IsMp string
}
// AccountRelationCols 账户关系表字段常量
var AccountRelationCols = AccountRelationCol{
ID: "id",
TenantID: "tenant_id",
Creator: "creator",
CreatedAt: "created_at",
Updater: "updater",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
AccountID: "account_id",
CorporationName: "corporation_name",
CommentDataList: "comment_data_list",
IsAdx: "is_adx",
IsBid: "is_bid",
IsMp: "is_mp",
}

View File

@@ -0,0 +1,62 @@
package tencent
import (
"time"
)
// Audio 腾讯广告音乐素材实体
type Audio struct {
Id int64 `orm:"id" json:"id" description:"主键ID"`
TenantId int64 `orm:"tenant_id" json:"tenantId" description:"租户ID"`
Creator string `orm:"creator" json:"creator" description:"创建人"`
CreatedAt *time.Time `orm:"created_at" json:"createdAt" description:"创建时间"`
Updater string `orm:"updater" json:"updater" description:"更新人"`
UpdatedAt *time.Time `orm:"updated_at" json:"updatedAt" description:"更新时间"`
DeletedAt *time.Time `orm:"deleted_at" json:"deletedAt" description:"软删除时间"`
AudioId string `orm:"audio_id" json:"audioId" description:"音乐ID"`
CoverImageUrl string `orm:"cover_image_url" json:"coverImageUrl" description:"封面图片URL"`
AudioName string `orm:"audio_name" json:"audioName" description:"音乐名称"`
Author string `orm:"author" json:"author" description:"作者"`
Duration float64 `orm:"duration" json:"duration" description:"时长(秒)"`
ExpireTime int64 `orm:"expire_time" json:"expireTime" description:"过期时间戳"`
FeelTags string `orm:"feel_tags" json:"feelTags" description:"情感标签数组JSON"`
GenreTags string `orm:"genre_tags" json:"genreTags" description:"风格标签数组JSON"`
}
// AudioCol 音乐素材表字段定义
type AudioCol struct {
Id string
TenantId string
Creator string
CreatedAt string
Updater string
UpdatedAt string
DeletedAt string
AudioId string
CoverImageUrl string
AudioName string
Author string
Duration string
ExpireTime string
FeelTags string
GenreTags string
}
// AudioCols 音乐素材表字段常量
var AudioCols = AudioCol{
Id: "id",
TenantId: "tenant_id",
Creator: "creator",
CreatedAt: "created_at",
Updater: "updater",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
AudioId: "audio_id",
CoverImageUrl: "cover_image_url",
AudioName: "audio_name",
Author: "author",
Duration: "duration",
ExpireTime: "expire_time",
FeelTags: "feel_tags",
GenreTags: "genre_tags",
}

View File

@@ -0,0 +1,117 @@
package tencent
import (
"gitea.com/red-future/common/beans"
)
// Image 腾讯广告图片素材实体
type Image 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"`
Status string `orm:"status" json:"status" 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类型"`
}
// ImageCol 图片素材表字段定义
type ImageCol 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
Status 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
}
// ImageCols 图片素材表字段常量
var ImageCols = ImageCol{
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",
Status: "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",
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
package tencent
import (
"context"
dao "dataengine/dao/tencent"
dto "dataengine/model/dto/tencent"
entity "dataengine/model/entity/tencent"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type accountRelationService struct{}
var AccountRelationService = new(accountRelationService)
// API响应结构
type accountRelationResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
List []struct {
AccountID int64 `json:"account_id"`
CorporationName string `json:"corporation_name"`
CommentDataList json.RawMessage `json:"comment_data_list"`
IsAdx bool `json:"is_adx"`
IsBid bool `json:"is_bid"`
IsMp bool `json:"is_mp"`
} `json:"list"`
PageInfo struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalNumber int `json:"total_number"`
TotalPage int `json:"total_page"`
} `json:"page_info"`
} `json:"data"`
TraceID string `json:"trace_id"`
}
// SyncAll 同步所有账户关系数据(自动分页)
func (s *accountRelationService) SyncAll(ctx context.Context, req *dto.SyncAccountRelationReq) (res *dto.SyncAccountRelationRes, err error) {
// 获取access_token
accessToken := req.AccessToken
if accessToken == "" {
accessToken = g.Cfg().MustGet(ctx, "tencent.oauth.access_token").String()
}
if accessToken == "" {
return nil, fmt.Errorf("access_token不能为空")
}
res = &dto.SyncAccountRelationRes{}
totalSynced := 0
// 先获取第一页,得到总页数
firstPageData, err := s.fetchPage(ctx, accessToken, 1, 100)
if err != nil {
return nil, fmt.Errorf("获取第一页数据失败: %w", err)
}
totalPage := firstPageData.Data.PageInfo.TotalPage
res.TotalNumber = firstPageData.Data.PageInfo.TotalNumber
res.TotalPage = totalPage
logrus.Infof("开始同步腾讯广告账户关系 - 总页数: %d, 总记录数: %d", totalPage, res.TotalNumber)
// 处理第一页数据
synced, err := s.savePageData(ctx, firstPageData)
if err != nil {
logrus.Errorf("保存第一页数据失败: %v", err)
}
totalSynced += synced
// 循环获取剩余页
for page := 2; page <= totalPage; page++ {
logrus.Infof("正在获取第 %d/%d 页...", page, totalPage)
pageData, err := s.fetchPage(ctx, accessToken, page, 100)
if err != nil {
logrus.Errorf("获取第 %d 页失败: %v继续下一页", page, err)
continue
}
synced, err := s.savePageData(ctx, pageData)
if err != nil {
logrus.Errorf("保存第 %d 页数据失败: %v", page, err)
continue
}
totalSynced += synced
// 避免请求过快休眠100ms
time.Sleep(100 * time.Millisecond)
}
res.SyncedCount = totalSynced
res.Message = fmt.Sprintf("同步完成,共处理 %d 条记录", totalSynced)
logrus.Infof("同步完成 - 总页数: %d, 总记录数: %d, 成功同步: %d", totalPage, res.TotalNumber, totalSynced)
return res, nil
}
// fetchPage 获取单页数据
func (s *accountRelationService) fetchPage(ctx context.Context, accessToken string, page, pageSize int) (*accountRelationResponse, error) {
timestamp := time.Now().Unix()
// 使用时间戳+随机数生成唯一的nonce
nonce := fmt.Sprintf("%d_%d", timestamp, time.Now().UnixNano())
url := fmt.Sprintf("https://api.e.qq.com/v3.0/organization_account_relation/get?"+
"access_token=%s&timestamp=%d&nonce=%s&pagination_mode=PAGINATION_MODE_NORMAL&page=%d&page_size=%d",
accessToken, timestamp, nonce, page, pageSize)
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var result accountRelationResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("API错误: code=%d, message=%s", result.Code, result.Message)
}
return &result, nil
}
// savePageData 保存单页数据到数据库
func (s *accountRelationService) savePageData(ctx context.Context, data *accountRelationResponse) (int, error) {
if len(data.Data.List) == 0 {
return 0, nil
}
logrus.Infof("准备保存 %d 条账户关系数据", len(data.Data.List))
var items []*entity.AccountRelation
for _, item := range data.Data.List {
commentJSON := "{}"
if len(item.CommentDataList) > 0 {
commentJSON = string(item.CommentDataList)
}
accountRelation := &entity.AccountRelation{
AccountID: item.AccountID,
CorporationName: item.CorporationName,
CommentDataList: commentJSON,
IsAdx: item.IsAdx,
IsBid: item.IsBid,
IsMp: item.IsMp,
}
// 设置 TenantID框架将0视为空值所以使用1
accountRelation.TenantId = 1
items = append(items, accountRelation)
}
logrus.Infof("调用 BatchUpsert...")
successCount, err := dao.AccountRelation.BatchUpsert(ctx, items)
logrus.Infof("BatchUpsert 返回: successCount=%d, err=%v", successCount, err)
return successCount, err
}
// ListAll 获取所有账户关系
func (s *accountRelationService) ListAll(ctx context.Context) ([]entity.AccountRelation, error) {
return dao.AccountRelation.ListAll(ctx)
}

View File

@@ -0,0 +1,212 @@
package tencent
import (
"bytes"
"context"
dao "dataengine/dao/tencent"
dto "dataengine/model/dto/tencent"
entity "dataengine/model/entity/tencent"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type audioService struct{}
var AudioService = new(audioService)
// API响应结构
type audioResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
List []struct {
AudioId string `json:"audio_id"`
CoverImageUrl string `json:"cover_image_url"`
AudioName string `json:"audio_name"`
Author string `json:"author"`
Duration float64 `json:"duration"`
ExpireTime int64 `json:"expire_time"`
FeelTags []string `json:"feel_tags"`
GenreTags []string `json:"genre_tags"`
} `json:"list"`
PageInfo struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalNumber int `json:"total_number"`
TotalPage int `json:"total_page"`
} `json:"page_info"`
} `json:"data"`
TraceId string `json:"trace_id"`
}
// SyncAll 同步所有音乐素材数据(自动分页)
func (s *audioService) SyncAll(ctx context.Context, req *dto.SyncAudioReq) (res *dto.SyncAudioRes, err error) {
// 获取access_token
accessToken := req.AccessToken
if accessToken == "" {
accessToken = g.Cfg().MustGet(ctx, "tencent.oauth.access_token").String()
}
if accessToken == "" {
return nil, fmt.Errorf("access_token不能为空")
}
res = &dto.SyncAudioRes{}
totalSynced := 0
// 先获取第一页,得到总页数
firstPageData, err := s.fetchPage(ctx, accessToken, 1, 100)
if err != nil {
return nil, fmt.Errorf("获取第一页数据失败: %w", err)
}
totalPage := firstPageData.Data.PageInfo.TotalPage
res.TotalNumber = firstPageData.Data.PageInfo.TotalNumber
res.TotalPage = totalPage
logrus.Infof("开始同步腾讯广告音乐素材 - 总页数: %d, 总记录数: %d", totalPage, res.TotalNumber)
// 处理第一页数据
synced, err := s.savePageData(ctx, firstPageData)
if err != nil {
logrus.Errorf("保存第一页数据失败: %v", err)
}
totalSynced += synced
// 循环获取剩余页
for page := 2; page <= totalPage; page++ {
logrus.Infof("正在获取第 %d/%d 页...", page, totalPage)
pageData, err := s.fetchPage(ctx, accessToken, page, 100)
if err != nil {
logrus.Errorf("获取第 %d 页失败: %v继续下一页", page, err)
continue
}
synced, err := s.savePageData(ctx, pageData)
if err != nil {
logrus.Errorf("保存第 %d 页数据失败: %v", page, err)
continue
}
totalSynced += synced
// 避免请求过快休眠100ms
time.Sleep(100 * time.Millisecond)
}
res.SyncedCount = totalSynced
res.Message = fmt.Sprintf("同步完成,共处理 %d 条记录", totalSynced)
logrus.Infof("同步完成 - 总页数: %d, 总记录数: %d, 成功同步: %d", totalPage, res.TotalNumber, totalSynced)
return res, nil
}
// fetchPage 获取单页数据
func (s *audioService) fetchPage(ctx context.Context, accessToken string, page, pageSize int) (*audioResponse, error) {
timestamp := time.Now().Unix()
nonce := fmt.Sprintf("%d_%d", timestamp, time.Now().UnixNano())
url := fmt.Sprintf("https://api.e.qq.com/v3.0/muse_audios/get?access_token=%s&timestamp=%d&nonce=%s",
accessToken, timestamp, nonce)
// 构建请求体
requestBody := map[string]interface{}{
"fields": []string{
"audio_id",
"cover_image_url",
"audio_name",
"author",
"duration",
"expire_time",
"feel_tags",
"genre_tags",
},
"page": page,
"page_size": pageSize,
}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("序列化请求体失败: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var result audioResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("API错误: code=%d, message=%s", result.Code, result.Message)
}
return &result, nil
}
// savePageData 保存单页数据到数据库
func (s *audioService) savePageData(ctx context.Context, data *audioResponse) (int, error) {
if len(data.Data.List) == 0 {
return 0, nil
}
logrus.Infof("准备保存 %d 条音乐素材数据", len(data.Data.List))
var items []*entity.Audio
for _, item := range data.Data.List {
// 序列化标签数组
feelTagsJSON, _ := json.Marshal(item.FeelTags)
genreTagsJSON, _ := json.Marshal(item.GenreTags)
audio := &entity.Audio{
TenantId: 1,
Creator: "system",
Updater: "system",
AudioId: item.AudioId,
CoverImageUrl: item.CoverImageUrl,
AudioName: item.AudioName,
Author: item.Author,
Duration: item.Duration,
ExpireTime: item.ExpireTime,
FeelTags: string(feelTagsJSON),
GenreTags: string(genreTagsJSON),
}
items = append(items, audio)
}
logrus.Infof("调用 BatchUpsert...")
successCount, err := dao.Audio.BatchUpsert(ctx, items)
logrus.Infof("BatchUpsert 返回: successCount=%d, err=%v", successCount, err)
return successCount, err
}
// ListAll 获取所有音乐素材
func (s *audioService) ListAll(ctx context.Context) ([]entity.Audio, error) {
return dao.Audio.ListAll(ctx)
}

View File

@@ -0,0 +1,363 @@
package tencent
import (
"context"
dao "dataengine/dao/tencent"
dto "dataengine/model/dto/tencent"
entity "dataengine/model/entity/tencent"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"time"
"gitea.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type imageService struct{}
var ImageService = new(imageService)
// API响应结构
type imageResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
List []struct {
ImageId string `json:"image_id"`
Width int `json:"width"`
Height int `json:"height"`
FileSize int64 `json:"file_size"`
Type string `json:"type"`
Signature string `json:"signature"`
Description string `json:"description"`
SourceSignature string `json:"source_signature"`
PreviewUrl string `json:"preview_url"`
ThumbPreviewUrl string `json:"thumb_preview_url"`
SourceType string `json:"source_type"`
ImageUsage string `json:"image_usage"`
CreatedTime int64 `json:"created_time"`
LastModifiedTime int64 `json:"last_modified_time"`
ProductCatalogId int64 `json:"product_catalog_id"`
ProductOuterId string `json:"product_outer_id"`
SourceReferenceId string `json:"source_reference_id"`
OwnerAccountId string `json:"owner_account_id"`
Status string `json:"status"`
SampleAspectRatio string `json:"sample_aspect_ratio"`
SourceMaterialId string `json:"source_material_id"`
NewSourceType string `json:"new_source_type"`
FirstPublicationStatus string `json:"first_publication_status"`
QualityStatus string `json:"quality_status"`
SimilarityStatus string `json:"similarity_status"`
UserAigcStatus string `json:"user_aigc_status"`
SystemAigcStatus string `json:"system_aigc_status"`
AigcSource string `json:"aigc_source"`
AigcFlag string `json:"aigc_flag"`
MuseAigcVersion int `json:"muse_aigc_version"`
AigcType int `json:"aigc_type"`
} `json:"list"`
PageInfo struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalNumber int `json:"total_number"`
TotalPage int `json:"total_page"`
} `json:"page_info"`
} `json:"data"`
TraceId string `json:"trace_id"`
}
// SyncAll 同步所有图片素材数据(遍历所有账户,自动分页)
func (s *imageService) SyncAll(ctx context.Context, req *dto.SyncImageReq) (res *dto.SyncImageRes, err error) {
// 创建独立的context避免HTTP请求超时导致context被取消
// 设置30分钟超时足够完成421个账户的同步任务
independentCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// 保留原context中的user信息供数据库中间件使用
if user := ctx.Value("user"); user != nil {
independentCtx = context.WithValue(independentCtx, "user", user)
}
// 获取access_token
accessToken := req.AccessToken
if accessToken == "" {
accessToken = g.Cfg().MustGet(independentCtx, "tencent.oauth.access_token").String()
}
if accessToken == "" {
return nil, fmt.Errorf("access_token不能为空")
}
res = &dto.SyncImageRes{}
totalSynced := 0
totalImages := 0
// 获取所有账户列表
accounts, err := s.getAccountList(independentCtx)
if err != nil {
return nil, fmt.Errorf("获取账户列表失败: %w", err)
}
res.TotalAccounts = len(accounts)
logrus.Infof("开始同步腾讯广告图片素材 - 账户数: %d", len(accounts))
// 遍历每个账户
for _, account := range accounts {
logrus.Infof("========== 开始处理账户: %d (%s) ==========", account.AccountID, account.CorporationName)
// 获取该账户的所有图片(分页)
accountImages, err := s.syncAccountImages(independentCtx, accessToken, account.AccountID)
if err != nil {
logrus.Errorf("账户 %d 同步失败: %v继续下一个账户", account.AccountID, err)
continue
}
totalImages += accountImages
totalSynced += accountImages
// 避免请求过快休眠200ms
time.Sleep(200 * time.Millisecond)
}
res.TotalImages = totalImages
res.SyncedCount = totalSynced
res.Message = fmt.Sprintf("同步完成,共处理 %d 个账户,%d 条图片记录", res.TotalAccounts, totalSynced)
logrus.Infof("同步完成 - 账户数: %d, 总图片数: %d, 成功同步: %d", res.TotalAccounts, totalImages, totalSynced)
return res, nil
}
// getAccountList 获取所有账户列表
func (s *imageService) getAccountList(ctx context.Context) ([]entity.AccountRelation, error) {
var accounts []entity.AccountRelation
err := gfdb.DB(ctx).Model(ctx, "tencent_account_relation").
WhereNull("deleted_at").
Scan(&accounts)
return accounts, err
}
// syncAccountImages 同步单个账户的图片数据
func (s *imageService) syncAccountImages(ctx context.Context, accessToken string, accountId int64) (int, error) {
totalSynced := 0
// 先获取第一页,得到总页数
firstPageData, err := s.fetchPage(ctx, accessToken, accountId, 1, 100)
if err != nil {
return 0, fmt.Errorf("获取第一页数据失败: %w", err)
}
totalPage := firstPageData.Data.PageInfo.TotalPage
logrus.Infof("账户 %d - 总页数: %d, 总记录数: %d", accountId, totalPage, firstPageData.Data.PageInfo.TotalNumber)
// 处理第一页数据
synced, err := s.savePageData(ctx, firstPageData, accountId)
if err != nil {
logrus.Errorf("保存第一页数据失败: %v", err)
}
totalSynced += synced
// 循环获取剩余页
for page := 2; page <= totalPage; page++ {
logrus.Infof("账户 %d - 正在获取第 %d/%d 页...", accountId, page, totalPage)
pageData, err := s.fetchPage(ctx, accessToken, accountId, page, 100)
if err != nil {
logrus.Errorf("账户 %d - 获取第 %d 页失败: %v继续下一页", accountId, page, err)
continue
}
synced, err := s.savePageData(ctx, pageData, accountId)
if err != nil {
logrus.Errorf("账户 %d - 保存第 %d 页数据失败: %v", accountId, page, err)
continue
}
totalSynced += synced
// 避免请求过快休眠100ms
time.Sleep(100 * time.Millisecond)
}
logrus.Infof("账户 %d - 同步完成,共 %d 条记录", accountId, totalSynced)
return totalSynced, nil
}
// fetchPage 获取单页数据
func (s *imageService) fetchPage(ctx context.Context, accessToken string, accountId int64, page, pageSize int) (*imageResponse, error) {
// 构建filtering参数状态为正常
filtering := `[{"field":"status","operator":"EQUALS","values":["ADSTATUS_NORMAL"]}]`
// URL编码filtering参数
encodedFiltering := url.QueryEscape(filtering)
// 在发送请求前生成最新的时间戳和nonce避免时间戳过期
timestamp := time.Now().Unix()
// 使用时间戳+纳秒后6位+随机数确保唯一性且不超过32字符
nanoSuffix := time.Now().UnixNano() % 1000000 // 取纳秒的后6位
nonce := fmt.Sprintf("%d%06d%d", timestamp, nanoSuffix, rand.Intn(1000))
urlStr := fmt.Sprintf("https://api.e.qq.com/v3.0/images/get?access_token=%s&nonce=%s&timestamp=%d&account_id=%d&filtering=%s&page=%d&page_size=%d",
accessToken, nonce, timestamp, accountId, encodedFiltering, page, pageSize)
logrus.Debugf("请求URL: %s", urlStr)
httpReq, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
logrus.Debugf("API响应: %s", string(body))
var result imageResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("API错误: code=%d, message=%s", result.Code, result.Message)
}
return &result, nil
}
// savePageData 保存单页数据到数据库
func (s *imageService) savePageData(ctx context.Context, data *imageResponse, accountId int64) (int, error) {
if len(data.Data.List) == 0 {
return 0, nil
}
logrus.Infof("准备保存 %d 条图片素材数据", len(data.Data.List))
var items []*entity.Image
for _, item := range data.Data.List {
image := &entity.Image{
ImageId: item.ImageId,
AccountId: accountId,
Width: item.Width,
Height: item.Height,
FileSize: item.FileSize,
Type: item.Type,
Signature: item.Signature,
Description: item.Description,
SourceSignature: item.SourceSignature,
PreviewUrl: item.PreviewUrl,
ThumbPreviewUrl: item.ThumbPreviewUrl,
SourceType: item.SourceType,
ImageUsage: item.ImageUsage,
CreatedTime: item.CreatedTime,
LastModifiedTime: item.LastModifiedTime,
ProductCatalogId: item.ProductCatalogId,
ProductOuterId: item.ProductOuterId,
SourceReferenceId: item.SourceReferenceId,
OwnerAccountId: item.OwnerAccountId,
Status: item.Status,
SampleAspectRatio: item.SampleAspectRatio,
SourceMaterialId: item.SourceMaterialId,
NewSourceType: item.NewSourceType,
FirstPublicationStatus: item.FirstPublicationStatus,
QualityStatus: item.QualityStatus,
SimilarityStatus: item.SimilarityStatus,
UserAigcStatus: item.UserAigcStatus,
SystemAigcStatus: item.SystemAigcStatus,
AigcSource: item.AigcSource,
AigcFlag: item.AigcFlag,
MuseAigcVersion: item.MuseAigcVersion,
AigcType: item.AigcType,
}
// 设置 TenantID框架将0视为空值所以使用1
image.TenantId = 1
items = append(items, image)
}
logrus.Infof("调用 BatchUpsert...")
successCount, err := dao.Image.BatchUpsert(ctx, items)
logrus.Infof("BatchUpsert 返回: successCount=%d, err=%v", successCount, err)
return successCount, err
}
// ListAll 获取所有图片素材
func (s *imageService) ListAll(ctx context.Context) ([]entity.Image, error) {
return dao.Image.ListAll(ctx)
}
// ListWithPage 分页查询图片素材(支持时间过滤)
func (s *imageService) ListWithPage(ctx context.Context, req *dto.ListImageQueryReq) (*dto.ListImageRes, error) {
// 设置默认值
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100 // 限制最大每页数量
}
// 调用DAO层查询
list, total, err := dao.Image.ListWithPage(ctx, page, pageSize, req.AccountId, req.StartTime, req.EndTime, req.Status)
if err != nil {
return nil, fmt.Errorf("查询图片素材失败: %w", err)
}
// 计算总页数
totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 && total > 0 {
totalPages = 1
}
// 转换为DTO
items := make([]dto.ImageItem, 0, len(list))
for _, item := range list {
items = append(items, dto.ImageItem{
Id: item.Id,
ImageId: item.ImageId,
AccountId: item.AccountId,
Width: item.Width,
Height: item.Height,
FileSize: item.FileSize,
Type: item.Type,
Signature: item.Signature,
Description: item.Description,
PreviewUrl: item.PreviewUrl,
ThumbPreviewUrl: item.ThumbPreviewUrl,
Status: item.Status,
CreatedTime: item.CreatedTime,
LastModifiedTime: item.LastModifiedTime,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
res := &dto.ListImageRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
logrus.Infof("查询图片素材 - 页码: %d, 每页: %d, 总数: %d, 总页数: %d", page, pageSize, total, totalPages)
return res, nil
}

View File

@@ -0,0 +1,78 @@
package tencent
import (
"context"
dto "dataengine/model/dto/tencent"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gogf/gf/v2/frame/g"
)
type oauthService struct{}
var OauthService = new(oauthService)
// RefreshToken 刷新腾讯广告Token
func (s *oauthService) RefreshToken(ctx context.Context, req *dto.RefreshTokenReq) (res *dto.RefreshTokenRes, err error) {
// 如果请求中没有提供参数,则从配置文件读取
clientID := req.ClientID
clientSecret := req.ClientSecret
refreshToken := req.RefreshToken
if clientID == "" || clientSecret == "" || refreshToken == "" {
clientID = g.Cfg().MustGet(ctx, "tencent.oauth.client_id").String()
clientSecret = g.Cfg().MustGet(ctx, "tencent.oauth.client_secret").String()
refreshToken = g.Cfg().MustGet(ctx, "tencent.oauth.refresh_token").String()
}
url := fmt.Sprintf("https://api.e.qq.com/oauth/refresh_token?client_id=%s&client_secret=%s&refresh_token=%s",
clientID, clientSecret, refreshToken)
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccessTokenExpiresIn int64 `json:"access_token_expires_in"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("API错误: code=%d, message=%s", result.Code, result.Message)
}
res = &dto.RefreshTokenRes{
AccessToken: result.Data.AccessToken,
RefreshToken: result.Data.RefreshToken,
AccessTokenExpiresIn: result.Data.AccessTokenExpiresIn,
RefreshTokenExpiresIn: result.Data.RefreshTokenExpiresIn,
}
return res, nil
}

View File

@@ -0,0 +1,41 @@
-- 腾讯广告账户关系表
CREATE SEQUENCE IF NOT EXISTS tencent_account_relation_id_seq START WITH 1 INCREMENT BY 1;
CREATE TABLE IF NOT EXISTS tencent_account_relation (
id BIGINT NOT NULL DEFAULT nextval('tencent_account_relation_id_seq'::regclass),
tenant_id BIGINT NOT NULL DEFAULT 0,
creator VARCHAR(100) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(100) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
-- 业务字段
account_id BIGINT NOT NULL,
corporation_name VARCHAR(500),
comment_data_list JSONB,
is_adx BOOLEAN DEFAULT FALSE,
is_bid BOOLEAN DEFAULT FALSE,
is_mp BOOLEAN DEFAULT FALSE,
PRIMARY KEY (id)
);
COMMENT ON TABLE tencent_account_relation IS '腾讯广告账户关系表';
COMMENT ON COLUMN tencent_account_relation.id IS '主键ID';
COMMENT ON COLUMN tencent_account_relation.tenant_id IS '租户ID';
COMMENT ON COLUMN tencent_account_relation.creator IS '创建人';
COMMENT ON COLUMN tencent_account_relation.created_at IS '创建时间';
COMMENT ON COLUMN tencent_account_relation.updater IS '更新人';
COMMENT ON COLUMN tencent_account_relation.updated_at IS '更新时间';
COMMENT ON COLUMN tencent_account_relation.deleted_at IS '软删除时间';
COMMENT ON COLUMN tencent_account_relation.account_id IS '账户ID';
COMMENT ON COLUMN tencent_account_relation.corporation_name IS '公司名称';
COMMENT ON COLUMN tencent_account_relation.comment_data_list IS '备注数据列表';
COMMENT ON COLUMN tencent_account_relation.is_adx IS '是否ADX';
COMMENT ON COLUMN tencent_account_relation.is_bid IS '是否BID';
COMMENT ON COLUMN tencent_account_relation.is_mp IS '是否MP';
-- 唯一索引根据account_id判断是否存在
CREATE UNIQUE INDEX idx_tencent_account_relation_account_id ON tencent_account_relation(tenant_id, account_id);
CREATE INDEX idx_tencent_account_relation_corporation ON tencent_account_relation(tenant_id, corporation_name);

46
sql/10_tencent_audio.sql Normal file
View File

@@ -0,0 +1,46 @@
-- 腾讯广告音乐素材表
CREATE SEQUENCE IF NOT EXISTS tencent_audio_id_seq START WITH 1 INCREMENT BY 1;
CREATE TABLE IF NOT EXISTS tencent_audio (
id BIGINT NOT NULL DEFAULT nextval('tencent_audio_id_seq'::regclass),
tenant_id BIGINT NOT NULL DEFAULT 0,
creator VARCHAR(100) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(100) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
-- 业务字段
audio_id VARCHAR(100) NOT NULL,
cover_image_url TEXT,
audio_name VARCHAR(500),
author VARCHAR(200),
duration NUMERIC(10, 2),
expire_time BIGINT,
feel_tags JSONB,
genre_tags JSONB,
PRIMARY KEY (id)
);
COMMENT ON TABLE tencent_audio IS '腾讯广告音乐素材表';
COMMENT ON COLUMN tencent_audio.id IS '主键ID';
COMMENT ON COLUMN tencent_audio.tenant_id IS '租户ID';
COMMENT ON COLUMN tencent_audio.creator IS '创建人';
COMMENT ON COLUMN tencent_audio.created_at IS '创建时间';
COMMENT ON COLUMN tencent_audio.updater IS '更新人';
COMMENT ON COLUMN tencent_audio.updated_at IS '更新时间';
COMMENT ON COLUMN tencent_audio.deleted_at IS '软删除时间';
COMMENT ON COLUMN tencent_audio.audio_id IS '音乐ID';
COMMENT ON COLUMN tencent_audio.cover_image_url IS '封面图片URL';
COMMENT ON COLUMN tencent_audio.audio_name IS '音乐名称';
COMMENT ON COLUMN tencent_audio.author IS '作者';
COMMENT ON COLUMN tencent_audio.duration IS '时长(秒)';
COMMENT ON COLUMN tencent_audio.expire_time IS '过期时间戳';
COMMENT ON COLUMN tencent_audio.feel_tags IS '情感标签数组';
COMMENT ON COLUMN tencent_audio.genre_tags IS '风格标签数组';
-- 唯一索引根据audio_id判断是否存在
CREATE UNIQUE INDEX idx_tencent_audio_audio_id ON tencent_audio(tenant_id, audio_id);
CREATE INDEX idx_tencent_audio_author ON tencent_audio(tenant_id, author);
CREATE INDEX idx_tencent_audio_expire_time ON tencent_audio(expire_time);

95
sql/11_tencent_image.sql Normal file
View File

@@ -0,0 +1,95 @@
-- 腾讯广告图片素材表
CREATE SEQUENCE IF NOT EXISTS tencent_image_id_seq START WITH 1 INCREMENT BY 1;
CREATE TABLE IF NOT EXISTS tencent_image (
id BIGINT NOT NULL DEFAULT nextval('tencent_image_id_seq'::regclass),
tenant_id BIGINT NOT NULL DEFAULT 0,
creator VARCHAR(100) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(100) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
-- 业务字段
image_id VARCHAR(100) NOT NULL,
account_id BIGINT NOT NULL,
width INT,
height INT,
file_size BIGINT,
type VARCHAR(50),
signature VARCHAR(200),
description TEXT,
source_signature VARCHAR(200),
preview_url TEXT,
thumb_preview_url TEXT,
source_type VARCHAR(100),
image_usage VARCHAR(100),
created_time BIGINT,
last_modified_time BIGINT,
product_catalog_id BIGINT,
product_outer_id VARCHAR(200),
source_reference_id VARCHAR(200),
owner_account_id VARCHAR(100),
status VARCHAR(50),
sample_aspect_ratio VARCHAR(50),
source_material_id VARCHAR(100),
new_source_type VARCHAR(100),
first_publication_status VARCHAR(100),
quality_status VARCHAR(100),
similarity_status VARCHAR(100),
user_aigc_status VARCHAR(100),
system_aigc_status VARCHAR(100),
aigc_source VARCHAR(200),
aigc_flag VARCHAR(50),
muse_aigc_version INT,
aigc_type INT,
PRIMARY KEY (id)
);
COMMENT ON TABLE tencent_image IS '腾讯广告图片素材表';
COMMENT ON COLUMN tencent_image.id IS '主键ID';
COMMENT ON COLUMN tencent_image.tenant_id IS '租户ID';
COMMENT ON COLUMN tencent_image.creator IS '创建人';
COMMENT ON COLUMN tencent_image.created_at IS '创建时间';
COMMENT ON COLUMN tencent_image.updater IS '更新人';
COMMENT ON COLUMN tencent_image.updated_at IS '更新时间';
COMMENT ON COLUMN tencent_image.deleted_at IS '软删除时间';
COMMENT ON COLUMN tencent_image.image_id IS '图片ID';
COMMENT ON COLUMN tencent_image.account_id IS '账户ID';
COMMENT ON COLUMN tencent_image.width IS '宽度';
COMMENT ON COLUMN tencent_image.height IS '高度';
COMMENT ON COLUMN tencent_image.file_size IS '文件大小';
COMMENT ON COLUMN tencent_image.type IS '图片类型';
COMMENT ON COLUMN tencent_image.signature IS '签名';
COMMENT ON COLUMN tencent_image.description IS '描述';
COMMENT ON COLUMN tencent_image.source_signature IS '源签名';
COMMENT ON COLUMN tencent_image.preview_url IS '预览URL';
COMMENT ON COLUMN tencent_image.thumb_preview_url IS '缩略图URL';
COMMENT ON COLUMN tencent_image.source_type IS '来源类型';
COMMENT ON COLUMN tencent_image.image_usage IS '图片用途';
COMMENT ON COLUMN tencent_image.created_time IS '创建时间戳';
COMMENT ON COLUMN tencent_image.last_modified_time IS '最后修改时间戳';
COMMENT ON COLUMN tencent_image.product_catalog_id IS '产品目录ID';
COMMENT ON COLUMN tencent_image.product_outer_id IS '产品外部ID';
COMMENT ON COLUMN tencent_image.source_reference_id IS '源引用ID';
COMMENT ON COLUMN tencent_image.owner_account_id IS '所有者账户ID';
COMMENT ON COLUMN tencent_image.status IS '状态';
COMMENT ON COLUMN tencent_image.sample_aspect_ratio IS '示例宽高比';
COMMENT ON COLUMN tencent_image.source_material_id IS '源素材ID';
COMMENT ON COLUMN tencent_image.new_source_type IS '新来源类型';
COMMENT ON COLUMN tencent_image.first_publication_status IS '首次发布状态';
COMMENT ON COLUMN tencent_image.quality_status IS '质量状态';
COMMENT ON COLUMN tencent_image.similarity_status IS '相似度状态';
COMMENT ON COLUMN tencent_image.user_aigc_status IS '用户AIGC状态';
COMMENT ON COLUMN tencent_image.system_aigc_status IS '系统AIGC状态';
COMMENT ON COLUMN tencent_image.aigc_source IS 'AIGC来源';
COMMENT ON COLUMN tencent_image.aigc_flag IS 'AIGC标志';
COMMENT ON COLUMN tencent_image.muse_aigc_version IS 'Muse AIGC版本';
COMMENT ON COLUMN tencent_image.aigc_type IS 'AIGC类型';
-- 唯一索引根据image_id和account_id判断是否存在
CREATE UNIQUE INDEX idx_tencent_image_image_account ON tencent_image(tenant_id, image_id, account_id);
CREATE INDEX idx_tencent_image_account_id ON tencent_image(account_id);
CREATE INDEX idx_tencent_image_last_modified ON tencent_image(last_modified_time);
CREATE INDEX idx_tencent_image_status ON tencent_image(status);