This commit is contained in:
Mrx
2026-03-13 18:07:24 +08:00
parent f16274d1e9
commit 209ffec51d
16 changed files with 309 additions and 1176 deletions

View File

@@ -14,7 +14,7 @@ import (
"github.com/tidwall/gjson"
)
// ProcessFLXG0V4BRequest FLXG0V4B API处理方法
// ProcessFLXG0V4BRequest FLXG0V4B API处理方法(身份证排空入口,身份证身份证身份证身份证身份证)
func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.FLXG0V4BReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
@@ -24,7 +24,7 @@ func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998"|| paramsDto.IDCard == "640102198708020925"{
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name)

View File

@@ -20,7 +20,7 @@ func ProcessFLXG5A3BRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"{
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name)

View File

@@ -20,7 +20,7 @@ func ProcessFLXG7E8FRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998" {
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
// 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名

View File

@@ -25,7 +25,7 @@ func ProcessFLXGDEA9Request(ctx context.Context, params []byte, deps *processors
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"|| paramsDto.IDCard == "640102198708020925"{
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard)

View File

@@ -1,168 +0,0 @@
# 📋 PDF表格转换日志查看指南
## 📍 日志文件位置
### 1. 开发环境
日志文件存储在项目根目录的 `logs/` 目录下:
```
tyapi-server/
└── logs/
├── 2024-12-02/ # 按日期分包(如果启用)
│ ├── debug.log # Debug级别日志包含JSON转换详情
│ ├── info.log # Info级别日志包含转换流程
│ └── error.log # Error级别日志包含错误信息
└── app.log # 传统模式(如果未启用按日分包)
```
### 2. 生产环境Docker
日志文件存储在容器的 `/app/logs/` 目录,映射到宿主机的 `./logs/` 目录:
```bash
# 查看宿主机日志
./logs/2024-12-02/info.log
./logs/2024-12-02/debug.log
```
## 🔍 如何查看转换日志
### 方法1实时查看日志推荐
```bash
# 查看Info级别日志转换流程
tail -f logs/2024-12-02/info.log | grep "表格\|JSON\|markdown"
# 查看Debug级别日志详细JSON数据
tail -f logs/2024-12-02/debug.log | grep "JSON\|表格"
# 查看所有PDF相关日志
tail -f logs/2024-12-02/*.log | grep -i "pdf\|table\|json"
```
### 方法2使用Docker查看
```bash
# 查看容器实时日志
docker logs -f tyapi-app-prod | grep -i "表格\|json\|markdown"
# 查看最近的100行日志
docker logs --tail 100 tyapi-app-prod | grep -i "表格\|json"
```
### 方法3搜索特定字段类型
```bash
# 查看请求参数的转换日志
grep "request_params" logs/2024-12-02/info.log
# 查看响应字段的转换日志
grep "response_fields" logs/2024-12-02/info.log
# 查看错误代码的转换日志
grep "error_codes" logs/2024-12-02/info.log
```
## 📊 日志级别说明
### Info级别日志info.log
包含转换流程的关键步骤:
- ✅ 数据格式检测JSON/Markdown
- ✅ Markdown表格解析开始
- ✅ 表格解析成功(表头数量、行数)
- ✅ JSON转换完成
**示例日志:**
```
2024-12-02T10:30:15Z INFO 开始解析markdown表格并转换为JSON {"field_type": "request_params", "content_length": 1234}
2024-12-02T10:30:15Z INFO markdown表格解析成功 {"field_type": "request_params", "header_count": 3, "row_count": 5}
2024-12-02T10:30:15Z INFO 表格数据已转换为JSON格式 {"field_type": "request_params", "json_array_length": 5}
```
### Debug级别日志debug.log
包含详细的转换数据:
- 🔍 原始内容预览
- 🔍 解析后的表头列表
- 🔍 转换后的完整JSON数据前1000字符
- 🔍 每行的转换详情
**示例日志:**
```
2024-12-02T10:30:15Z DEBUG 转换后的JSON数据预览 {"field_type": "request_params", "json_length": 2345, "json_preview": "[{\"字段名\":\"name\",\"类型\":\"string\",\"说明\":\"姓名\"}...]"}
```
### Error级别日志error.log
包含转换过程中的错误:
- ❌ Markdown解析失败
- ❌ JSON序列化失败
- ❌ 数据格式错误
**示例日志:**
```
2024-12-02T10:30:15Z ERROR 解析markdown表格失败 {"field_type": "request_params", "error": "无法解析表格:未找到表头", "content_preview": "..."}
```
## 🔎 日志关键词搜索
### 转换流程关键词
- `开始解析markdown表格` - 转换开始
- `markdown表格解析成功` - 解析完成
- `表格数据已转换为JSON格式` - JSON转换完成
- `转换后的JSON数据预览` - JSON数据详情
### 数据格式关键词
- `数据已经是JSON格式` - 数据源是JSON
- `从JSON对象中提取数组数据` - 从JSON对象提取
- `解析markdown表格并转换为JSON` - Markdown转JSON
### 错误关键词
- `解析markdown表格失败` - 解析错误
- `JSON序列化失败` - JSON错误
- `字段内容为空` - 空数据
## 📝 日志配置
确保日志级别设置为 `debug` 才能看到详细的JSON转换日志
```yaml
# config.yaml 或 configs/env.development.yaml
logger:
level: "debug" # 开发环境使用debug级别
format: "console" # 或 "json"
output: "file" # 输出到文件
log_dir: "logs" # 日志目录
use_daily: true # 启用按日分包
```
## 🛠️ 常用命令
```bash
# 查看今天的Info日志
cat logs/$(date +%Y-%m-%d)/info.log | grep "表格\|JSON"
# 查看最近的转换日志最后50行
tail -n 50 logs/$(date +%Y-%m-%d)/info.log
# 搜索特定产品的转换日志
grep "product_id.*xxx" logs/$(date +%Y-%m-%d)/info.log
# 查看所有错误
grep "ERROR" logs/$(date +%Y-%m-%d)/error.log
# 统计转换次数
grep "表格数据已转换为JSON格式" logs/$(date +%Y-%m-%d)/info.log | wc -l
```
## 💡 调试技巧
1. **查看完整JSON数据**如果JSON数据超过1000字符查看debug.log获取完整内容
2. **追踪转换流程**:使用 `field_type` 字段过滤特定字段的转换日志
3. **定位错误**查看error.log中的 `content_preview` 字段了解原始数据
4. **性能监控**:统计转换次数和耗时,优化转换逻辑
## 📌 注意事项
- Debug级别日志可能包含大量数据注意日志文件大小
- 生产环境建议使用 `info` 级别,减少日志量
- JSON预览限制在1000字符完整数据请查看debug日志
- 日志文件按日期自动分包,便于管理和查找

View File

@@ -87,6 +87,7 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa
if currentY+estimatedHeaderHeight > pageHeight-bottomMargin {
r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY))
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
}
// 绘制表头
@@ -448,6 +449,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
zap.Float64("current_y", currentY),
zap.Float64("page_height", pageHeight))
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
// 在新页面上重新绘制表头
if len(headers) > 0 && len(headerColWidths) > 0 {
newHeaderStartY := pdf.GetY()
@@ -523,6 +525,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
zap.Int("row_index", rowIndex),
zap.Float64("row_height", maxCellHeight))
pdf.AddPage()
pdf.SetY(ContentStartYBelowHeader)
startY = pdf.GetY()
}

View File

@@ -205,8 +205,10 @@ func (fm *FontManager) getChineseFontPaths() []string {
func (fm *FontManager) getWatermarkFontPaths() []string {
// 水印字体文件名(尝试大小写变体)
fontNames := []string{
"YunFengFeiYunTi-2.ttf", // 优先尝试大写版本
"yunfengfeiyunti-2.ttf", // 小写版本(兼容)
// "XuanZongTi-v0.1.otf", //玄宗字体不支持otf
"WenYuanSerifSC-Bold.ttf", //文渊雅黑
// "YunFengFeiYunTi-2.ttf", // 毛笔字体
// "yunfengfeiyunti-2.ttf", // 毛笔字体小写版本(兼容)
}
return fm.buildFontPaths(fontNames)

View File

@@ -56,109 +56,142 @@ func NewPageBuilder(
}
}
// 封面页底部为价格预留的高度mm避免价格被挤到单独一页
const firstPagePriceReservedHeight = 18.0
// ContentStartYBelowHeader 页眉logo+横线)下方的正文起始 Ymm表格等 AddPage 后须设为此值,避免与 logo 重叠(留足顶间距)
const ContentStartYBelowHeader = 50.0
// AddFirstPage 添加第一页(封面页 - 产品功能简述)
// 页眉与水印由 SetHeaderFunc 在每页 AddPage 时自动绘制,此处不再重复调用
// 自动限制描述/详情高度,保证价格与封面同页,不单独成页
func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
// 添加页眉logo和文字
pb.addHeader(pdf, chineseFontAvailable)
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
// 内容区最大 Y超出则不再绘制留出底部给价格避免价格单独一页
maxContentY := pageHeight - bottomMargin - firstPagePriceReservedHeight
// 添加水印
pb.addWatermark(pdf, chineseFontAvailable)
// 封面页布局 - 居中显示
pageWidth, _ := pdf.GetPageSize()
// 标题区域(页面中上部)
pdf.SetY(80)
// 标题区域(在页眉下方留足间距,避免与 logo 重叠)
pdf.SetY(ContentStartYBelowHeader + 6)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 32)
_, lineHt := pdf.GetFontSize()
// 清理产品名称中的无效字符
cleanName := pb.textProcessor.CleanText(product.Name)
pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "")
// 添加"接口文档"副标题
pdf.Ln(10)
pdf.Ln(6)
pb.fontManager.SetFont(pdf, "", 18)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
// 分隔线
pdf.Ln(20)
pdf.SetLineWidth(0.5)
pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
// 产品编码(居中)
pdf.Ln(30)
// 产品编码
pdf.Ln(16)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "")
// 产品描述(居中显示,段落格式)
pdf.Ln(12)
pdf.SetLineWidth(0.5)
pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
// 产品描述(居中,无单独标题)
if product.Description != "" {
pdf.Ln(25)
desc := pb.textProcessor.StripHTML(product.Description)
pdf.Ln(10)
desc := pb.textProcessor.HTMLToPlainWithBreaks(product.Description)
desc = pb.textProcessor.CleanText(desc)
pb.fontManager.SetFont(pdf, "", 14)
_, lineHt = pdf.GetFontSize()
// 居中对齐的MultiCell通过计算宽度实现
descWidth := pageWidth * 0.7
descLines := pb.safeSplitText(pdf, desc, descWidth, chineseFontAvailable)
currentX := (pageWidth - descWidth) / 2
for _, line := range descLines {
pdf.SetX(currentX)
pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "")
}
pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable)
}
// 产品详情(如果存在)
if product.Content != "" {
pdf.Ln(20)
content := pb.textProcessor.StripHTML(product.Content)
content = pb.textProcessor.CleanText(content)
pb.fontManager.SetFont(pdf, "", 12)
_, lineHt = pdf.GetFontSize()
contentWidth := pageWidth * 0.7
contentLines := pb.safeSplitText(pdf, content, contentWidth, chineseFontAvailable)
currentX := (pageWidth - contentWidth) / 2
for _, line := range contentLines {
pdf.SetX(currentX)
pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "")
}
}
// 价格信息(右下角,在产品详情之后)
// 产品详情已移至单独一页,见 AddProductContentPage
if !product.Price.IsZero() {
// 获取产品详情结束后的Y坐标稍微下移显示价格
contentEndY := pdf.GetY()
pdf.SetY(contentEndY + 5)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 14)
_, priceLineHt := pdf.GetFontSize()
reservedZoneY := pageHeight - bottomMargin - firstPagePriceReservedHeight + 6
priceY := reservedZoneY
if pdf.GetY()+5 > reservedZoneY {
priceY = pdf.GetY() + 5
}
pdf.SetY(priceY)
pdf.SetTextColor(0, 0, 0)
priceText := fmt.Sprintf("价格:%s 元", product.Price.String())
textWidth := pdf.GetStringWidth(priceText)
// 右对齐从页面宽度减去文本宽度和右边距15mm
pdf.SetX(pageWidth - textWidth - 15)
pdf.CellFormat(textWidth, priceLineHt, priceText, "", 0, "R", false, 0, "")
}
}
// AddProductContentPage 添加产品详情页(另起一页,左对齐,段前两空格)
func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities.Product, chineseFontAvailable bool) {
if product.Content == "" {
return
}
pdf.AddPage()
pageWidth, _ := pdf.GetPageSize()
pdf.SetY(ContentStartYBelowHeader)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, titleHt := pdf.GetFontSize()
pdf.CellFormat(0, titleHt, "产品详情", "", 1, "L", false, 0, "")
pdf.Ln(6)
content := pb.textProcessor.HTMLToPlainWithBreaks(product.Content)
content = pb.textProcessor.CleanText(content)
pb.fontManager.SetFont(pdf, "", 12)
_, lineHt := pdf.GetFontSize()
// 产品详情页不做“略写”截断,全部内容都渲染出来,允许 gofpdf 自动分页
pb.drawRichTextBlockNoLimit(pdf, content, pageWidth*0.9, lineHt*1.4, "L", true, chineseFontAvailable)
}
// drawRichTextBlockNoLimit 渲染富文本块,不根据 maxContentY 截断,允许自动分页,适合“产品详情”等必须全部展示的内容
func (pb *PageBuilder) drawRichTextBlockNoLimit(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, align string, firstLineIndent bool, chineseFontAvailable bool) {
pageWidth, _ := pdf.GetPageSize()
leftMargin, _, _, _ := pdf.GetMargins()
currentX := (pageWidth - contentWidth) / 2
if align == "L" {
currentX = leftMargin
}
paragraphs := strings.Split(text, "\n\n")
for pIdx, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if pIdx > 0 {
pdf.Ln(4)
}
firstLineOfPara := true
lines := strings.Split(para, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable)
for _, w := range wrapped {
x := currentX
if align == "L" && firstLineIndent && firstLineOfPara {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "")
firstLineOfPara = false
}
}
}
}
// AddDocumentationPages 添加接口文档页面
// 每页的页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制
func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
pdf.AddPage()
addPageWithWatermark()
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
@@ -212,10 +245,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
@@ -227,7 +257,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
// 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐)
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
@@ -235,9 +265,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
@@ -253,8 +281,9 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
}
}
// 返回字段说明
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
@@ -306,18 +335,11 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
}
// AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明)
// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明
// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc 自动绘制。
func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
pdf.AddPage()
addPageWithWatermark()
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
@@ -371,10 +393,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
@@ -386,7 +405,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
// 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐)
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
@@ -394,9 +413,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
@@ -412,8 +429,9 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp
}
}
// 返回字段说明
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
@@ -463,17 +481,11 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp
}
// AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包)
// 每页页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制
func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
pdf.AddPage()
addPageWithWatermark()
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
@@ -533,10 +545,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt)
}
}
@@ -548,7 +557,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
// 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐)
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
@@ -556,9 +565,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
@@ -574,8 +581,9 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd
}
}
// 返回字段说明
// 返回字段说明(确保在页眉下方,避免与 logo 重叠)
if doc.ResponseFields != "" {
pb.ensureContentBelowHeader(pdf)
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
@@ -979,73 +987,121 @@ func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
// 绘制下横线优化位置左边距是15mm
pdf.Line(15, 22, 75, 22)
// 所有自动分页后的正文统一从页眉下方固定位置开始,避免内容顶到 logo 或水印
pdf.SetY(ContentStartYBelowHeader)
}
// addWatermark 添加水印从左边开始向上倾斜45度考虑可用区域
// ensureContentBelowHeader 若当前 Y 在页眉区内则下移到正文区,避免与 logo 重叠
func (pb *PageBuilder) ensureContentBelowHeader(pdf *gofpdf.Fpdf) {
if pdf.GetY() < ContentStartYBelowHeader {
pdf.SetY(ContentStartYBelowHeader)
}
}
// addWatermark 添加水印:自左下角往右上角倾斜 45°单条水印居中于页面样式柔和
func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
// 如果中文字体不可用,跳过水印(避免显示乱码)
if !chineseFontAvailable {
return
}
// 保存当前图形状态
pdf.TransformBegin()
defer pdf.TransformEnd()
// 获取页面尺寸和边距
_, pageHeight := pdf.GetPageSize()
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
// 计算实际可用区域高度
usableHeight := pageHeight - topMargin - bottomMargin
usableWidth := pageWidth - leftMargin*2
// 设置水印样式(使用水印字体,非黑体)
fontSize := 45.0
fontSize := 42.0
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
// 设置灰色和透明度(加深水印,使其更明显)
pdf.SetTextColor(180, 180, 180) // 深一点的灰色
pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显
// 加深水印:更深的灰与更高不透明度,保证可见
pdf.SetTextColor(150, 150, 150)
pdf.SetAlpha(0.32, "Normal")
// 计算文字宽度
textWidth := pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
// 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
// 从左边开始,计算起始位置
// 起始X左边距
// 起始Y考虑水印文字长度和旋转后需要的空间
startX := leftMargin
startY := topMargin + textWidth*0.5 // 为旋转留出空间
// 移动到起始位置
pdf.TransformTranslate(startX, startY)
// 向上倾斜45度顺时针旋转45度即-45度或逆时针315度
pdf.TransformRotate(-45, 0, 0)
// 检查文字是否会超出可用区域(旋转后的对角线长度)
// 旋转后对角线长度,用于缩放与定位
rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
if rotatedDiagonal > usableHeight*0.8 {
// 如果太大,缩小字体
fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal
if rotatedDiagonal > usableHeight*0.75 {
fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
textWidth = pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize)
}
// 从左边开始绘制水印文字
// 自左下角往右上角:起点在可用区域左下角,逆时针旋转 +45°
startX := leftMargin
startY := pageHeight - bottomMargin
// 沿 +45° 方向居中:对角线在可用区域内居中
diagW := rotatedDiagonal * math.Cos(45*math.Pi/180)
offsetX := (usableWidth - diagW) * 0.5
startX += offsetX
startY -= rotatedDiagonal * 0.5
pdf.TransformTranslate(startX, startY)
pdf.TransformRotate(45, 0, 0)
pdf.SetXY(0, 0)
pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "")
// 恢复透明度和颜色
pdf.SetAlpha(1.0, "Normal")
pdf.SetTextColor(0, 0, 0) // 恢复为黑色
pdf.SetTextColor(0, 0, 0)
}
// 段前缩进宽度约两字符mm
const paragraphIndentMM = 7.0
// drawRichTextBlock 按段落与换行绘制文本块(还原 HTML 换行),超出 maxContentY 截断并显示 …
// align: "C" 居中;"L" 左对齐。firstLineIndent 为 true 时每段首行缩进(段前两空格效果)。
func (pb *PageBuilder) drawRichTextBlock(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, maxContentY float64, align string, firstLineIndent bool, chineseFontAvailable bool) {
pageWidth, _ := pdf.GetPageSize()
leftMargin, _, _, _ := pdf.GetMargins()
currentX := (pageWidth - contentWidth) / 2
if align == "L" {
currentX = leftMargin
}
paragraphs := strings.Split(text, "\n\n")
for pIdx, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if pIdx > 0 && pdf.GetY()+lineHeight <= maxContentY {
pdf.Ln(4)
}
firstLineOfPara := true
lines := strings.Split(para, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable)
for _, w := range wrapped {
if pdf.GetY()+lineHeight > maxContentY {
pdf.SetX(currentX)
pdf.CellFormat(contentWidth, lineHeight, "…", "", 1, align, false, 0, "")
return
}
x := currentX
if align == "L" && firstLineIndent && firstLineOfPara {
x = leftMargin + paragraphIndentMM
}
pdf.SetX(x)
pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "")
firstLineOfPara = false
}
}
}
}
// getContentPreview 获取内容预览(用于日志记录)
@@ -1057,6 +1113,49 @@ func (pb *PageBuilder) getContentPreview(content string, maxLen int) string {
return content[:maxLen] + "..."
}
// drawJSONInCenteredTable 在居中表格中绘制 JSON 文本(表格居中,内容左对齐);空间不足时自动换页避免压住 logo
func (pb *PageBuilder) drawJSONInCenteredTable(pdf *gofpdf.Fpdf, jsonContent string, lineHt float64) {
jsonContent = strings.TrimSpace(jsonContent)
if jsonContent == "" {
return
}
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, _, rightMargin, bottomMargin := pdf.GetMargins()
usableWidth := pageWidth - leftMargin - rightMargin
// 表格宽度取可用宽度的 85%,居中
tableWidth := usableWidth * 0.85
startX := (pageWidth - tableWidth) / 2
padding := 4.0
innerWidth := tableWidth - 2*padding
lineHeight := lineHt * 1.3
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 9)
lines := pdf.SplitText(jsonContent, innerWidth)
totalHeight := float64(len(lines))*lineHeight + 2*padding
// 当前页剩余高度不足时先换页,避免表格被 logo 或底部裁掉
currentY := pdf.GetY()
if currentY+totalHeight > pageHeight-bottomMargin {
pdf.AddPage()
currentY = ContentStartYBelowHeader
pdf.SetY(currentY)
}
startY := currentY
// 绘制表格边框
pdf.SetDrawColor(180, 180, 180)
pdf.Rect(startX, startY, tableWidth, totalHeight, "D")
pdf.SetDrawColor(0, 0, 0)
// 表格内内容左对齐
pdf.SetY(startY + padding)
for _, line := range lines {
pdf.SetX(startX + padding)
pdf.CellFormat(innerWidth, lineHeight, line, "", 1, "L", false, 0, "")
}
pdf.SetY(startY + totalHeight)
}
// safeSplitText 安全地分割文本避免在没有中文字体时调用SplitText导致panic
func (pb *PageBuilder) safeSplitText(pdf *gofpdf.Fpdf, text string, width float64, chineseFontAvailable bool) []string {
// 检查文本是否包含中文字符
@@ -1167,12 +1266,10 @@ func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.Product
currentY := pdf.GetY()
remainingHeight := pageHeight - currentY - bottomMargin
// 如果剩余空间不足,添加新页
// 如果剩余空间不足,添加新页(页眉与水印由 SetHeaderFunc 自动绘制)
if remainingHeight < 100 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
}
// 添加分隔线
@@ -1327,9 +1424,7 @@ func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductD
// 检查是否需要换页(为二维码预留空间)
if pageHeight-currentY-bottomMargin < 120 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
}
// 添加二维码标题
@@ -1381,9 +1476,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF
qrSize := 40.0
if pageHeight-currentY-bottomMargin < qrSize+20 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
pdf.SetY(ContentStartYBelowHeader)
}
// 生成二维码

View File

@@ -434,4 +434,3 @@ func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) {
"max_size": m.maxSize,
}, nil
}

View File

@@ -2078,69 +2078,55 @@ func (g *PDFGenerator) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
pdf.Line(15, 22, 75, 22)
}
// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域)
// addWatermark 添加水印:自左下角往右上角倾斜 45°,单条水印居中于页面,样式柔和
func (g *PDFGenerator) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
// 如果中文字体不可用,跳过水印(避免显示乱码)
if !chineseFontAvailable {
return
}
// 保存当前图形状态
pdf.TransformBegin()
defer pdf.TransformEnd()
// 获取页面尺寸和边距
_, pageHeight := pdf.GetPageSize()
pageWidth, pageHeight := pdf.GetPageSize()
leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
// 计算实际可用区域高度
usableHeight := pageHeight - topMargin - bottomMargin
usableWidth := pageWidth - leftMargin*2
// 设置水印样式(使用中文字体)
fontSize := 45.0
fontSize := 42.0
pdf.SetFont("ChineseFont", "", fontSize)
// 设置灰色和透明度(加深水印,使其更明显)
pdf.SetTextColor(180, 180, 180) // 深一点的灰色
pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显
pdf.SetTextColor(150, 150, 150)
pdf.SetAlpha(0.32, "Normal")
// 计算文字宽度
textWidth := pdf.GetStringWidth(g.watermarkText)
if textWidth == 0 {
// 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm
textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0
}
// 从左边开始,计算起始位置
// 起始X左边距
// 起始Y考虑水印文字长度和旋转后需要的空间
startX := leftMargin
startY := topMargin + textWidth*0.5 // 为旋转留出空间
// 移动到起始位置
pdf.TransformTranslate(startX, startY)
// 向上倾斜45度顺时针旋转45度即-45度或逆时针315度
pdf.TransformRotate(-45, 0, 0)
// 检查文字是否会超出可用区域(旋转后的对角线长度)
rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
if rotatedDiagonal > usableHeight*0.8 {
// 如果太大,缩小字体
fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal
if rotatedDiagonal > usableHeight*0.75 {
fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal
pdf.SetFont("ChineseFont", "", fontSize)
textWidth = pdf.GetStringWidth(g.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0
}
rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize)
}
// 从左边开始绘制水印文字
startX := leftMargin
startY := pageHeight - bottomMargin
diagW := rotatedDiagonal * math.Cos(45*math.Pi/180)
offsetX := (usableWidth - diagW) * 0.5
startX += offsetX
startY -= rotatedDiagonal * 0.5
pdf.TransformTranslate(startX, startY)
pdf.TransformRotate(45, 0, 0)
pdf.SetXY(0, 0)
pdf.CellFormat(textWidth, fontSize, g.watermarkText, "", 0, "L", false, 0, "")
// 恢复透明度和颜色
pdf.SetAlpha(1.0, "Normal")
pdf.SetTextColor(0, 0, 0) // 恢复为黑色
pdf.SetTextColor(0, 0, 0)
}

View File

@@ -151,8 +151,8 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
// 创建PDF文档 (A4大小gofpdf v2 默认支持UTF-8)
pdf := gofpdf.New("P", "mm", "A4", "")
// 优化边距,减少空白
pdf.SetMargins(15, 25, 15)
// 上边距与 ContentStartYBelowHeader 一致,这样自动分页后新页内容从 logo 下方开始,不被遮挡
pdf.SetMargins(15, ContentStartYBelowHeader, 15)
// 加载黑体字体(用于所有内容,除了水印)
// 注意:此时工作目录应该是根目录(/这样gofpdf处理路径时就能正确解析
@@ -176,9 +176,22 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
// 创建页面构建器
pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText)
// 添加第一页(产品信息)
// 页眉只画 logo+横线。水印用 SetFooterFunc 画gofpdf 在每页内容画完后再调 Footer水印最后画z 层在最上,不会被表格等盖住
pdf.SetHeaderFunc(func() {
pageBuilder.addHeader(pdf, chineseFontAvailable)
})
pdf.SetFooterFunc(func() {
pageBuilder.addWatermark(pdf, chineseFontAvailable)
})
// 添加第一页(封面:产品信息 + 产品描述 + 价格)
pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable)
// 产品详情单独一页(左对齐,段前两空格)
if product.Content != "" {
pageBuilder.AddProductContentPage(pdf, product, chineseFontAvailable)
}
// 如果是组合包,需要特殊处理:先渲染所有文档,最后统一添加二维码
if product.IsPackage {
// 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加)

View File

@@ -90,6 +90,22 @@ func (tp *TextProcessor) StripHTML(text string) string {
return text
}
// HTMLToPlainWithBreaks 将 HTML 转为纯文本并保留富文本换行效果(<p><br><div> 等变为换行)
// 用于在 PDF 中还原段落与换行,避免内容挤成一团
func (tp *TextProcessor) HTMLToPlainWithBreaks(text string) string {
text = html.UnescapeString(text)
// 块级结束标签转为换行
text = regexp.MustCompile(`(?i)</(p|div|br|tr|li|h[1-6])>\s*`).ReplaceAllString(text, "\n")
// <br> 自闭合
text = regexp.MustCompile(`(?i)<br\s*/?>\s*`).ReplaceAllString(text, "\n")
// 剩余标签移除
text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "")
// 连续空白/换行压缩为最多两个换行(段间距)
text = regexp.MustCompile(`[ \t]+`).ReplaceAllString(text, " ")
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// RemoveMarkdownSyntax 移除markdown语法保留纯文本
func (tp *TextProcessor) RemoveMarkdownSyntax(text string) string {
// 移除粗体标记 **text** 或 __text__