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 "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 }