Files
tyapi-server/docs/产品示例报告下载功能实现方案.md

33 KiB
Raw Blame History

产品示例报告下载功能实现方案

一、功能概述

在产品详情页面添加"下载示例报告"功能,允许用户下载与产品对应的前端组件报告。报告文件位于 resources/Pure Component/src/ui 目录下通过产品编号product_code匹配对应的文件夹或文件。

二、核心需求

2.1 基本功能

  1. 报告匹配:根据子产品的 product_code 模糊匹配 resources/Pure Component/src/ui 下的文件夹或文件

    • 支持前缀匹配(如产品编号为 DWBG6A2C,文件夹可能是 DWBG6A2C多cDWBG6A2C
    • 匹配规则:文件夹名称包含产品编号,或产品编号包含文件夹名称的核心部分
  2. 文件打包:将匹配到的文件夹或文件压缩成 ZIP 格式供用户下载

    • ZIP 文件结构:

      component-report.zip
      ├── src/
      │   └── ui/
      │       ├── DWBG6A2C/          # 子产品1的UI组件
      │       │   ├── index.vue
      │       │   ├── components/
      │       │   └── ...
      │       ├── CFLXG0V4B/         # 子产品2的UI组件
      │       └── ...
      └── public/
          └── example.json           # 响应示例数据文件
      
    • 同时需要生成 public/example.json 文件,包含子产品的响应示例数据

    • example.json 文件格式:

      [
          {
              "feature": {
                  "featureName": "产品名称",
                  "sort": 1
              },
              "data": {
                  "apiID": "产品编号",
                  "data": {
                      // 子产品的响应示例数据JSON对象
                      "code": 0,
                      "message": "success",
                      "data": { ... }
                  }
              }
          },
          {
              "feature": {
                  "featureName": "另一个产品名称",
                  "sort": 2
              },
              "data": {
                  "apiID": "另一个产品编号",
                  "data": { ... }
              }
          }
      ]
      
  3. 支付流程

    • 下载前检查用户已下载过的组件报告
    • 根据子产品价格计算报告总价(已下载过的子产品价格需减免)
    • 用户扫码支付(微信/支付宝)成功后允许下载

2.2 组合包产品特殊处理

  1. 子产品筛选:组合包产品只下载包含的子产品对应的报告
  2. 价格减免:如果用户已下载过某个子产品的报告,该子产品的价格不计入总价
  3. 批量下载:将多个子产品的报告打包成一个 ZIP 文件

2.3 性能优化

  1. 缓存机制

    • 缓存已生成的 ZIP 文件基于产品ID和子产品列表的哈希值
    • 缓存文件匹配结果(产品编号到文件夹路径的映射)
    • 缓存有效期24小时
  2. 二次下载

    • 用户已支付过的报告支持免费重新下载(在有效期内)
    • 记录下载历史,避免重复支付

三、数据库设计

3.1 报告下载记录表 (component_report_downloads)

CREATE TABLE component_report_downloads (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL,
    product_id VARCHAR(36) NOT NULL,
    product_code VARCHAR(50) NOT NULL,
    sub_product_ids TEXT,  -- JSON数组存储子产品ID列表组合包使用
    sub_product_codes TEXT, -- JSON数组存储子产品编号列表
    download_price DECIMAL(10,2) NOT NULL,  -- 实际支付价格
    original_price DECIMAL(10,2) NOT NULL,  -- 原始总价
    discount_amount DECIMAL(10,2) DEFAULT 0, -- 减免金额
    payment_order_id VARCHAR(64),  -- 支付订单号(关联充值记录)
    payment_type VARCHAR(20),  -- 支付类型alipay, wechat
    payment_status VARCHAR(20) DEFAULT 'pending',  -- pending, success, failed
    file_path VARCHAR(500),  -- 生成的ZIP文件路径用于二次下载
    file_hash VARCHAR(64),  -- 文件哈希值(用于缓存验证)
    download_count INT DEFAULT 0,  -- 下载次数
    last_download_at TIMESTAMP,  -- 最后下载时间
    expires_at TIMESTAMP,  -- 下载有效期支付成功后30天
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_product_id (product_id),
    INDEX idx_product_code (product_code),
    INDEX idx_payment_status (payment_status),
    INDEX idx_expires_at (expires_at)
);

3.2 报告文件匹配缓存表 (component_report_cache)

CREATE TABLE component_report_cache (
    id VARCHAR(36) PRIMARY KEY,
    product_code VARCHAR(50) NOT NULL UNIQUE,
    matched_path VARCHAR(500) NOT NULL,  -- 匹配到的文件夹/文件路径
    file_type VARCHAR(20) NOT NULL,  -- folder, file
    cache_key VARCHAR(64) NOT NULL UNIQUE,  -- 缓存键用于ZIP文件缓存
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_product_code (product_code),
    INDEX idx_cache_key (cache_key)
);

四、后端实现方案

4.1 领域层 (Domain Layer)

4.1.1 实体定义

文件位置: internal/domains/product/entities/component_report_download.go

package entities

import (
    "time"
    "github.com/google/uuid"
    "github.com/shopspring/decimal"
    "gorm.io/gorm"
)

// ComponentReportDownload 组件报告下载记录
type ComponentReportDownload struct {
    ID            string          `gorm:"primaryKey;type:varchar(36)"`
    UserID        string          `gorm:"type:varchar(36);not null;index"`
    ProductID     string          `gorm:"type:varchar(36);not null;index"`
    ProductCode   string          `gorm:"type:varchar(50);not null;index"`
    SubProductIDs string          `gorm:"type:text"` // JSON数组
    SubProductCodes string        `gorm:"type:text"` // JSON数组
    DownloadPrice decimal.Decimal `gorm:"type:decimal(10,2);not null"`
    OriginalPrice decimal.Decimal `gorm:"type:decimal(10,2);not null"`
    DiscountAmount decimal.Decimal `gorm:"type:decimal(10,2);default:0"`
    PaymentOrderID *string         `gorm:"type:varchar(64)"`
    PaymentType   *string          `gorm:"type:varchar(20)"`
    PaymentStatus string           `gorm:"type:varchar(20);default:'pending';index"`
    FilePath      *string          `gorm:"type:varchar(500)"`
    FileHash      *string          `gorm:"type:varchar(64)"`
    DownloadCount int              `gorm:"default:0"`
    LastDownloadAt *time.Time
    ExpiresAt     *time.Time       `gorm:"index"`
    
    CreatedAt time.Time      `gorm:"autoCreateTime"`
    UpdatedAt time.Time      `gorm:"autoUpdateTime"`
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

// ComponentReportCache 报告文件匹配缓存
type ComponentReportCache struct {
    ID          string    `gorm:"primaryKey;type:varchar(36)"`
    ProductCode string    `gorm:"type:varchar(50);not null;uniqueIndex"`
    MatchedPath string    `gorm:"type:varchar(500);not null"`
    FileType    string    `gorm:"type:varchar(20);not null"`
    CacheKey    string    `gorm:"type:varchar(64);not null;uniqueIndex"`
    
    CreatedAt time.Time      `gorm:"autoCreateTime"`
    UpdatedAt time.Time      `gorm:"autoUpdateTime"`
}

4.1.2 仓储接口

文件位置: internal/domains/product/repositories/component_report_repository_interface.go

package repositories

import (
    "context"
    "tyapi-server/internal/domains/product/entities"
)

// ComponentReportRepository 组件报告仓储接口
type ComponentReportRepository interface {
    // 创建下载记录
    CreateDownload(ctx context.Context, download *entities.ComponentReportDownload) (*entities.ComponentReportDownload, error)
    
    // 更新下载记录
    UpdateDownload(ctx context.Context, download *entities.ComponentReportDownload) error
    
    // 根据ID获取下载记录
    GetDownloadByID(ctx context.Context, id string) (*entities.ComponentReportDownload, error)
    
    // 获取用户的下载记录列表
    GetUserDownloads(ctx context.Context, userID string, productID *string) ([]*entities.ComponentReportDownload, error)
    
    // 检查用户是否已下载过指定产品
    HasUserDownloaded(ctx context.Context, userID string, productCode string) (bool, error)
    
    // 获取用户已下载的产品编号列表
    GetUserDownloadedProductCodes(ctx context.Context, userID string) ([]string, error)
    
    // 根据支付订单号获取下载记录
    GetDownloadByPaymentOrderID(ctx context.Context, orderID string) (*entities.ComponentReportDownload, error)
    
    // 缓存相关
    GetCacheByProductCode(ctx context.Context, productCode string) (*entities.ComponentReportCache, error)
    CreateCache(ctx context.Context, cache *entities.ComponentReportCache) error
    UpdateCache(ctx context.Context, cache *entities.ComponentReportCache) error
}

4.1.3 领域服务

文件位置: internal/domains/product/services/component_report_service.go

package services

import (
    "context"
    "path/filepath"
    "strings"
    "os"
    "tyapi-server/internal/domains/product/repositories"
)

// ComponentReportService 组件报告服务
type ComponentReportService interface {
    // 匹配产品编号到文件路径
    MatchProductCodeToPath(ctx context.Context, productCode string) (string, string, error) // 返回路径和类型
    
    // 计算报告价格(考虑已下载的产品)
    CalculateReportPrice(ctx context.Context, userID string, productID string, subProductCodes []string) (decimal.Decimal, decimal.Decimal, []string, error) // 返回总价、减免金额、已下载的产品编号列表
    
    // 生成 example.json 文件内容
    GenerateExampleJSON(ctx context.Context, productID string, subProductCodes []string) ([]byte, error)
    
    // 生成ZIP文件
    GenerateZipFile(ctx context.Context, productID string, subProductCodes []string, cacheKey string) (string, string, error) // 返回文件路径和哈希值
    
    // 验证下载权限
    ValidateDownloadPermission(ctx context.Context, userID string, downloadID string) (bool, error)
}

4.2 应用层 (Application Layer)

文件位置: internal/application/product/component_report_application_service.go

package application

import (
    "context"
    "tyapi-server/internal/domains/product/services"
    "tyapi-server/internal/domains/finance/services"
)

// ComponentReportApplicationService 组件报告应用服务
type ComponentReportApplicationService interface {
    // 1. 获取报告下载信息(价格、已下载列表等)
    GetReportDownloadInfo(ctx context.Context, userID string, productID string) (*ReportDownloadInfoResponse, error)
    
    // 2. 创建支付订单
    CreateReportPaymentOrder(ctx context.Context, userID string, productID string) (*PaymentOrderResponse, error)
    
    // 3. 处理支付成功回调
    HandlePaymentSuccess(ctx context.Context, orderID string) error
    
    // 4. 下载报告文件
    DownloadReport(ctx context.Context, userID string, downloadID string) (*DownloadResponse, error)
    
    // 5. 获取用户下载历史
    GetUserDownloadHistory(ctx context.Context, userID string, params *PaginationParams) (*DownloadHistoryResponse, error)
}

4.3 接口层 (Interface Layer)

文件位置: internal/infrastructure/http/handlers/component_report_handler.go

API 接口设计

  1. GET /api/v1/products/{productId}/component-report/info

    • 获取报告下载信息(价格、已下载列表等)
    • 响应示例:
    {
      "code": 200,
      "data": {
        "product_id": "xxx",
        "product_code": "DWBG6A2C",
        "is_package": true,
        "sub_products": [
          {
            "product_id": "xxx",
            "product_code": "DWBG6A2C",
            "product_name": "产品名称",
            "price": 100.00,
            "is_downloaded": false,
            "matched": true,
            "has_response_example": true
          }
        ],
        "original_total_price": 300.00,
        "discount_amount": 100.00,
        "final_price": 200.00,
        "downloaded_product_codes": ["DWBG6A2C"],
        "includes_example_json": true
      }
    }
    
  2. POST /api/v1/products/{productId}/component-report/create-order

    • 创建支付订单
    • 请求体:
    {
      "payment_type": "wechat" // 或 "alipay"
    }
    
    • 响应返回支付二维码URL或支付链接
  3. POST /api/v1/products/{productId}/component-report/payment-callback

    • 支付成功回调(内部调用,由支付服务触发)
  4. GET /api/v1/products/{productId}/component-report/download/{downloadId}

    • 下载报告文件
    • 响应ZIP文件流
  5. GET /api/v1/my/component-reports

    • 获取用户下载历史
    • 查询参数page, page_size, product_id

五、前端实现方案

5.1 API 接口定义

文件位置: tyapi-frontend/src/api/index.js

// 组件报告相关接口
export const componentReportApi = {
  // 获取报告下载信息
  getReportDownloadInfo: (productId) => request.get(`/products/${productId}/component-report/info`),
  
  // 创建支付订单
  createReportPaymentOrder: (productId, data) => request.post(`/products/${productId}/component-report/create-order`, data),
  
  // 下载报告
  downloadReport: (productId, downloadId) => request.get(`/products/${productId}/component-report/download/${downloadId}`, {
    responseType: 'blob'
  }),
  
  // 获取用户下载历史
  getUserDownloadHistory: (params) => request.get('/my/component-reports', { params })
}

5.2 页面组件修改

文件位置: tyapi-frontend/src/pages/products/detail.vue

5.2.1 添加下载按钮

在页面头部操作区域添加"下载示例报告"按钮:

<el-button
  v-if="isSubscribed"
  :size="isMobile ? 'small' : 'default'"
  type="success"
  @click="handleDownloadReport"
  :loading="reportDownloading"
>
  <el-icon><Download /></el-icon>
  下载示例报告
</el-button>

5.2.2 下载流程实现

// 响应式数据
const reportDownloading = ref(false)
const reportDownloadInfo = ref(null)
const showReportPaymentDialog = ref(false)
const reportPaymentType = ref('wechat') // 或 'alipay'

// 获取报告下载信息
const loadReportDownloadInfo = async () => {
  if (!product.value) return
  
  try {
    const response = await componentReportApi.getReportDownloadInfo(product.value.id)
    reportDownloadInfo.value = response.data
  } catch (error) {
    console.error('获取报告下载信息失败:', error)
  }
}

// 处理下载报告
const handleDownloadReport = async () => {
  if (!product.value) return
  
  try {
    // 1. 获取下载信息
    await loadReportDownloadInfo()
    
    // 2. 如果价格大于0需要支付
    if (reportDownloadInfo.value.final_price > 0) {
      showReportPaymentDialog.value = true
      return
    }
    
    // 3. 如果已支付过,直接下载
    await downloadReportFile()
  } catch (error) {
    console.error('下载报告失败:', error)
    ElMessage.error('下载报告失败')
  }
}

// 创建支付订单并支付
const createReportPayment = async () => {
  if (!product.value) return
  
  try {
    const response = await componentReportApi.createReportPaymentOrder(
      product.value.id,
      { payment_type: reportPaymentType.value }
    )
    
    // 显示支付二维码(参考现有的支付流程)
    if (reportPaymentType.value === 'wechat') {
      await showWechatQrCode(response.data.code_url)
    } else {
      // 支付宝支付
      window.location.href = response.data.pay_url
    }
    
    // 开始轮询支付状态
    startPaymentPolling(response.data.order_id)
  } catch (error) {
    console.error('创建支付订单失败:', error)
    ElMessage.error('创建支付订单失败')
  }
}

// 下载报告文件
const downloadReportFile = async (downloadId) => {
  if (!product.value) return
  
  reportDownloading.value = true
  try {
    // 如果没有downloadId从下载信息中获取
    const id = downloadId || reportDownloadInfo.value?.download_id
    
    if (!id) {
      ElMessage.warning('请先完成支付')
      return
    }
    
    const response = await componentReportApi.downloadReport(product.value.id, id)
    
    // 创建下载链接
    const blob = new Blob([response.data], { type: 'application/zip' })
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `${product.value.name || '产品'}_示例报告.zip`
    
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    URL.revokeObjectURL(url)
    
    ElMessage.success('报告下载成功')
    
    // 关闭支付对话框
    showReportPaymentDialog.value = false
  } catch (error) {
    console.error('下载报告文件失败:', error)
    ElMessage.error('下载报告文件失败')
  } finally {
    reportDownloading.value = false
  }
}

六、核心算法实现

6.1 响应示例数据提取

在生成 example.json 时,需要从产品文档中提取响应示例数据。数据来源优先级:

  1. 产品文档的 response_example 字段JSON格式
  2. 产品文档的 response_example 字段Markdown代码块中的JSON
  3. 产品API配置的 response_example 字段(如果产品文档中没有)
  4. 默认空对象 {}(如果都没有)

6.2 产品编号匹配算法

// MatchProductCodeToPath 匹配产品编号到文件路径
func (s *ComponentReportServiceImpl) MatchProductCodeToPath(ctx context.Context, productCode string) (string, string, error) {
    // 1. 先查缓存
    cache, err := s.repo.GetCacheByProductCode(ctx, productCode)
    if err == nil && cache != nil {
        return cache.MatchedPath, cache.FileType, nil
    }
    
    // 2. 扫描目录
    basePath := "resources/Pure Component/src/ui"
    entries, err := os.ReadDir(basePath)
    if err != nil {
        return "", "", err
    }
    
    // 3. 模糊匹配
    for _, entry := range entries {
        name := entry.Name()
        
        // 精确匹配
        if name == productCode {
            path := filepath.Join(basePath, name)
            fileType := "folder"
            if !entry.IsDir() {
                fileType = "file"
            }
            
            // 保存到缓存
            cache := &entities.ComponentReportCache{
                ID: uuid.New().String(),
                ProductCode: productCode,
                MatchedPath: path,
                FileType: fileType,
                CacheKey: generateCacheKey(productCode),
            }
            s.repo.CreateCache(ctx, cache)
            
            return path, fileType, nil
        }
        
        // 模糊匹配:文件夹名称包含产品编号,或产品编号包含文件夹名称的核心部分
        if strings.Contains(name, productCode) || strings.Contains(productCode, extractCoreCode(name)) {
            path := filepath.Join(basePath, name)
            fileType := "folder"
            if !entry.IsDir() {
                fileType = "file"
            }
            
            // 保存到缓存
            cache := &entities.ComponentReportCache{
                ID: uuid.New().String(),
                ProductCode: productCode,
                MatchedPath: path,
                FileType: fileType,
                CacheKey: generateCacheKey(productCode),
            }
            s.repo.CreateCache(ctx, cache)
            
            return path, fileType, nil
        }
    }
    
    return "", "", fmt.Errorf("未找到匹配的报告文件: %s", productCode)
}

// extractCoreCode 提取文件夹名称的核心代码(去除前缀)
func extractCoreCode(name string) string {
    // 如果名称以字母开头,提取后面的代码部分
    // 例如多cDWBG6A2C -> DWBG6A2C
    for i, r := range name {
        if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
            return name[i:]
        }
    }
    return name
}

6.3 价格计算算法

// CalculateReportPrice 计算报告价格
func (s *ComponentReportServiceImpl) CalculateReportPrice(ctx context.Context, userID string, productID string, subProductCodes []string) (decimal.Decimal, decimal.Decimal, []string, error) {
    // 1. 获取用户已下载的产品编号列表
    downloadedCodes, err := s.repo.GetUserDownloadedProductCodes(ctx, userID)
    if err != nil {
        return decimal.Zero, decimal.Zero, nil, err
    }
    
    // 2. 获取产品信息(包括子产品价格)
    product, err := s.productRepo.GetByID(ctx, productID)
    if err != nil {
        return decimal.Zero, decimal.Zero, nil, err
    }
    
    // 3. 计算总价和减免金额
    originalTotal := decimal.Zero
    discountAmount := decimal.Zero
    downloadedList := []string{}
    
    if product.IsPackage {
        // 组合包:遍历子产品
        for _, item := range product.PackageItems {
            if contains(subProductCodes, item.ProductCode) {
                originalTotal = originalTotal.Add(item.Price)
                
                // 检查是否已下载
                if contains(downloadedCodes, item.ProductCode) {
                    discountAmount = discountAmount.Add(item.Price)
                    downloadedList = append(downloadedList, item.ProductCode)
                }
            }
        }
    } else {
        // 单品
        originalTotal = product.Price
        if contains(downloadedCodes, product.Code) {
            discountAmount = product.Price
            downloadedList = append(downloadedList, product.Code)
        }
    }
    
    finalPrice := originalTotal.Sub(discountAmount)
    
    return finalPrice, discountAmount, downloadedList, nil
}

6.4 example.json 文件生成算法

// GenerateExampleJSON 生成 example.json 文件内容
func (s *ComponentReportServiceImpl) GenerateExampleJSON(ctx context.Context, productID string, subProductCodes []string) ([]byte, error) {
    // 1. 获取产品信息(包括子产品)
    product, err := s.productRepo.GetByID(ctx, productID)
    if err != nil {
        return nil, err
    }
    
    // 2. 构建 example.json 数组
    var examples []map[string]interface{}
    
    if product.IsPackage {
        // 组合包:遍历子产品
        for sort, item := range product.PackageItems {
            // 只处理在 subProductCodes 列表中的子产品
            if !contains(subProductCodes, item.ProductCode) {
                continue
            }
            
            // 获取子产品的文档信息(包含响应示例)
            subProduct, err := s.productRepo.GetByID(ctx, item.ProductID)
            if err != nil {
                s.logger.Warn("获取子产品信息失败", zap.String("product_id", item.ProductID), zap.Error(err))
                continue
            }
            
            // 获取响应示例数据(优先级:文档 > API配置 > 默认值)
            var responseData interface{}
            responseData = s.extractResponseExample(ctx, subProduct)
            
            // 构建示例项
            example := map[string]interface{}{
                "feature": map[string]interface{}{
                    "featureName": item.ProductName,
                    "sort":        sort + 1,
                },
                "data": map[string]interface{}{
                    "apiID": item.ProductCode,
                    "data":  responseData,
                },
            }
            
            examples = append(examples, example)
        }
    } else {
        // 单品
        responseData := s.extractResponseExample(ctx, product)
        
        example := map[string]interface{}{
            "feature": map[string]interface{}{
                "featureName": product.Name,
                "sort":        1,
            },
            "data": map[string]interface{}{
                "apiID": product.Code,
                "data":  responseData,
            },
        }
        
        examples = append(examples, example)
    }
    
    // 3. 序列化为JSON
    jsonData, err := json.MarshalIndent(examples, "", "    ")
    if err != nil {
        return nil, fmt.Errorf("序列化example.json失败: %w", err)
    }
    
    return jsonData, nil
}

// extractResponseExample 提取产品响应示例数据(优先级:文档 > API配置 > 默认值)
func (s *ComponentReportServiceImpl) extractResponseExample(ctx context.Context, product *entities.Product) interface{} {
    var responseData interface{}
    
    // 1. 优先从产品文档中获取
    if product.Documentation != nil && product.Documentation.ResponseExample != "" {
        // 尝试直接解析为JSON
        err := json.Unmarshal([]byte(product.Documentation.ResponseExample), &responseData)
        if err == nil {
            return responseData
        }
        
        // 如果解析失败尝试从Markdown代码块中提取JSON
        responseData = extractJSONFromMarkdown(product.Documentation.ResponseExample)
        if responseData != nil {
            return responseData
        }
    }
    
    // 2. 如果文档中没有尝试从产品API配置中获取
    apiConfig, err := s.productApiConfigRepo.GetByProductID(ctx, product.ID)
    if err == nil && apiConfig != nil && apiConfig.ResponseExample != nil {
        // API配置的响应示例通常是 map[string]interface{} 类型
        return apiConfig.ResponseExample
    }
    
    // 3. 如果都没有,返回默认空对象
    return map[string]interface{}{}
}

// extractJSONFromMarkdown 从Markdown代码块中提取JSON
func extractJSONFromMarkdown(markdown string) interface{} {
    // 查找 ```json 代码块
    re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```")
    matches := re.FindStringSubmatch(markdown)
    
    if len(matches) > 1 {
        var jsonData interface{}
        err := json.Unmarshal([]byte(matches[1]), &jsonData)
        if err == nil {
            return jsonData
        }
    }
    
    // 如果提取失败,返回 nil由调用者决定默认值
    return nil
}

6.5 ZIP文件生成算法

// GenerateZipFile 生成ZIP文件
func (s *ComponentReportServiceImpl) GenerateZipFile(ctx context.Context, productID string, subProductCodes []string, cacheKey string) (string, string, error) {
    // 1. 检查缓存文件是否存在
    cacheDir := "storage/component-reports"
    zipPath := filepath.Join(cacheDir, cacheKey+".zip")
    
    if _, err := os.Stat(zipPath); err == nil {
        // 文件已存在,计算哈希值
        hash, err := calculateFileHash(zipPath)
        if err == nil {
            return zipPath, hash, nil
        }
    }
    
    // 2. 创建ZIP文件
    zipFile, err := os.Create(zipPath)
    if err != nil {
        return "", "", err
    }
    defer zipFile.Close()
    
    zipWriter := zip.NewWriter(zipFile)
    defer zipWriter.Close()
    
    // 3. 遍历子产品添加UI组件文件到ZIP
    basePath := "resources/Pure Component/src/ui"
    for _, productCode := range subProductCodes {
        path, fileType, err := s.MatchProductCodeToPath(ctx, productCode)
        if err != nil {
            continue // 跳过未找到的文件
        }
        
        if fileType == "folder" {
            // 递归添加文件夹
            err = addFolderToZip(zipWriter, path, basePath)
        } else {
            // 添加单个文件
            err = addFileToZip(zipWriter, path, basePath)
        }
        
        if err != nil {
            s.logger.Warn("添加文件到ZIP失败", zap.String("path", path), zap.Error(err))
        }
    }
    
    // 4. 生成并添加 example.json 文件
    exampleJSON, err := s.GenerateExampleJSON(ctx, productID, subProductCodes)
    if err != nil {
        s.logger.Warn("生成example.json失败", zap.Error(err))
        // 不阻断流程继续生成ZIP
    } else {
        // 添加 example.json 到 ZIP 的 public 目录
        exampleWriter, err := zipWriter.Create("public/example.json")
        if err != nil {
            s.logger.Warn("创建example.json文件失败", zap.Error(err))
        } else {
            _, err = exampleWriter.Write(exampleJSON)
            if err != nil {
                s.logger.Warn("写入example.json失败", zap.Error(err))
            }
        }
    }
    
    // 5. 添加其他必要的文件(如果需要)
    // 例如:复制 public 目录下的其他文件(如果有)
    publicBasePath := "resources/Pure Component/public"
    publicFiles, err := os.ReadDir(publicBasePath)
    if err == nil {
        for _, file := range publicFiles {
            // 跳过 example.json已经生成
            if file.Name() == "example.json" {
                continue
            }
            
            filePath := filepath.Join(publicBasePath, file.Name())
            if !file.IsDir() {
                err = addFileToZip(zipWriter, filePath, publicBasePath)
                if err != nil {
                    s.logger.Warn("添加public文件失败", zap.String("file", file.Name()), zap.Error(err))
                }
            }
        }
    }
    
    // 6. 计算文件哈希值
    hash, err := calculateFileHash(zipPath)
    if err != nil {
        return "", "", err
    }
    
    return zipPath, hash, nil
}

七、支付流程集成

7.1 支付订单创建

在创建报告支付订单时,需要:

  1. 创建充值记录RechargeRecord类型为 component_report
  2. 创建支付订单(微信/支付宝)
  3. 创建下载记录ComponentReportDownload状态为 pending

7.2 支付成功回调

支付成功后:

  1. 更新充值记录状态
  2. 更新下载记录状态为 success
  3. 生成ZIP文件包括UI组件文件和 public/example.json
  4. 更新下载记录的 file_pathfile_hash
  5. 设置 expires_at30天后

7.3 二次下载

用户已支付过的报告:

  1. 检查下载记录是否存在且未过期
  2. 如果ZIP文件存在直接返回
  3. 如果ZIP文件不存在重新生成使用相同的cache_key

八、性能优化策略

8.1 缓存策略

  1. 文件匹配缓存:产品编号到文件路径的映射缓存到数据库
  2. ZIP文件缓存生成的ZIP文件保存到 storage/component-reports 目录
  3. 缓存键生成基于产品ID和子产品编号列表生成唯一缓存键

8.2 异步处理

对于大文件或包含多个子产品的组合包:

  1. 支付成功后异步生成ZIP文件
  2. 生成完成后通知用户(可选:邮件/站内消息)
  3. 用户下载时检查文件是否已生成完成

8.3 文件清理

定期清理过期的ZIP文件

  1. 下载记录过期后expires_at + 7天删除ZIP文件
  2. 使用定时任务cron定期执行清理

九、错误处理

9.1 文件未找到

  • 如果某个子产品的报告文件未找到,记录警告日志,但不影响其他文件的打包
  • 在响应中返回未找到的文件列表

9.4 example.json 生成失败

  • 如果某个子产品没有响应示例数据,使用空对象 {} 作为默认值
  • 如果响应示例解析失败尝试从Markdown代码块中提取JSON
  • 如果所有子产品都没有响应示例,生成包含空数据的 example.json 文件
  • 记录警告日志但不阻断ZIP文件生成流程

9.2 支付失败

  • 支付失败时,下载记录状态保持为 pending
  • 用户可以重新发起支付

9.3 文件生成失败

  • 记录错误日志
  • 通知管理员
  • 用户可以联系客服处理

十、测试要点

  1. 匹配算法测试

    • 精确匹配
    • 模糊匹配(前缀、后缀)
    • 未找到文件的情况
  2. 价格计算测试

    • 单品价格计算
    • 组合包价格计算
    • 已下载产品价格减免
  3. example.json 生成测试

    • 单品 example.json 生成
    • 组合包 example.json 生成(多个子产品)
    • 响应示例数据提取JSON格式、Markdown代码块格式
    • 缺少响应示例时的默认值处理
  4. 支付流程测试

    • 创建支付订单
    • 支付成功回调
    • 支付失败处理
  5. 下载功能测试

    • 首次下载
    • 二次下载
    • 文件过期处理
  6. 性能测试

    • 大文件生成时间
    • 并发下载处理
    • 缓存命中率

十一、部署注意事项

  1. 目录权限:确保 storage/component-reports 目录有写权限
  2. 存储空间ZIP文件可能较大需要足够的磁盘空间
  3. 备份策略:定期备份下载记录和缓存表
  4. 监控告警:监控文件生成失败、支付回调异常等情况

十二、后续优化方向

  1. CDN加速将ZIP文件上传到CDN加速下载
  2. 分片下载:对于超大文件,支持分片下载
  3. 预览功能:在下载前提供报告预览
  4. 版本管理:支持报告版本更新,用户可选择下载历史版本
  5. 批量下载:支持用户选择多个产品批量下载