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/data/竞品报告pdf模板.pdf b/data/竞品报告pdf模板.pdf new file mode 100644 index 00000000..c31daace Binary files /dev/null and b/data/竞品报告pdf模板.pdf differ 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= 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 diff --git a/pkg/utils/pdf.go b/pkg/utils/pdf.go index 0339b170..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" @@ -11,6 +12,8 @@ import ( "unicode" "github.com/phpdave11/gofpdf" + "github.com/signintech/gopdf" + "go.uber.org/zap" ) // cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji) @@ -172,3 +175,325 @@ 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) + 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(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(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个字 + 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) + } + } + + // 如果有图片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 +} + +// 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 == "" { + 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 +} diff --git a/pkg/utils/pdf_competitor_test.go b/pkg/utils/pdf_competitor_test.go new file mode 100644 index 00000000..71521c44 --- /dev/null +++ b/pkg/utils/pdf_competitor_test.go @@ -0,0 +1,175 @@ +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) +} + +// 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) +}