From b2b570003eab57b3e58d9bbec7c253c250cc964c Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 09:16:23 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=AB=9E=E5=93=81=E6=8A=A5=E5=91=8A=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index 1ef21af0..b159719b 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -143,8 +143,44 @@ func AICompetitorReport(ctx *gin.Context) { if needText { textChan = make(chan textResult, 1) go func() { + // 根据是否有视频来判断作品类型 + isVideo := len(req.Videos) > 0 + + // 根据作品类型设置差异化内容 + var extraPoint, extraData string + if isVideo { + extraPoint = "6. 配乐亮点:" + extraData = "2. 完播率表现:\n3. 点赞/分享/评论表现:" + } else { + extraData = "2. 点赞/分享/评论表现:" + } + // 构建文本生成提示词:理解内容 + 用户要求 - textPrompt := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告:注意不要输出markdown格式来进行排版,请直接输出纯文本。只需要回复竞品报告的内容,其他无关的内容不要输出,输出的内容第一行不要标题,直接输出竞品报告的正文即可\n我的要求是:\n%s", vlContent, req.TextPrompt) + textPrompt := fmt.Sprintf(`基于以下视频和图片的内容描述: +%s + +请根据以下要求生成竞品报告。注意不要输出markdown格式来进行排版,请直接输出纯文本。只需要回复竞品报告的内容,其他无关的内容不要输出,输出的内容第一行不要标题,直接输出竞品报告的正文即可 + +我的要求是: +%s + +请严格按照以下模板输出: + +一、亮点表现分析 +[100字以内的概述] + +1. 标题亮点: +2. 题材亮点: +3. 内容亮点: +4. 文案亮点: +5. 数据亮点: +%s + +二、数据表现分析 +1. 浏览量表现: +%s + +三、整体总结及可优化建议`, vlContent, req.TextPrompt, extraPoint, extraData) chatReq, err := buildChatRequest(textPrompt, nil) if err != nil { From d2ee1c86b820000c714658330eacb9a99e75c4eb Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 09:19:03 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=AB=9E=E5=93=81=E6=8A=A5=E5=91=8A=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index b159719b..3150b8ec 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -208,7 +208,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 { From cb1345a55ddcbbad401d1b63915b72cc387b1c3d Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 14:03:29 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=AB=9E=E5=93=81=E6=8A=A5=E5=91=8A=E7=9A=84prompts=EF=BC=8C?= =?UTF-8?q?=E8=AE=A9=E5=85=B6=E7=94=9F=E6=88=90json=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E7=9A=84=E5=9B=9E=E5=A4=8D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 78 +++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index 3150b8ec..9e4f4bca 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -146,41 +146,73 @@ func AICompetitorReport(ctx *gin.Context) { // 根据是否有视频来判断作品类型 isVideo := len(req.Videos) > 0 - // 根据作品类型设置差异化内容 - var extraPoint, extraData string + // 构建文本生成提示词:理解内容 + 用户要求(JSON格式) + var textPrompt string if isVideo { - extraPoint = "6. 配乐亮点:" - extraData = "2. 完播率表现:\n3. 点赞/分享/评论表现:" - } else { - extraData = "2. 点赞/分享/评论表现:" - } - - // 构建文本生成提示词:理解内容 + 用户要求 - textPrompt := fmt.Sprintf(`基于以下视频和图片的内容描述: + textPrompt = fmt.Sprintf(`基于以下视频和图片的内容描述: %s -请根据以下要求生成竞品报告。注意不要输出markdown格式来进行排版,请直接输出纯文本。只需要回复竞品报告的内容,其他无关的内容不要输出,输出的内容第一行不要标题,直接输出竞品报告的正文即可 +请根据以下要求生成竞品报告,输出严格的JSON格式,不要输出markdown格式,不要输出其他无关内容。 我的要求是: %s -请严格按照以下模板输出: +请严格按照以下JSON模板输出(注意不要修改key,只填写value): +{ + "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字]" +} -一、亮点表现分析 -[100字以内的概述] - -1. 标题亮点: -2. 题材亮点: -3. 内容亮点: -4. 文案亮点: -5. 数据亮点: +重要约束: +1. 严禁输出 JSON 以外的任何字符(包括换行说明、注释、Markdown) +2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, vlContent, req.TextPrompt) + } else { + textPrompt = fmt.Sprintf(`基于以下视频和图片的内容描述: %s -二、数据表现分析 -1. 浏览量表现: +请根据以下要求生成竞品报告,输出严格的JSON格式,不要输出markdown格式,不要输出其他无关内容。 + +我的要求是: %s -三、整体总结及可优化建议`, vlContent, req.TextPrompt, extraPoint, extraData) +请严格按照以下JSON模板输出(注意不要修改key,只填写value): +{ + "highlight_analysis": { + "summary": "[78字以内的概述]", + "points": { + "theme": "[标题亮点,最多60字]", + "narrative": "[题材亮点,最多60字]", + "content": "[内容亮点,最多60字]", + "copywriting": "[文案亮点,最多60字]", + "data": "[数据亮点,最多60字]" + } + }, + "data_performance_analysis": { + "views": "[浏览量表现,最多60字]", + "engagement": "[点赞/分享/评论表现,最多60字]" + }, + "overall_summary_and_optimization": "[整体总结及可优化建议,最多300字]" +} + +重要约束: +1. 严禁输出 JSON 以外的任何字符(包括换行说明、注释、Markdown) +2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, vlContent, req.TextPrompt) + } chatReq, err := buildChatRequest(textPrompt, nil) if err != nil { From ce9edf513c934fc9148cbfc5eb909c116fa84e82 Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 14:28:36 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20ai=20=E7=94=9F=E6=88=90=E7=AB=9E?= =?UTF-8?q?=E5=93=81=E6=8A=A5=E5=91=8A=E5=A2=9E=E5=8A=A0=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5json=EF=BC=8C=E7=94=A8=E6=9D=A5=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E7=94=9F=E6=88=90pdf=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 99 +++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index 9e4f4bca..11140182 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -1,6 +1,7 @@ package ai import ( + "encoding/json" "errors" "fmt" "fonchain-fiee/pkg/common/qwen" @@ -74,6 +75,93 @@ type CompetitorReportRequest struct { type CompetitorReportResponse struct { ImageURL string `json:"image_url,omitempty"` // 生成的图片URL(1024*1024),非必须返回 Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回 + JSON string `json:"json,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() } // AICompetitorReport 生成竞品报告接口 @@ -335,7 +423,16 @@ func AICompetitorReport(ctx *gin.Context) { // 返回结果(只返回实际生成的内容) result := CompetitorReportResponse{} if needText { - result.Text = textRes.text + result.JSON = textRes.text // JSON 字段直接返回 AI 生成的 JSON + + // 将 JSON 解析并转换为纯文本 + var jsonData CompetitorReportJSON + if err := json.Unmarshal([]byte(textRes.text), &jsonData); err != nil { + // 如果解析失败,回退使用原始文本 + result.Text = textRes.text + } else { + result.Text = convertJSONToText(jsonData, len(req.Videos) > 0) + } } if needImage { result.ImageURL = imageRes.imageURL From 016f2c2459d83bef0562b2753222c8470cc3cf44 Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 14:58:40 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index 11140182..b9b12ca0 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -319,7 +319,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} }() } @@ -426,11 +432,21 @@ func AICompetitorReport(ctx *gin.Context) { result.JSON = textRes.text // JSON 字段直接返回 AI 生成的 JSON // 将 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 CompetitorReportJSON 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("==================================") result.Text = convertJSONToText(jsonData, len(req.Videos) > 0) } } From 75ba332397d8aac2c381fe14447b61b839a2ab9f Mon Sep 17 00:00:00 2001 From: cjy Date: Sat, 28 Feb 2026 15:25:58 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix=EF=BC=9A=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=EF=BC=8C=E9=81=BF=E5=85=8Dai?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E7=BB=93=E6=9E=9C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?json=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/ai/video_vl.go | 66 +++++++------------------------------- 1 file changed, 12 insertions(+), 54 deletions(-) diff --git a/pkg/service/ai/video_vl.go b/pkg/service/ai/video_vl.go index b9b12ca0..1a945855 100644 --- a/pkg/service/ai/video_vl.go +++ b/pkg/service/ai/video_vl.go @@ -237,69 +237,27 @@ func AICompetitorReport(ctx *gin.Context) { // 构建文本生成提示词:理解内容 + 用户要求(JSON格式) var textPrompt string if isVideo { - textPrompt = fmt.Sprintf(`基于以下视频和图片的内容描述: + textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出必须以 { 开头并以 } 结束。 + +基于以下视频和图片的内容描述: %s -请根据以下要求生成竞品报告,输出严格的JSON格式,不要输出markdown格式,不要输出其他无关内容。 - -我的要求是: +用户要求(仅作为内容参考,不会改变JSON结构): %s -请严格按照以下JSON模板输出(注意不要修改key,只填写value): -{ - "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字]" -} - -重要约束: -1. 严禁输出 JSON 以外的任何字符(包括换行说明、注释、Markdown) -2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, vlContent, req.TextPrompt) +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(`基于以下视频和图片的内容描述: + textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出必须以 { 开头并以 } 结束。 + +基于以下视频和图片的内容描述: %s -请根据以下要求生成竞品报告,输出严格的JSON格式,不要输出markdown格式,不要输出其他无关内容。 - -我的要求是: +用户要求(仅作为内容参考,不会改变JSON结构): %s -请严格按照以下JSON模板输出(注意不要修改key,只填写value): -{ - "highlight_analysis": { - "summary": "[78字以内的概述]", - "points": { - "theme": "[标题亮点,最多60字]", - "narrative": "[题材亮点,最多60字]", - "content": "[内容亮点,最多60字]", - "copywriting": "[文案亮点,最多60字]", - "data": "[数据亮点,最多60字]" - } - }, - "data_performance_analysis": { - "views": "[浏览量表现,最多60字]", - "engagement": "[点赞/分享/评论表现,最多60字]" - }, - "overall_summary_and_optimization": "[整体总结及可优化建议,最多300字]" -} - -重要约束: -1. 严禁输出 JSON 以外的任何字符(包括换行说明、注释、Markdown) -2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, vlContent, req.TextPrompt) +JSON结构是固定的,请将内容填充到对应的value中,禁止修改key,禁止添加额外字段,禁止输出任何说明文字: +{"highlight_analysis":{"summary":"[78字以内的概述]","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) From 264114fce8a59d4686ec29a5842a4507f9dd3169 Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 09:20:55 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=AB=9E?= =?UTF-8?q?=E5=93=81=E6=8A=A5=E5=91=8Apdf=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/竞品报告pdf模板.pdf | Bin 0 -> 68612 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 data/竞品报告pdf模板.pdf diff --git a/data/竞品报告pdf模板.pdf b/data/竞品报告pdf模板.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c31daaceae4622e78bff9b3fce42a8eff283360f GIT binary patch literal 68612 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 Date: Mon, 2 Mar 2026 11:08:31 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E6=9A=82=E6=97=B6=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/utils/pdf.go | 177 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 0339b170..045cb7ff 100644 --- a/pkg/utils/pdf.go +++ b/pkg/utils/pdf.go @@ -11,6 +11,8 @@ import ( "unicode" "github.com/phpdave11/gofpdf" + "github.com/signintech/gopdf" + "go.uber.org/zap" ) // cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji) @@ -172,3 +174,178 @@ 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"` +} + +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}) + + // 导入模板文件中的页面 + err := pdf.ImportPagesFromSource(templatePath, "/MediaBox") + if err != nil { + return fmt.Errorf("无法导入页面: %v", err) + } + + // 获取模板文件的总页数 + totalPages := pdf.GetNumberOfPages() + fmt.Printf("模板文件的总页数: %d\n", totalPages) + + // 根据模板路径推断字体路径(假设字体文件和模板在同一目录或data目录下) + dir := filepath.Dir(templatePath) + fontPath := filepath.Join(dir, "simfang.ttf") + if _, err := os.Stat(fontPath); err != nil { + // 尝试使用项目根目录下的data目录 + 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 + + pdf.SetPage(1) + + // 概述 - 使用MultiCell自动换行,一行最多35个字 + pdf.SetXY(200, 104) + summaryRect := gopdf.Rect{W: 350.0, H: lineHeight} + pdf.MultiCell(&summaryRect, data.HighlightAnalysis.Summary) + + // 标题亮点 - 一行最多9个字 + pdf.SetXY(200, 184) + themeRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(&themeRect, data.HighlightAnalysis.Points.Theme) + + // 题材亮点 - 一行最多9个字 + pdf.SetXY(330, 184) + narrativeRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(&narrativeRect, data.HighlightAnalysis.Points.Narrative) + + // 内容亮点 - 一行最多9个字 + pdf.SetXY(460, 184) + contentRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(&contentRect, data.HighlightAnalysis.Points.Content) + + // 文案亮点 - 一行最多9个字 + pdf.SetXY(200, 323) + copywritingRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(©writingRect, data.HighlightAnalysis.Points.Copywriting) + + // 数据亮点 - 一行最多9个字 + pdf.SetXY(330, 323) + dataRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(&dataRect, data.HighlightAnalysis.Points.Data) + + // 配乐亮点(仅视频) - 一行最多9个字 + if data.HighlightAnalysis.Points.Music != "" { + pdf.SetXY(460, 323) + musicRect := gopdf.Rect{W: 120.0, H: lineHeight} + pdf.MultiCell(&musicRect, data.HighlightAnalysis.Points.Music) + } + + // 浏览量 - 一行最多35个字 + pdf.SetXY(200, 474) + viewsRect := gopdf.Rect{W: 350.0, H: lineHeight} + pdf.MultiCell(&viewsRect, data.DataPerformance.Views) + + // 完播率 - 一行最多35个字 + // 始终显示在固定位置,有内容时填充内容 + pdf.SetXY(200, 539) + if data.DataPerformance.Completion != "" { + completionRect := gopdf.Rect{W: 350.0, H: lineHeight} + pdf.MultiCell(&completionRect, data.DataPerformance.Completion) + } + + // 点赞/分享/评论 - 一行最多35个字 + // 始终固定在完播率下面的位置 + pdf.SetXY(200, 600) + engagementRect := gopdf.Rect{W: 350.0, H: lineHeight} + pdf.MultiCell(&engagementRect, data.DataPerformance.Engagement) + + // 整体总结及可优化建议 - 一行最多35个字 + pdf.SetXY(200, 676) + summaryRect = gopdf.Rect{W: 350.0, H: lineHeight} + pdf.MultiCell(&summaryRect, data.OverallSummary) + + // 生成新的 PDF + if err = pdf.WritePdf(outputPath); err != nil { + return fmt.Errorf("error writing final PDF: %v", err) + } + + return nil +} + +// wrapText 将文本按指定宽度换行(按字符数计算) +func wrapText(text string, maxLen int) []string { + if text == "" { + return []string{} + } + + var lines []string + runes := []rune(text) + currentLine := "" + + for _, r := range runes { + // 如果当前行字符数达到最大限度,换行 + if len(currentLine) >= maxLen { + lines = append(lines, currentLine) + currentLine = string(r) + } else { + currentLine += string(r) + } + } + + // 添加最后一行 + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + + return lines +} From e1e453322ab888230c90744d77a859db1615cf4e Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 13:07:33 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96=E7=AB=9E?= =?UTF-8?q?=E5=93=81=E6=8A=A5=E5=91=8Apdf=E7=94=9F=E6=88=90=E6=95=88?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + pkg/utils/pdf.go | 100 +++++++++++++++++++------ pkg/utils/pdf_competitor_test.go | 123 +++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 pkg/utils/pdf_competitor_test.go diff --git a/.gitignore b/.gitignore index 4ec01dac..55306709 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ /cmd/logs/*.log /cmd/runtime/log/*.log /build/* +CLAUDE.md +.claude/settings.local.json diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 045cb7ff..8d6bce1d 100644 --- a/pkg/utils/pdf.go +++ b/pkg/utils/pdf.go @@ -253,66 +253,99 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito pdf.SetPage(1) - // 概述 - 使用MultiCell自动换行,一行最多35个字 + // 概述 - 使用逐行写入,一行最多35个字 pdf.SetXY(200, 104) - summaryRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&summaryRect, data.HighlightAnalysis.Summary) + summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35) + for i, line := range summaryLines { + pdf.SetXY(200, 104+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 标题亮点 - 一行最多9个字 pdf.SetXY(200, 184) - themeRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&themeRect, data.HighlightAnalysis.Points.Theme) + themeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Theme), 9) + for i, line := range themeLines { + pdf.SetXY(200, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 题材亮点 - 一行最多9个字 pdf.SetXY(330, 184) - narrativeRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&narrativeRect, data.HighlightAnalysis.Points.Narrative) + narrativeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Narrative), 9) + for i, line := range narrativeLines { + pdf.SetXY(330, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 内容亮点 - 一行最多9个字 pdf.SetXY(460, 184) - contentRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&contentRect, data.HighlightAnalysis.Points.Content) + contentLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Content), 9) + for i, line := range contentLines { + pdf.SetXY(460, 184+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 文案亮点 - 一行最多9个字 pdf.SetXY(200, 323) - copywritingRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(©writingRect, data.HighlightAnalysis.Points.Copywriting) + copywritingLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Copywriting), 9) + for i, line := range copywritingLines { + pdf.SetXY(200, 323+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 数据亮点 - 一行最多9个字 pdf.SetXY(330, 323) - dataRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&dataRect, data.HighlightAnalysis.Points.Data) + dataLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Data), 9) + 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) - musicRect := gopdf.Rect{W: 120.0, H: lineHeight} - pdf.MultiCell(&musicRect, data.HighlightAnalysis.Points.Music) + musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9) + for i, line := range musicLines { + pdf.SetXY(460, 323+float64(i)*lineHeight) + pdf.Cell(nil, line) + } } // 浏览量 - 一行最多35个字 pdf.SetXY(200, 474) - viewsRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&viewsRect, data.DataPerformance.Views) + viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35) + 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 != "" { - completionRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&completionRect, data.DataPerformance.Completion) + completionLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Completion), 35) + for i, line := range completionLines { + pdf.SetXY(200, 539+float64(i)*lineHeight) + pdf.Cell(nil, line) + } } // 点赞/分享/评论 - 一行最多35个字 // 始终固定在完播率下面的位置 pdf.SetXY(200, 600) - engagementRect := gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&engagementRect, data.DataPerformance.Engagement) + engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35) + for i, line := range engagementLines { + pdf.SetXY(200, 600+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 整体总结及可优化建议 - 一行最多35个字 pdf.SetXY(200, 676) - summaryRect = gopdf.Rect{W: 350.0, H: lineHeight} - pdf.MultiCell(&summaryRect, data.OverallSummary) + overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35) + for i, line := range overallSummaryLines { + pdf.SetXY(200, 676+float64(i)*lineHeight) + pdf.Cell(nil, line) + } // 生成新的 PDF if err = pdf.WritePdf(outputPath); err != nil { @@ -322,6 +355,27 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito return nil } +// splitTextByRune 将文本按指定字符数拆分成多行 +// 按每行最大字符数拆分,中文、英文都按 1 个 rune 计 +func splitTextByRune(text string, maxRunesPerLine int) []string { + if text == "" { + return []string{} + } + runes := []rune(text) + if len(runes) <= maxRunesPerLine { + return []string{text} + } + var lines []string + for i := 0; i < len(runes); i += maxRunesPerLine { + end := i + maxRunesPerLine + if end > len(runes) { + end = len(runes) + } + lines = append(lines, string(runes[i:end])) + } + return lines +} + // wrapText 将文本按指定宽度换行(按字符数计算) func wrapText(text string, maxLen int) []string { if text == "" { diff --git a/pkg/utils/pdf_competitor_test.go b/pkg/utils/pdf_competitor_test.go new file mode 100644 index 00000000..e507dfb5 --- /dev/null +++ b/pkg/utils/pdf_competitor_test.go @@ -0,0 +1,123 @@ +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 TestGenerateCompetitorReportPDF(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) +} From 667139a6107b0bce497c7a5fc0446c27e6346b89 Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 13:09:31 +0800 Subject: [PATCH 10/12] go mod tidy --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index efe5b770..3beeb778 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 58aae838..9fa53579 100644 --- a/go.sum +++ b/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= From f751b37e66f53c305a784a2ed53cacd6eb4c9113 Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 13:32:29 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=E7=AB=9E=E5=93=81=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=94=AF=E6=8C=81=E7=94=9F=E6=88=90=E5=B8=A6=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=9A=84pdf=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/utils/pdf.go | 278 +++++++++++++++++++++---------- pkg/utils/pdf_competitor_test.go | 52 ++++++ 2 files changed, 238 insertions(+), 92 deletions(-) diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 8d6bce1d..47618d12 100644 --- a/pkg/utils/pdf.go +++ b/pkg/utils/pdf.go @@ -3,6 +3,7 @@ package utils import ( "errors" "fmt" + "image" "io" "net/http" "net/url" @@ -180,6 +181,7 @@ 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 { @@ -204,7 +206,7 @@ type DataPerformanceData struct { // GenerateCompetitorReportPDF 生成竞品报告PDF // 参数: -// - templatePath: 模板文件路径 +// - templatePath: 模板文件路径(保留参数以兼容现有调用,传空则不使用模板) // - outputPath: 输出PDF路径 // - data: 竞品报告数据 // @@ -216,21 +218,27 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito pdf := gopdf.GoPdf{} pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) - // 导入模板文件中的页面 - err := pdf.ImportPagesFromSource(templatePath, "/MediaBox") - if err != nil { - return fmt.Errorf("无法导入页面: %v", err) + // 如果有模板路径,则导入模板 + if templatePath != "" { + err := pdf.ImportPagesFromSource(templatePath, "/MediaBox") + if err != nil { + return fmt.Errorf("无法导入页面: %v", err) + } } - // 获取模板文件的总页数 + // 获取模板文件的总页数(如果有模板) totalPages := pdf.GetNumberOfPages() fmt.Printf("模板文件的总页数: %d\n", totalPages) - // 根据模板路径推断字体路径(假设字体文件和模板在同一目录或data目录下) - dir := filepath.Dir(templatePath) - fontPath := filepath.Join(dir, "simfang.ttf") - if _, err := os.Stat(fontPath); err != nil { - // 尝试使用项目根目录下的data目录 + // 确定字体路径 + 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) @@ -243,7 +251,7 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito } // 设置字体和字号 - err = pdf.SetFont("simfang", "", 10) + err := pdf.SetFont("simfang", "", 10) if err != nil { return fmt.Errorf("设置字体失败: %v", err) } @@ -251,104 +259,190 @@ func GenerateCompetitorReportPDF(templatePath, outputPath string, data Competito // 行高15pt lineHeight := 15.0 - pdf.SetPage(1) + // 如果有内容要写入,确保在第一页 + if totalPages > 0 || (data.HighlightAnalysis.Summary != "" || data.OverallSummary != "") { + pdf.SetPage(1) - // 概述 - 使用逐行写入,一行最多35个字 - pdf.SetXY(200, 104) - summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35) - for i, line := range summaryLines { - pdf.SetXY(200, 104+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 概述 - 使用逐行写入,一行最多35个字 + pdf.SetXY(200, 104) + summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35) + 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) - for i, line := range themeLines { - pdf.SetXY(200, 184+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 标题亮点 - 一行最多9个字 + pdf.SetXY(200, 184) + themeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Theme), 9) + 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) - for i, line := range narrativeLines { - pdf.SetXY(330, 184+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 题材亮点 - 一行最多9个字 + pdf.SetXY(330, 184) + narrativeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Narrative), 9) + 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) - for i, line := range contentLines { - pdf.SetXY(460, 184+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 内容亮点 - 一行最多9个字 + pdf.SetXY(460, 184) + contentLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Content), 9) + 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) - for i, line := range copywritingLines { - pdf.SetXY(200, 323+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 文案亮点 - 一行最多9个字 + pdf.SetXY(200, 323) + copywritingLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Copywriting), 9) + 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) - for i, line := range dataLines { - pdf.SetXY(330, 323+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 数据亮点 - 一行最多9个字 + pdf.SetXY(330, 323) + dataLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Data), 9) + 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) - for i, line := range musicLines { - pdf.SetXY(460, 323+float64(i)*lineHeight) + // 配乐亮点(仅视频) - 一行最多9个字 + if data.HighlightAnalysis.Points.Music != "" { + pdf.SetXY(460, 323) + musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9) + 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) + 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) + 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) + 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) + for i, line := range overallSummaryLines { + pdf.SetXY(200, 676+float64(i)*lineHeight) pdf.Cell(nil, line) } } - // 浏览量 - 一行最多35个字 - pdf.SetXY(200, 474) - viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35) - for i, line := range viewsLines { - pdf.SetXY(200, 474+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + // 如果有图片URL,添加新页面并居中显示图片 + if data.ImageURL != "" { + // 添加新页面 + pdf.AddPage() - // 完播率 - 一行最多35个字 - // 始终显示在固定位置,有内容时填充内容 - pdf.SetXY(200, 539) - if data.DataPerformance.Completion != "" { - completionLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Completion), 35) - for i, line := range completionLines { - pdf.SetXY(200, 539+float64(i)*lineHeight) - pdf.Cell(nil, line) + // 下载图片 + resp, err := http.Get(data.ImageURL) + if err != nil { + return fmt.Errorf("下载图片失败: %v", err) } - } + defer resp.Body.Close() - // 点赞/分享/评论 - 一行最多35个字 - // 始终固定在完播率下面的位置 - pdf.SetXY(200, 600) - engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35) - for i, line := range engagementLines { - pdf.SetXY(200, 600+float64(i)*lineHeight) - pdf.Cell(nil, line) - } + imageData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取图片数据失败: %v", err) + } - // 整体总结及可优化建议 - 一行最多35个字 - pdf.SetXY(200, 676) - overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35) - for i, line := range overallSummaryLines { - pdf.SetXY(200, 676+float64(i)*lineHeight) - pdf.Cell(nil, line) + // 解析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 { + if err := pdf.WritePdf(outputPath); err != nil { return fmt.Errorf("error writing final PDF: %v", err) } diff --git a/pkg/utils/pdf_competitor_test.go b/pkg/utils/pdf_competitor_test.go index e507dfb5..71521c44 100644 --- a/pkg/utils/pdf_competitor_test.go +++ b/pkg/utils/pdf_competitor_test.go @@ -121,3 +121,55 @@ func TestGenerateCompetitorReportPDFImageOnly(t *testing.T) { fmt.Printf("PDF生成成功: %s\n", outputPath) } + +// TestGenerateCompetitorReportPDFWithImage 测试带图片的竞品报告PDF +// 注意:此测试需要网络连接来下载图片,如果网络不可用会被跳过 +func TestGenerateCompetitorReportPDFWithImage(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) +} From 6ef35e0852818e80ad84687ec05d881fb7b871dc Mon Sep 17 00:00:00 2001 From: cjy Date: Mon, 2 Mar 2026 13:53:03 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AB=9E?= =?UTF-8?q?=E5=93=81=E6=8A=A5=E5=91=8A=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E7=B1=BB=E5=9E=8B=E7=9A=84pdf=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E5=92=8C=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=B0=E9=82=A3=E8=BE=B9=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/service/cast/report.go | 76 +++++++++++++++++++++++++++--- pkg/service/taskbench/taskBench.go | 3 +- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/pkg/service/cast/report.go b/pkg/service/cast/report.go index 8388a8ab..1f9c1e2e 100644 --- a/pkg/service/cast/report.go +++ b/pkg/service/cast/report.go @@ -2,6 +2,7 @@ package cast import ( "context" + "encoding/json" "errors" "fmt" "fonchain-fiee/api/accountFiee" @@ -32,15 +33,23 @@ import ( "go.uber.org/zap" ) +// CreateCompetitiveReportReqEx 扩展的竞品报告请求(包含AI生成的JSON数据) +type CreateCompetitiveReportReqEx struct { + *cast.CreateCompetitiveReportReq // 嵌入原有请求 + ReportData string `json:"reportData"` // AI生成的竞品报告JSON数据 +} + // 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 string) (*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,9 @@ func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveRe } req.BundleOrderUuid = resp1.OrderUUID - if req.ReportContent == "" && req.ImageUrl == "" { - return nil, errors.New("报告内容和图片不能同时为空") + // 验证:报告内容、AI生成的JSON数据和图片不能同时为空 + if req.ReportContent == "" && reportData == "" && req.ImageUrl == "" { + return nil, errors.New("报告内容、AI数据和图片不能同时为空") } if req.ImageUrl != "" { @@ -116,7 +126,59 @@ func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveRe req.ImageUrl = newImageUrl } - if req.ReportContent != "" { + // 判断使用哪种方式生成PDF + if reportData != "" { + // 使用 GenerateCompetitorReportPDF 生成PDF + // 解析 JSON 数据 + var competitorReportData utils.CompetitorReportData + if err := json.Unmarshal([]byte(reportData), &competitorReportData); err != nil { + zap.L().Error("解析竞品报告数据失败", zap.String("reportData", reportData), zap.Error(err)) + return nil, errors.New("竞品报告数据格式错误") + } + + // 如果有图片URL,设置到reportData中 + if req.ImageUrl != "" { + competitorReportData.ImageURL = req.ImageUrl + } + + 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 + + _, 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 + } else if req.ReportContent != "" { + // 使用原有的 GeneratePDF 生成PDF today := time.Now().Format("20060102") timestamp := time.Now().UnixMicro() pdfFileName := fmt.Sprintf("%s%s老师的竞品报告%d.pdf", today, req.ArtistName, timestamp) diff --git a/pkg/service/taskbench/taskBench.go b/pkg/service/taskbench/taskBench.go index de41db92..527dfbe3 100644 --- a/pkg/service/taskbench/taskBench.go +++ b/pkg/service/taskbench/taskBench.go @@ -384,6 +384,7 @@ type CreateWorkAnalysisWithTaskUUIDReq struct { type CreateCompetitiveReportWithTaskUUIDReq struct { *cast.CreateCompetitiveReportReq AssignRecordsUUID string `json:"assignRecordsUUID"` + ReportData string `json:"reportData"` // AI生成的竞品报告JSON数据 } 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