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

1002 lines
33 KiB
Markdown
Raw Normal View History

# 产品示例报告下载功能实现方案
## 一、功能概述
在产品详情页面添加"下载示例报告"功能,允许用户下载与产品对应的前端组件报告。报告文件位于 `resources/Pure Component/src/ui` 目录下通过产品编号product_code匹配对应的文件夹或文件。
## 二、核心需求
### 2.1 基本功能
1. **报告匹配**:根据子产品的 `product_code` 模糊匹配 `resources/Pure Component/src/ui` 下的文件夹或文件
- 支持前缀匹配(如产品编号为 `DWBG6A2C`,文件夹可能是 `DWBG6A2C``多cDWBG6A2C`
- 匹配规则:文件夹名称包含产品编号,或产品编号包含文件夹名称的核心部分
2. **文件打包**:将匹配到的文件夹或文件压缩成 ZIP 格式供用户下载
- ZIP 文件结构:
```text
component-report.zip
├── src/
│ └── ui/
│ ├── DWBG6A2C/ # 子产品1的UI组件
│ │ ├── index.vue
│ │ ├── components/
│ │ └── ...
│ ├── CFLXG0V4B/ # 子产品2的UI组件
│ └── ...
└── public/
└── example.json # 响应示例数据文件
```
- 同时需要生成 `public/example.json` 文件,包含子产品的响应示例数据
- `example.json` 文件格式:
```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)
```sql
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)
```sql
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`
```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`
```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`
```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`
```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`**
- 获取报告下载信息(价格、已下载列表等)
- 响应示例:
```json
{
"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`**
- 创建支付订单
- 请求体:
```json
{
"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`
```javascript
// 组件报告相关接口
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 添加下载按钮
在页面头部操作区域添加"下载示例报告"按钮:
```vue
<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 下载流程实现
```javascript
// 响应式数据
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 产品编号匹配算法
```go
// 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 价格计算算法
```go
// 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 文件生成算法
```go
// 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文件生成算法
```go
// 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_path``file_hash`
5. 设置 `expires_at`30天后
### 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. **批量下载**:支持用户选择多个产品批量下载