Merge branch 'feat-cjy-report-new'
This commit is contained in:
commit
4cc813de6e
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,3 +31,5 @@
|
||||
/cmd/logs/*.log
|
||||
/cmd/runtime/log/*.log
|
||||
/build/*
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
|
||||
BIN
data/竞品报告pdf模板.pdf
Normal file
BIN
data/竞品报告pdf模板.pdf
Normal file
Binary file not shown.
BIN
data/竞品报告pdf模板_old.pdf
Normal file
BIN
data/竞品报告pdf模板_old.pdf
Normal file
Binary file not shown.
Binary file not shown.
1
go.mod
1
go.mod
@ -106,7 +106,6 @@ require (
|
||||
github.com/BurntSushi/toml v1.2.1
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/duke-git/lancet/v2 v2.3.8
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0
|
||||
github.com/fonchain/utils/security v0.0.0-00010101000000-000000000000
|
||||
github.com/fonchain/utils/voice v0.0.0-00010101000000-000000000000
|
||||
|
||||
2
go.sum
2
go.sum
@ -258,8 +258,6 @@ github.com/dubbogo/net v0.0.4/go.mod h1:1CGOnM7X3he+qgGNqjeADuE5vKZQx/eMSeUkpU3u
|
||||
github.com/dubbogo/triple v1.0.9/go.mod h1:1t9me4j4CTvNDcsMZy6/OGarbRyAUSY0tFXGXHCp7Iw=
|
||||
github.com/dubbogo/triple v1.1.8 h1:yE+J3W1NTZCEPa1FoX+VWZH6UF1c0+A2MGfERlU2zbI=
|
||||
github.com/dubbogo/triple v1.1.8/go.mod h1:9pgEahtmsY/avYJp3dzUQE8CMMVe1NtGBmUhfICKLJk=
|
||||
github.com/duke-git/lancet/v2 v2.3.8 h1:dlkqn6Nj2LRWFuObNxttkMHxrFeaV6T26JR8jbEVbPg=
|
||||
github.com/duke-git/lancet/v2 v2.3.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"fonchain-fiee/pkg/common/qwen"
|
||||
@ -70,10 +71,183 @@ type CompetitorReportRequest struct {
|
||||
Model string `json:"model"` // 可选的模型名称,默认使用 qwen3-vl-plus
|
||||
}
|
||||
|
||||
// CompetitorReportData 竞品报告数据(用于返回给前端)
|
||||
type CompetitorReportData struct {
|
||||
HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"`
|
||||
DataPerformance DataPerformanceData `json:"data_performance_analysis"`
|
||||
OverallSummary string `json:"overall_summary_and_optimization"`
|
||||
}
|
||||
|
||||
type HighlightAnalysisData struct {
|
||||
Summary string `json:"summary"`
|
||||
Points PointsData `json:"points"`
|
||||
}
|
||||
|
||||
type PointsData struct {
|
||||
Theme string `json:"theme"`
|
||||
Narrative string `json:"narrative"`
|
||||
Content string `json:"content"`
|
||||
Copywriting string `json:"copywriting"`
|
||||
Data string `json:"data"`
|
||||
Music string `json:"music,omitempty"`
|
||||
}
|
||||
|
||||
type DataPerformanceData struct {
|
||||
Views string `json:"views"`
|
||||
Completion string `json:"completion_rate,omitempty"`
|
||||
Engagement string `json:"engagement"`
|
||||
}
|
||||
|
||||
// CompetitorReportResponse 竞品报告响应数据
|
||||
type CompetitorReportResponse struct {
|
||||
ImageURL string `json:"image_url,omitempty"` // 生成的图片URL(1024*1024),非必须返回
|
||||
Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回
|
||||
ImageURL string `json:"image_url,omitempty"` // 生成的图片URL(1024*1024),非必须返回
|
||||
Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回
|
||||
JsonData *CompetitorReportData `json:"json_data,omitempty"` // 竞品报告JSON数据
|
||||
}
|
||||
|
||||
// CompetitorReportJSON AI返回的JSON结构
|
||||
type CompetitorReportJSON struct {
|
||||
HighlightAnalysis HighlightAnalysis `json:"highlight_analysis"`
|
||||
DataPerformance DataPerformance `json:"data_performance_analysis"`
|
||||
OverallSummary string `json:"overall_summary_and_optimization"`
|
||||
}
|
||||
|
||||
type HighlightAnalysis struct {
|
||||
Summary string `json:"summary"`
|
||||
Points Points `json:"points"`
|
||||
}
|
||||
|
||||
type Points struct {
|
||||
Theme string `json:"theme"`
|
||||
Narrative string `json:"narrative"`
|
||||
Content string `json:"content"`
|
||||
Copywriting string `json:"copywriting"`
|
||||
Data string `json:"data"`
|
||||
Music string `json:"music,omitempty"`
|
||||
}
|
||||
|
||||
type DataPerformance struct {
|
||||
Views string `json:"views"`
|
||||
Completion string `json:"completion_rate,omitempty"`
|
||||
Engagement string `json:"engagement"`
|
||||
}
|
||||
|
||||
// convertJSONToText 将 JSON 转换为纯文本格式
|
||||
func convertJSONToText(data CompetitorReportJSON, isVideo bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 一、亮点表现分析
|
||||
sb.WriteString("一、亮点表现分析\n")
|
||||
sb.WriteString(data.HighlightAnalysis.Summary)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("1. 标题亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Theme)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("2. 题材亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("3. 内容亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Content)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("4. 文案亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("5. 数据亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Data)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.HighlightAnalysis.Points.Music != "" {
|
||||
sb.WriteString("6. 配乐亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Music)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 二、数据表现分析
|
||||
sb.WriteString("\n二、数据表现分析\n")
|
||||
sb.WriteString("1. 浏览量表现:")
|
||||
sb.WriteString(data.DataPerformance.Views)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.DataPerformance.Completion != "" {
|
||||
sb.WriteString("2. 完播率表现:")
|
||||
sb.WriteString(data.DataPerformance.Completion)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("3. 点赞/分享/评论表现:")
|
||||
} else {
|
||||
sb.WriteString("2. 点赞/分享/评论表现:")
|
||||
}
|
||||
sb.WriteString(data.DataPerformance.Engagement)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 三、整体总结及可优化建议
|
||||
sb.WriteString("\n三、整体总结及可优化建议\n")
|
||||
sb.WriteString(data.OverallSummary)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// convertJSONToTextFromData 将 JSON 转换为纯文本格式(使用新的 CompetitorReportData 结构)
|
||||
func convertJSONToTextFromData(data CompetitorReportData, isVideo bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 一、亮点表现分析
|
||||
sb.WriteString("一、亮点表现分析\n")
|
||||
sb.WriteString(data.HighlightAnalysis.Summary)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("1. 标题亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Theme)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("2. 题材亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("3. 内容亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Content)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("4. 文案亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("5. 数据亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Data)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.HighlightAnalysis.Points.Music != "" {
|
||||
sb.WriteString("6. 配乐亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Music)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 二、数据表现分析
|
||||
sb.WriteString("\n二、数据表现分析\n")
|
||||
sb.WriteString("1. 浏览量表现:")
|
||||
sb.WriteString(data.DataPerformance.Views)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.DataPerformance.Completion != "" {
|
||||
sb.WriteString("2. 完播率表现:")
|
||||
sb.WriteString(data.DataPerformance.Completion)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("3. 点赞/分享/评论表现:")
|
||||
} else {
|
||||
sb.WriteString("2. 点赞/分享/评论表现:")
|
||||
}
|
||||
sb.WriteString(data.DataPerformance.Engagement)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 三、整体总结及可优化建议
|
||||
sb.WriteString("\n三、整体总结及可优化建议\n")
|
||||
sb.WriteString(data.OverallSummary)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// AICompetitorReport 生成竞品报告接口
|
||||
@ -143,8 +317,42 @@ func AICompetitorReport(ctx *gin.Context) {
|
||||
if needText {
|
||||
textChan = make(chan textResult, 1)
|
||||
go func() {
|
||||
// 构建文本生成提示词:理解内容 + 用户要求
|
||||
textPrompt := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告:注意不要输出markdown格式来进行排版,请直接输出纯文本。只需要回复竞品报告的内容,其他无关的内容不要输出,输出的内容第一行不要标题,直接输出竞品报告的正文即可\n我的要求是:\n%s", vlContent, req.TextPrompt)
|
||||
// 根据是否有视频来判断作品类型
|
||||
isVideo := len(req.Videos) > 0
|
||||
|
||||
// 构建文本生成提示词:理解内容 + 用户要求(JSON格式)
|
||||
// 重要:必须明确要求使用英文标点符号,确保返回的JSON符合规范
|
||||
// 重要:必须基于内容给出分析性回复,即使没有提供具体数据
|
||||
var textPrompt string
|
||||
if isVideo {
|
||||
textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出必须以 { 开头并以 } 结束。
|
||||
重要提示:
|
||||
1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。
|
||||
2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据时长分析完播率潜力、根据内容质量分析互动潜力等)。
|
||||
|
||||
基于以下视频和图片的内容描述:
|
||||
%s
|
||||
|
||||
用户要求(仅作为内容参考,不会改变JSON结构):
|
||||
%s
|
||||
|
||||
JSON结构是固定的,请将内容填充到对应的value中,禁止修改key,禁止添加额外字段,禁止输出任何说明文字:
|
||||
{"highlight_analysis":{"summary":"[78字以内的概述]","points":{"theme":"[标题亮点,最多60字]","narrative":"[题材亮点,最多60字]","content":"[内容亮点,最多60字]","copywriting":"[文案亮点,最多60字]","data":"[数据亮点,最多60字]","music":"[配乐亮点,仅视频,最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现,最多60字]","completion_rate":"[完播率表现,仅视频,最多60字]","engagement":"[点赞/分享/评论表现,最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议,最多300字]"}`, vlContent, req.TextPrompt)
|
||||
} else {
|
||||
textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出开头并以 }必须以 { 结束。
|
||||
重要提示:
|
||||
1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。
|
||||
2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据内容质量分析互动潜力等)。
|
||||
|
||||
基于以下视频和图片的内容描述:
|
||||
%s
|
||||
|
||||
用户要求(仅作为内容参考,不会改变JSON结构):
|
||||
%s
|
||||
|
||||
JSON结构是固定的,请将内容填充到对应的value中,禁止修改key,禁止添加额外字段,禁止输出任何说明文字:
|
||||
{"highlight_analysis":{"summary":"[100字以内的概述]","points":{"theme":"[标题亮点,最多60字]","narrative":"[题材亮点,最多60字]","content":"[内容亮点,最多60字]","copywriting":"[文案亮点,最多60字]","data":"[数据亮点,最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现,最多60字]","engagement":"[点赞/分享/评论表现,最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议,最多300字]"}`, vlContent, req.TextPrompt)
|
||||
}
|
||||
|
||||
chatReq, err := buildChatRequest(textPrompt, nil)
|
||||
if err != nil {
|
||||
@ -163,7 +371,13 @@ func AICompetitorReport(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
textChan <- textResult{text: chatResp.Choices[0].Message.Content}
|
||||
// 打印 AI 返回的原始内容(用于调试)
|
||||
aiText := chatResp.Choices[0].Message.Content
|
||||
fmt.Println("========== AI 返回的原始内容 ==========")
|
||||
fmt.Println(aiText)
|
||||
fmt.Println("=========================================")
|
||||
|
||||
textChan <- textResult{text: aiText}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -172,7 +386,7 @@ func AICompetitorReport(ctx *gin.Context) {
|
||||
imageChan = make(chan imageResult, 1)
|
||||
go func() {
|
||||
// 先请求聊天获取图片提示词
|
||||
imagePromptText := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告图片的提示词:\n%s", vlContent, req.ImagePrompt)
|
||||
imagePromptText := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告图片的提示词:\n%s\n\n重要提示:生成的图片内容中不要包含任何文字,仅仅是根据内容生成一张配图即可", vlContent, req.ImagePrompt)
|
||||
|
||||
chatReq, err := buildChatRequest(imagePromptText, nil)
|
||||
if err != nil {
|
||||
@ -267,7 +481,28 @@ func AICompetitorReport(ctx *gin.Context) {
|
||||
// 返回结果(只返回实际生成的内容)
|
||||
result := CompetitorReportResponse{}
|
||||
if needText {
|
||||
result.Text = textRes.text
|
||||
// 将 JSON 解析为结构化数据
|
||||
fmt.Println("========== 开始解析 JSON ==========")
|
||||
fmt.Println("原始内容是否以 { 开头:", strings.HasPrefix(strings.TrimSpace(textRes.text), "{"))
|
||||
fmt.Println("原始内容前100字符:", strings.TrimSpace(textRes.text)[:min(100, len(strings.TrimSpace(textRes.text)))])
|
||||
|
||||
var jsonData CompetitorReportData
|
||||
if err := json.Unmarshal([]byte(textRes.text), &jsonData); err != nil {
|
||||
// 如果解析失败,回退使用原始文本
|
||||
fmt.Println("========== JSON 解析失败 ==========")
|
||||
fmt.Println("解析错误:", err)
|
||||
fmt.Println("===================================")
|
||||
result.Text = textRes.text
|
||||
} else {
|
||||
fmt.Println("========== JSON 解析成功 ==========")
|
||||
fmt.Println("Summary:", jsonData.HighlightAnalysis.Summary)
|
||||
fmt.Println("==================================")
|
||||
|
||||
// 赋值结构体到 JsonData 中
|
||||
result.JsonData = &jsonData
|
||||
|
||||
result.Text = convertJSONToTextFromData(jsonData, len(req.Videos) > 0)
|
||||
}
|
||||
}
|
||||
if needImage {
|
||||
result.ImageURL = imageRes.imageURL
|
||||
|
||||
@ -10,9 +10,11 @@ import (
|
||||
"fonchain-fiee/api/bundle"
|
||||
"fonchain-fiee/api/cast"
|
||||
"fonchain-fiee/pkg/cache"
|
||||
"fonchain-fiee/pkg/common/qwen"
|
||||
"fonchain-fiee/pkg/e"
|
||||
modelCast "fonchain-fiee/pkg/model/cast"
|
||||
"fonchain-fiee/pkg/model/login"
|
||||
modelQwen "fonchain-fiee/pkg/model/qwen"
|
||||
"fonchain-fiee/pkg/service"
|
||||
"fonchain-fiee/pkg/service/bundle/common"
|
||||
"fonchain-fiee/pkg/utils"
|
||||
@ -561,44 +563,64 @@ func ArtistMetricsSeries(ctx *gin.Context) {
|
||||
subNum = infoResp.SubNum
|
||||
}
|
||||
|
||||
subInfoResp, err1 := service.AccountFieeProvider.SubNumGetInfo(context.Background(), &accountFiee.SubNumGetInfoRequest{
|
||||
SubNum: subNum,
|
||||
Domain: "app",
|
||||
})
|
||||
var subInfoResp *accountFiee.UserInfoResponse
|
||||
var subInfoErr error
|
||||
var dataListResp *cast.ArtistDataListResp
|
||||
var metricsResp *cast.ArtistMetricsSeriesResp
|
||||
var dataListErr, metricsErr error
|
||||
|
||||
if err1 != nil {
|
||||
err1 = errors.New("自媒体用户查询失败")
|
||||
service.Error(ctx, err1)
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(3)
|
||||
|
||||
// 并行调用 SubNumGetInfo、ArtistDataList、ArtistMetricsSeries
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
subInfoResp, subInfoErr = service.AccountFieeProvider.SubNumGetInfo(context.Background(), &accountFiee.SubNumGetInfoRequest{
|
||||
SubNum: subNum,
|
||||
Domain: "app",
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dataListResp, dataListErr = service.CastProvider.ArtistDataList(context.Background(), &cast.ArtistDataListReq{
|
||||
SubNum: subNum,
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
metricsResp, metricsErr = service.CastProvider.ArtistMetricsSeries(context.Background(), req)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if subInfoErr != nil {
|
||||
service.Error(ctx, errors.New("自媒体用户查询失败"))
|
||||
return
|
||||
}
|
||||
if subInfoResp == nil || subInfoResp.Id == 0 {
|
||||
err1 = errors.New("自媒体用户不存在")
|
||||
service.Error(ctx, err1)
|
||||
service.Error(ctx, errors.New("自媒体用户不存在"))
|
||||
return
|
||||
}
|
||||
req.ArtistUUID = fmt.Sprint(subInfoResp.Id)
|
||||
newCtx := NewCtxWithUserInfo(ctx)
|
||||
|
||||
var accountConsumptionNumber int32
|
||||
var videoCount int64
|
||||
var imageCount int64
|
||||
dataListResp, err := service.CastProvider.ArtistDataList(newCtx, &cast.ArtistDataListReq{
|
||||
SubNum: subNum,
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
})
|
||||
if err == nil && dataListResp != nil && len(dataListResp.Data) > 0 && dataListResp.Data[0] != nil {
|
||||
if dataListErr == nil && dataListResp != nil && len(dataListResp.Data) > 0 && dataListResp.Data[0] != nil {
|
||||
accountConsumptionNumber = dataListResp.Data[0].AccountConsumptionNumber
|
||||
videoCount = dataListResp.Data[0].VideoCount
|
||||
imageCount = dataListResp.Data[0].ImageCount
|
||||
}
|
||||
|
||||
resp, err := service.CastProvider.ArtistMetricsSeries(newCtx, req)
|
||||
if err != nil {
|
||||
err = errors.New("查询失败")
|
||||
service.Error(ctx, err)
|
||||
if metricsErr != nil {
|
||||
service.Error(ctx, errors.New("查询失败"))
|
||||
return
|
||||
}
|
||||
resp := metricsResp
|
||||
|
||||
raw, _ := json.Marshal(resp)
|
||||
respMap := make(map[string]interface{})
|
||||
@ -607,10 +629,101 @@ func ArtistMetricsSeries(ctx *gin.Context) {
|
||||
respMap["videoCount"] = videoCount
|
||||
respMap["imageCount"] = imageCount
|
||||
|
||||
// 调用 AI 分析数据
|
||||
analysis, err := generateArtistMetricsAnalysis(resp)
|
||||
if err != nil {
|
||||
zap.L().Error("生成艺人指标分析失败", zap.Error(err))
|
||||
// AI 分析失败不影响主业务,返回空字符串
|
||||
respMap["analysis"] = ""
|
||||
} else {
|
||||
respMap["analysis"] = analysis
|
||||
}
|
||||
|
||||
service.Success(ctx, respMap)
|
||||
return
|
||||
}
|
||||
|
||||
// generateArtistMetricsAnalysis 调用 AI 分析艺人指标数据
|
||||
func generateArtistMetricsAnalysis(resp *cast.ArtistMetricsSeriesResp) (string, error) {
|
||||
if resp == nil {
|
||||
return "", errors.New("数据为空")
|
||||
}
|
||||
|
||||
// 构建分析用的数据摘要
|
||||
var dataSummary strings.Builder
|
||||
dataSummary.WriteString("艺人各平台数据表现如下:\n")
|
||||
|
||||
// 粉丝数
|
||||
if resp.FansSeries != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("粉丝数总数: %d (周期类型: %d, 开始日期: %d, 结束日期: %d)\n",
|
||||
resp.FansSeries.FansCount, resp.FansSeries.PeriodType, resp.FansSeries.StartDate, resp.FansSeries.EndDate))
|
||||
}
|
||||
|
||||
// 播放量
|
||||
if resp.ViewsSeries != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("播放量总数: %d (周期类型: %d, 开始日期: %d, 结束日期: %d)\n",
|
||||
resp.ViewsSeries.ViewsCount, resp.ViewsSeries.PeriodType, resp.ViewsSeries.StartDate, resp.ViewsSeries.EndDate))
|
||||
}
|
||||
|
||||
// 点赞数
|
||||
if resp.LikesSeries != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("点赞数总数: %d (周期类型: %d, 开始日期: %d, 结束日期: %d)\n",
|
||||
resp.LikesSeries.LikesCount, resp.LikesSeries.PeriodType, resp.LikesSeries.StartDate, resp.LikesSeries.EndDate))
|
||||
}
|
||||
|
||||
// 评论数
|
||||
if resp.CommentsSeries != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("评论数总数: %d (周期类型: %d, 开始日期: %d, 结束日期: %d)\n",
|
||||
resp.CommentsSeries.CommentsCount, resp.CommentsSeries.PeriodType, resp.CommentsSeries.StartDate, resp.CommentsSeries.EndDate))
|
||||
}
|
||||
|
||||
// 分享数
|
||||
if resp.SharesSeries != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("分享数总数: %d (周期类型: %d, 开始日期: %d, 结束日期: %d)\n",
|
||||
resp.SharesSeries.SharesCount, resp.SharesSeries.PeriodType, resp.SharesSeries.StartDate, resp.SharesSeries.EndDate))
|
||||
}
|
||||
|
||||
// 最佳发布时间
|
||||
if resp.BestPostTime != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("最佳发布时间: %s\n", resp.BestPostTime.DetailJSON))
|
||||
}
|
||||
|
||||
// 最活跃日期
|
||||
if resp.MostActiveDay != nil {
|
||||
dataSummary.WriteString(fmt.Sprintf("最活跃日期: %s\n", resp.MostActiveDay.DetailJSON))
|
||||
}
|
||||
|
||||
// 构建 prompt
|
||||
prompt := fmt.Sprintf(`根据以下艺人各平台运营数据分析各平台数据表现,结合相关数据简要表述优点,字数在200字内(但是回复的时候不要返回具体的字数。注意:回复时请使用平台名称,如TIKTOK、INS等而非数字。重要:不要逐一列举所有平台名称,只需提及有亮点的平台即可:\n%s`, dataSummary.String())
|
||||
|
||||
// 调用 AI
|
||||
req := modelQwen.ChatRequest{
|
||||
Model: "qwen-plus",
|
||||
Messages: []modelQwen.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []modelQwen.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
respAI, err := qwen.Chat(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if respAI == nil || len(respAI.Choices) == 0 {
|
||||
return "", errors.New("AI 返回结果为空")
|
||||
}
|
||||
|
||||
return respAI.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// ArtistMetricsDailyWindow 艺人指标日窗口
|
||||
func ArtistMetricsDailyWindow(ctx *gin.Context) {
|
||||
var req *cast.ArtistMetricsDailyWindowReq
|
||||
|
||||
@ -19,6 +19,7 @@ import (
|
||||
"fonchain-fiee/pkg/utils/stime"
|
||||
"net/url"
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -32,15 +33,23 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreateCompetitiveReportReqEx 扩展的竞品报告请求(包含AI生成的JSON数据)
|
||||
type CreateCompetitiveReportReqEx struct {
|
||||
*cast.CreateCompetitiveReportReq // 嵌入原有请求
|
||||
ReportData utils.CompetitorReportData `json:"json_data"` // AI生成的竞品报告数据(支持 reportData 和 json_data 两种字段名)
|
||||
}
|
||||
|
||||
// CreateCompetitiveReport 创建竞品报告
|
||||
func CreateCompetitiveReport(ctx *gin.Context) {
|
||||
var req *cast.CreateCompetitiveReportReq
|
||||
var reqEx CreateCompetitiveReportReqEx
|
||||
var err error
|
||||
if err = ctx.ShouldBind(&req); err != nil {
|
||||
if err = ctx.ShouldBindJSON(&reqEx); err != nil {
|
||||
service.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := CreateCompetitiveReportCore(ctx, req)
|
||||
// 转换为原有类型
|
||||
req := reqEx.CreateCompetitiveReportReq
|
||||
resp, err := CreateCompetitiveReportCore(ctx, req, reqEx.ReportData)
|
||||
if err != nil {
|
||||
service.Error(ctx, err)
|
||||
return
|
||||
@ -49,7 +58,7 @@ func CreateCompetitiveReport(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveReportReq) (*cast.CreateCompetitiveReportResp, error) {
|
||||
func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveReportReq, reportData utils.CompetitorReportData) (*cast.CreateCompetitiveReportResp, error) {
|
||||
loginInfo := login.GetUserInfoFromC(ctx)
|
||||
lockKey := fmt.Sprintf("lock_create_competitive_report_%d", loginInfo.ID)
|
||||
reply := cache.RedisClient.SetNX(lockKey, time.Now().Format("2006-01-02 15:04:05"), time.Second*5)
|
||||
@ -103,8 +112,10 @@ func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveRe
|
||||
}
|
||||
req.BundleOrderUuid = resp1.OrderUUID
|
||||
|
||||
if req.ReportContent == "" && req.ImageUrl == "" {
|
||||
return nil, errors.New("报告内容和图片不能同时为空")
|
||||
// 验证:必须传入 json_data(使用模板方式生成PDF)
|
||||
hasReportData := reportData.OverallSummary != "" || reportData.HighlightAnalysis.Summary != ""
|
||||
if !hasReportData {
|
||||
return nil, errors.New("参数错误")
|
||||
}
|
||||
|
||||
if req.ImageUrl != "" {
|
||||
@ -116,44 +127,59 @@ func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveRe
|
||||
req.ImageUrl = newImageUrl
|
||||
}
|
||||
|
||||
if req.ReportContent != "" {
|
||||
today := time.Now().Format("20060102")
|
||||
timestamp := time.Now().UnixMicro()
|
||||
pdfFileName := fmt.Sprintf("%s%s老师的竞品报告%d.pdf", today, req.ArtistName, timestamp)
|
||||
pdfFilePath := "./runtime/report_pdf/" + pdfFileName
|
||||
// 使用模板方式生成PDF
|
||||
zap.L().Info("reportData内容", zap.Any("reportData", reportData))
|
||||
fmt.Println(reportData)
|
||||
|
||||
_, err = utils.CheckDirPath("./runtime/report_pdf/", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建PDF目录失败: %v", err)
|
||||
}
|
||||
// 直接使用传入的结构体数据
|
||||
competitorReportData := reportData
|
||||
|
||||
fontPath := "./data/simfang.ttf"
|
||||
err = utils.GeneratePDF(req.ReportContent, req.ImageUrl, pdfFilePath, fontPath)
|
||||
if err != nil {
|
||||
zap.L().Error("生成PDF失败", zap.Error(err))
|
||||
return nil, errors.New("生成PDF失败")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if _, err := os.Stat(pdfFilePath); err == nil {
|
||||
if err := os.Remove(pdfFilePath); err != nil {
|
||||
zap.L().Warn("删除临时PDF文件失败", zap.String("path", pdfFilePath), zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("删除临时PDF文件成功", zap.String("path", pdfFilePath))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
pdfUrl, uploadErr := upload.PutBos(pdfFilePath, upload.PdfType, true)
|
||||
if uploadErr != nil {
|
||||
zap.L().Error("上传PDF失败: %v", zap.Error(uploadErr))
|
||||
return nil, errors.New("上传PDF失败")
|
||||
}
|
||||
req.PdfUrl = pdfUrl
|
||||
} else {
|
||||
req.PdfUrl = req.ImageUrl
|
||||
// 如果有图片URL,设置到reportData中
|
||||
if req.ImageUrl != "" {
|
||||
competitorReportData.ImageURL = req.ImageUrl
|
||||
}
|
||||
|
||||
// 截断超长字段(按AI生成的字段长度要求)
|
||||
competitorReportData = truncateCompetitorReportData(competitorReportData)
|
||||
|
||||
zap.L().Info("解析成功", zap.Any("competitorReportData", competitorReportData))
|
||||
|
||||
// 生成PDF文件名,使用报告标题命名
|
||||
pdfFileName := generateReportFileName(req.Title, req.ArtistName) + ".pdf"
|
||||
pdfFilePath := "./runtime/report_pdf/" + pdfFileName
|
||||
|
||||
_, err = utils.CheckDirPath("./runtime/report_pdf/", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建PDF目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 模板路径
|
||||
templatePath := "./data/竞品报告pdf模板.pdf"
|
||||
|
||||
// 调用 GenerateCompetitorReportPDF
|
||||
err = utils.GenerateCompetitorReportPDF(templatePath, pdfFilePath, competitorReportData)
|
||||
if err != nil {
|
||||
zap.L().Error("生成PDF失败", zap.Error(err))
|
||||
return nil, errors.New("生成PDF失败")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if _, err := os.Stat(pdfFilePath); err == nil {
|
||||
if err := os.Remove(pdfFilePath); err != nil {
|
||||
zap.L().Warn("删除临时PDF文件失败", zap.String("path", pdfFilePath), zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("删除临时PDF文件成功", zap.String("path", pdfFilePath))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
pdfUrl, uploadErr := upload.PutBos(pdfFilePath, upload.PdfType, true)
|
||||
if uploadErr != nil {
|
||||
zap.L().Error("上传PDF失败: %v", zap.Error(uploadErr))
|
||||
return nil, errors.New("上传PDF失败")
|
||||
}
|
||||
req.PdfUrl = pdfUrl
|
||||
|
||||
resp, err := service.CastProvider.CreateCompetitiveReport(newCtx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -308,14 +334,94 @@ func ImportCompetitiveReportBatch(ctx *gin.Context) {
|
||||
temp.Title = nowDate + temp.ArtistName + "老师竞品报告"
|
||||
}
|
||||
}
|
||||
// 解析报告内容(D列,row[3])
|
||||
if len(row) > 3 {
|
||||
temp.ReportContent = row[3]
|
||||
|
||||
// 构建竞品报告数据(新模板格式)
|
||||
// D列(row[3]):亮点表现分析 - 对应Summary字段
|
||||
// E列(row[4]):标题亮点
|
||||
// F列(row[5]):题材亮点
|
||||
// G列(row[6]):内容亮点
|
||||
// H列(row[7]):文案亮点
|
||||
// I列(row[8]):数据亮点
|
||||
// J列(row[9]):配乐亮点
|
||||
// K列(row[10]):浏览量
|
||||
// L列(row[11]):完播率
|
||||
// M列(row[12]):点赞/分享/评论
|
||||
// N列(row[13]):整体总结及可优化建议
|
||||
// O列(row[14]):图片
|
||||
|
||||
var competitorReportData utils.CompetitorReportData
|
||||
|
||||
// 解析亮点表现分析
|
||||
highlightAnalysis := utils.HighlightAnalysisData{
|
||||
Points: utils.PointsData{},
|
||||
}
|
||||
|
||||
// 解析图片URL(E列,row[4])
|
||||
if len(row) > 4 && utils.CleanString(row[4]) != "" {
|
||||
temp.ImageUrl = utils.CleanString(row[4])
|
||||
// 亮点表现分析摘要(D列,row[3])
|
||||
if len(row) > 3 {
|
||||
highlightAnalysis.Summary = utils.CleanString(row[3])
|
||||
}
|
||||
|
||||
// 标题亮点(E列,row[4])
|
||||
if len(row) > 4 {
|
||||
highlightAnalysis.Points.Theme = utils.CleanString(row[4])
|
||||
}
|
||||
// 题材亮点(F列,row[5])
|
||||
if len(row) > 5 {
|
||||
highlightAnalysis.Points.Narrative = utils.CleanString(row[5])
|
||||
}
|
||||
// 内容亮点(G列,row[6])
|
||||
if len(row) > 6 {
|
||||
highlightAnalysis.Points.Content = utils.CleanString(row[6])
|
||||
}
|
||||
// 文案亮点(H列,row[7])
|
||||
if len(row) > 7 {
|
||||
highlightAnalysis.Points.Copywriting = utils.CleanString(row[7])
|
||||
}
|
||||
// 数据亮点(I列,row[8])
|
||||
if len(row) > 8 {
|
||||
highlightAnalysis.Points.Data = utils.CleanString(row[8])
|
||||
}
|
||||
// 配乐亮点(J列,row[9])
|
||||
if len(row) > 9 {
|
||||
highlightAnalysis.Points.Music = utils.CleanString(row[9])
|
||||
}
|
||||
|
||||
// 解析数据表现
|
||||
dataPerformance := utils.DataPerformanceData{}
|
||||
// 浏览量(K列,row[10])
|
||||
if len(row) > 10 {
|
||||
dataPerformance.Views = utils.CleanString(row[10])
|
||||
}
|
||||
// 完播率(L列,row[11])
|
||||
if len(row) > 11 {
|
||||
dataPerformance.Completion = utils.CleanString(row[11])
|
||||
}
|
||||
// 点赞/分享/评论(M列,row[12])
|
||||
if len(row) > 12 {
|
||||
dataPerformance.Engagement = utils.CleanString(row[12])
|
||||
}
|
||||
|
||||
// 整体总结及可优化建议(N列,row[13])
|
||||
if len(row) > 13 {
|
||||
competitorReportData.OverallSummary = utils.CleanString(row[13])
|
||||
}
|
||||
|
||||
// 图片URL(O列,row[14])
|
||||
if len(row) > 14 && utils.CleanString(row[14]) != "" {
|
||||
competitorReportData.ImageURL = utils.CleanString(row[14])
|
||||
}
|
||||
|
||||
competitorReportData.HighlightAnalysis = highlightAnalysis
|
||||
competitorReportData.DataPerformance = dataPerformance
|
||||
|
||||
// 截断超长字段(按AI生成的字段长度要求)
|
||||
competitorReportData = truncateCompetitorReportData(competitorReportData)
|
||||
|
||||
// 验证标题长度(数据库字段为varchar(50),按字符数计算)
|
||||
if utf8.RuneCountInString(temp.Title) > 50 {
|
||||
temp.Remark = "标题长度超出限制"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
@ -325,82 +431,133 @@ func ImportCompetitiveReportBatch(ctx *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证报告内容和图片不能同时为空
|
||||
if temp.ReportContent == "" && temp.ImageUrl == "" {
|
||||
temp.Remark = "报告内容和图片不能同时为空"
|
||||
// 验证亮点表现分析(D列:Summary)
|
||||
if highlightAnalysis.Summary == "" {
|
||||
temp.Remark = "亮点表现分析摘要不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果已经有错误信息,跳过PDF生成
|
||||
if temp.Remark != "" {
|
||||
// 验证标题亮点(E列)
|
||||
if highlightAnalysis.Points.Theme == "" {
|
||||
temp.Remark = "标题亮点不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查图片URL是否包含阿里云,如果包含则下载并重新上传到OSS
|
||||
if temp.ImageUrl != "" {
|
||||
newImageUrl, err := checkAndReuploadImageForReport(temp.ImageUrl)
|
||||
// 验证题材亮点(F列)
|
||||
if highlightAnalysis.Points.Narrative == "" {
|
||||
temp.Remark = "题材亮点不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证内容亮点(G列)
|
||||
if highlightAnalysis.Points.Content == "" {
|
||||
temp.Remark = "内容亮点不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证文案亮点(H列)
|
||||
if highlightAnalysis.Points.Copywriting == "" {
|
||||
temp.Remark = "文案亮点不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证数据亮点(I列)
|
||||
if highlightAnalysis.Points.Data == "" {
|
||||
temp.Remark = "数据亮点不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证浏览量(K列)
|
||||
if dataPerformance.Views == "" {
|
||||
temp.Remark = "浏览量不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证点赞/分享/评论(M列)
|
||||
if dataPerformance.Engagement == "" {
|
||||
temp.Remark = "点赞/分享/评论不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证整体总结及可优化建议(N列)
|
||||
if competitorReportData.OverallSummary == "" {
|
||||
temp.Remark = "整体总结及可优化建议不能为空"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理图片URL
|
||||
if competitorReportData.ImageURL != "" {
|
||||
newImageUrl, err := checkAndReuploadImageForReport(competitorReportData.ImageURL)
|
||||
if err != nil {
|
||||
temp.Remark = fmt.Sprintf("图片处理失败: %v", err)
|
||||
zap.L().Error("图片重新上传失败", zap.String("imageUrl", temp.ImageUrl), zap.Error(err))
|
||||
zap.L().Error("图片重新上传失败", zap.String("imageUrl", competitorReportData.ImageURL), zap.Error(err))
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
competitorReportData.ImageURL = newImageUrl
|
||||
temp.ImageUrl = newImageUrl
|
||||
}
|
||||
|
||||
// 如果提供了报告内容,则生成PDF并上传
|
||||
if temp.ReportContent != "" {
|
||||
// 生成临时PDF文件路径
|
||||
today := time.Now().Format("20060102")
|
||||
timestamp := time.Now().UnixMicro()
|
||||
pdfFileName := fmt.Sprintf("%s%s老师的竞品报告%d.pdf", today, temp.ArtistName, timestamp)
|
||||
pdfFilePath := "./runtime/report_pdf/" + pdfFileName
|
||||
// 生成PDF并上传
|
||||
// 生成临时PDF文件路径,使用报告标题命名
|
||||
pdfFileName := generateReportFileName(temp.Title, temp.ArtistName) + ".pdf"
|
||||
pdfFilePath := "./runtime/report_pdf/" + pdfFileName
|
||||
|
||||
// 确保目录存在
|
||||
_, err = utils.CheckDirPath("./runtime/report_pdf/", true)
|
||||
if err != nil {
|
||||
temp.Remark = fmt.Sprintf("创建PDF目录失败: %v", err)
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
// 确保目录存在
|
||||
_, err = utils.CheckDirPath("./runtime/report_pdf/", true)
|
||||
if err != nil {
|
||||
temp.Remark = fmt.Sprintf("创建PDF目录失败: %v", err)
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 生成PDF文件
|
||||
fontPath := "./data/simfang.ttf"
|
||||
err = utils.GeneratePDF(temp.ReportContent, temp.ImageUrl, pdfFilePath, fontPath)
|
||||
if err != nil {
|
||||
zap.L().Error("生成PDF失败", zap.Error(err))
|
||||
temp.Remark = "生成PDF失败"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
// 模板路径
|
||||
templatePath := "./data/竞品报告pdf模板.pdf"
|
||||
|
||||
// 上传PDF到OSS
|
||||
pdfUrl, uploadErr := upload.PutBos(pdfFilePath, upload.PdfType, true)
|
||||
if uploadErr != nil {
|
||||
zap.L().Error("上传PDF失败", zap.Error(uploadErr))
|
||||
temp.Remark = "上传PDF失败"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
// 清理临时PDF文件
|
||||
if _, err := os.Stat(pdfFilePath); err == nil {
|
||||
os.Remove(pdfFilePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 将上传后的PDF链接设置到请求中
|
||||
temp.PdfUrl = pdfUrl
|
||||
// 使用新的 GenerateCompetitorReportPDF 生成PDF
|
||||
err = utils.GenerateCompetitorReportPDF(templatePath, pdfFilePath, competitorReportData)
|
||||
if err != nil {
|
||||
zap.L().Error("生成PDF失败", zap.Error(err))
|
||||
temp.Remark = "生成PDF失败"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
continue
|
||||
}
|
||||
|
||||
// 上传PDF到OSS
|
||||
pdfUrl, uploadErr := upload.PutBos(pdfFilePath, upload.PdfType, true)
|
||||
if uploadErr != nil {
|
||||
zap.L().Error("上传PDF失败", zap.Error(uploadErr))
|
||||
temp.Remark = "上传PDF失败"
|
||||
req.Reports = append(req.Reports, temp)
|
||||
// 清理临时PDF文件
|
||||
if _, err := os.Stat(pdfFilePath); err == nil {
|
||||
if err := os.Remove(pdfFilePath); err != nil {
|
||||
zap.L().Warn("删除临时PDF文件失败", zap.String("path", pdfFilePath), zap.Error(err))
|
||||
}
|
||||
os.Remove(pdfFilePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 将上传后的PDF链接设置到请求中
|
||||
temp.PdfUrl = pdfUrl
|
||||
|
||||
// 生成竞品报告正文
|
||||
// 判断是否为视频:如果有图片URL则为图片,否则根据配乐亮点和完播率是否有值来判断
|
||||
isVideo := competitorReportData.HighlightAnalysis.Points.Music != "" || competitorReportData.DataPerformance.Completion != ""
|
||||
temp.ReportContent = utils.ConvertCompetitorReportToText(competitorReportData, isVideo)
|
||||
|
||||
// 清理临时PDF文件
|
||||
if _, err := os.Stat(pdfFilePath); err == nil {
|
||||
if err := os.Remove(pdfFilePath); err != nil {
|
||||
zap.L().Warn("删除临时PDF文件失败", zap.String("path", pdfFilePath), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
// 如果没有报告内容,则将图片URL设置为PDF URL
|
||||
temp.PdfUrl = temp.ImageUrl
|
||||
}
|
||||
|
||||
req.Reports = append(req.Reports, temp)
|
||||
@ -433,7 +590,7 @@ func ImportCompetitiveReportBatch(ctx *gin.Context) {
|
||||
// 通过请求对象找到对应的Excel行号
|
||||
if excelRowNum, ok := reportRowMap[reqReport]; ok {
|
||||
// 将错误信息写入最后一列(F列)
|
||||
excelData.SetCellValue("Sheet1", fmt.Sprintf("F%d", excelRowNum), v.Remark)
|
||||
excelData.SetCellValue("Sheet1", fmt.Sprintf("P%d", excelRowNum), v.Remark)
|
||||
hasValueRows[excelRowNum] = true
|
||||
}
|
||||
}
|
||||
@ -1164,3 +1321,70 @@ func checkAndReuploadImageForReport(imageUrl string) (string, error) {
|
||||
|
||||
return compressUrl, nil
|
||||
}
|
||||
|
||||
// generateReportFileName 生成竞品报告PDF文件名
|
||||
// 始终使用标题+时间戳格式,避免文件名冲突导致OSS覆盖
|
||||
func generateReportFileName(title, artistName string) string {
|
||||
timestamp := time.Now().UnixMicro()
|
||||
|
||||
// 如果有标题,使用标题+时间戳
|
||||
if title != "" {
|
||||
// 替换标题中的特殊字符为合法字符
|
||||
fileName := strings.NewReplacer(
|
||||
"/", "",
|
||||
"\\", "",
|
||||
":", "",
|
||||
"*", "",
|
||||
"?", "",
|
||||
"\"", "",
|
||||
"<", "",
|
||||
">", "",
|
||||
"|", "",
|
||||
" ", "_",
|
||||
).Replace(title)
|
||||
// 限制文件名长度,避免过长(预留时间戳的空间)
|
||||
if len(fileName) > 80 {
|
||||
fileName = fileName[:80]
|
||||
}
|
||||
return fmt.Sprintf("%s_%d", fileName, timestamp)
|
||||
}
|
||||
|
||||
// 没有标题时使用默认格式
|
||||
today := time.Now().Format("20060102")
|
||||
return fmt.Sprintf("%s%s老师的竞品报告%d", today, artistName, timestamp)
|
||||
}
|
||||
|
||||
// truncateCompetitorReportData 截断竞品报告数据中超长的字段
|
||||
// 字段长度要求参考 AI 生成竞品报告的限制
|
||||
func truncateCompetitorReportData(data utils.CompetitorReportData) utils.CompetitorReportData {
|
||||
// 字段长度限制
|
||||
const (
|
||||
MaxSummary = 100 // 概述
|
||||
MaxPointField = 60 // 标题/题材/内容/文案/数据/配乐亮点
|
||||
MaxViews = 60 // 浏览量
|
||||
MaxCompletion = 60 // 完播率
|
||||
MaxEngagement = 60 // 点赞/分享/评论
|
||||
MaxOverallSummary = 300 // 整体总结及可优化建议
|
||||
)
|
||||
|
||||
// 截断亮点分析摘要
|
||||
data.HighlightAnalysis.Summary = utils.TruncateTextByRune(data.HighlightAnalysis.Summary, MaxSummary)
|
||||
|
||||
// 截断各亮点字段
|
||||
data.HighlightAnalysis.Points.Theme = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Theme, MaxPointField)
|
||||
data.HighlightAnalysis.Points.Narrative = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Narrative, MaxPointField)
|
||||
data.HighlightAnalysis.Points.Content = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Content, MaxPointField)
|
||||
data.HighlightAnalysis.Points.Copywriting = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Copywriting, MaxPointField)
|
||||
data.HighlightAnalysis.Points.Data = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Data, MaxPointField)
|
||||
data.HighlightAnalysis.Points.Music = utils.TruncateTextByRune(data.HighlightAnalysis.Points.Music, MaxPointField)
|
||||
|
||||
// 截断数据表现字段
|
||||
data.DataPerformance.Views = utils.TruncateTextByRune(data.DataPerformance.Views, MaxViews)
|
||||
data.DataPerformance.Completion = utils.TruncateTextByRune(data.DataPerformance.Completion, MaxCompletion)
|
||||
data.DataPerformance.Engagement = utils.TruncateTextByRune(data.DataPerformance.Engagement, MaxEngagement)
|
||||
|
||||
// 截断整体总结
|
||||
data.OverallSummary = utils.TruncateTextByRune(data.OverallSummary, MaxOverallSummary)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@ -383,7 +383,8 @@ type CreateWorkAnalysisWithTaskUUIDReq struct {
|
||||
|
||||
type CreateCompetitiveReportWithTaskUUIDReq struct {
|
||||
*cast.CreateCompetitiveReportReq
|
||||
AssignRecordsUUID string `json:"assignRecordsUUID"`
|
||||
AssignRecordsUUID string `json:"assignRecordsUUID"`
|
||||
ReportData utils.CompetitorReportData `json:"json_data"` // AI生成的竞品报告数据
|
||||
}
|
||||
|
||||
func UpdateWorkImageWithTaskUUID(ctx *gin.Context) {
|
||||
@ -575,7 +576,7 @@ func CreateCompetitiveReportWithTaskUUID(ctx *gin.Context) {
|
||||
service.Error(ctx, errors.New("任务已中止"))
|
||||
return
|
||||
}
|
||||
resp, err := castService.CreateCompetitiveReportCore(ctx, req.CreateCompetitiveReportReq)
|
||||
resp, err := castService.CreateCompetitiveReportCore(ctx, req.CreateCompetitiveReportReq, req.ReportData)
|
||||
if err != nil {
|
||||
service.Error(ctx, err)
|
||||
return
|
||||
|
||||
423
pkg/utils/pdf.go
423
pkg/utils/pdf.go
@ -3,14 +3,18 @@ package utils
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/phpdave11/gofpdf"
|
||||
"github.com/signintech/gopdf"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji)
|
||||
@ -172,3 +176,422 @@ func GeneratePDF(text, imageURL, outputPath, fontPath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompetitorReportData 竞品报告数据
|
||||
type CompetitorReportData struct {
|
||||
HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"`
|
||||
DataPerformance DataPerformanceData `json:"data_performance_analysis"`
|
||||
OverallSummary string `json:"overall_summary_and_optimization"`
|
||||
ImageURL string `json:"image_url"` // 图片URL,如果有图片则生成单独一页PDF
|
||||
}
|
||||
|
||||
type HighlightAnalysisData struct {
|
||||
Summary string `json:"summary"`
|
||||
Points PointsData `json:"points"`
|
||||
}
|
||||
|
||||
type PointsData struct {
|
||||
Theme string `json:"theme"`
|
||||
Narrative string `json:"narrative"`
|
||||
Content string `json:"content"`
|
||||
Copywriting string `json:"copywriting"`
|
||||
Data string `json:"data"`
|
||||
Music string `json:"music,omitempty"`
|
||||
}
|
||||
|
||||
type DataPerformanceData struct {
|
||||
Views string `json:"views"`
|
||||
Completion string `json:"completion_rate,omitempty"`
|
||||
Engagement string `json:"engagement"`
|
||||
}
|
||||
|
||||
// GenerateCompetitorReportPDF 生成竞品报告PDF
|
||||
// 参数:
|
||||
// - templatePath: 模板文件路径(保留参数以兼容现有调用,传空则不使用模板)
|
||||
// - outputPath: 输出PDF路径
|
||||
// - data: 竞品报告数据
|
||||
//
|
||||
// 返回: 错误信息
|
||||
func GenerateCompetitorReportPDF(templatePath, outputPath string, data CompetitorReportData) error {
|
||||
fmt.Println("================================templatePath:", templatePath)
|
||||
fmt.Println("================================outputPath:", outputPath)
|
||||
|
||||
pdf := gopdf.GoPdf{}
|
||||
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
|
||||
|
||||
// 如果有模板路径,则导入模板
|
||||
if templatePath != "" {
|
||||
err := pdf.ImportPagesFromSource(templatePath, "/MediaBox")
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法导入页面: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模板文件的总页数(如果有模板)
|
||||
totalPages := pdf.GetNumberOfPages()
|
||||
fmt.Printf("模板文件的总页数: %d\n", totalPages)
|
||||
|
||||
// 确定字体路径
|
||||
var fontPath string
|
||||
if templatePath != "" {
|
||||
dir := filepath.Dir(templatePath)
|
||||
fontPath = filepath.Join(dir, "simfang.ttf")
|
||||
if _, err := os.Stat(fontPath); err != nil {
|
||||
fontPath = filepath.Join("data", "simfang.ttf")
|
||||
}
|
||||
} else {
|
||||
fontPath = filepath.Join("data", "simfang.ttf")
|
||||
}
|
||||
fmt.Printf("字体文件路径: %s\n", fontPath)
|
||||
|
||||
// 加载中文字体
|
||||
ttfErr := pdf.AddTTFFont("simfang", fontPath)
|
||||
if ttfErr != nil {
|
||||
zap.L().Error("加载字体失败", zap.String("fontPath", fontPath), zap.Error(ttfErr))
|
||||
return fmt.Errorf("加载中文字体失败: %v", ttfErr)
|
||||
}
|
||||
|
||||
// 设置字体和字号
|
||||
err := pdf.SetFont("simfang", "", 10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("设置字体失败: %v", err)
|
||||
}
|
||||
|
||||
// 行高15pt
|
||||
lineHeight := 15.0
|
||||
|
||||
// 如果有内容要写入,确保在第一页
|
||||
if totalPages > 0 || (data.HighlightAnalysis.Summary != "" || data.OverallSummary != "") {
|
||||
pdf.SetPage(1)
|
||||
|
||||
// 概述 - 使用逐行写入,一行最多35个字
|
||||
pdf.SetXY(200, 104)
|
||||
summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35.0)
|
||||
for i, line := range summaryLines {
|
||||
pdf.SetXY(200, 104+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 标题亮点 - 一行最多9个字
|
||||
pdf.SetXY(200, 184)
|
||||
themeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Theme), 9.0)
|
||||
for i, line := range themeLines {
|
||||
pdf.SetXY(200, 184+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 题材亮点 - 一行最多9个字
|
||||
pdf.SetXY(330, 184)
|
||||
narrativeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Narrative), 9.0)
|
||||
for i, line := range narrativeLines {
|
||||
pdf.SetXY(330, 184+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 内容亮点 - 一行最多9个字
|
||||
pdf.SetXY(460, 184)
|
||||
contentLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Content), 9.0)
|
||||
for i, line := range contentLines {
|
||||
pdf.SetXY(460, 184+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 文案亮点 - 一行最多9个字
|
||||
pdf.SetXY(200, 323)
|
||||
copywritingLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Copywriting), 9.0)
|
||||
for i, line := range copywritingLines {
|
||||
pdf.SetXY(200, 323+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 数据亮点 - 一行最多9个字
|
||||
pdf.SetXY(330, 323)
|
||||
dataLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Data), 9.0)
|
||||
for i, line := range dataLines {
|
||||
pdf.SetXY(330, 323+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 配乐亮点(仅视频) - 一行最多9个字
|
||||
if data.HighlightAnalysis.Points.Music != "" {
|
||||
pdf.SetXY(460, 323)
|
||||
musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9.0)
|
||||
for i, line := range musicLines {
|
||||
pdf.SetXY(460, 323+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览量 - 一行最多35个字
|
||||
pdf.SetXY(200, 474)
|
||||
viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35.0)
|
||||
for i, line := range viewsLines {
|
||||
pdf.SetXY(200, 474+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 完播率 - 一行最多35个字
|
||||
pdf.SetXY(200, 539)
|
||||
if data.DataPerformance.Completion != "" {
|
||||
completionLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Completion), 35.0)
|
||||
for i, line := range completionLines {
|
||||
pdf.SetXY(200, 539+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞/分享/评论 - 一行最多35个字
|
||||
pdf.SetXY(200, 600)
|
||||
engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35.0)
|
||||
for i, line := range engagementLines {
|
||||
pdf.SetXY(200, 600+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
|
||||
// 整体总结及可优化建议 - 一行最多35个字
|
||||
pdf.SetXY(200, 676)
|
||||
overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35.0)
|
||||
for i, line := range overallSummaryLines {
|
||||
pdf.SetXY(200, 676+float64(i)*lineHeight)
|
||||
pdf.Cell(nil, line)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有图片URL,添加新页面并居中显示图片
|
||||
if data.ImageURL != "" {
|
||||
// 添加新页面
|
||||
pdf.AddPage()
|
||||
|
||||
// 下载图片
|
||||
resp, err := http.Get(data.ImageURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载图片失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取图片数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析URL获取文件扩展名
|
||||
u, err := url.Parse(data.ImageURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("图片链接解析错误: %v", err)
|
||||
}
|
||||
fileExt := filepath.Ext(u.Path)
|
||||
if fileExt == "" {
|
||||
fileExt = ".jpg"
|
||||
}
|
||||
|
||||
// 保存到临时文件
|
||||
tmpFile, err := os.CreateTemp("", "pdf_image_*"+fileExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
_, err = tmpFile.Write(imageData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入临时文件失败: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// 使用 image 包获取图片原始尺寸
|
||||
imgFile, err := os.Open(tmpFile.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开图片文件失败: %v", err)
|
||||
}
|
||||
defer imgFile.Close()
|
||||
|
||||
config, format, err := image.DecodeConfig(imgFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取图片尺寸失败: %v", err)
|
||||
}
|
||||
_ = format // 忽略格式
|
||||
|
||||
// A4页面宽度595pt(210mm),高度842pt(297mm)
|
||||
pageWidth := 595.0
|
||||
pageHeight := 842.0
|
||||
margin := 20.0
|
||||
|
||||
// 计算可用宽度
|
||||
availableWidth := pageWidth - 2*margin
|
||||
|
||||
// 计算缩放后的图片尺寸(保持宽高比,宽度为可用宽度的80%)
|
||||
imageWidth := availableWidth * 0.8
|
||||
originalWidth := float64(config.Width)
|
||||
originalHeight := float64(config.Height)
|
||||
imageHeight := (imageWidth / originalWidth) * originalHeight
|
||||
|
||||
// 计算居中位置
|
||||
imageX := (pageWidth - imageWidth) / 2
|
||||
imageY := (pageHeight - imageHeight) / 2
|
||||
|
||||
// 使用 ImageHolderByBytes 添加图片
|
||||
imgH1, err := gopdf.ImageHolderByBytes(imageData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建图片Holder失败: %v", err)
|
||||
}
|
||||
|
||||
// 绘制图片
|
||||
err = pdf.ImageByHolder(imgH1, imageX, imageY, &gopdf.Rect{W: imageWidth, H: imageHeight})
|
||||
if err != nil {
|
||||
return fmt.Errorf("绘制图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新的 PDF
|
||||
if err := pdf.WritePdf(outputPath); err != nil {
|
||||
return fmt.Errorf("error writing final PDF: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCharWidth 获取字符的宽度权重
|
||||
// 英文字母、数字、英文符号返回 0.5,其他字符返回 1.0
|
||||
func getCharWidth(r rune) float64 {
|
||||
// 英文字母 (A-Z, a-z)
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||||
return 0.5
|
||||
}
|
||||
// 数字 (0-9)
|
||||
if r >= '0' && r <= '9' {
|
||||
return 0.5
|
||||
}
|
||||
// 英文符号
|
||||
if (r >= 0x21 && r <= 0x2F) || // ! " # $ % & ' ( ) * + , - . /
|
||||
(r >= 0x3A && r <= 0x40) || // : ; < = > ? @
|
||||
(r >= 0x5B && r <= 0x60) || // [ \ ] ^ _ `
|
||||
(r >= 0x7B && r <= 0x7E) { // { | } ~
|
||||
return 0.5
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// splitTextByRune 将文本按指定字符宽度拆分成多行
|
||||
// 按每行最大宽度拆分,英文字母/数字/英文符号按0.5计算,其他按1计算
|
||||
func splitTextByRune(text string, maxWidth float64) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
runes := []rune(text)
|
||||
|
||||
var lines []string
|
||||
currentLine := ""
|
||||
currentWidth := 0.0
|
||||
|
||||
for _, r := range runes {
|
||||
charWidth := getCharWidth(r)
|
||||
|
||||
// 检查加上这个字符是否会超过最大宽度
|
||||
if currentWidth+charWidth > maxWidth {
|
||||
// 超过最大宽度,保存当前行并开始新行
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
currentLine = string(r)
|
||||
currentWidth = charWidth
|
||||
} else {
|
||||
currentLine += string(r)
|
||||
currentWidth += charWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一行
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// TruncateTextByRune 按字符权重截断文本
|
||||
// 中文字符算1.0,英文字母/数字/英文符号算0.5
|
||||
// 返回截断后的文本,确保总权重不超过maxWidth
|
||||
func TruncateTextByRune(text string, maxWidth float64) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
var result []rune
|
||||
currentWidth := 0.0
|
||||
|
||||
for _, r := range runes {
|
||||
charWidth := getCharWidth(r)
|
||||
if currentWidth+charWidth > maxWidth {
|
||||
break
|
||||
}
|
||||
result = append(result, r)
|
||||
currentWidth += charWidth
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// ConvertCompetitorReportToText 将竞品报告数据转换为文本格式
|
||||
// 参数:
|
||||
// - data: 竞品报告数据
|
||||
// - isVideo: 是否为视频(视频需要包含完播率和配乐亮点)
|
||||
//
|
||||
// 返回: 转换后的文本内容
|
||||
func ConvertCompetitorReportToText(data CompetitorReportData, isVideo bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 一、亮点表现分析
|
||||
sb.WriteString("一、亮点表现分析\n")
|
||||
sb.WriteString(data.HighlightAnalysis.Summary)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("1. 标题亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Theme)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("2. 题材亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("3. 内容亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Content)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("4. 文案亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("5. 数据亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Data)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.HighlightAnalysis.Points.Music != "" {
|
||||
sb.WriteString("6. 配乐亮点:")
|
||||
sb.WriteString(data.HighlightAnalysis.Points.Music)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 二、数据表现分析
|
||||
sb.WriteString("\n二、数据表现分析\n")
|
||||
sb.WriteString("1. 浏览量表现:")
|
||||
sb.WriteString(data.DataPerformance.Views)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isVideo && data.DataPerformance.Completion != "" {
|
||||
sb.WriteString("2. 完播率表现:")
|
||||
sb.WriteString(data.DataPerformance.Completion)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("3. 点赞/分享/评论表现:")
|
||||
} else {
|
||||
sb.WriteString("2. 点赞/分享/评论表现:")
|
||||
}
|
||||
sb.WriteString(data.DataPerformance.Engagement)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 三、整体总结及可优化建议
|
||||
sb.WriteString("\n三、整体总结及可优化建议\n")
|
||||
sb.WriteString(data.OverallSummary)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
304
pkg/utils/pdf_competitor_test.go
Normal file
304
pkg/utils/pdf_competitor_test.go
Normal file
@ -0,0 +1,304 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// getProjectRoot 获取项目根目录
|
||||
func getProjectRoot() string {
|
||||
// 假设测试从项目根目录运行
|
||||
dir, _ := os.Getwd()
|
||||
// 向上查找 go.mod 确定项目根目录
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestGenerateCompetitorReportPDF 测试生成竞品报告PDF
|
||||
func TestGenerateCompetitorReportPDF1(t *testing.T) {
|
||||
// 获取项目根目录
|
||||
root := getProjectRoot()
|
||||
fmt.Printf("项目根目录: %s\n", root)
|
||||
|
||||
// 准备测试数据
|
||||
data := CompetitorReportData{
|
||||
HighlightAnalysis: HighlightAnalysisData{
|
||||
Summary: "本视频通过展示产品使用的真实场景,突出用户产品优势和痛点,内容详实且具有吸引力。",
|
||||
Points: PointsData{
|
||||
Theme: "标题简洁有力,突出核心卖点'省时省力',引发用户好奇心",
|
||||
Narrative: "采用情景剧形式展示产品使用场景,剧情贴近生活,易引发共鸣",
|
||||
Content: "通过前后对比展示产品效果,直观呈现产品价值",
|
||||
Copywriting: "文案简洁明了,突出用户痛点解决方案,语气亲切自然",
|
||||
Data: "点赞量10万+,评论5000+,分享2万+,数据表现优异",
|
||||
Music: "背景音乐节奏轻快,与视频内容匹配度高,增强观看体验",
|
||||
},
|
||||
},
|
||||
DataPerformance: DataPerformanceData{
|
||||
Views: "播放量突破500万,推荐流量占比60%,自然流量表现优秀",
|
||||
Completion: "完播率45%,高于同类视频平均值(30%),前3秒吸引力强",
|
||||
Engagement: "点赞率2%,评论率0.1%,分享率0.4%,互动数据表现优秀",
|
||||
},
|
||||
OverallSummary: "整体来看,该竞品视频在内容策划、表现形式和互动数据方面都表现优秀。优势在于:1)内容真实可信,通过实际使用场景展示产品效果;2)剧情设计合理,前3秒抓住用户注意力;3)文案简洁有力,直击用户痛点。建议优化方向:1)可以增加更多用户评价内容,增强可信度;2)适当增加福利引导,提高转化率;3)结尾可以增加引导关注话术,提升粉丝沉淀。",
|
||||
}
|
||||
|
||||
// 模板路径
|
||||
templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf")
|
||||
// 输出路径
|
||||
outputPath := filepath.Join(root, "data", "output", "竞品报告测试_multicell.pdf")
|
||||
|
||||
// 确保输出目录存在
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
t.Errorf("创建输出目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用函数生成PDF
|
||||
err := GenerateCompetitorReportPDF(templatePath, outputPath, data)
|
||||
if err != nil {
|
||||
t.Errorf("生成竞品报告PDF失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("PDF生成成功: %s\n", outputPath)
|
||||
}
|
||||
|
||||
// TestGenerateCompetitorReportPDFImageOnly 测试仅图片的竞品报告PDF(无配乐和完播率)
|
||||
func TestGenerateCompetitorReportPDFImageOnly(t *testing.T) {
|
||||
// 获取项目根目录
|
||||
root := getProjectRoot()
|
||||
|
||||
// 准备测试数据(仅图片,没有视频的配乐和完播率)
|
||||
data := CompetitorReportData{
|
||||
HighlightAnalysis: HighlightAnalysisData{
|
||||
Summary: "该图文内容通过精美的视觉设计和精准的标签定位,成功吸引目标用户关注。",
|
||||
Points: PointsData{
|
||||
Theme: "标题设置悬念,引发用户点击欲望",
|
||||
Narrative: "采用九宫格形式展示产品特点,视觉冲击力强",
|
||||
Content: "内容排版清晰,重点突出,便于用户快速获取信息",
|
||||
Copywriting: "文案简洁,配合表情符号增加趣味性",
|
||||
Data: "收藏量5万+,评论1000+,分享8000+",
|
||||
Music: "", // 图片无配乐
|
||||
},
|
||||
},
|
||||
DataPerformance: DataPerformanceData{
|
||||
Views: "曝光量100万+,点击率3%,表现良好",
|
||||
Completion: "", // 图文无完播率
|
||||
Engagement: "收藏率5%,评论率0.1%,分享率0.8%",
|
||||
},
|
||||
OverallSummary: "该图文内容整体表现优秀,特别是在视觉设计和内容排版方面。亮点:1)九宫格形式统一,视觉效果好;2)标签设置精准,触达目标用户;3)发布时间合理,获得更多曝光。优化建议:1)可以增加更多用户案例展示;2)适当加入互动话题,提高评论量。",
|
||||
}
|
||||
|
||||
// 模板路径
|
||||
templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf")
|
||||
// 输出路径
|
||||
outputPath := filepath.Join(root, "data", "output", "竞品报告测试_图文11.pdf")
|
||||
|
||||
// 确保输出目录存在
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
t.Errorf("创建输出目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用函数生成PDF
|
||||
err := GenerateCompetitorReportPDF(templatePath, outputPath, data)
|
||||
if err != nil {
|
||||
t.Errorf("生成竞品报告PDF失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("PDF生成成功: %s\n", outputPath)
|
||||
}
|
||||
|
||||
// TestGenerateCompetitorReportPDFWithImage 测试带图片的竞品报告PDF
|
||||
// 注意:此测试需要网络连接来下载图片,如果网络不可用会被跳过
|
||||
func TestGenerateCompetitorReportPDFWithImage1(t *testing.T) {
|
||||
// 获取项目根目录
|
||||
root := getProjectRoot()
|
||||
|
||||
// 准备测试数据(带图片)
|
||||
// 使用一个已知可用的测试图片URL
|
||||
data := CompetitorReportData{
|
||||
HighlightAnalysis: HighlightAnalysisData{
|
||||
Summary: "本视频通过展示产品使用的真实场景,突出用户产品优势和痛点,内容详实且具有吸引力。",
|
||||
Points: PointsData{
|
||||
Theme: "标题简洁有力,突出核心卖点'省时省力',引发用户好奇心",
|
||||
Narrative: "采用情景剧形式展示产品使用场景,剧情贴近生活,易引发共鸣",
|
||||
Content: "通过前后对比展示产品效果,直观呈现产品价值",
|
||||
Copywriting: "文案简洁明了,突出用户痛点解决方案,语气亲切自然",
|
||||
Data: "点赞量10万+,评论5000+,分享2万+,数据表现优异",
|
||||
Music: "背景音乐节奏轻快,与视频内容匹配度高,增强观看体验",
|
||||
},
|
||||
},
|
||||
DataPerformance: DataPerformanceData{
|
||||
Views: "播放量突破500万,推荐流量占比60%,自然流量表现优秀",
|
||||
Completion: "完播率45%,高于同类视频平均值(30%),前3秒吸引力强",
|
||||
Engagement: "点赞率2%,评论率0.1%,分享率0.4%,互动数据表现优秀",
|
||||
},
|
||||
OverallSummary: "整体来看,该竞品视频在内容策划、表现形式和互动数据方面都表现优秀。",
|
||||
ImageURL: "https://cdn-test.szjixun.cn/fonchain-main/test/image/12345/artwork/0.png", // 测试用图片URL
|
||||
}
|
||||
|
||||
// 模板路径
|
||||
templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf")
|
||||
// 输出路径
|
||||
outputPath := filepath.Join(root, "data", "output", "竞品报告测试_带图片.pdf")
|
||||
|
||||
// 确保输出目录存在
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
t.Errorf("创建输出目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用函数生成PDF
|
||||
err := GenerateCompetitorReportPDF(templatePath, outputPath, data)
|
||||
if err != nil {
|
||||
t.Logf("图片下载测试跳过(网络问题): %v", err)
|
||||
t.Skip("网络不可用,跳过图片测试")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("PDF生成成功: %s\n", outputPath)
|
||||
}
|
||||
|
||||
// TestGenerateCompetitorReportPDFMixedContent 测试中英混合、数字、符号的复杂情况
|
||||
func TestGenerateCompetitorReportPDFMixedContent(t *testing.T) {
|
||||
root := getProjectRoot()
|
||||
|
||||
// 准备包含中英混合、数字、符号的复杂测试数据
|
||||
data := CompetitorReportData{
|
||||
HighlightAnalysis: HighlightAnalysisData{
|
||||
Summary: "这是一个关于2024年短视频创作的爆款分析报告!视频总播放量达到1.2亿次,点赞率8.5%(远超行业平均3.2%)。内容包括:①生活技巧类占比40% ②知识科普类30% ③娱乐搞笑类30%。推荐算法推荐流量占比85%,搜索流量10%,其他渠道5%。",
|
||||
Points: PointsData{
|
||||
Theme: "标题:【逆袭】3个月从0到100万粉丝!我是如何做到的?必看!🔥",
|
||||
Narrative: "采用问题-解决叙事结构:前3秒抛出痛点你还在为XX发愁吗,然后展示解决方案ABC",
|
||||
Content: "内容分为3个板块:①前置干货预告15秒 ②核心内容展示30秒 ③互动引导15秒",
|
||||
Copywriting: "文案使用了大量emoji表情🔥💯👍,增加年轻化气息!结尾一句评论区见强化互动",
|
||||
Data: "点赞率8.5%,高于同类平均3.2%,转发率2.1%,评论数2.3万条,评论区活跃度TOP10%",
|
||||
Music: "使用热门BGM孤勇者前奏3秒,配合画面节奏卡点!音量为Original-3dB",
|
||||
},
|
||||
},
|
||||
DataPerformance: DataPerformanceData{
|
||||
Views: "发布24小时内播放量突破500万,48小时达到1200万,72小时稳定在1500万。推荐流量占比85%,搜索流量占比10%,其他渠道5%。数据表现:PV=15000000,UV=8500000。",
|
||||
Completion: "平均完播率45.2%,高于行业均值28%,3秒留存率72%,5秒完播率58%,10秒完播率35%,属于高质量流量。平均观看时长18.5秒,视频总时长42秒。",
|
||||
Engagement: "点赞数128000,点赞率0.85%,评论数23000,其中神评论占比15%,高赞评论TOP3:①学到了 ②太棒了 ③收藏了,分享数56000。评论区@相关账号12个,引发二次传播。",
|
||||
},
|
||||
OverallSummary: "该视频成功因素:①标题使用数字热门词激发点击逆袭必看 ②内容结构清晰,每15秒一个高潮点 ③BGM选择契合内容情绪孤勇者 ④评论区运营到位。建议优化:1)可增加合集功能,将同类内容串联 2)提升粉丝粘性到10%以上 3)适当增加直播预告。整体来看,这是一条典型的爆款体质视频,值得借鉴学习!数据支撑:ROI=3.5,CPM=25元,CPC=0.8元。",
|
||||
ImageURL: "",
|
||||
}
|
||||
|
||||
// 模板路径
|
||||
templatePath := filepath.Join(root, "data", "竞品报告pdf模板.pdf")
|
||||
// 输出路径
|
||||
outputPath := filepath.Join(root, "data", "output", "竞品报告测试_中英混合.pdf")
|
||||
|
||||
// 确保输出目录存在
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
t.Errorf("创建输出目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用函数生成PDF
|
||||
err := GenerateCompetitorReportPDF(templatePath, outputPath, data)
|
||||
if err != nil {
|
||||
t.Errorf("生成PDF失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("PDF生成成功(中英混合测试): %s\n", outputPath)
|
||||
}
|
||||
|
||||
// TestTruncateTextByRune 测试按字符权重截断文本
|
||||
func TestTruncateTextByRune(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxWidth float64
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "空文本",
|
||||
text: "",
|
||||
maxWidth: 10,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "纯中文截断",
|
||||
text: "这是一段很长的中文内容需要被截断",
|
||||
maxWidth: 10,
|
||||
want: "这是一段很长的中文内", // 10个中文字=10,正好
|
||||
},
|
||||
{
|
||||
name: "纯英文截断(按0.5计算)",
|
||||
text: "abcdefghijklmnop",
|
||||
maxWidth: 5,
|
||||
want: "abcdefghij", // 10个字母=5.0,正好
|
||||
},
|
||||
{
|
||||
name: "数字截断(按0.5计算)",
|
||||
text: "1234567890",
|
||||
maxWidth: 3,
|
||||
want: "123456", // 6个数字=3.0,正好
|
||||
},
|
||||
{
|
||||
name: "中英混合截断",
|
||||
text: "hello你好world世界",
|
||||
maxWidth: 6,
|
||||
// h(0.5)+e(0.5)+l(0.5)+l(0.5)+o(0.5)=2.5
|
||||
// +你(1)+好(1)=4.5
|
||||
// +w(0.5)+o(0.5)+r(0.5)+l(0.5)=2,再加就超过6了
|
||||
// 所以应该是 hello你好wor
|
||||
want: "hello你好wor",
|
||||
},
|
||||
{
|
||||
name: "符号截断(按0.5计算)",
|
||||
text: "hello,world!",
|
||||
maxWidth: 5,
|
||||
// h(0.5)+e(0.5)+l(0.5)+l(0.5)+o(0.5)+,(0.5)=3
|
||||
// +w(0.5)+o(0.5)+r(0.5)+l(0.5)+d(0.5)+!(0.5)=6,超过5
|
||||
// 所以保留 hello,worl
|
||||
want: "hello,worl",
|
||||
},
|
||||
{
|
||||
name: "截断到正好边界",
|
||||
text: "中文字符",
|
||||
maxWidth: 4,
|
||||
want: "中文字符", // 4个中文字=4.0,正好
|
||||
},
|
||||
{
|
||||
name: "宽度为0",
|
||||
text: "hello",
|
||||
maxWidth: 0,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "宽度小于单个字符",
|
||||
text: "hello",
|
||||
maxWidth: 0.3,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TruncateTextByRune(tt.text, tt.maxWidth)
|
||||
if got != tt.want {
|
||||
t.Errorf("TruncateTextByRune(%q, %v) = %q, want %q", tt.text, tt.maxWidth, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user