Merge branch 'jng-QuestionnairePDF-0311' into feat-hjj-QuestionnaireSurvey

This commit is contained in:
JNG 2026-03-11 21:11:19 +08:00
commit dc34ad2043
5 changed files with 479 additions and 0 deletions

Binary file not shown.

View File

@ -0,0 +1,35 @@
package model
type QuestionnairePDFData struct {
// 基本信息
CustomerNum string `json:"customerNum"`
CustomerName string `json:"customerName"`
BundleName string `json:"bundleName"`
BundleStartDate string `json:"bundleStartDate"`
BundleEndDate string `json:"bundleEndDate"`
VideoNum string `json:"videoNum"`
AccountNum string `json:"accountNum"`
ImagesNum string `json:"imagesNum"`
DataAnalysisNum string `json:"dataAnalysisNum"`
CompetitiveNum string `json:"competitiveNum"`
ValueAddVideoNum string `json:"valueAddVideoNum"`
// 评分1-5
Score1 int `json:"score1"`
Score2 int `json:"score2"`
Score3 int `json:"score3"`
Score4 int `json:"score4"`
Score5 int `json:"score5"`
Score6 int `json:"score6"`
Score7 int `json:"score7"`
// 意见
Opinion1 string `json:"opinion1"`
Opinion2 string `json:"opinion2"`
Opinion3 string `json:"opinion3"`
// 提交信息
Submitter string `json:"submitter"`
SubmissionDate string `json:"submissionDate"`
Address string `json:"address"`
}

View File

@ -2,10 +2,18 @@ package bundle
import (
"context"
"errors"
"fonchain-fiee/api/bundle"
"fonchain-fiee/pkg/service"
"fonchain-fiee/pkg/service/bundle/model"
"fonchain-fiee/pkg/service/upload"
"fonchain-fiee/pkg/utils"
"strconv"
"time"
m "fonchain-fiee/pkg/model"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func IsSendSurvey(c *gin.Context) {
@ -56,6 +64,58 @@ func QuestionnaireSurveyCreate(c *gin.Context) {
service.Error(c, err)
return
}
address, err := utils.ReverseGeo(req.Longitude, req.Latitude, "ZhCN")
if err != nil {
service.Error(c, errors.New("获取地址失败"))
return
}
surveyInfo, err := service.BundleProvider.GetQuestionnaireSurveyInfo(c, &bundle.GetQuestionnaireSurveyInfoRequest{UserTel: req.UserTel})
if err != nil {
service.Error(c, err)
return
}
templateDir := "./data/满意度调成报告模板.pdf"
outputPath := m.MediaPath + "questionnaire_" + uuid.NewString() + ".pdf"
err = utils.QuestionnaireSurveyPDF(templateDir, outputPath, &model.QuestionnairePDFData{
//CustomerNum: surveyInfo.BundleInfo.,
CustomerName: surveyInfo.UserName,
BundleName: surveyInfo.BundleInfo.BundleName,
BundleStartDate: surveyInfo.BundleInfo.StartAt,
BundleEndDate: surveyInfo.BundleInfo.ExpiredAt,
VideoNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.BundleVideoNumber), 10),
AccountNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.BundleAccountNumber), 10),
ImagesNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.BundleImageNumber), 10),
DataAnalysisNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.BundleDataNumber), 10),
CompetitiveNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.BundleCompetitiveNumber), 10),
ValueAddVideoNum: strconv.FormatInt(int64(surveyInfo.BundleInfo.IncreaseVideoNumber), 10),
Score1: int(req.SurveyAnswer.BundleAccountScore),
Score2: int(req.SurveyAnswer.BundleAccountScore),
Score3: int(req.SurveyAnswer.BundleImageScore),
Score4: int(req.SurveyAnswer.BundleDataScore),
Score5: int(req.SurveyAnswer.IncreaseVideoScore),
Score6: int(req.SurveyAnswer.ServiceResponseSpeed),
Score7: int(req.SurveyAnswer.ServiceStaffProfessionalism),
Opinion1: req.SurveyFeedback.MeritsReview,
Opinion2: req.SurveyFeedback.SuggestionsorImprovements,
Opinion3: req.SurveyFeedback.AdditionalComments,
Submitter: surveyInfo.UserName,
SubmissionDate: time.Now().Format(time.DateOnly),
Address: address,
})
if err != nil {
service.Error(c, err)
return
}
outputUrl, ossErr := upload.PutBos(outputPath, upload.PdfType, true)
if ossErr != nil {
service.Error(c, err)
return
}
//service.Success(c, outputUrl)
//return
req.SurveyUrl = outputUrl
res, err := service.BundleProvider.CreateQuestionnaireSurveyAnswer(context.Background(), &req)
if err != nil {
service.Error(c, err)

88
pkg/utils/map.go Normal file
View File

@ -0,0 +1,88 @@
package utils
import (
"encoding/json"
"io/ioutil"
"net/http"
"strings"
)
type ReverseGeocodingReq struct {
Ak string `json:"ak"`
Coordtype string `json:"coordtype"`
RetCoordtype string `json:"retCoordtype"`
Location string `json:"location"`
Output string `json:"output"`
Language string `json:"language"`
}
type ReverseGeocodingRes struct {
Status int `json:"status"`
Result struct {
Location struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
} `json:"location"`
FormattedAddress string `json:"formatted_address"`
Edz struct {
Name string `json:"name"`
} `json:"edz"`
Business string `json:"business"`
AddressComponent struct {
Country string `json:"country"`
CountryCodeIso string `json:"country_code_iso"`
CountryCodeIso2 string `json:"country_code_iso2"`
CountryCode int `json:"country_code"`
Province string `json:"province"`
City string `json:"city"`
CityLevel int `json:"city_level"`
District string `json:"district"`
Town string `json:"town"`
TownCode string `json:"town_code"`
Distance string `json:"distance"`
Direction string `json:"direction"`
Adcode string `json:"adcode"`
Street string `json:"street"`
StreetNumber string `json:"street_number"`
} `json:"addressComponent"`
} `json:"result"`
}
// ReverseGeo 经纬度逆编码
func ReverseGeo(longitude, latitude string, language string) (address string, err error) {
var reverseGeocodingReq ReverseGeocodingReq
reverseGeocodingReq.Ak = "3bAjKGA0pv7qvszGe98RsVZ04Ob5r4ZZ"
reverseGeocodingReq.Coordtype = "gcj02ll"
reverseGeocodingReq.Output = "json"
reverseGeocodingReq.RetCoordtype = "gcj02ll"
reverseGeocodingReq.Location = strings.Join([]string{latitude, longitude}, ",")
reverseGeocodingReq.Language = language
url := "https://api.map.baidu.com/reverse_geocoding/v3/?ak=" + reverseGeocodingReq.Ak + "&output=" + reverseGeocodingReq.Output + "&coordtype=" + reverseGeocodingReq.Coordtype + "&location=" + reverseGeocodingReq.Location + "&ret_coordtype=" + reverseGeocodingReq.RetCoordtype + "&language=" + reverseGeocodingReq.Language
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
var results ReverseGeocodingRes
err = json.Unmarshal(body, &results)
if err != nil {
return "", err
}
if results.Status != 0 {
address = "未知地址"
return address, err
}
address = results.Result.FormattedAddress
return address, nil
}

View File

@ -3,6 +3,7 @@ package utils
import (
"errors"
"fmt"
"fonchain-fiee/pkg/service/bundle/model"
"image"
"io"
"net/http"
@ -10,7 +11,9 @@ import (
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/phpdave11/gofpdf"
"github.com/signintech/gopdf"
@ -595,3 +598,296 @@ func ConvertCompetitorReportToText(data CompetitorReportData, isVideo bool) stri
return sb.String()
}
//生成问卷调查pdf
func QuestionnaireSurveyPDF(templatePath, outputPath string, data *model.QuestionnairePDFData) error {
pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
if err := pdf.ImportPagesFromSource(templatePath, "/MediaBox"); err != nil {
return fmt.Errorf("导入模板失败: %w", err)
}
if err := pdf.AddTTFFont("simfang", "./data/simfang.ttf"); err != nil {
return fmt.Errorf("加载字体失败: %w", err)
}
if err := pdf.SetFont("simfang", "", 12); err != nil {
return fmt.Errorf("设置字体失败: %w", err)
}
startTime, err := parseFlexibleDate(data.BundleStartDate)
if err != nil {
return fmt.Errorf("BundleStartDate格式错误: %w", err)
}
endTime, err := parseFlexibleDate(data.BundleEndDate)
if err != nil {
return fmt.Errorf("BundleEndDate格式错误: %w", err)
}
submissionDate, err := parseFlexibleDate(data.SubmissionDate)
if err != nil {
return fmt.Errorf("SubmissionDate格式错误: %w", err)
}
nowTime := time.Now().Format(time.DateTime)
onePage := 1
twoPage := 2
threePage := 3
fourPage := 4
// 第1页客户基本信息
pdf.SetPage(onePage)
//姓名
pdf.SetX(165)
pdf.SetY(420)
pdf.Cell(nil, data.CustomerName)
//套餐名称
pdf.SetX(205)
pdf.SetY(443)
pdf.Cell(nil, data.BundleName)
//开始日期
pdf.SetX(205)
pdf.SetY(467)
pdf.Cell(nil, startTime.Format("2006"))
pdf.SetX(260)
pdf.SetY(467)
pdf.Cell(nil, startTime.Format("01"))
pdf.SetX(300)
pdf.SetY(467)
pdf.Cell(nil, startTime.Format("02"))
//结束日期
pdf.SetX(350)
pdf.SetY(467)
pdf.Cell(nil, endTime.Format("2006"))
pdf.SetX(398)
pdf.SetY(467)
pdf.Cell(nil, endTime.Format("01"))
pdf.SetX(437)
pdf.SetY(467)
pdf.Cell(nil, endTime.Format("02"))
//视频数
pdf.SetX(220)
pdf.SetY(583)
pdf.Cell(nil, data.VideoNum)
//"账号数: "+
pdf.SetX(230)
pdf.SetY(625)
pdf.Cell(nil, data.AccountNum)
// "图文数: "+
pdf.SetX(253)
pdf.SetY(667)
pdf.Cell(nil, data.ImagesNum)
//"数据分析数: "+
pdf.SetX(280)
pdf.SetY(727)
pdf.Cell(nil, data.DataAnalysisNum)
// 第1页内容写完后
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间"+nowTime); err != nil {
return err
}
// 第2页服务数量
pdf.SetPage(twoPage)
//"竞品分析数: "+
pdf.SetX(205)
pdf.SetY(72)
pdf.Cell(nil, data.CompetitiveNum)
//"增值视频数: "+
pdf.SetX(270)
pdf.SetY(156)
pdf.Cell(nil, data.ValueAddVideoNum)
//"评分1: "+
pdf.SetX(123)
pdf.SetY(485)
pdf.Cell(nil, scoreStars(data.Score1))
//"评分2: "+
pdf.SetX(343)
pdf.SetY(526)
pdf.Cell(nil, scoreStars(data.Score2))
//"评分3: "+
pdf.SetX(230)
pdf.SetY(568)
pdf.Cell(nil, scoreStars(data.Score3))
//"评分4: "+
pdf.SetX(362)
pdf.SetY(610)
pdf.Cell(nil, scoreStars(data.Score4))
//"评分5: "+
pdf.SetX(220)
pdf.SetY(652)
pdf.Cell(nil, scoreStars(data.Score5))
//"评分6: "+
pdf.SetX(164)
pdf.SetY(694)
pdf.Cell(nil, scoreStars(data.Score6))
//"评分7: "+
pdf.SetX(197)
pdf.SetY(735)
pdf.Cell(nil, scoreStars(data.Score7))
//水印
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间"+nowTime); err != nil {
return err
}
// 第3页评分与意见
pdf.SetPage(threePage)
// Opinion 超过100字符时自动换行每行约20个中文字符行高18pt
drawWrappedText(&pdf, data.Opinion1, 90, 145, 24, 34)
drawWrappedText(&pdf, data.Opinion2, 90, 377, 24, 34)
drawWrappedText(&pdf, data.Opinion3, 90, 574, 24, 34)
//水印
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间"+nowTime); err != nil {
return err
}
// 第4页提交信息
pdf.SetPage(fourPage)
//"提交人: "+
pdf.SetX(135)
pdf.SetY(103)
pdf.Cell(nil, data.Submitter)
//提交时间: "
pdf.SetX(148)
pdf.SetY(128)
pdf.Cell(nil, submissionDate.Format("2006"))
pdf.SetX(207)
pdf.SetY(128)
pdf.Cell(nil, submissionDate.Format("01"))
pdf.SetX(260)
pdf.SetY(128)
pdf.Cell(nil, submissionDate.Format("02"))
//水印
if err = addWatermark(&pdf, "确认地址:"+data.Address+"\n确认时间"+nowTime); err != nil {
return err
}
if err := pdf.WritePdf(outputPath); err != nil {
return fmt.Errorf("写入PDF失败: %w", err)
}
return nil
}
// addWatermark 在当前页叠加浅色斜向双行水印,并在结束后恢复样式
func addWatermark(pdf *gopdf.GoPdf, text string) error {
const normalFontSize = 12
const watermarkFontSize = 22
const lineHeight = 32.0 // 水印行间距
// 设置水印样式
pdf.SetGrayFill(0.85)
if err := pdf.SetFont("simfang", "", watermarkFontSize); err != nil {
return fmt.Errorf("设置水印字体失败: %w", err)
}
// 按换行拆分逐行绘制Cell 不负责自动换行)
lines := strings.Split(text, "\n")
if len(lines) == 0 {
lines = []string{text}
}
drawBlock := func(x, y, cx, cy float64) {
pdf.Rotate(35, cx, cy)
for i, line := range lines {
pdf.SetX(x)
pdf.SetY(y + float64(i)*lineHeight)
pdf.Cell(nil, line)
}
pdf.RotateReset()
}
// 两处重复水印
drawBlock(90, 420, 300, 420)
drawBlock(130, 620, 300, 420)
// 恢复样式,避免影响后续正文
pdf.SetGrayFill(0)
if err := pdf.SetFont("simfang", "", normalFontSize); err != nil {
return fmt.Errorf("恢复字体失败: %w", err)
}
return nil
}
// drawWrappedText 在 PDF 上绘制自动换行的文字
// pdf: GoPdf 实例, text: 文字内容, startX/startY: 起始坐标
// maxWidth: 最大宽度pt, lineHeight: 行高pt, charsPerLine: 每行最多字符数(按中文字符计)
func drawWrappedText(pdf *gopdf.GoPdf, text string, startX, startY, lineHeight float64, charsPerLine int) {
runes := []rune(text)
total := len(runes)
if total == 0 {
return
}
lineStart := 0
currentLine := 0
for lineStart < total {
end := lineStart + charsPerLine
if end > total {
end = total
}
// 计算实际宽度以决定换行点按字节估算ASCII=0.5中文字符宽)
charCount := 0
splitAt := lineStart
for i := lineStart; i < total; i++ {
r := runes[i]
runeBytes := utf8.RuneLen(r)
if runeBytes > 1 {
charCount += 2 // 中文等宽字符算2个单位
} else {
charCount += 1 // ASCII 算1个单位
}
if charCount > charsPerLine*2 {
break
}
splitAt = i + 1
}
if splitAt == lineStart {
splitAt = lineStart + 1
}
line := string(runes[lineStart:splitAt])
pdf.SetX(startX)
pdf.SetY(startY + float64(currentLine)*lineHeight)
pdf.Cell(nil, line)
lineStart = splitAt
currentLine++
}
}
func scoreStars(score int) string {
switch {
case score <= 1:
return "★☆☆☆☆"
case score == 2:
return "★★☆☆☆"
case score == 3:
return "★★★☆☆"
case score == 4:
return "★★★★☆"
default:
return "★★★★★"
}
}
func parseFlexibleDate(value string) (time.Time, error) {
v := strings.TrimSpace(value)
if v == "" {
return time.Time{}, fmt.Errorf("日期为空")
}
layouts := []string{
"2006-01-02",
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
}
var lastErr error
for _, layout := range layouts {
t, err := time.Parse(layout, v)
if err == nil {
return t, nil
}
lastErr = err
}
return time.Time{}, fmt.Errorf("不支持的日期格式: %s, %w", v, lastErr)
}