Merge branch 'dev' of http://gitea.tools.fontree.cn:3000/fiee/fonchain-fiee into dev
This commit is contained in:
commit
8ff82ede22
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,3 +31,5 @@
|
|||||||
/cmd/logs/*.log
|
/cmd/logs/*.log
|
||||||
/cmd/runtime/log/*.log
|
/cmd/runtime/log/*.log
|
||||||
/build/*
|
/build/*
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
BIN
data/竞品报告pdf模板.pdf
Normal file
BIN
data/竞品报告pdf模板.pdf
Normal file
Binary file not shown.
1
go.mod
1
go.mod
@ -106,7 +106,6 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.2.1
|
github.com/BurntSushi/toml v1.2.1
|
||||||
github.com/PuerkitoBio/goquery v1.8.1
|
github.com/PuerkitoBio/goquery v1.8.1
|
||||||
github.com/disintegration/imaging v1.6.2
|
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/envoyproxy/protoc-gen-validate v0.1.0
|
||||||
github.com/fonchain/utils/security v0.0.0-00010101000000-000000000000
|
github.com/fonchain/utils/security v0.0.0-00010101000000-000000000000
|
||||||
github.com/fonchain/utils/voice 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.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 h1:yE+J3W1NTZCEPa1FoX+VWZH6UF1c0+A2MGfERlU2zbI=
|
||||||
github.com/dubbogo/triple v1.1.8/go.mod h1:9pgEahtmsY/avYJp3dzUQE8CMMVe1NtGBmUhfICKLJk=
|
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 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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package cast
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fonchain-fiee/api/accountFiee"
|
"fonchain-fiee/api/accountFiee"
|
||||||
@ -32,15 +33,23 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CreateCompetitiveReportReqEx 扩展的竞品报告请求(包含AI生成的JSON数据)
|
||||||
|
type CreateCompetitiveReportReqEx struct {
|
||||||
|
*cast.CreateCompetitiveReportReq // 嵌入原有请求
|
||||||
|
ReportData string `json:"reportData"` // AI生成的竞品报告JSON数据
|
||||||
|
}
|
||||||
|
|
||||||
// CreateCompetitiveReport 创建竞品报告
|
// CreateCompetitiveReport 创建竞品报告
|
||||||
func CreateCompetitiveReport(ctx *gin.Context) {
|
func CreateCompetitiveReport(ctx *gin.Context) {
|
||||||
var req *cast.CreateCompetitiveReportReq
|
var reqEx CreateCompetitiveReportReqEx
|
||||||
var err error
|
var err error
|
||||||
if err = ctx.ShouldBind(&req); err != nil {
|
if err = ctx.ShouldBindJSON(&reqEx); err != nil {
|
||||||
service.Error(ctx, err)
|
service.Error(ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := CreateCompetitiveReportCore(ctx, req)
|
// 转换为原有类型
|
||||||
|
req := reqEx.CreateCompetitiveReportReq
|
||||||
|
resp, err := CreateCompetitiveReportCore(ctx, req, reqEx.ReportData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.Error(ctx, err)
|
service.Error(ctx, err)
|
||||||
return
|
return
|
||||||
@ -49,7 +58,7 @@ func CreateCompetitiveReport(ctx *gin.Context) {
|
|||||||
return
|
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)
|
loginInfo := login.GetUserInfoFromC(ctx)
|
||||||
lockKey := fmt.Sprintf("lock_create_competitive_report_%d", loginInfo.ID)
|
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)
|
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
|
req.BundleOrderUuid = resp1.OrderUUID
|
||||||
|
|
||||||
if req.ReportContent == "" && req.ImageUrl == "" {
|
// 验证:报告内容、AI生成的JSON数据和图片不能同时为空
|
||||||
return nil, errors.New("报告内容和图片不能同时为空")
|
if req.ReportContent == "" && reportData == "" && req.ImageUrl == "" {
|
||||||
|
return nil, errors.New("报告内容、AI数据和图片不能同时为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ImageUrl != "" {
|
if req.ImageUrl != "" {
|
||||||
@ -116,7 +126,59 @@ func CreateCompetitiveReportCore(ctx *gin.Context, req *cast.CreateCompetitiveRe
|
|||||||
req.ImageUrl = newImageUrl
|
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")
|
today := time.Now().Format("20060102")
|
||||||
timestamp := time.Now().UnixMicro()
|
timestamp := time.Now().UnixMicro()
|
||||||
pdfFileName := fmt.Sprintf("%s%s老师的竞品报告%d.pdf", today, req.ArtistName, timestamp)
|
pdfFileName := fmt.Sprintf("%s%s老师的竞品报告%d.pdf", today, req.ArtistName, timestamp)
|
||||||
|
|||||||
@ -384,6 +384,7 @@ type CreateWorkAnalysisWithTaskUUIDReq struct {
|
|||||||
type CreateCompetitiveReportWithTaskUUIDReq struct {
|
type CreateCompetitiveReportWithTaskUUIDReq struct {
|
||||||
*cast.CreateCompetitiveReportReq
|
*cast.CreateCompetitiveReportReq
|
||||||
AssignRecordsUUID string `json:"assignRecordsUUID"`
|
AssignRecordsUUID string `json:"assignRecordsUUID"`
|
||||||
|
ReportData string `json:"reportData"` // AI生成的竞品报告JSON数据
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateWorkImageWithTaskUUID(ctx *gin.Context) {
|
func UpdateWorkImageWithTaskUUID(ctx *gin.Context) {
|
||||||
@ -575,7 +576,7 @@ func CreateCompetitiveReportWithTaskUUID(ctx *gin.Context) {
|
|||||||
service.Error(ctx, errors.New("任务已中止"))
|
service.Error(ctx, errors.New("任务已中止"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := castService.CreateCompetitiveReportCore(ctx, req.CreateCompetitiveReportReq)
|
resp, err := castService.CreateCompetitiveReportCore(ctx, req.CreateCompetitiveReportReq, req.ReportData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.Error(ctx, err)
|
service.Error(ctx, err)
|
||||||
return
|
return
|
||||||
|
|||||||
325
pkg/utils/pdf.go
325
pkg/utils/pdf.go
@ -3,6 +3,7 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -11,6 +12,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/phpdave11/gofpdf"
|
"github.com/phpdave11/gofpdf"
|
||||||
|
"github.com/signintech/gopdf"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji)
|
// cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji)
|
||||||
@ -172,3 +175,325 @@ func GeneratePDF(text, imageURL, outputPath, fontPath string) error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
175
pkg/utils/pdf_competitor_test.go
Normal file
175
pkg/utils/pdf_competitor_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user