Files
hr_data_analyzer/controllers/ai_analysis_pdf.go
T
2026-05-01 20:29:53 +08:00

660 lines
16 KiB
Go

package controllers
import (
"bytes"
"fmt"
"hr_receiver/models"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-pdf/fpdf"
"gorm.io/gorm"
)
var orderedListPattern = regexp.MustCompile(`^\d+\.\s+`)
func (sc *StatisticsController) DownloadAIAnalysisRecordPDF(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
writeError(c, http.StatusBadRequest, "id is required")
return
}
var record models.AIAnalysisRecord
if err := sc.DB.First(&record, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
writeError(c, http.StatusNotFound, "record not found")
return
}
writeError(c, http.StatusInternalServerError, "failed to query record")
return
}
kindergartenName := ""
if record.RegionID != nil && *record.RegionID > 0 {
var kindergarten models.Kindergarten
if err := sc.DB.Where("region_id = ?", *record.RegionID).First(&kindergarten).Error; err == nil {
kindergartenName = kindergarten.Name
}
}
fileBytes, err := buildAIAnalysisRecordPDF(record, kindergartenName)
if err != nil {
writeError(c, http.StatusInternalServerError, err.Error())
return
}
fileName := fmt.Sprintf("analysis-result-%d.pdf", record.ID)
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
c.Data(http.StatusOK, "application/pdf", fileBytes)
}
func buildAIAnalysisRecordPDF(record models.AIAnalysisRecord, kindergartenName string) ([]byte, error) {
normalFontPath, boldFontPath, err := resolveAnalysisPDFFontPaths()
if err != nil {
return nil, err
}
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetMargins(12, 12, 12)
pdf.SetAutoPageBreak(true, 12)
pdf.AddUTF8Font("SimHei", "", normalFontPath)
pdf.AddUTF8Font("SimHei", "B", boldFontPath)
pdf.SetFont("SimHei", "", 14)
pdf.AddPage()
renderer := &analysisPDFRenderer{pdf: pdf}
renderer.addTitle(fmt.Sprintf("AI 分析结果 #%d", record.ID))
renderer.addMetaTable([][2]string{
{"幼儿园", valueOrDash(kindergartenName)},
{"Region ID", formatNullableRegionID(record.RegionID)},
{"来源", translateSourceType(record.SourceType)},
{"分析类型", translateAnalysisType(record.AnalysisType)},
{"输入 Token", strconv.Itoa(record.InputTokens)},
{"输出 Token", strconv.Itoa(record.OutputTokens)},
{"输入大小", formatBytesForPDF(int64(record.InputSizeBytes))},
{"输出大小", formatBytesForPDF(int64(record.OutputSizeBytes))},
{"耗时", formatDurationForPDF(record.DurationMs)},
{"原始文件", formatBytesForPDF(record.OriginalFileSize)},
{"压缩后", formatBytesForPDF(record.CompressedContentSize)},
{"上传时间", formatDateTime(record.UploadTime)},
{"总花费", formatCostForPDF(record.TotalCost)},
})
renderer.addSectionTitle("分析内容")
renderer.renderMarkdown(record.AnalysisResult)
var buffer bytes.Buffer
if err := pdf.Output(&buffer); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func resolveAnalysisPDFFontPaths() (string, string, error) {
normalCandidates := []string{
filepath.Join("..", "hr-receiver-web", "apps", "web-ele", "src", "assets", "fonts", "simhei.ttf"),
filepath.Join("assets", "fonts", "simhei.ttf"),
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simhei.ttf"),
`C:\Windows\Fonts\simhei.ttf`,
}
boldCandidates := []string{
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simsunb.ttf"),
`C:\Windows\Fonts\simsunb.ttf`,
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simhei.ttf"),
`C:\Windows\Fonts\simhei.ttf`,
}
normalPath := firstExistingPath(normalCandidates)
if normalPath == "" {
return "", "", fmt.Errorf("failed to locate chinese font for pdf export")
}
boldPath := firstExistingPath(boldCandidates)
if boldPath == "" {
boldPath = normalPath
}
return normalPath, boldPath, nil
}
func firstExistingPath(candidates []string) string {
for _, candidate := range candidates {
if candidate == "" {
continue
}
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
type analysisPDFRenderer struct {
pdf *fpdf.Fpdf
}
func (r *analysisPDFRenderer) addTitle(title string) {
r.pdf.SetFont("SimHei", "", 18)
r.pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "")
r.pdf.Ln(2)
}
func (r *analysisPDFRenderer) addSectionTitle(title string) {
r.ensureSpace(8)
r.pdf.SetFont("SimHei", "", 14)
r.pdf.CellFormat(0, 8, title, "", 1, "L", false, 0, "")
r.pdf.Ln(1)
}
func (r *analysisPDFRenderer) addParagraph(text string) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return
}
r.writeStyledText(trimmed, 11, 0, "")
r.pdf.Ln(1)
}
func (r *analysisPDFRenderer) addBullet(text string) {
r.writeStyledText(strings.TrimSpace(text), 11, 5, "• ")
}
func (r *analysisPDFRenderer) addMetaTable(rows [][2]string) {
leftWidth := 34.0
rightWidth := 152.0
r.pdf.SetFont("SimHei", "", 11)
for _, row := range rows {
r.ensureSpace(8)
r.pdf.SetFillColor(241, 245, 249)
r.pdf.CellFormat(leftWidth, 8, row[0], "1", 0, "L", true, 0, "")
r.pdf.CellFormat(rightWidth, 8, row[1], "1", 1, "L", false, 0, "")
}
r.pdf.Ln(4)
}
func (r *analysisPDFRenderer) addTable(headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
pageWidth, _ := r.pdf.GetPageSize()
left, _, right, _ := r.pdf.GetMargins()
tableWidth := pageWidth - left - right
colWidths := r.calculateTableColumnWidths(headers, rows, tableWidth)
lineHeight := 5.0
r.pdf.SetFont("SimHei", "", 10)
r.pdf.SetFillColor(241, 245, 249)
for index, header := range headers {
r.pdf.CellFormat(colWidths[index], 8, stripMarkdownMarkers(header), "1", 0, "L", true, 0, "")
}
r.pdf.Ln(-1)
for _, row := range rows {
normalizedRow := make([]string, len(headers))
copy(normalizedRow, row)
for len(normalizedRow) < len(headers) {
normalizedRow = append(normalizedRow, "")
}
splitCells := make([][]string, len(headers))
maxLines := 1
for index := range headers {
cellText := stripMarkdownMarkers(normalizedRow[index])
lines := r.pdf.SplitText(cellText, colWidths[index]-2)
if len(lines) == 0 {
lines = []string{""}
}
splitCells[index] = lines
if len(lines) > maxLines {
maxLines = len(lines)
}
}
rowHeight := float64(maxLines)*lineHeight + 2
r.ensureSpace(rowHeight)
x := left
y := r.pdf.GetY()
for index := range headers {
width := colWidths[index]
r.pdf.Rect(x, y, width, rowHeight, "")
r.pdf.SetXY(x+1, y+1)
r.pdf.MultiCell(width-2, lineHeight, strings.Join(splitCells[index], "\n"), "", "L", false)
x += width
if index < len(headers)-1 {
r.pdf.SetXY(x, y)
}
}
r.pdf.SetXY(left, y+rowHeight)
}
r.pdf.Ln(2)
}
func (r *analysisPDFRenderer) calculateTableColumnWidths(headers []string, rows [][]string, totalWidth float64) []float64 {
widths := make([]float64, len(headers))
minWidth := 22.0
maxWidth := totalWidth * 0.42
totalMeasured := 0.0
r.pdf.SetFont("SimHei", "", 10)
for index, header := range headers {
measuredWidth := r.pdf.GetStringWidth(stripMarkdownMarkers(header)) + 8
for _, row := range rows {
if index >= len(row) {
continue
}
cellWidth := r.pdf.GetStringWidth(stripMarkdownMarkers(row[index])) + 8
if cellWidth > measuredWidth {
measuredWidth = cellWidth
}
}
measuredWidth = clampFloat(measuredWidth, minWidth, maxWidth)
if measuredWidth < minWidth {
measuredWidth = minWidth
}
widths[index] = measuredWidth
totalMeasured += measuredWidth
}
if totalMeasured <= totalWidth {
extra := (totalWidth - totalMeasured) / float64(len(widths))
for index := range widths {
widths[index] += extra
}
return widths
}
shrinkCapacity := 0.0
for _, width := range widths {
shrinkCapacity += width - minWidth
}
overflow := totalMeasured - totalWidth
if shrinkCapacity > 0 {
for index := range widths {
reducible := widths[index] - minWidth
if reducible <= 0 {
continue
}
widths[index] -= overflow * (reducible / shrinkCapacity)
if widths[index] < minWidth {
widths[index] = minWidth
}
}
}
currentTotal := 0.0
for _, width := range widths {
currentTotal += width
}
if currentTotal > totalWidth {
widths[len(widths)-1] -= currentTotal - totalWidth
}
return widths
}
func (r *analysisPDFRenderer) renderMarkdown(markdown string) {
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
for index := 0; index < len(lines); {
line := strings.TrimSpace(lines[index])
if line == "" {
index++
continue
}
if headers, rows, consumed, ok := parseMarkdownTable(lines[index:]); ok {
r.addTable(headers, rows)
index += consumed
continue
}
if strings.HasPrefix(line, "#") {
depth := countHeadingDepth(line)
title := strings.TrimSpace(line[depth:])
r.pdf.SetFont("SimHei", "", headingFontSize(depth))
r.pdf.MultiCell(0, 7, stripMarkdownMarkers(title), "", "L", false)
r.pdf.Ln(1)
index++
continue
}
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
r.addBullet(strings.TrimSpace(line[2:]))
index++
continue
}
if orderedListPattern.MatchString(line) {
r.addParagraph(line)
index++
continue
}
paragraphLines := []string{line}
index++
for index < len(lines) {
nextLine := strings.TrimSpace(lines[index])
if nextLine == "" || strings.HasPrefix(nextLine, "#") || strings.HasPrefix(nextLine, "- ") || strings.HasPrefix(nextLine, "* ") || orderedListPattern.MatchString(nextLine) {
break
}
if _, _, _, ok := parseMarkdownTable(lines[index:]); ok {
break
}
paragraphLines = append(paragraphLines, nextLine)
index++
}
r.addParagraph(strings.Join(paragraphLines, "\n"))
}
}
func (r *analysisPDFRenderer) ensureSpace(height float64) {
_, pageHeight := r.pdf.GetPageSize()
_, _, _, bottom := r.pdf.GetMargins()
if r.pdf.GetY()+height <= pageHeight-bottom {
return
}
r.pdf.AddPage()
r.pdf.SetFont("SimHei", "", 11)
}
func (r *analysisPDFRenderer) writeStyledText(text string, fontSize float64, indent float64, prefix string) {
baseLeft, _, rightMargin, _ := r.pdf.GetMargins()
contentLeft := baseLeft + indent
lineHeight := 6.0
r.ensureSpace(lineHeight)
r.pdf.SetLeftMargin(contentLeft)
r.pdf.SetRightMargin(rightMargin)
r.pdf.SetXY(contentLeft, r.pdf.GetY())
r.pdf.SetFont("SimHei", "", fontSize)
if prefix != "" {
r.pdf.Write(lineHeight, prefix)
}
for _, segment := range parseStyledSegments(text) {
style := ""
if segment.Bold {
style = "B"
}
r.pdf.SetFont("SimHei", style, fontSize)
r.pdf.Write(lineHeight, segment.Text)
}
r.pdf.Ln(lineHeight)
r.pdf.SetLeftMargin(baseLeft)
r.pdf.SetRightMargin(rightMargin)
}
type styledTextSegment struct {
Text string
Bold bool
}
func parseStyledSegments(text string) []styledTextSegment {
if strings.TrimSpace(text) == "" {
return []styledTextSegment{{Text: "", Bold: false}}
}
segments := make([]styledTextSegment, 0, 4)
for len(text) > 0 {
marker, start := findNextBoldMarker(text)
if start < 0 {
segments = append(segments, styledTextSegment{
Text: stripMarkdownMarkers(text),
Bold: false,
})
break
}
if start > 0 {
segments = append(segments, styledTextSegment{
Text: stripMarkdownMarkers(text[:start]),
Bold: false,
})
}
contentStart := start + len(marker)
endOffset := strings.Index(text[contentStart:], marker)
if endOffset < 0 {
segments = append(segments, styledTextSegment{
Text: stripMarkdownMarkers(text[start:]),
Bold: false,
})
break
}
boldText := text[contentStart : contentStart+endOffset]
if boldText != "" {
segments = append(segments, styledTextSegment{
Text: stripMarkdownMarkers(boldText),
Bold: true,
})
}
text = text[contentStart+endOffset+len(marker):]
}
if len(segments) == 0 {
return []styledTextSegment{{Text: "", Bold: false}}
}
return mergeAdjacentStyledSegments(segments)
}
func findNextBoldMarker(text string) (string, int) {
doubleStar := strings.Index(text, "**")
doubleUnderscore := strings.Index(text, "__")
switch {
case doubleStar >= 0 && (doubleUnderscore < 0 || doubleStar < doubleUnderscore):
return "**", doubleStar
case doubleUnderscore >= 0:
return "__", doubleUnderscore
default:
return "", -1
}
}
func mergeAdjacentStyledSegments(segments []styledTextSegment) []styledTextSegment {
merged := make([]styledTextSegment, 0, len(segments))
for _, segment := range segments {
if segment.Text == "" {
continue
}
if len(merged) > 0 && merged[len(merged)-1].Bold == segment.Bold {
merged[len(merged)-1].Text += segment.Text
continue
}
merged = append(merged, segment)
}
if len(merged) == 0 {
return []styledTextSegment{{Text: "", Bold: false}}
}
return merged
}
func parseMarkdownTable(lines []string) ([]string, [][]string, int, bool) {
if len(lines) < 2 {
return nil, nil, 0, false
}
headerLine := strings.TrimSpace(lines[0])
separatorLine := strings.TrimSpace(lines[1])
if !strings.Contains(headerLine, "|") || !isMarkdownTableSeparator(separatorLine) {
return nil, nil, 0, false
}
headers := splitMarkdownTableRow(headerLine)
if len(headers) == 0 {
return nil, nil, 0, false
}
rows := make([][]string, 0)
consumed := 2
for consumed < len(lines) {
line := strings.TrimSpace(lines[consumed])
if line == "" || !strings.Contains(line, "|") {
break
}
row := splitMarkdownTableRow(line)
if len(row) == 0 {
break
}
for len(row) < len(headers) {
row = append(row, "")
}
if len(row) > len(headers) {
row = row[:len(headers)]
}
rows = append(rows, row)
consumed++
}
return headers, rows, consumed, true
}
func splitMarkdownTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := strings.Split(trimmed, "|")
cells := make([]string, 0, len(parts))
for _, part := range parts {
cells = append(cells, strings.TrimSpace(part))
}
return cells
}
func isMarkdownTableSeparator(line string) bool {
if !strings.Contains(line, "|") {
return false
}
for _, char := range line {
if char == '|' || char == '-' || char == ':' || char == ' ' || char == '\t' {
continue
}
return false
}
return true
}
func countHeadingDepth(line string) int {
depth := 0
for depth < len(line) && line[depth] == '#' {
depth++
}
if depth == 0 {
return 1
}
return depth
}
func headingFontSize(depth int) float64 {
switch depth {
case 1:
return 16
case 2:
return 14
case 3:
return 13
default:
return 12
}
}
func stripMarkdownMarkers(value string) string {
replacer := strings.NewReplacer(
"**", "",
"__", "",
"`", "",
)
return replacer.Replace(value)
}
func clampFloat(value float64, min float64, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func translateSourceType(sourceType string) string {
switch strings.TrimSpace(strings.ToLower(sourceType)) {
case "upload":
return "上传文件"
case "wechat":
return "微信分享"
case "cloud":
return "云端文件"
default:
if sourceType == "" {
return "-"
}
return sourceType
}
}
func translateAnalysisType(analysisType string) string {
switch strings.TrimSpace(strings.ToLower(analysisType)) {
case "heart_rate_only":
return "仅心率"
case "heart_rate_with_steps":
return "心率+步数"
default:
if analysisType == "" {
return "-"
}
return analysisType
}
}
func formatNullableRegionID(regionID *uint32) string {
if regionID == nil || *regionID == 0 {
return "-"
}
return strconv.FormatUint(uint64(*regionID), 10)
}
func formatBytesForPDF(value int64) string {
if value <= 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB"}
size := float64(value)
unitIndex := 0
for size >= 1024 && unitIndex < len(units)-1 {
size /= 1024
unitIndex++
}
if size >= 10 || unitIndex == 0 {
return fmt.Sprintf("%.0f %s", size, units[unitIndex])
}
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
}
func formatDurationForPDF(value int64) string {
if value <= 0 {
return "-"
}
if value < 1000 {
return fmt.Sprintf("%d ms", value)
}
return fmt.Sprintf("%.1f s", float64(value)/1000)
}
func formatCostForPDF(value float64) string {
if value == 0 {
return "0.00 元"
}
return fmt.Sprintf("%.6f 元", value)
}
func formatDateTime(value int64) string {
if value <= 0 {
return "-"
}
return time.UnixMilli(value).Format("2006-01-02 15:04:05")
}
func valueOrDash(value string) string {
if strings.TrimSpace(value) == "" {
return "-"
}
return value
}