diff --git a/controllers/ai_analysis_pdf.go b/controllers/ai_analysis_pdf.go new file mode 100644 index 0000000..64cc73e --- /dev/null +++ b/controllers/ai_analysis_pdf.go @@ -0,0 +1,657 @@ +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 +} diff --git a/go.mod b/go.mod index f2b87d1..2950073 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/fumiama/imgsz v0.0.2 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect diff --git a/go.sum b/go.sum index ac1fc8a..ffe7193 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/routes/routes.go b/routes/routes.go index f4471a3..c69b4cd 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -121,6 +121,7 @@ func SetupRouter() *gin.Engine { admin.POST("/system-debug/mqtt/stop", systemDebugController.StopMqtt) admin.GET("/statistics/ai-analysis-records", statisticsController.ListAIAnalysisRecords) + admin.GET("/statistics/ai-analysis-records/:id/pdf", statisticsController.DownloadAIAnalysisRecordPDF) admin.DELETE("/statistics/ai-analysis-records/:id", statisticsController.DeleteAIAnalysisRecord) admin.GET("/statistics/ai-analysis", statisticsController.StatisticsByRegion) admin.GET("/statistics/ai-analysis-timeline", statisticsController.TimelineStatistics)