This commit is contained in:
2025-07-31 15:41:00 +08:00
parent f3a3bc84c7
commit 934dce2776
36 changed files with 1614 additions and 264 deletions

View File

@@ -90,11 +90,6 @@ ocr:
api_key: "your-baidu-api-key" api_key: "your-baidu-api-key"
secret_key: "your-baidu-secret-key" secret_key: "your-baidu-secret-key"
# 充值配置
recharge:
min_amount: "100.00" # 生产环境最低充值金额
max_amount: "100000.00" # 单次最高充值金额
ratelimit: ratelimit:
requests: 5000 requests: 5000
window: 60s window: 60s
@@ -161,6 +156,16 @@ esign:
# =========================================== # ===========================================
wallet: wallet:
default_credit_limit: 50.00 default_credit_limit: 50.00
min_amount: "100.00" # 生产环境最低充值金额
max_amount: "100000.00" # 单次最高充值金额
# 支付宝充值赠送配置
alipay_recharge_bonus:
- recharge_amount: 1000.00 # 充值1000元
bonus_amount: 50.00 # 赠送50元
- recharge_amount: 5000.00 # 充值5000元
bonus_amount: 300.00 # 赠送300元
- recharge_amount: 10000.00 # 充值10000元
bonus_amount: 800.00 # 赠送800元
# =========================================== # ===========================================
# 🌍 西部数据配置 # 🌍 西部数据配置
@@ -190,16 +195,9 @@ alipay:
notify_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/callback" notify_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/callback"
return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return" return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return"
# ===========================================
# 🌐 域名配置
# ===========================================
domain:
api: "" # 开发环境不限制域名,生产环境为 "api.tianyuancha.com"
# =========================================== # ===========================================
# 🔍 天眼查配置 # 🔍 天眼查配置
# =========================================== # ===========================================
tianyancha: tianyancha:
base_url: http://open.api.tianyancha.com/services base_url: http://open.api.tianyancha.com/services
api_key: e6a43dc9-786e-4a16-bb12-392b8201d8e2 api_key: e6a43dc9-786e-4a16-bb12-392b8201d8e2

View File

@@ -92,11 +92,20 @@ alipay:
return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return" return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return"
# =========================================== # ===========================================
# 💰 充值配置 # 💰 钱包配置
# =========================================== # ===========================================
recharge: wallet:
min_amount: "0.01" # 开发环境最低充值金额 default_credit_limit: 0.01
max_amount: "100000.00" # 单次最高充值金额 min_amount: "0.01" # 生产环境最低充值金额
max_amount: "100000.00" # 单次最高充值金额
# 支付宝充值赠送配置
alipay_recharge_bonus:
- recharge_amount: 0.01 # 充值1000元
bonus_amount: 50.00 # 赠送50元
- recharge_amount: 0.05 # 充值5000元
bonus_amount: 300.00 # 赠送300元
- recharge_amount: 0.1 # 充值10000元
bonus_amount: 800.00 # 赠送800元
# =========================================== # ===========================================
# 🔍 天眼查配置 # 🔍 天眼查配置

View File

@@ -96,6 +96,9 @@ logger:
# =========================================== # ===========================================
jwt: jwt:
secret: JwT8xR4mN9vP2sL7kH3oB6yC1zA5uF0qE9tW secret: JwT8xR4mN9vP2sL7kH3oB6yC1zA5uF0qE9tW
api:
domain: "apitest.tianyuanapi.com"
# =========================================== # ===========================================
# 📁 存储服务配置 - 七牛云 # 📁 存储服务配置 - 七牛云
# =========================================== # ===========================================
@@ -155,8 +158,18 @@ alipay:
return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return" return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return"
# =========================================== # ===========================================
# 💰 充值配置 # 💰 钱包配置
# =========================================== # ===========================================
recharge: wallet:
min_amount: "0.01" # 开发环境最低充值金额 default_credit_limit: 50.00
min_amount: "100.00" # 生产环境最低充值金额
max_amount: "100000.00" # 单次最高充值金额 max_amount: "100000.00" # 单次最高充值金额
# 支付宝充值赠送配置
alipay_recharge_bonus:
- recharge_amount: 1000.00 # 充值1000元
bonus_amount: 50.00 # 赠送50元
- recharge_amount: 5000.00 # 充值5000元
bonus_amount: 300.00 # 赠送300元
- recharge_amount: 10000.00 # 充值10000元
bonus_amount: 800.00 # 赠送800元

View File

@@ -0,0 +1,209 @@
# 支付宝充值赠送功能说明
## 功能概述
本功能实现了支付宝充值的自动赠送机制,根据充值金额自动计算并赠送相应的金额到用户钱包。
## 赠送规则
当前配置的赠送规则如下:
| 充值金额 | 赠送金额 | 说明 |
|---------|---------|------|
| 1000元 | 50元 | 充值1000元及以上赠送50元 |
| 5000元 | 300元 | 充值5000元及以上赠送300元 |
| 10000元 | 800元 | 充值10000元及以上赠送800元 |
**注意**:赠送规则按充值金额从高到低匹配,即充值金额满足多个条件时,按最高档次的赠送金额计算。
## 配置说明
### 1. 配置文件位置
- 主配置文件:`config.yaml`
- 环境配置文件:`configs/env.*.yaml`
### 2. 配置结构
```yaml
wallet:
default_credit_limit: 50.00
# 支付宝充值赠送配置
alipay_recharge_bonus:
- recharge_amount: 1000.00 # 充值1000元
bonus_amount: 50.00 # 赠送50元
- recharge_amount: 5000.00 # 充值5000元
bonus_amount: 300.00 # 赠送300元
- recharge_amount: 10000.00 # 充值10000元
bonus_amount: 800.00 # 赠送800元
```
### 3. 配置结构体
```go
type WalletConfig struct {
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
AliPayRechargeBonus []AliPayRechargeBonusRule `mapstructure:"alipay_recharge_bonus"`
}
type AliPayRechargeBonusRule struct {
RechargeAmount float64 `mapstructure:"recharge_amount"` // 充值金额
BonusAmount float64 `mapstructure:"bonus_amount"` // 赠送金额
}
```
## 实现逻辑
### 1. 赠送金额计算
`calculateAlipayRechargeBonus` 函数中实现:
```go
func calculateAlipayRechargeBonus(rechargeAmount decimal.Decimal, walletConfig *config.WalletConfig) decimal.Decimal {
if walletConfig == nil || len(walletConfig.AliPayRechargeBonus) == 0 {
return decimal.Zero
}
// 按充值金额从高到低排序,找到第一个匹配的赠送规则
// 由于配置中规则是按充值金额从低到高排列的,我们需要反向遍历
for i := len(walletConfig.AliPayRechargeBonus) - 1; i >= 0; i-- {
rule := walletConfig.AliPayRechargeBonus[i]
if rechargeAmount.GreaterThanOrEqual(decimal.NewFromFloat(rule.RechargeAmount)) {
return decimal.NewFromFloat(rule.BonusAmount)
}
}
return decimal.Zero
}
```
### 2. 充值成功处理
`HandleAlipayPaymentSuccess` 方法中实现:
1. **计算赠送金额**:根据充值金额计算应赠送的金额
2. **创建赠送记录**:如果有赠送金额,创建一条赠送类型的充值记录
3. **更新钱包余额**:将充值金额和赠送金额的总和添加到用户钱包
```go
// 计算充值赠送金额
bonusAmount := calculateAlipayRechargeBonus(amount, &s.cfg.Wallet)
totalAmount := amount.Add(bonusAmount)
// 如果有赠送金额,创建赠送充值记录
if bonusAmount.GreaterThan(decimal.Zero) {
giftRechargeRecord := entities.NewGiftRechargeRecord(rechargeRecord.UserID, bonusAmount, "充值活动赠送")
_, err = s.rechargeRecordRepo.Create(txCtx, *giftRechargeRecord)
if err != nil {
s.logger.Error("创建赠送充值记录失败", zap.Error(err))
return err
}
}
// 使用钱包聚合服务更新钱包余额(包含赠送金额)
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, totalAmount)
```
## 数据库记录
### 1. 充值记录表 (recharge_records)
每次支付宝充值成功会产生两条记录:
1. **支付宝充值记录**
- `recharge_type`: "alipay"
- `amount`: 实际充值金额
- `status`: "success"
2. **赠送充值记录**(如果有赠送):
- `recharge_type`: "gift"
- `amount`: 赠送金额
- `notes`: "充值活动赠送"
- `status`: "success"
### 2. 钱包余额
钱包余额会更新为:`原余额 + 充值金额 + 赠送金额`
## 测试用例
### 1. 单元测试
运行测试命令:
```bash
go test ./internal/domains/finance/services -v
```
测试覆盖的场景:
- 充值500元无赠送
- 充值1000元赠送50元
- 充值2000元赠送50元
- 充值5000元赠送300元
- 充值8000元赠送300元
- 充值10000元赠送800元
- 充值15000元赠送800元
### 2. 配置测试
运行配置测试:
```bash
go test ./internal/config -v
```
验证配置文件中的赠送规则是否正确加载。
## 日志记录
系统会记录详细的日志信息:
```
支付宝支付成功回调处理成功
- user_id: 用户ID
- recharge_amount: 充值金额
- bonus_amount: 赠送金额
- total_amount: 总金额(充值+赠送)
- out_trade_no: 支付宝订单号
- trade_no: 支付宝交易号
- recharge_id: 充值记录ID
- order_id: 支付宝订单ID
```
## 扩展说明
### 1. 添加新的赠送规则
在配置文件中添加新的规则:
```yaml
wallet:
alipay_recharge_bonus:
- recharge_amount: 1000.00
bonus_amount: 50.00
- recharge_amount: 5000.00
bonus_amount: 300.00
- recharge_amount: 10000.00
bonus_amount: 800.00
- recharge_amount: 20000.00 # 新增规则
bonus_amount: 2000.00 # 充值20000元赠送2000元
```
### 2. 修改赠送规则
直接修改配置文件中的 `recharge_amount``bonus_amount` 值即可。
### 3. 禁用赠送功能
将配置文件中的 `alipay_recharge_bonus` 设置为空数组:
```yaml
wallet:
alipay_recharge_bonus: []
```
## 注意事项
1. **事务保护**:所有数据库操作都在事务中执行,确保数据一致性
2. **重复处理防护**:系统会检查订单状态,避免重复处理
3. **精确计算**:使用 `decimal.Decimal` 进行金额计算,避免浮点数精度问题
4. **日志记录**:详细记录所有操作,便于问题排查
5. **配置热更新**:修改配置后需要重启服务才能生效

View File

@@ -174,14 +174,13 @@ func (s *ApiApplicationServiceImpl) CallApi(ctx context.Context, cmd *commands.A
} }
// apiCall.ResponseData = &encryptedResponse // apiCall.ResponseData = &encryptedResponse
// 8. 更新订阅使用次数 // 8. 更新订阅使用次数(使用乐观锁)
subscription.IncrementAPIUsage(1) // err = s.productSubscriptionService.IncrementSubscriptionAPIUsage(txCtx, subscription.ID, 1)
err = s.productSubscriptionService.SaveSubscription(txCtx, subscription) // if err != nil {
if err != nil { // s.logger.Error("更新订阅使用次数失败", zap.Error(err))
s.logger.Error("保存订阅失败", zap.Error(err)) // businessError = ErrSystem
businessError = ErrSystem // return ErrSystem
return ErrSystem // }
}
// 9. 扣钱 // 9. 扣钱
err = s.walletService.Deduct(txCtx, apiUser.UserId, subscription.Price, apiCall.ID, transactionId, product.ID) err = s.walletService.Deduct(txCtx, apiUser.UserId, subscription.Price, apiCall.ID, transactionId, product.ID)

View File

@@ -102,6 +102,13 @@ type AlipayRechargeOrderResponse struct {
// RechargeConfigResponse 充值配置响应 // RechargeConfigResponse 充值配置响应
type RechargeConfigResponse struct { type RechargeConfigResponse struct {
MinAmount string `json:"min_amount"` // 最低充值金额 MinAmount string `json:"min_amount"` // 最低充值金额
MaxAmount string `json:"max_amount"` // 最高充值金额 MaxAmount string `json:"max_amount"` // 最高充值金额
AlipayRechargeBonus []AlipayRechargeBonusRuleResponse `json:"alipay_recharge_bonus"`
}
// AlipayRechargeBonusRuleResponse 支付宝充值赠送规则响应
type AlipayRechargeBonusRuleResponse struct {
RechargeAmount float64 `json:"recharge_amount"`
BonusAmount float64 `json:"bonus_amount"`
} }

View File

@@ -113,15 +113,15 @@ func (s *FinanceApplicationServiceImpl) CreateAlipayRechargeOrder(ctx context.Co
} }
// 从配置中获取充值限制 // 从配置中获取充值限制
minAmount, err := decimal.NewFromString(s.config.Recharge.MinAmount) minAmount, err := decimal.NewFromString(s.config.Wallet.MinAmount)
if err != nil { if err != nil {
s.logger.Error("配置中的最低充值金额格式错误", zap.String("min_amount", s.config.Recharge.MinAmount), zap.Error(err)) s.logger.Error("配置中的最低充值金额格式错误", zap.String("min_amount", s.config.Wallet.MinAmount), zap.Error(err))
return nil, fmt.Errorf("系统配置错误: %w", err) return nil, fmt.Errorf("系统配置错误: %w", err)
} }
maxAmount, err := decimal.NewFromString(s.config.Recharge.MaxAmount) maxAmount, err := decimal.NewFromString(s.config.Wallet.MaxAmount)
if err != nil { if err != nil {
s.logger.Error("配置中的最高充值金额格式错误", zap.String("max_amount", s.config.Recharge.MaxAmount), zap.Error(err)) s.logger.Error("配置中的最高充值金额格式错误", zap.String("max_amount", s.config.Wallet.MaxAmount), zap.Error(err))
return nil, fmt.Errorf("系统配置错误: %w", err) return nil, fmt.Errorf("系统配置错误: %w", err)
} }
@@ -643,8 +643,16 @@ func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Cont
// GetRechargeConfig 获取充值配置 // GetRechargeConfig 获取充值配置
func (s *FinanceApplicationServiceImpl) GetRechargeConfig(ctx context.Context) (*responses.RechargeConfigResponse, error) { func (s *FinanceApplicationServiceImpl) GetRechargeConfig(ctx context.Context) (*responses.RechargeConfigResponse, error) {
bonus := make([]responses.AlipayRechargeBonusRuleResponse, 0, len(s.config.Wallet.AliPayRechargeBonus))
for _, rule := range s.config.Wallet.AliPayRechargeBonus {
bonus = append(bonus, responses.AlipayRechargeBonusRuleResponse{
RechargeAmount: rule.RechargeAmount,
BonusAmount: rule.BonusAmount,
})
}
return &responses.RechargeConfigResponse{ return &responses.RechargeConfigResponse{
MinAmount: s.config.Recharge.MinAmount, MinAmount: s.config.Wallet.MinAmount,
MaxAmount: s.config.Recharge.MaxAmount, MaxAmount: s.config.Wallet.MaxAmount,
AlipayRechargeBonus: bonus,
}, nil }, nil
} }

View File

@@ -0,0 +1,138 @@
package product
import (
"context"
"tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/responses"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/services"
)
// DocumentationApplicationServiceInterface 文档应用服务接口
type DocumentationApplicationServiceInterface interface {
// CreateDocumentation 创建文档
CreateDocumentation(ctx context.Context, cmd *commands.CreateDocumentationCommand) (*responses.DocumentationResponse, error)
// UpdateDocumentation 更新文档
UpdateDocumentation(ctx context.Context, id string, cmd *commands.UpdateDocumentationCommand) (*responses.DocumentationResponse, error)
// GetDocumentation 获取文档
GetDocumentation(ctx context.Context, id string) (*responses.DocumentationResponse, error)
// GetDocumentationByProductID 通过产品ID获取文档
GetDocumentationByProductID(ctx context.Context, productID string) (*responses.DocumentationResponse, error)
// DeleteDocumentation 删除文档
DeleteDocumentation(ctx context.Context, id string) error
// GetDocumentationsByProductIDs 批量获取文档
GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error)
}
// DocumentationApplicationService 文档应用服务
type DocumentationApplicationService struct {
docService *services.ProductDocumentationService
}
// NewDocumentationApplicationService 创建文档应用服务实例
func NewDocumentationApplicationService(docService *services.ProductDocumentationService) *DocumentationApplicationService {
return &DocumentationApplicationService{
docService: docService,
}
}
// CreateDocumentation 创建文档
func (s *DocumentationApplicationService) CreateDocumentation(ctx context.Context, cmd *commands.CreateDocumentationCommand) (*responses.DocumentationResponse, error) {
// 创建文档实体
doc := &entities.ProductDocumentation{
RequestURL: cmd.RequestURL,
RequestMethod: cmd.RequestMethod,
BasicInfo: cmd.BasicInfo,
RequestParams: cmd.RequestParams,
ResponseFields: cmd.ResponseFields,
ResponseExample: cmd.ResponseExample,
ErrorCodes: cmd.ErrorCodes,
}
// 调用领域服务创建文档
err := s.docService.CreateDocumentation(ctx, cmd.ProductID, doc)
if err != nil {
return nil, err
}
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
}
// UpdateDocumentation 更新文档
func (s *DocumentationApplicationService) UpdateDocumentation(ctx context.Context, id string, cmd *commands.UpdateDocumentationCommand) (*responses.DocumentationResponse, error) {
// 调用领域服务更新文档
err := s.docService.UpdateDocumentation(ctx, id,
cmd.RequestURL,
cmd.RequestMethod,
cmd.BasicInfo,
cmd.RequestParams,
cmd.ResponseFields,
cmd.ResponseExample,
cmd.ErrorCodes,
)
if err != nil {
return nil, err
}
// 获取更新后的文档
doc, err := s.docService.GetDocumentation(ctx, id)
if err != nil {
return nil, err
}
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
}
// GetDocumentation 获取文档
func (s *DocumentationApplicationService) GetDocumentation(ctx context.Context, id string) (*responses.DocumentationResponse, error) {
doc, err := s.docService.GetDocumentation(ctx, id)
if err != nil {
return nil, err
}
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
}
// GetDocumentationByProductID 通过产品ID获取文档
func (s *DocumentationApplicationService) GetDocumentationByProductID(ctx context.Context, productID string) (*responses.DocumentationResponse, error) {
doc, err := s.docService.GetDocumentationByProductID(ctx, productID)
if err != nil {
return nil, err
}
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
}
// DeleteDocumentation 删除文档
func (s *DocumentationApplicationService) DeleteDocumentation(ctx context.Context, id string) error {
return s.docService.DeleteDocumentation(ctx, id)
}
// GetDocumentationsByProductIDs 批量获取文档
func (s *DocumentationApplicationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error) {
docs, err := s.docService.GetDocumentationsByProductIDs(ctx, productIDs)
if err != nil {
return nil, err
}
var docResponses []responses.DocumentationResponse
for _, doc := range docs {
docResponses = append(docResponses, responses.NewDocumentationResponse(doc))
}
return docResponses, nil
}

View File

@@ -0,0 +1,24 @@
package commands
// CreateDocumentationCommand 创建文档命令
type CreateDocumentationCommand struct {
ProductID string `json:"product_id" binding:"required" validate:"required"`
RequestURL string `json:"request_url" binding:"required" validate:"required"`
RequestMethod string `json:"request_method" binding:"required" validate:"required"`
BasicInfo string `json:"basic_info" validate:"required"`
RequestParams string `json:"request_params" validate:"required"`
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
}
// UpdateDocumentationCommand 更新文档命令
type UpdateDocumentationCommand struct {
RequestURL string `json:"request_url"`
RequestMethod string `json:"request_method"`
BasicInfo string `json:"basic_info"`
RequestParams string `json:"request_params"`
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
}

View File

@@ -36,6 +36,13 @@ type GetProductQuery struct {
Code string `form:"code" binding:"omitempty,product_code" comment:"产品编号"` Code string `form:"code" binding:"omitempty,product_code" comment:"产品编号"`
} }
// GetProductDetailQuery 获取产品详情查询(支持可选文档)
type GetProductDetailQuery struct {
ID string `uri:"id" binding:"omitempty,uuid" comment:"产品ID"`
Code string `form:"code" binding:"omitempty,product_code" comment:"产品编号"`
WithDocument *bool `form:"with_document" comment:"是否包含文档信息"`
}
// GetProductsByIDsQuery 根据ID列表获取产品查询 // GetProductsByIDsQuery 根据ID列表获取产品查询
type GetProductsByIDsQuery struct { type GetProductsByIDsQuery struct {
IDs []string `form:"ids" binding:"required,dive,uuid" comment:"产品ID列表"` IDs []string `form:"ids" binding:"required,dive,uuid" comment:"产品ID列表"`

View File

@@ -0,0 +1,41 @@
package responses
import (
"time"
"tyapi-server/internal/domains/product/entities"
)
// DocumentationResponse 文档响应
type DocumentationResponse struct {
ID string `json:"id"`
ProductID string `json:"product_id"`
RequestURL string `json:"request_url"`
RequestMethod string `json:"request_method"`
BasicInfo string `json:"basic_info"`
RequestParams string `json:"request_params"`
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewDocumentationResponse 从实体创建响应
func NewDocumentationResponse(doc *entities.ProductDocumentation) DocumentationResponse {
return DocumentationResponse{
ID: doc.ID,
ProductID: doc.ProductID,
RequestURL: doc.RequestURL,
RequestMethod: doc.RequestMethod,
BasicInfo: doc.BasicInfo,
RequestParams: doc.RequestParams,
ResponseFields: doc.ResponseFields,
ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
}

View File

@@ -100,10 +100,19 @@ type ProductAdminInfoResponse struct {
// 组合包信息 // 组合包信息
PackageItems []*PackageItemResponse `json:"package_items,omitempty" comment:"组合包项目列表"` PackageItems []*PackageItemResponse `json:"package_items,omitempty" comment:"组合包项目列表"`
// 文档信息
Documentation *DocumentationResponse `json:"documentation,omitempty" comment:"产品文档"`
CreatedAt time.Time `json:"created_at" comment:"创建时间"` CreatedAt time.Time `json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"` UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
} }
// ProductInfoWithDocumentResponse 包含文档的产品详情响应
type ProductInfoWithDocumentResponse struct {
ProductInfoResponse
Documentation *DocumentationResponse `json:"documentation,omitempty" comment:"产品文档"`
}
// ProductAdminListResponse 管理员产品列表响应 // ProductAdminListResponse 管理员产品列表响应
type ProductAdminListResponse struct { type ProductAdminListResponse struct {
Total int64 `json:"total" comment:"总数"` Total int64 `json:"total" comment:"总数"`

View File

@@ -22,10 +22,10 @@ type ProductApplicationService interface {
// 管理员专用方法 // 管理员专用方法
ListProductsForAdmin(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAdminListResponse, error) ListProductsForAdmin(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAdminListResponse, error)
GetProductByIDForAdmin(ctx context.Context, query *queries.GetProductQuery) (*responses.ProductAdminInfoResponse, error) GetProductByIDForAdmin(ctx context.Context, query *queries.GetProductDetailQuery) (*responses.ProductAdminInfoResponse, error)
// 用户端专用方法 // 用户端专用方法
GetProductByIDForUser(ctx context.Context, query *queries.GetProductQuery) (*responses.ProductInfoResponse, error) GetProductByIDForUser(ctx context.Context, query *queries.GetProductDetailQuery) (*responses.ProductInfoWithDocumentResponse, error)
// 业务查询 // 业务查询
GetSubscribableProducts(ctx context.Context, query *queries.GetSubscribableProductsQuery) ([]*responses.ProductInfoResponse, error) GetSubscribableProducts(ctx context.Context, query *queries.GetSubscribableProductsQuery) ([]*responses.ProductInfoResponse, error)

View File

@@ -21,6 +21,7 @@ type ProductApplicationServiceImpl struct {
productManagementService *product_service.ProductManagementService productManagementService *product_service.ProductManagementService
productSubscriptionService *product_service.ProductSubscriptionService productSubscriptionService *product_service.ProductSubscriptionService
productApiConfigAppService ProductApiConfigApplicationService productApiConfigAppService ProductApiConfigApplicationService
documentationAppService DocumentationApplicationServiceInterface
logger *zap.Logger logger *zap.Logger
} }
@@ -29,12 +30,14 @@ func NewProductApplicationService(
productManagementService *product_service.ProductManagementService, productManagementService *product_service.ProductManagementService,
productSubscriptionService *product_service.ProductSubscriptionService, productSubscriptionService *product_service.ProductSubscriptionService,
productApiConfigAppService ProductApiConfigApplicationService, productApiConfigAppService ProductApiConfigApplicationService,
documentationAppService DocumentationApplicationServiceInterface,
logger *zap.Logger, logger *zap.Logger,
) ProductApplicationService { ) ProductApplicationService {
return &ProductApplicationServiceImpl{ return &ProductApplicationServiceImpl{
productManagementService: productManagementService, productManagementService: productManagementService,
productSubscriptionService: productSubscriptionService, productSubscriptionService: productSubscriptionService,
productApiConfigAppService: productApiConfigAppService, productApiConfigAppService: productApiConfigAppService,
documentationAppService: documentationAppService,
logger: logger, logger: logger,
} }
} }
@@ -411,18 +414,28 @@ func (s *ProductApplicationServiceImpl) ListProductsForAdmin(ctx context.Context
// GetProductByIDForAdmin 根据ID获取产品管理员专用 // GetProductByIDForAdmin 根据ID获取产品管理员专用
// 业务流程1. 获取产品信息 2. 构建管理员响应数据 // 业务流程1. 获取产品信息 2. 构建管理员响应数据
func (s *ProductApplicationServiceImpl) GetProductByIDForAdmin(ctx context.Context, query *appQueries.GetProductQuery) (*responses.ProductAdminInfoResponse, error) { func (s *ProductApplicationServiceImpl) GetProductByIDForAdmin(ctx context.Context, query *appQueries.GetProductDetailQuery) (*responses.ProductAdminInfoResponse, error) {
product, err := s.productManagementService.GetProductWithCategory(ctx, query.ID) product, err := s.productManagementService.GetProductWithCategory(ctx, query.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.convertToProductAdminInfoResponse(product), nil response := s.convertToProductAdminInfoResponse(product)
// 如果需要包含文档信息
if query.WithDocument != nil && *query.WithDocument {
doc, err := s.documentationAppService.GetDocumentationByProductID(ctx, query.ID)
if err == nil && doc != nil {
response.Documentation = doc
}
}
return response, nil
} }
// GetProductByIDForUser 根据ID获取产品用户端专用 // GetProductByIDForUser 根据ID获取产品用户端专用
// 业务流程1. 获取产品信息 2. 验证产品可见性 3. 构建用户响应数据 // 业务流程1. 获取产品信息 2. 验证产品可见性 3. 构建用户响应数据
func (s *ProductApplicationServiceImpl) GetProductByIDForUser(ctx context.Context, query *appQueries.GetProductQuery) (*responses.ProductInfoResponse, error) { func (s *ProductApplicationServiceImpl) GetProductByIDForUser(ctx context.Context, query *appQueries.GetProductDetailQuery) (*responses.ProductInfoWithDocumentResponse, error) {
product, err := s.productManagementService.GetProductWithCategory(ctx, query.ID) product, err := s.productManagementService.GetProductWithCategory(ctx, query.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -433,7 +446,19 @@ func (s *ProductApplicationServiceImpl) GetProductByIDForUser(ctx context.Contex
return nil, fmt.Errorf("产品不存在或不可见") return nil, fmt.Errorf("产品不存在或不可见")
} }
return s.convertToProductInfoResponse(product), nil response := &responses.ProductInfoWithDocumentResponse{
ProductInfoResponse: *s.convertToProductInfoResponse(product),
}
// 如果需要包含文档信息
if query.WithDocument != nil && *query.WithDocument {
doc, err := s.documentationAppService.GetDocumentationByProductID(ctx, query.ID)
if err == nil && doc != nil {
response.Documentation = doc
}
}
return response, nil
} }
// convertToProductInfoResponse 转换为产品信息响应 // convertToProductInfoResponse 转换为产品信息响应

View File

@@ -27,10 +27,8 @@ type Config struct {
Wallet WalletConfig `mapstructure:"wallet"` Wallet WalletConfig `mapstructure:"wallet"`
WestDex WestDexConfig `mapstructure:"westdex"` WestDex WestDexConfig `mapstructure:"westdex"`
AliPay AliPayConfig `mapstructure:"alipay"` AliPay AliPayConfig `mapstructure:"alipay"`
Recharge RechargeConfig `mapstructure:"recharge"`
Yushan YushanConfig `mapstructure:"yushan"` Yushan YushanConfig `mapstructure:"yushan"`
TianYanCha TianYanChaConfig `mapstructure:"tianyancha"` TianYanCha TianYanChaConfig `mapstructure:"tianyancha"`
Domain DomainConfig `mapstructure:"domain"`
} }
// ServerConfig HTTP服务器配置 // ServerConfig HTTP服务器配置
@@ -275,7 +273,16 @@ type SignConfig struct {
// WalletConfig 钱包配置 // WalletConfig 钱包配置
type WalletConfig struct { type WalletConfig struct {
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"` DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
MinAmount string `mapstructure:"min_amount"` // 最低充值金额
MaxAmount string `mapstructure:"max_amount"` // 最高充值金额
AliPayRechargeBonus []AliPayRechargeBonusRule `mapstructure:"alipay_recharge_bonus"`
}
// AliPayRechargeBonusRule 支付宝充值赠送规则
type AliPayRechargeBonusRule struct {
RechargeAmount float64 `mapstructure:"recharge_amount"` // 充值金额
BonusAmount float64 `mapstructure:"bonus_amount"` // 赠送金额
} }
// WestDexConfig WestDex配置 // WestDexConfig WestDex配置
@@ -296,12 +303,6 @@ type AliPayConfig struct {
ReturnURL string `mapstructure:"return_url"` ReturnURL string `mapstructure:"return_url"`
} }
// RechargeConfig 充值配置
type RechargeConfig struct {
MinAmount string `mapstructure:"min_amount"` // 最低充值金额
MaxAmount string `mapstructure:"max_amount"` // 最高充值金额
}
// YushanConfig 羽山配置 // YushanConfig 羽山配置
type YushanConfig struct { type YushanConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`

View File

@@ -0,0 +1,53 @@
package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWalletConfig_AlipayRechargeBonus(t *testing.T) {
// 切换到项目根目录,这样配置加载器就能找到配置文件
originalWd, err := os.Getwd()
assert.NoError(t, err)
// 切换到项目根目录(从 internal/config 目录向上两级)
err = os.Chdir("../../")
assert.NoError(t, err)
defer os.Chdir(originalWd) // 测试结束后恢复原目录
// 加载配置
cfg, err := LoadConfig()
assert.NoError(t, err)
assert.NotNil(t, cfg)
// 验证钱包配置
assert.NotNil(t, cfg.Wallet)
assert.Greater(t, len(cfg.Wallet.AliPayRechargeBonus), 0, "支付宝充值赠送规则不能为空")
// 验证具体的赠送规则
expectedRules := []struct {
rechargeAmount float64
bonusAmount float64
}{
{1000.00, 50.00}, // 充1000送50
{5000.00, 300.00}, // 充5000送300
{10000.00, 800.00}, // 充10000送800
}
for i, expected := range expectedRules {
if i < len(cfg.Wallet.AliPayRechargeBonus) {
rule := cfg.Wallet.AliPayRechargeBonus[i]
assert.Equal(t, expected.rechargeAmount, rule.RechargeAmount,
"充值金额不匹配,期望: %f, 实际: %f", expected.rechargeAmount, rule.RechargeAmount)
assert.Equal(t, expected.bonusAmount, rule.BonusAmount,
"赠送金额不匹配,期望: %f, 实际: %f", expected.bonusAmount, rule.BonusAmount)
}
}
t.Logf("钱包配置加载成功,包含 %d 条支付宝充值赠送规则", len(cfg.Wallet.AliPayRechargeBonus))
for i, rule := range cfg.Wallet.AliPayRechargeBonus {
t.Logf("规则 %d: 充值 %.2f 元,赠送 %.2f 元", i+1, rule.RechargeAmount, rule.BonusAmount)
}
}

View File

@@ -14,6 +14,12 @@ import (
// SetupGormCache 设置GORM缓存插件 // SetupGormCache 设置GORM缓存插件
func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *config.Config, logger *zap.Logger) error { func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *config.Config, logger *zap.Logger) error {
// 缓存功能已完全禁用
logger.Info("GORM缓存插件已禁用 - 所有查询将直接访问数据库")
return nil
// 以下是原有的缓存配置代码,已注释掉
/*
// 创建缓存配置 // 创建缓存配置
cacheConfig := cache.CacheConfig{ cacheConfig := cache.CacheConfig{
DefaultTTL: 30 * time.Minute, DefaultTTL: 30 * time.Minute,
@@ -29,16 +35,16 @@ func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *conf
// 配置启用缓存的表 // 配置启用缓存的表
EnabledTables: []string{ EnabledTables: []string{
"product", // "product",
"product_category", // "product_category",
"enterprise_info_submit_records", // "enterprise_info_submit_records",
"sms_codes", // "sms_codes",
"wallets", // "wallets",
"subscription", // "subscription",
"product_category", // "product_category",
"product_documentation", // "product_documentation",
"enterprise_infos", // "enterprise_infos",
"api_users", // "api_users",
// 添加更多需要缓存的表 // 添加更多需要缓存的表
}, },
@@ -68,8 +74,9 @@ func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *conf
zap.Strings("enabled_tables", cacheConfig.EnabledTables), zap.Strings("enabled_tables", cacheConfig.EnabledTables),
zap.Strings("disabled_tables", cacheConfig.DisabledTables), zap.Strings("disabled_tables", cacheConfig.DisabledTables),
) )
*/
return nil // return nil
} }
// GetCacheConfig 根据环境获取缓存配置 // GetCacheConfig 根据环境获取缓存配置
@@ -89,10 +96,10 @@ func GetCacheConfig(cfg *config.Config) cache.CacheConfig {
InvalidateDelay: 50 * time.Millisecond, InvalidateDelay: 50 * time.Millisecond,
EnabledTables: []string{ EnabledTables: []string{
"product", // "product",
"product_category", // "product_category",
"enterprise_info_submit_records", // "enterprise_info_submit_records",
"sms_codes", // "sms_codes",
}, },
DisabledTables: []string{ DisabledTables: []string{
@@ -120,10 +127,10 @@ func GetCacheConfig(cfg *config.Config) cache.CacheConfig {
InvalidateDelay: 200 * time.Millisecond, InvalidateDelay: 200 * time.Millisecond,
EnabledTables: []string{ EnabledTables: []string{
"product", // "product",
"product_category", // "product_category",
"enterprise_info_submit_records", // "enterprise_info_submit_records",
"sms_codes", // "sms_codes",
}, },
DisabledTables: []string{ DisabledTables: []string{

View File

@@ -77,12 +77,29 @@ func NewContainer() *Container {
// 基础设施模块 // 基础设施模块
fx.Provide( fx.Provide(
// 日志器 - 提供自定义Logger和*zap.Logger // 日志器 - 提供自定义Logger和*zap.Logger
func(cfg *config.Config) (logger.Logger, error) { func(cfg *config.Config) (logger.Logger, error) {
if cfg.Logger.EnableLevelSeparation { if cfg.Logger.EnableLevelSeparation {
// 使用按级别分文件的日志器 // 使用按级别分文件的日志器
levelConfig := logger.LevelLoggerConfig{ levelConfig := logger.LevelLoggerConfig{
BaseConfig: logger.Config{ BaseConfig: logger.Config{
Level: cfg.Logger.Level,
Format: cfg.Logger.Format,
Output: cfg.Logger.Output,
LogDir: cfg.Logger.LogDir,
MaxSize: cfg.Logger.MaxSize,
MaxBackups: cfg.Logger.MaxBackups,
MaxAge: cfg.Logger.MaxAge,
Compress: cfg.Logger.Compress,
UseDaily: cfg.Logger.UseDaily,
},
EnableLevelSeparation: true,
LevelConfigs: convertLevelConfigs(cfg.Logger.LevelConfigs),
}
return logger.NewLevelLogger(levelConfig)
} else {
// 使用普通日志器
logCfg := logger.Config{
Level: cfg.Logger.Level, Level: cfg.Logger.Level,
Format: cfg.Logger.Format, Format: cfg.Logger.Format,
Output: cfg.Logger.Output, Output: cfg.Logger.Output,
@@ -92,44 +109,27 @@ func NewContainer() *Container {
MaxAge: cfg.Logger.MaxAge, MaxAge: cfg.Logger.MaxAge,
Compress: cfg.Logger.Compress, Compress: cfg.Logger.Compress,
UseDaily: cfg.Logger.UseDaily, UseDaily: cfg.Logger.UseDaily,
}, }
EnableLevelSeparation: true, return logger.NewLogger(logCfg)
LevelConfigs: convertLevelConfigs(cfg.Logger.LevelConfigs),
} }
return logger.NewLevelLogger(levelConfig) },
} else { // 提供普通的*zap.Logger用于大多数场景
// 使用普通日志器 func(log logger.Logger) *zap.Logger {
logCfg := logger.Config{ // 尝试转换为ZapLogger
Level: cfg.Logger.Level, if zapLogger, ok := log.(*logger.ZapLogger); ok {
Format: cfg.Logger.Format, return zapLogger.GetZapLogger()
Output: cfg.Logger.Output,
LogDir: cfg.Logger.LogDir,
MaxSize: cfg.Logger.MaxSize,
MaxBackups: cfg.Logger.MaxBackups,
MaxAge: cfg.Logger.MaxAge,
Compress: cfg.Logger.Compress,
UseDaily: cfg.Logger.UseDaily,
} }
return logger.NewLogger(logCfg) // 尝试转换为LevelLogger
} if levelLogger, ok := log.(*logger.LevelLogger); ok {
}, // 获取Info级别的日志器作为默认
// 提供普通的*zap.Logger用于大多数场景 if infoLogger := levelLogger.GetLevelLogger(zapcore.InfoLevel); infoLogger != nil {
func(log logger.Logger) *zap.Logger { return infoLogger
// 尝试转换为ZapLogger }
if zapLogger, ok := log.(*logger.ZapLogger); ok {
return zapLogger.GetZapLogger()
}
// 尝试转换为LevelLogger
if levelLogger, ok := log.(*logger.LevelLogger); ok {
// 获取Info级别的日志器作为默认
if infoLogger := levelLogger.GetLevelLogger(zapcore.InfoLevel); infoLogger != nil {
return infoLogger
} }
} // 如果类型转换失败创建一个默认的zap logger
// 如果类型转换失败创建一个默认的zap logger defaultLogger, _ := zap.NewProduction()
defaultLogger, _ := zap.NewProduction() return defaultLogger
return defaultLogger },
},
// 数据库连接 // 数据库连接
func(cfg *config.Config, cacheService interfaces.CacheService, logger *zap.Logger) (*gorm.DB, error) { func(cfg *config.Config, cacheService interfaces.CacheService, logger *zap.Logger) (*gorm.DB, error) {
@@ -147,7 +147,7 @@ func NewContainer() *Container {
} }
db, err := database.NewConnection(dbCfg) db, err := database.NewConnection(dbCfg)
if err != nil { if err != nil {
logger.Error("数据库连接失败", logger.Error("数据库连接失败",
zap.String("host", cfg.Database.Host), zap.String("host", cfg.Database.Host),
zap.String("port", cfg.Database.Port), zap.String("port", cfg.Database.Port),
zap.String("database", cfg.Database.Name), zap.String("database", cfg.Database.Name),
@@ -155,8 +155,8 @@ func NewContainer() *Container {
zap.Error(err)) zap.Error(err))
return nil, err return nil, err
} }
logger.Info("数据库连接成功", logger.Info("数据库连接成功",
zap.String("host", cfg.Database.Host), zap.String("host", cfg.Database.Host),
zap.String("port", cfg.Database.Port), zap.String("port", cfg.Database.Port),
zap.String("database", cfg.Database.Name)) zap.String("database", cfg.Database.Name))
@@ -445,6 +445,10 @@ func NewContainer() *Container {
product_repo.NewGormProductApiConfigRepository, product_repo.NewGormProductApiConfigRepository,
fx.As(new(domain_product_repo.ProductApiConfigRepository)), fx.As(new(domain_product_repo.ProductApiConfigRepository)),
), ),
fx.Annotate(
product_repo.NewGormProductDocumentationRepository,
fx.As(new(domain_product_repo.ProductDocumentationRepository)),
),
), ),
// API域仓储层 // API域仓储层
@@ -468,6 +472,7 @@ func NewContainer() *Container {
product_service.NewProductManagementService, product_service.NewProductManagementService,
product_service.NewProductSubscriptionService, product_service.NewProductSubscriptionService,
product_service.NewProductApiConfigService, product_service.NewProductApiConfigService,
product_service.NewProductDocumentationService,
finance_service.NewWalletAggregateService, finance_service.NewWalletAggregateService,
finance_service.NewRechargeRecordService, finance_service.NewRechargeRecordService,
certification_service.NewCertificationAggregateService, certification_service.NewCertificationAggregateService,
@@ -518,6 +523,10 @@ func NewContainer() *Container {
product.NewCategoryApplicationService, product.NewCategoryApplicationService,
fx.As(new(product.CategoryApplicationService)), fx.As(new(product.CategoryApplicationService)),
), ),
fx.Annotate(
product.NewDocumentationApplicationService,
fx.As(new(product.DocumentationApplicationServiceInterface)),
),
// 订阅应用服务 - 绑定到接口 // 订阅应用服务 - 绑定到接口
fx.Annotate( fx.Annotate(
product.NewSubscriptionApplicationService, product.NewSubscriptionApplicationService,
@@ -670,7 +679,7 @@ func RegisterRoutes(
logger.Info("HTTP服务器启动成功", zap.String("addr", addr)) logger.Info("HTTP服务器启动成功", zap.String("addr", addr))
} }
}() }()
logger.Info("路由注册完成HTTP服务器启动中...") logger.Info("路由注册完成HTTP服务器启动中...")
} }
@@ -691,7 +700,7 @@ func NewRequestBodyLoggerMiddlewareWrapper(logger *zap.Logger, cfg *config.Confi
// convertLevelConfigs 转换级别配置 // convertLevelConfigs 转换级别配置
func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore.Level]logger.LevelFileConfig { func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore.Level]logger.LevelFileConfig {
result := make(map[zapcore.Level]logger.LevelFileConfig) result := make(map[zapcore.Level]logger.LevelFileConfig)
levelMap := map[string]zapcore.Level{ levelMap := map[string]zapcore.Level{
"debug": zapcore.DebugLevel, "debug": zapcore.DebugLevel,
"info": zapcore.InfoLevel, "info": zapcore.InfoLevel,
@@ -700,7 +709,7 @@ func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore.
"fatal": zapcore.FatalLevel, "fatal": zapcore.FatalLevel,
"panic": zapcore.PanicLevel, "panic": zapcore.PanicLevel,
} }
for levelStr, config := range configs { for levelStr, config := range configs {
if level, exists := levelMap[levelStr]; exists { if level, exists := levelMap[levelStr]; exists {
result[level] = logger.LevelFileConfig{ result[level] = logger.LevelFileConfig{
@@ -711,7 +720,7 @@ func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore.
} }
} }
} }
return result return result
} }

View File

@@ -3,12 +3,11 @@ package flxg
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"time"
"tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors" "tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/westdex"
) )
// ProcessFLXG0V3Bequest FLXG0V3B API处理方法 // ProcessFLXG0V3Bequest FLXG0V3B API处理方法
@@ -32,20 +31,27 @@ func ProcessFLXG0V3Bequest(ctx context.Context, params []byte, deps *processors.
return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err) return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err)
} }
reqData := map[string]interface{}{ // mock 1秒不用真实请求
"data": map[string]interface{}{ // 模拟耗时
"name": encryptedName, select {
"id_card": encryptedIDCard, case <-ctx.Done():
}, return nil, ctx.Err()
case <-time.After(1 * time.Second):
} }
respBytes, err := deps.WestDexService.CallAPI("G34BJ03", reqData) // 构造模拟响应
mockResp := map[string]interface{}{
"code": 0,
"msg": "mock success",
"data": map[string]interface{}{
"name": encryptedName,
"id_card": encryptedIDCard,
"result": "mocked",
},
}
respBytes, err := json.Marshal(mockResp)
if err != nil { if err != nil {
if errors.Is(err, westdex.ErrDatasource) { return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err)
return nil, fmt.Errorf("%s: %w", processors.ErrDatasource, err)
} else {
return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err)
}
} }
return respBytes, nil return respBytes, nil

View File

@@ -7,12 +7,31 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"go.uber.org/zap" "go.uber.org/zap"
"tyapi-server/internal/config"
"tyapi-server/internal/domains/finance/entities" "tyapi-server/internal/domains/finance/entities"
"tyapi-server/internal/domains/finance/repositories" "tyapi-server/internal/domains/finance/repositories"
"tyapi-server/internal/shared/database" "tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
) )
// calculateAlipayRechargeBonus 计算支付宝充值赠送金额
func calculateAlipayRechargeBonus(rechargeAmount decimal.Decimal, walletConfig *config.WalletConfig) decimal.Decimal {
if walletConfig == nil || len(walletConfig.AliPayRechargeBonus) == 0 {
return decimal.Zero
}
// 按充值金额从高到低排序,找到第一个匹配的赠送规则
// 由于配置中规则是按充值金额从低到高排列的,我们需要反向遍历
for i := len(walletConfig.AliPayRechargeBonus) - 1; i >= 0; i-- {
rule := walletConfig.AliPayRechargeBonus[i]
if rechargeAmount.GreaterThanOrEqual(decimal.NewFromFloat(rule.RechargeAmount)) {
return decimal.NewFromFloat(rule.BonusAmount)
}
}
return decimal.Zero
}
// RechargeRecordService 充值记录服务接口 // RechargeRecordService 充值记录服务接口
type RechargeRecordService interface { type RechargeRecordService interface {
// 对公转账充值 // 对公转账充值
@@ -47,6 +66,7 @@ type RechargeRecordServiceImpl struct {
walletService WalletAggregateService walletService WalletAggregateService
txManager *database.TransactionManager txManager *database.TransactionManager
logger *zap.Logger logger *zap.Logger
cfg *config.Config
} }
func NewRechargeRecordService( func NewRechargeRecordService(
@@ -56,6 +76,7 @@ func NewRechargeRecordService(
walletService WalletAggregateService, walletService WalletAggregateService,
txManager *database.TransactionManager, txManager *database.TransactionManager,
logger *zap.Logger, logger *zap.Logger,
cfg *config.Config,
) RechargeRecordService { ) RechargeRecordService {
return &RechargeRecordServiceImpl{ return &RechargeRecordServiceImpl{
rechargeRecordRepo: rechargeRecordRepo, rechargeRecordRepo: rechargeRecordRepo,
@@ -64,6 +85,7 @@ func NewRechargeRecordService(
walletService: walletService, walletService: walletService,
txManager: txManager, txManager: txManager,
logger: logger, logger: logger,
cfg: cfg,
} }
} }
@@ -273,6 +295,10 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
return nil return nil
} }
// 计算充值赠送金额
bonusAmount := calculateAlipayRechargeBonus(amount, &s.cfg.Wallet)
totalAmount := amount.Add(bonusAmount)
// 在事务中执行所有更新操作 // 在事务中执行所有更新操作
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
// 更新支付宝订单状态为成功 // 更新支付宝订单状态为成功
@@ -291,8 +317,22 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
return err return err
} }
// 使用钱包聚合服务更新钱包余额 // 如果有赠送金额,创建赠送充值记录
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, amount) if bonusAmount.GreaterThan(decimal.Zero) {
giftRechargeRecord := entities.NewGiftRechargeRecord(rechargeRecord.UserID, bonusAmount, "充值活动赠送")
_, err = s.rechargeRecordRepo.Create(txCtx, *giftRechargeRecord)
if err != nil {
s.logger.Error("创建赠送充值记录失败", zap.Error(err))
return err
}
s.logger.Info("创建赠送充值记录成功",
zap.String("user_id", rechargeRecord.UserID),
zap.String("bonus_amount", bonusAmount.String()),
zap.String("gift_recharge_id", giftRechargeRecord.ID))
}
// 使用钱包聚合服务更新钱包余额(包含赠送金额)
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, totalAmount)
if err != nil { if err != nil {
s.logger.Error("更新钱包余额失败", zap.String("user_id", rechargeRecord.UserID), zap.Error(err)) s.logger.Error("更新钱包余额失败", zap.String("user_id", rechargeRecord.UserID), zap.Error(err))
return err return err
@@ -307,7 +347,9 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
s.logger.Info("支付宝支付成功回调处理成功", s.logger.Info("支付宝支付成功回调处理成功",
zap.String("user_id", rechargeRecord.UserID), zap.String("user_id", rechargeRecord.UserID),
zap.String("amount", amount.String()), zap.String("recharge_amount", amount.String()),
zap.String("bonus_amount", bonusAmount.String()),
zap.String("total_amount", totalAmount.String()),
zap.String("out_trade_no", outTradeNo), zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", tradeNo), zap.String("trade_no", tradeNo),
zap.String("recharge_id", rechargeRecord.ID), zap.String("recharge_id", rechargeRecord.ID),

View File

@@ -0,0 +1,88 @@
package services
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"tyapi-server/internal/config"
)
func TestCalculateAlipayRechargeBonus(t *testing.T) {
// 创建测试配置
walletConfig := &config.WalletConfig{
AliPayRechargeBonus: []config.AliPayRechargeBonusRule{
{RechargeAmount: 1000.00, BonusAmount: 50.00}, // 充1000送50
{RechargeAmount: 5000.00, BonusAmount: 300.00}, // 充5000送300
{RechargeAmount: 10000.00, BonusAmount: 800.00}, // 充10000送800
},
}
tests := []struct {
name string
rechargeAmount decimal.Decimal
expectedBonus decimal.Decimal
}{
{
name: "充值500元无赠送",
rechargeAmount: decimal.NewFromFloat(500.00),
expectedBonus: decimal.Zero,
},
{
name: "充值1000元赠送50元",
rechargeAmount: decimal.NewFromFloat(1000.00),
expectedBonus: decimal.NewFromFloat(50.00),
},
{
name: "充值2000元赠送50元",
rechargeAmount: decimal.NewFromFloat(2000.00),
expectedBonus: decimal.NewFromFloat(50.00),
},
{
name: "充值5000元赠送300元",
rechargeAmount: decimal.NewFromFloat(5000.00),
expectedBonus: decimal.NewFromFloat(300.00),
},
{
name: "充值8000元赠送300元",
rechargeAmount: decimal.NewFromFloat(8000.00),
expectedBonus: decimal.NewFromFloat(300.00),
},
{
name: "充值10000元赠送800元",
rechargeAmount: decimal.NewFromFloat(10000.00),
expectedBonus: decimal.NewFromFloat(800.00),
},
{
name: "充值15000元赠送800元",
rechargeAmount: decimal.NewFromFloat(15000.00),
expectedBonus: decimal.NewFromFloat(800.00),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bonus := calculateAlipayRechargeBonus(tt.rechargeAmount, walletConfig)
assert.True(t, bonus.Equal(tt.expectedBonus),
"充值金额: %s, 期望赠送: %s, 实际赠送: %s",
tt.rechargeAmount.String(), tt.expectedBonus.String(), bonus.String())
})
}
}
func TestCalculateAlipayRechargeBonus_EmptyConfig(t *testing.T) {
// 测试空配置
walletConfig := &config.WalletConfig{
AliPayRechargeBonus: []config.AliPayRechargeBonusRule{},
}
bonus := calculateAlipayRechargeBonus(decimal.NewFromFloat(1000.00), walletConfig)
assert.True(t, bonus.Equal(decimal.Zero), "空配置应该返回零赠送金额")
// 测试nil配置
bonus = calculateAlipayRechargeBonus(decimal.NewFromFloat(1000.00), nil)
assert.True(t, bonus.Equal(decimal.Zero), "nil配置应该返回零赠送金额")
}

View File

@@ -28,7 +28,8 @@ type Product struct {
SEOKeywords string `gorm:"type:text" comment:"SEO关键词"` SEOKeywords string `gorm:"type:text" comment:"SEO关键词"`
// 关联关系 // 关联关系
Category *ProductCategory `gorm:"foreignKey:CategoryID" comment:"产品分类"` Category *ProductCategory `gorm:"foreignKey:CategoryID" comment:"产品分类"`
Documentation *ProductDocumentation `gorm:"foreignKey:ProductID" comment:"产品文档"`
CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"`

View File

@@ -1,6 +1,9 @@
package entities package entities
import ( import (
"errors"
"fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -9,20 +12,20 @@ import (
// ProductDocumentation 产品文档实体 // ProductDocumentation 产品文档实体
type ProductDocumentation struct { type ProductDocumentation struct {
ID string `gorm:"primaryKey;type:varchar(36)" comment:"文档ID"` ID string `gorm:"primaryKey;type:varchar(36)" comment:"文档ID"`
ProductID string `gorm:"type:varchar(36);not null;uniqueIndex" comment:"产品ID"` ProductID string `gorm:"type:varchar(36);not null;uniqueIndex" comment:"产品ID"`
Title string `gorm:"type:varchar(200);not null" comment:"文档标题"` RequestURL string `gorm:"type:varchar(500);not null" comment:"请求链接"`
Content string `gorm:"type:text;not null" comment:"文档内容"` RequestMethod string `gorm:"type:varchar(20);not null" comment:"请求方法"`
UsageGuide string `gorm:"type:text" comment:"使用指南"` BasicInfo string `gorm:"type:text" comment:"基础说明(请求头配置、参数加密等)"`
APIDocs string `gorm:"type:text" comment:"API文档"` RequestParams string `gorm:"type:text" comment:"请求参数"`
Examples string `gorm:"type:text" comment:"使用示例"` ResponseFields string `gorm:"type:text" comment:"返回字段说明"`
FAQ string `gorm:"type:text" comment:"常见问题"` ResponseExample string `gorm:"type:text" comment:"响应示例"`
Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"` ErrorCodes string `gorm:"type:text" comment:"错误代码"`
Published bool `gorm:"default:false" comment:"是否已发布"` Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"`
// 关联关系 // 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"` Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"` DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"`
@@ -41,29 +44,16 @@ func (pd *ProductDocumentation) IsValid() bool {
return pd.DeletedAt.Time.IsZero() return pd.DeletedAt.Time.IsZero()
} }
// IsPublished 检查文档是否已发布
func (pd *ProductDocumentation) IsPublished() bool {
return pd.Published
}
// Publish 发布文档
func (pd *ProductDocumentation) Publish() {
pd.Published = true
}
// Unpublish 取消发布文档
func (pd *ProductDocumentation) Unpublish() {
pd.Published = false
}
// UpdateContent 更新文档内容 // UpdateContent 更新文档内容
func (pd *ProductDocumentation) UpdateContent(title, content, usageGuide, apiDocs, examples, faq string) { func (pd *ProductDocumentation) UpdateContent(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes string) {
pd.Title = title pd.RequestURL = requestURL
pd.Content = content pd.RequestMethod = requestMethod
pd.UsageGuide = usageGuide pd.BasicInfo = basicInfo
pd.APIDocs = apiDocs pd.RequestParams = requestParams
pd.Examples = examples pd.ResponseFields = responseFields
pd.FAQ = faq pd.ResponseExample = responseExample
pd.ErrorCodes = errorCodes
} }
// IncrementVersion 增加版本号 // IncrementVersion 增加版本号
@@ -75,4 +65,157 @@ func (pd *ProductDocumentation) IncrementVersion() {
// 这里可以实现更复杂的版本号递增逻辑 // 这里可以实现更复杂的版本号递增逻辑
pd.Version = pd.Version + ".1" pd.Version = pd.Version + ".1"
} }
} }
// Validate 验证文档完整性
func (pd *ProductDocumentation) Validate() error {
if pd.RequestURL == "" {
return errors.New("请求链接不能为空")
}
if pd.RequestMethod == "" {
return errors.New("请求方法不能为空")
}
if pd.ProductID == "" {
return errors.New("产品ID不能为空")
}
// 验证请求方法
validMethods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
methodValid := false
for _, method := range validMethods {
if strings.ToUpper(pd.RequestMethod) == method {
methodValid = true
break
}
}
if !methodValid {
return fmt.Errorf("无效的请求方法: %s", pd.RequestMethod)
}
// 验证URL格式简单验证
if !strings.HasPrefix(pd.RequestURL, "http://") && !strings.HasPrefix(pd.RequestURL, "https://") {
return errors.New("请求链接必须以http://或https://开头")
}
// 验证版本号格式
if pd.Version != "" {
if !isValidVersion(pd.Version) {
return fmt.Errorf("无效的版本号格式: %s", pd.Version)
}
}
return nil
}
// CanPublish 检查是否可以发布
func (pd *ProductDocumentation) CanPublish() error {
if err := pd.Validate(); err != nil {
return fmt.Errorf("文档验证失败: %w", err)
}
if pd.BasicInfo == "" {
return errors.New("基础说明不能为空")
}
if pd.RequestParams == "" {
return errors.New("请求参数不能为空")
}
return nil
}
// UpdateDocumentation 更新文档内容并自动递增版本
func (pd *ProductDocumentation) UpdateDocumentation(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes string) error {
// 验证必填字段
if requestURL == "" || requestMethod == "" {
return errors.New("请求链接和请求方法不能为空")
}
// 更新内容
pd.UpdateContent(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes)
// 自动递增版本
pd.IncrementVersion()
return nil
}
// GetDocumentationSummary 获取文档摘要
func (pd *ProductDocumentation) GetDocumentationSummary() map[string]interface{} {
return map[string]interface{}{
"id": pd.ID,
"product_id": pd.ProductID,
"request_url": pd.RequestURL,
"method": pd.RequestMethod,
"version": pd.Version,
"created_at": pd.CreatedAt,
"updated_at": pd.UpdatedAt,
}
}
// HasRequiredFields 检查是否包含必需字段
func (pd *ProductDocumentation) HasRequiredFields() bool {
return pd.RequestURL != "" &&
pd.RequestMethod != "" &&
pd.ProductID != "" &&
pd.BasicInfo != "" &&
pd.RequestParams != ""
}
// IsComplete 检查文档是否完整
func (pd *ProductDocumentation) IsComplete() bool {
return pd.HasRequiredFields() &&
pd.ResponseFields != "" &&
pd.ResponseExample != "" &&
pd.ErrorCodes != ""
}
// GetCompletionPercentage 获取文档完成度百分比
func (pd *ProductDocumentation) GetCompletionPercentage() int {
totalFields := 8 // 总字段数
completedFields := 0
if pd.RequestURL != "" {
completedFields++
}
if pd.RequestMethod != "" {
completedFields++
}
if pd.BasicInfo != "" {
completedFields++
}
if pd.RequestParams != "" {
completedFields++
}
if pd.ResponseFields != "" {
completedFields++
}
if pd.ResponseExample != "" {
completedFields++
}
if pd.ErrorCodes != "" {
completedFields++
}
return (completedFields * 100) / totalFields
}
// isValidVersion 验证版本号格式
func isValidVersion(version string) bool {
// 简单的版本号验证x.y.z 格式
parts := strings.Split(version, ".")
if len(parts) < 1 || len(parts) > 3 {
return false
}
for _, part := range parts {
if part == "" {
return false
}
// 检查是否为数字
for _, char := range part {
if char < '0' || char > '9' {
return false
}
}
}
return true
}

View File

@@ -15,6 +15,7 @@ type Subscription struct {
ProductID string `gorm:"type:varchar(36);not null;index" comment:"产品ID"` ProductID string `gorm:"type:varchar(36);not null;index" comment:"产品ID"`
Price decimal.Decimal `gorm:"type:decimal(10,2);not null" comment:"订阅价格"` Price decimal.Decimal `gorm:"type:decimal(10,2);not null" comment:"订阅价格"`
APIUsed int64 `gorm:"default:0" comment:"已使用API调用次数"` APIUsed int64 `gorm:"default:0" comment:"已使用API调用次数"`
Version int64 `gorm:"default:1" comment:"乐观锁版本号"`
// 关联关系 // 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"` Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
@@ -40,9 +41,11 @@ func (s *Subscription) IsValid() bool {
// IncrementAPIUsage 增加API使用次数 // IncrementAPIUsage 增加API使用次数
func (s *Subscription) IncrementAPIUsage(count int64) { func (s *Subscription) IncrementAPIUsage(count int64) {
s.APIUsed += count s.APIUsed += count
s.Version++ // 增加版本号
} }
// ResetAPIUsage 重置API使用次数 // ResetAPIUsage 重置API使用次数
func (s *Subscription) ResetAPIUsage() { func (s *Subscription) ResetAPIUsage() {
s.APIUsed = 0 s.APIUsed = 0
s.Version++ // 增加版本号
} }

View File

@@ -0,0 +1,26 @@
package repositories
import (
"context"
"tyapi-server/internal/domains/product/entities"
)
// ProductDocumentationRepository 产品文档仓储接口
type ProductDocumentationRepository interface {
// 基础CRUD操作
Create(ctx context.Context, documentation *entities.ProductDocumentation) error
Update(ctx context.Context, documentation *entities.ProductDocumentation) error
Delete(ctx context.Context, id string) error
FindByID(ctx context.Context, id string) (*entities.ProductDocumentation, error)
// 业务查询操作
FindByProductID(ctx context.Context, productID string) (*entities.ProductDocumentation, error)
// 批量操作
FindByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error)
UpdateBatch(ctx context.Context, documentations []*entities.ProductDocumentation) error
// 统计操作
CountByProductID(ctx context.Context, productID string) (int64, error)
}

View File

@@ -22,4 +22,7 @@ type SubscriptionRepository interface {
// 统计方法 // 统计方法
CountByUser(ctx context.Context, userID string) (int64, error) CountByUser(ctx context.Context, userID string) (int64, error)
CountByProduct(ctx context.Context, productID string) (int64, error) CountByProduct(ctx context.Context, productID string) (int64, error)
// 乐观锁更新方法
IncrementAPIUsageWithOptimisticLock(ctx context.Context, subscriptionID string, increment int64) error
} }

View File

@@ -0,0 +1,116 @@
package services
import (
"context"
"errors"
"fmt"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
)
// ProductDocumentationService 产品文档服务
type ProductDocumentationService struct {
docRepo repositories.ProductDocumentationRepository
productRepo repositories.ProductRepository
}
// NewProductDocumentationService 创建文档服务实例
func NewProductDocumentationService(
docRepo repositories.ProductDocumentationRepository,
productRepo repositories.ProductRepository,
) *ProductDocumentationService {
return &ProductDocumentationService{
docRepo: docRepo,
productRepo: productRepo,
}
}
// CreateDocumentation 创建文档
func (s *ProductDocumentationService) CreateDocumentation(ctx context.Context, productID string, doc *entities.ProductDocumentation) error {
// 验证产品是否存在
product, err := s.productRepo.GetByID(ctx, productID)
if err != nil {
return fmt.Errorf("产品不存在: %w", err)
}
if !product.IsValid() {
return errors.New("产品已禁用或删除")
}
// 检查是否已存在文档
existingDoc, err := s.docRepo.FindByProductID(ctx, productID)
if err == nil && existingDoc != nil {
return errors.New("该产品已存在文档")
}
// 设置产品ID
doc.ProductID = productID
// 验证文档完整性
if err := doc.Validate(); err != nil {
return fmt.Errorf("文档验证失败: %w", err)
}
// 创建文档
return s.docRepo.Create(ctx, doc)
}
// UpdateDocumentation 更新文档
func (s *ProductDocumentationService) UpdateDocumentation(ctx context.Context, id string, requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes string) error {
// 查找现有文档
doc, err := s.docRepo.FindByID(ctx, id)
if err != nil {
return fmt.Errorf("文档不存在: %w", err)
}
// 使用实体的更新方法
err = doc.UpdateDocumentation(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes)
if err != nil {
return fmt.Errorf("文档更新失败: %w", err)
}
// 保存更新
return s.docRepo.Update(ctx, doc)
}
// GetDocumentation 获取文档
func (s *ProductDocumentationService) GetDocumentation(ctx context.Context, id string) (*entities.ProductDocumentation, error) {
return s.docRepo.FindByID(ctx, id)
}
// GetDocumentationByProductID 通过产品ID获取文档
func (s *ProductDocumentationService) GetDocumentationByProductID(ctx context.Context, productID string) (*entities.ProductDocumentation, error) {
return s.docRepo.FindByProductID(ctx, productID)
}
// DeleteDocumentation 删除文档
func (s *ProductDocumentationService) DeleteDocumentation(ctx context.Context, id string) error {
_, err := s.docRepo.FindByID(ctx, id)
if err != nil {
return fmt.Errorf("文档不存在: %w", err)
}
return s.docRepo.Delete(ctx, id)
}
// GetDocumentationWithProduct 获取文档及其关联的产品信息
func (s *ProductDocumentationService) GetDocumentationWithProduct(ctx context.Context, id string) (*entities.ProductDocumentation, error) {
doc, err := s.docRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// 加载产品信息
product, err := s.productRepo.GetByID(ctx, doc.ProductID)
if err != nil {
return nil, fmt.Errorf("获取产品信息失败: %w", err)
}
doc.Product = &product
return doc, nil
}
// GetDocumentationsByProductIDs 批量获取文档
func (s *ProductDocumentationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) {
return s.docRepo.FindByProductIDs(ctx, productIDs)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -190,4 +191,58 @@ func (s *ProductSubscriptionService) SaveSubscription(ctx context.Context, subsc
} }
return nil return nil
} }
} }
// IncrementSubscriptionAPIUsage 增加订阅API使用次数使用乐观锁带重试机制
func (s *ProductSubscriptionService) IncrementSubscriptionAPIUsage(ctx context.Context, subscriptionID string, increment int64) error {
const maxRetries = 3
const baseDelay = 10 * time.Millisecond
for attempt := 0; attempt < maxRetries; attempt++ {
// 使用乐观锁直接更新数据库
err := s.subscriptionRepo.IncrementAPIUsageWithOptimisticLock(ctx, subscriptionID, increment)
if err == nil {
// 更新成功
if attempt > 0 {
s.logger.Info("订阅API使用次数更新成功重试后",
zap.String("subscription_id", subscriptionID),
zap.Int64("increment", increment),
zap.Int("retry_count", attempt))
} else {
s.logger.Info("订阅API使用次数更新成功",
zap.String("subscription_id", subscriptionID),
zap.Int64("increment", increment))
}
return nil
}
// 检查是否是版本冲突错误
if errors.Is(err, gorm.ErrRecordNotFound) {
// 版本冲突,等待后重试
if attempt < maxRetries-1 {
delay := time.Duration(attempt+1) * baseDelay
s.logger.Debug("订阅版本冲突,准备重试",
zap.String("subscription_id", subscriptionID),
zap.Int("attempt", attempt+1),
zap.Duration("delay", delay))
time.Sleep(delay)
continue
}
// 最后一次重试失败
s.logger.Error("订阅不存在或版本冲突,重试次数已用完",
zap.String("subscription_id", subscriptionID),
zap.Int("max_retries", maxRetries),
zap.Error(err))
return fmt.Errorf("订阅不存在或已被其他操作修改(重试%d次后失败: %w", maxRetries, err)
}
// 其他错误直接返回,不重试
s.logger.Error("更新订阅API使用次数失败",
zap.String("subscription_id", subscriptionID),
zap.Int64("increment", increment),
zap.Error(err))
return fmt.Errorf("更新订阅API使用次数失败: %w", err)
}
return fmt.Errorf("更新失败,已重试%d次", maxRetries)
}

View File

@@ -0,0 +1,108 @@
package repositories
import (
"context"
"errors"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
"tyapi-server/internal/shared/database"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
ProductDocumentationsTable = "product_documentations"
)
type GormProductDocumentationRepository struct {
*database.CachedBaseRepositoryImpl
}
func (r *GormProductDocumentationRepository) Delete(ctx context.Context, id string) error {
return r.DeleteEntity(ctx, id, &entities.ProductDocumentation{})
}
var _ repositories.ProductDocumentationRepository = (*GormProductDocumentationRepository)(nil)
func NewGormProductDocumentationRepository(db *gorm.DB, logger *zap.Logger) repositories.ProductDocumentationRepository {
return &GormProductDocumentationRepository{
CachedBaseRepositoryImpl: database.NewCachedBaseRepositoryImpl(db, logger, ProductDocumentationsTable),
}
}
// Create 创建文档
func (r *GormProductDocumentationRepository) Create(ctx context.Context, documentation *entities.ProductDocumentation) error {
return r.CreateEntity(ctx, documentation)
}
// Update 更新文档
func (r *GormProductDocumentationRepository) Update(ctx context.Context, documentation *entities.ProductDocumentation) error {
return r.UpdateEntity(ctx, documentation)
}
// FindByID 根据ID查找文档
func (r *GormProductDocumentationRepository) FindByID(ctx context.Context, id string) (*entities.ProductDocumentation, error) {
var entity entities.ProductDocumentation
err := r.SmartGetByID(ctx, id, &entity)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &entity, nil
}
// FindByProductID 根据产品ID查找文档
func (r *GormProductDocumentationRepository) FindByProductID(ctx context.Context, productID string) (*entities.ProductDocumentation, error) {
var entity entities.ProductDocumentation
err := r.GetDB(ctx).Where("product_id = ?", productID).First(&entity).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &entity, nil
}
// FindByProductIDs 根据产品ID列表批量查找文档
func (r *GormProductDocumentationRepository) FindByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) {
var documentations []entities.ProductDocumentation
err := r.GetDB(ctx).Where("product_id IN ?", productIDs).Find(&documentations).Error
if err != nil {
return nil, err
}
// 转换为指针切片
result := make([]*entities.ProductDocumentation, len(documentations))
for i := range documentations {
result[i] = &documentations[i]
}
return result, nil
}
// UpdateBatch 批量更新文档
func (r *GormProductDocumentationRepository) UpdateBatch(ctx context.Context, documentations []*entities.ProductDocumentation) error {
if len(documentations) == 0 {
return nil
}
// 使用事务进行批量更新
return r.GetDB(ctx).Transaction(func(tx *gorm.DB) error {
for _, doc := range documentations {
if err := tx.Save(doc).Error; err != nil {
return err
}
}
return nil
})
}
// CountByProductID 统计指定产品的文档数量
func (r *GormProductDocumentationRepository) CountByProductID(ctx context.Context, productID string) (int64, error) {
var count int64
err := r.GetDB(ctx).Model(&entities.ProductDocumentation{}).Where("product_id = ?", productID).Count(&count).Error
return count, err
}

View File

@@ -289,3 +289,26 @@ func (r *GormSubscriptionRepository) WithTx(tx interface{}) interfaces.Repositor
} }
return r return r
} }
// IncrementAPIUsageWithOptimisticLock 使用乐观锁增加API使用次数
func (r *GormSubscriptionRepository) IncrementAPIUsageWithOptimisticLock(ctx context.Context, subscriptionID string, increment int64) error {
// 使用原生SQL进行乐观锁更新
result := r.GetDB(ctx).WithContext(ctx).Exec(`
UPDATE subscription
SET api_used = api_used + ?, version = version + 1, updated_at = NOW()
WHERE id = ? AND version = (
SELECT version FROM subscription WHERE id = ?
)
`, increment, subscriptionID, subscriptionID)
if result.Error != nil {
return result.Error
}
// 检查是否有行被更新
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -14,12 +14,13 @@ import (
// ProductAdminHandler 产品管理员HTTP处理器 // ProductAdminHandler 产品管理员HTTP处理器
type ProductAdminHandler struct { type ProductAdminHandler struct {
productAppService product.ProductApplicationService productAppService product.ProductApplicationService
categoryAppService product.CategoryApplicationService categoryAppService product.CategoryApplicationService
subscriptionAppService product.SubscriptionApplicationService subscriptionAppService product.SubscriptionApplicationService
responseBuilder interfaces.ResponseBuilder documentationAppService product.DocumentationApplicationServiceInterface
validator interfaces.RequestValidator responseBuilder interfaces.ResponseBuilder
logger *zap.Logger validator interfaces.RequestValidator
logger *zap.Logger
} }
// NewProductAdminHandler 创建产品管理员HTTP处理器 // NewProductAdminHandler 创建产品管理员HTTP处理器
@@ -27,17 +28,19 @@ func NewProductAdminHandler(
productAppService product.ProductApplicationService, productAppService product.ProductApplicationService,
categoryAppService product.CategoryApplicationService, categoryAppService product.CategoryApplicationService,
subscriptionAppService product.SubscriptionApplicationService, subscriptionAppService product.SubscriptionApplicationService,
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder, responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
logger *zap.Logger, logger *zap.Logger,
) *ProductAdminHandler { ) *ProductAdminHandler {
return &ProductAdminHandler{ return &ProductAdminHandler{
productAppService: productAppService, productAppService: productAppService,
categoryAppService: categoryAppService, categoryAppService: categoryAppService,
subscriptionAppService: subscriptionAppService, subscriptionAppService: subscriptionAppService,
responseBuilder: responseBuilder, documentationAppService: documentationAppService,
validator: validator, responseBuilder: responseBuilder,
logger: logger, validator: validator,
logger: logger,
} }
} }
@@ -357,14 +360,15 @@ func (h *ProductAdminHandler) getIntQuery(c *gin.Context, key string, defaultVal
return defaultValue return defaultValue
} }
// GetProductDetail 获取产品详情(管理员) // GetProductDetail 获取产品详情
// @Summary 获取产品详情 // @Summary 获取产品详情
// @Description 管理员获取产品详细信息,包含可见状态 // @Description 管理员获取产品详细信息
// @Tags 产品管理 // @Tags 产品管理
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security Bearer // @Security Bearer
// @Param id path string true "产品ID" // @Param id path string true "产品ID"
// @Param with_document query bool false "是否包含文档信息"
// @Success 200 {object} responses.ProductAdminInfoResponse "获取产品详情成功" // @Success 200 {object} responses.ProductAdminInfoResponse "获取产品详情成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误" // @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证" // @Failure 401 {object} map[string]interface{} "未认证"
@@ -372,18 +376,23 @@ func (h *ProductAdminHandler) getIntQuery(c *gin.Context, key string, defaultVal
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/{id} [get] // @Router /api/v1/admin/products/{id} [get]
func (h *ProductAdminHandler) GetProductDetail(c *gin.Context) { func (h *ProductAdminHandler) GetProductDetail(c *gin.Context) {
var query queries.GetProductQuery var query queries.GetProductDetailQuery
query.ID = c.Param("id") query.ID = c.Param("id")
if query.ID == "" { if query.ID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空") h.responseBuilder.BadRequest(c, "产品ID不能为空")
return return
} }
// 使用管理员专用的产品详情获取方法 // 解析可选参数
if withDocument := c.Query("with_document"); withDocument != "" {
if withDoc, err := strconv.ParseBool(withDocument); err == nil {
query.WithDocument = &withDoc
}
}
result, err := h.productAppService.GetProductByIDForAdmin(c.Request.Context(), &query) result, err := h.productAppService.GetProductByIDForAdmin(c.Request.Context(), &query)
if err != nil { if err != nil {
h.logger.Error("获取产品详情失败", zap.Error(err), zap.String("product_id", query.ID)) h.logger.Error("获取产品详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "产品不存在") h.responseBuilder.NotFound(c, "产品不存在")
return return
} }
@@ -793,7 +802,7 @@ func (h *ProductAdminHandler) GetProductApiConfig(c *gin.Context) {
h.responseBuilder.Success(c, emptyConfig, "获取API配置成功") h.responseBuilder.Success(c, emptyConfig, "获取API配置成功")
return return
} }
h.logger.Error("获取产品API配置失败", zap.Error(err), zap.String("product_id", productID)) h.logger.Error("获取产品API配置失败", zap.Error(err), zap.String("product_id", productID))
h.responseBuilder.NotFound(c, "产品不存在") h.responseBuilder.NotFound(c, "产品不存在")
return return
@@ -893,7 +902,7 @@ func (h *ProductAdminHandler) UpdateProductApiConfig(c *gin.Context) {
// @Success 200 {object} map[string]interface{} "API配置删除成功" // @Success 200 {object} map[string]interface{} "API配置删除成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误" // @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证" // @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "产品或配置不存在" // @Failure 404 {object} map[string]interface{} "产品或API配置不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/{id}/api-config [delete] // @Router /api/v1/admin/products/{id}/api-config [delete]
func (h *ProductAdminHandler) DeleteProductApiConfig(c *gin.Context) { func (h *ProductAdminHandler) DeleteProductApiConfig(c *gin.Context) {
@@ -903,19 +912,144 @@ func (h *ProductAdminHandler) DeleteProductApiConfig(c *gin.Context) {
return return
} }
// 先获取现有配置以获取配置ID if err := h.productAppService.DeleteProductApiConfig(c.Request.Context(), productID); err != nil {
existingConfig, err := h.productAppService.GetProductApiConfig(c.Request.Context(), productID) h.logger.Error("删除产品API配置失败", zap.Error(err))
if err != nil {
h.logger.Error("获取现有API配置失败", zap.Error(err), zap.String("product_id", productID))
h.responseBuilder.NotFound(c, "产品API配置不存在")
return
}
if err := h.productAppService.DeleteProductApiConfig(c.Request.Context(), existingConfig.ID); err != nil {
h.logger.Error("删除产品API配置失败", zap.Error(err), zap.String("product_id", productID))
h.responseBuilder.BadRequest(c, err.Error()) h.responseBuilder.BadRequest(c, err.Error())
return return
} }
h.responseBuilder.Success(c, nil, "API配置删除成功") h.responseBuilder.Success(c, nil, "API配置删除成功")
} }
// GetProductDocumentation 获取产品文档
// @Summary 获取产品文档
// @Description 管理员获取产品的文档信息
// @Tags 产品管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "产品ID"
// @Success 200 {object} responses.DocumentationResponse "获取文档成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "产品或文档不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/{id}/documentation [get]
func (h *ProductAdminHandler) GetProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
doc, err := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if err != nil {
// 文档不存在时,返回空数据而不是错误
h.logger.Info("产品文档不存在,返回空数据", zap.String("product_id", productID))
h.responseBuilder.Success(c, nil, "文档不存在")
return
}
h.responseBuilder.Success(c, doc, "获取文档成功")
}
// CreateOrUpdateProductDocumentation 创建或更新产品文档
// @Summary 创建或更新产品文档
// @Description 管理员创建或更新产品的文档信息,如果文档不存在则创建,存在则更新
// @Tags 产品管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "产品ID"
// @Param request body commands.CreateDocumentationCommand true "文档信息"
// @Success 200 {object} responses.DocumentationResponse "文档操作成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "产品不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/{id}/documentation [post]
func (h *ProductAdminHandler) CreateOrUpdateProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
var cmd commands.CreateDocumentationCommand
cmd.ProductID = productID
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
// 先尝试获取现有文档
existingDoc, err := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if err != nil {
// 文档不存在,创建新文档
doc, err := h.documentationAppService.CreateDocumentation(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("创建产品文档失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, doc, "文档创建成功")
return
}
// 文档存在,更新文档
updateCmd := commands.UpdateDocumentationCommand{
RequestURL: cmd.RequestURL,
RequestMethod: cmd.RequestMethod,
BasicInfo: cmd.BasicInfo,
RequestParams: cmd.RequestParams,
ResponseFields: cmd.ResponseFields,
ResponseExample: cmd.ResponseExample,
ErrorCodes: cmd.ErrorCodes,
}
doc, err := h.documentationAppService.UpdateDocumentation(c.Request.Context(), existingDoc.ID, &updateCmd)
if err != nil {
h.logger.Error("更新产品文档失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, doc, "文档更新成功")
}
// DeleteProductDocumentation 删除产品文档
// @Summary 删除产品文档
// @Description 管理员删除产品的文档
// @Tags 产品管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "产品ID"
// @Success 200 {object} map[string]interface{} "文档删除成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "产品或文档不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/{id}/documentation [delete]
func (h *ProductAdminHandler) DeleteProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
// 先获取文档
doc, err := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if err != nil {
h.responseBuilder.NotFound(c, "文档不存在")
return
}
// 删除文档
if err := h.documentationAppService.DeleteDocumentation(c.Request.Context(), doc.ID); err != nil {
h.logger.Error("删除产品文档失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "文档删除成功")
}

View File

@@ -17,6 +17,7 @@ type ProductHandler struct {
apiConfigService product.ProductApiConfigApplicationService apiConfigService product.ProductApiConfigApplicationService
categoryService product.CategoryApplicationService categoryService product.CategoryApplicationService
subAppService product.SubscriptionApplicationService subAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface
responseBuilder interfaces.ResponseBuilder responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator validator interfaces.RequestValidator
logger *zap.Logger logger *zap.Logger
@@ -28,6 +29,7 @@ func NewProductHandler(
apiConfigService product.ProductApiConfigApplicationService, apiConfigService product.ProductApiConfigApplicationService,
categoryService product.CategoryApplicationService, categoryService product.CategoryApplicationService,
subAppService product.SubscriptionApplicationService, subAppService product.SubscriptionApplicationService,
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder, responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
logger *zap.Logger, logger *zap.Logger,
@@ -37,6 +39,7 @@ func NewProductHandler(
apiConfigService: apiConfigService, apiConfigService: apiConfigService,
categoryService: categoryService, categoryService: categoryService,
subAppService: subAppService, subAppService: subAppService,
documentationAppService: documentationAppService,
responseBuilder: responseBuilder, responseBuilder: responseBuilder,
validator: validator, validator: validator,
logger: logger, logger: logger,
@@ -171,30 +174,36 @@ func (h *ProductHandler) getCurrentUserID(c *gin.Context) string {
// GetProductDetail 获取产品详情 // GetProductDetail 获取产品详情
// @Summary 获取产品详情 // @Summary 获取产品详情
// @Description 根据产品ID获取产品详细信息,只能获取可见的产品 // @Description 获取产品详细信息,用户端只能查看可见的产品
// @Tags 数据大厅 // @Tags 数据大厅
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path string true "产品ID" // @Param id path string true "产品ID"
// @Success 200 {object} responses.ProductInfoResponse "获取产品详情成功" // @Param with_document query bool false "是否包含文档信息"
// @Success 200 {object} responses.ProductInfoWithDocumentResponse "获取产品详情成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误" // @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "产品不存在" // @Failure 404 {object} map[string]interface{} "产品不存在或不可见"
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/products/{id} [get] // @Router /api/v1/products/{id} [get]
func (h *ProductHandler) GetProductDetail(c *gin.Context) { func (h *ProductHandler) GetProductDetail(c *gin.Context) {
var query queries.GetProductQuery var query queries.GetProductDetailQuery
query.ID = c.Param("id") query.ID = c.Param("id")
if query.ID == "" { if query.ID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空") h.responseBuilder.BadRequest(c, "产品ID不能为空")
return return
} }
// 使用用户端专用的产品详情获取方法 // 解析可选参数
if withDocument := c.Query("with_document"); withDocument != "" {
if withDoc, err := strconv.ParseBool(withDocument); err == nil {
query.WithDocument = &withDoc
}
}
result, err := h.appService.GetProductByIDForUser(c.Request.Context(), &query) result, err := h.appService.GetProductByIDForUser(c.Request.Context(), &query)
if err != nil { if err != nil {
h.logger.Error("获取产品详情失败", zap.Error(err), zap.String("product_id", query.ID)) h.logger.Error("获取产品详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "产品不存在") h.responseBuilder.NotFound(c, "产品不存在或不可见")
return return
} }
@@ -523,31 +532,61 @@ func (h *ProductHandler) GetMySubscriptionDetail(c *gin.Context) {
// @Produce json // @Produce json
// @Security Bearer // @Security Bearer
// @Param id path string true "订阅ID" // @Param id path string true "订阅ID"
// @Success 200 {object} responses.SubscriptionUsageResponse "获取使用情况成功" // @Success 200 {object} map[string]interface{} "获取使用情况成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误" // @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证" // @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "订阅不存在" // @Failure 404 {object} map[string]interface{} "订阅不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/my/subscriptions/{id}/usage [get] // @Router /api/v1/my/subscriptions/{id}/usage [get]
func (h *ProductHandler) GetMySubscriptionUsage(c *gin.Context) { func (h *ProductHandler) GetMySubscriptionUsage(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
subscriptionID := c.Param("id") subscriptionID := c.Param("id")
if subscriptionID == "" { if subscriptionID == "" {
h.responseBuilder.BadRequest(c, "订阅ID不能为空") h.responseBuilder.BadRequest(c, "订阅ID不能为空")
return return
} }
result, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID) // 获取当前用户ID
if err != nil { userID := h.getCurrentUserID(c)
h.logger.Error("获取我的订阅使用情况失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID)) if userID == "" {
h.responseBuilder.NotFound(c, "订阅不存在") h.responseBuilder.Unauthorized(c, "用户未认证")
return return
} }
h.responseBuilder.Success(c, result, "获取我的订阅使用情况成功") usage, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取订阅使用情况失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, usage, "获取使用情况成功")
}
// GetProductDocumentation 获取产品文档
// @Summary 获取产品文档
// @Description 获取指定产品的文档信息
// @Tags 数据大厅
// @Accept json
// @Produce json
// @Param id path string true "产品ID"
// @Success 200 {object} responses.DocumentationResponse "获取文档成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "产品或文档不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/products/{id}/documentation [get]
func (h *ProductHandler) GetProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
doc, err := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if err != nil {
h.logger.Error("获取产品文档失败", zap.Error(err))
h.responseBuilder.NotFound(c, "文档不存在")
return
}
h.responseBuilder.Success(c, doc, "获取文档成功")
} }

View File

@@ -55,6 +55,11 @@ func (r *ProductAdminRoutes) Register(router *sharedhttp.GinRouter) {
products.POST("/:id/api-config", r.handler.CreateProductApiConfig) products.POST("/:id/api-config", r.handler.CreateProductApiConfig)
products.PUT("/:id/api-config", r.handler.UpdateProductApiConfig) products.PUT("/:id/api-config", r.handler.UpdateProductApiConfig)
products.DELETE("/:id/api-config", r.handler.DeleteProductApiConfig) products.DELETE("/:id/api-config", r.handler.DeleteProductApiConfig)
// 文档管理
products.GET("/:id/documentation", r.handler.GetProductDocumentation)
products.POST("/:id/documentation", r.handler.CreateOrUpdateProductDocumentation)
products.DELETE("/:id/documentation", r.handler.DeleteProductDocumentation)
} }
// 分类管理 // 分类管理

View File

@@ -50,6 +50,7 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
// 产品详情和API配置 - 使用具体路径避免冲突 // 产品详情和API配置 - 使用具体路径避免冲突
products.GET("/:id", r.productHandler.GetProductDetail) products.GET("/:id", r.productHandler.GetProductDetail)
products.GET("/:id/api-config", r.productHandler.GetProductApiConfig) products.GET("/:id/api-config", r.productHandler.GetProductApiConfig)
products.GET("/:id/documentation", r.productHandler.GetProductDocumentation)
// 订阅产品(需要认证) // 订阅产品(需要认证)
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct) products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)

View File

@@ -22,7 +22,7 @@ type GormCachePlugin struct {
cache interfaces.CacheService cache interfaces.CacheService
logger *zap.Logger logger *zap.Logger
config CacheConfig config CacheConfig
// 缓存失效去重机制 // 缓存失效去重机制
invalidationQueue map[string]*time.Timer invalidationQueue map[string]*time.Timer
queueMutex sync.RWMutex queueMutex sync.RWMutex
@@ -64,7 +64,7 @@ func DefaultCacheConfig() CacheConfig {
BloomFilter: false, BloomFilter: false,
AutoInvalidate: true, AutoInvalidate: true,
// 增加延迟失效时间,减少频繁的缓存失效操作 // 增加延迟失效时间,减少频繁的缓存失效操作
InvalidateDelay: 500 * time.Millisecond, InvalidateDelay: 500 * time.Millisecond,
} }
} }
@@ -76,9 +76,9 @@ func NewGormCachePlugin(cache interfaces.CacheService, logger *zap.Logger, confi
} }
return &GormCachePlugin{ return &GormCachePlugin{
cache: cache, cache: cache,
logger: logger, logger: logger,
config: cfg, config: cfg,
invalidationQueue: make(map[string]*time.Timer), invalidationQueue: make(map[string]*time.Timer),
} }
} }
@@ -105,16 +105,16 @@ func (p *GormCachePlugin) Initialize(db *gorm.DB) error {
func (p *GormCachePlugin) Shutdown() { func (p *GormCachePlugin) Shutdown() {
p.queueMutex.Lock() p.queueMutex.Lock()
defer p.queueMutex.Unlock() defer p.queueMutex.Unlock()
// 停止所有定时器 // 停止所有定时器
for table, timer := range p.invalidationQueue { for table, timer := range p.invalidationQueue {
timer.Stop() timer.Stop()
p.logger.Debug("停止缓存失效定时器", zap.String("table", table)) p.logger.Debug("停止缓存失效定时器", zap.String("table", table))
} }
// 清空队列 // 清空队列
p.invalidationQueue = make(map[string]*time.Timer) p.invalidationQueue = make(map[string]*time.Timer)
p.logger.Info("GORM缓存插件已关闭") p.logger.Info("GORM缓存插件已关闭")
} }
@@ -174,13 +174,13 @@ func (p *GormCachePlugin) beforeQuery(db *gorm.DB) {
} }
return return
} else { } else {
p.logger.Warn("缓存数据恢复失败,将执行数据库查询", p.logger.Warn("缓存数据恢复失败,将执行数据库查询",
zap.String("cache_key", cacheKey), zap.String("cache_key", cacheKey),
zap.Error(err)) zap.Error(err))
} }
} else { } else {
p.logger.Debug("缓存未命中", p.logger.Debug("缓存未命中",
zap.String("cache_key", cacheKey), zap.String("cache_key", cacheKey),
zap.Error(err)) zap.Error(err))
} }
@@ -421,7 +421,7 @@ func (p *GormCachePlugin) saveToCache(ctx context.Context, cacheKey string, db *
// 修复:改进缓存数据保存逻辑 // 修复:改进缓存数据保存逻辑
var dataToCache interface{} var dataToCache interface{}
// 如果dest是切片需要特殊处理 // 如果dest是切片需要特殊处理
destValue := reflect.ValueOf(dest) destValue := reflect.ValueOf(dest)
if destValue.Kind() == reflect.Ptr && destValue.Elem().Kind() == reflect.Slice { if destValue.Kind() == reflect.Ptr && destValue.Elem().Kind() == reflect.Slice {
@@ -474,7 +474,7 @@ func (p *GormCachePlugin) restoreFromCache(db *gorm.DB, cachedResult *CachedResu
// 修复:改进缓存数据恢复逻辑 // 修复:改进缓存数据恢复逻辑
cachedValue := reflect.ValueOf(cachedResult.Data) cachedValue := reflect.ValueOf(cachedResult.Data)
// 如果类型完全匹配,直接赋值 // 如果类型完全匹配,直接赋值
if cachedValue.Type().AssignableTo(destValue.Elem().Type()) { if cachedValue.Type().AssignableTo(destValue.Elem().Type()) {
destValue.Elem().Set(cachedValue) destValue.Elem().Set(cachedValue)
@@ -487,8 +487,8 @@ func (p *GormCachePlugin) restoreFromCache(db *gorm.DB, cachedResult *CachedResu
} }
if err := json.Unmarshal(jsonData, db.Statement.Dest); err != nil { if err := json.Unmarshal(jsonData, db.Statement.Dest); err != nil {
p.logger.Error("反序列化缓存数据失败", p.logger.Error("反序列化缓存数据失败",
zap.String("json_data", string(jsonData)), zap.String("json_data", string(jsonData)),
zap.Error(err)) zap.Error(err))
return fmt.Errorf("JSON反序列化失败: %w", err) return fmt.Errorf("JSON反序列化失败: %w", err)
} }
@@ -497,7 +497,7 @@ func (p *GormCachePlugin) restoreFromCache(db *gorm.DB, cachedResult *CachedResu
// 设置影响行数 // 设置影响行数
db.Statement.RowsAffected = cachedResult.RowCount db.Statement.RowsAffected = cachedResult.RowCount
p.logger.Debug("从缓存恢复数据成功", p.logger.Debug("从缓存恢复数据成功",
zap.Int64("rows", cachedResult.RowCount), zap.Int64("rows", cachedResult.RowCount),
zap.Time("timestamp", cachedResult.Timestamp)) zap.Time("timestamp", cachedResult.Timestamp))
@@ -521,34 +521,34 @@ func (p *GormCachePlugin) invalidateTableCache(ctx context.Context, table string
// 使用去重机制,避免重复的缓存失效操作 // 使用去重机制,避免重复的缓存失效操作
p.queueMutex.Lock() p.queueMutex.Lock()
defer p.queueMutex.Unlock() defer p.queueMutex.Unlock()
// 如果已经有相同的失效操作在队列中,取消之前的定时器 // 如果已经有相同的失效操作在队列中,取消之前的定时器
if timer, exists := p.invalidationQueue[table]; exists { if timer, exists := p.invalidationQueue[table]; exists {
timer.Stop() timer.Stop()
delete(p.invalidationQueue, table) delete(p.invalidationQueue, table)
} }
// 创建独立的上下文,避免受到原始请求上下文的影响 // 创建独立的上下文,避免受到原始请求上下文的影响
// 设置合理的超时时间,避免缓存失效操作阻塞 // 设置合理的超时时间,避免缓存失效操作阻塞
cacheCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) cacheCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// 创建新的定时器 // 创建新的定时器
timer := time.AfterFunc(p.config.InvalidateDelay, func() { timer := time.AfterFunc(p.config.InvalidateDelay, func() {
// 执行缓存失效 // 执行缓存失效
p.doInvalidateTableCache(cacheCtx, table) p.doInvalidateTableCache(cacheCtx, table)
// 清理定时器引用 // 清理定时器引用
p.queueMutex.Lock() p.queueMutex.Lock()
delete(p.invalidationQueue, table) delete(p.invalidationQueue, table)
p.queueMutex.Unlock() p.queueMutex.Unlock()
// 取消上下文 // 取消上下文
cancel() cancel()
}) })
// 将定时器加入队列 // 将定时器加入队列
p.invalidationQueue[table] = timer p.invalidationQueue[table] = timer
p.logger.Debug("缓存失效操作已加入队列", p.logger.Debug("缓存失效操作已加入队列",
zap.String("table", table), zap.String("table", table),
zap.Duration("delay", p.config.InvalidateDelay), zap.Duration("delay", p.config.InvalidateDelay),
@@ -574,7 +574,7 @@ func (p *GormCachePlugin) doInvalidateTableCache(ctx context.Context, table stri
time.Sleep(time.Duration(attempt) * 100 * time.Millisecond) time.Sleep(time.Duration(attempt) * 100 * time.Millisecond)
continue continue
} }
p.logger.Warn("失效表缓存失败", p.logger.Warn("失效表缓存失败",
zap.String("table", table), zap.String("table", table),
zap.String("pattern", pattern), zap.String("pattern", pattern),
@@ -583,7 +583,7 @@ func (p *GormCachePlugin) doInvalidateTableCache(ctx context.Context, table stri
) )
return return
} }
// 成功删除,记录日志并退出 // 成功删除,记录日志并退出
p.logger.Debug("表缓存已失效", p.logger.Debug("表缓存已失效",
zap.String("table", table), zap.String("table", table),

View File

@@ -35,9 +35,9 @@ func (r *CachedBaseRepositoryImpl) isTableCacheEnabled() bool {
if cache.GlobalCacheConfigManager != nil { if cache.GlobalCacheConfigManager != nil {
return cache.GlobalCacheConfigManager.IsTableCacheEnabled(r.tableName) return cache.GlobalCacheConfigManager.IsTableCacheEnabled(r.tableName)
} }
// 如果全局管理器未初始化,默认启用缓存 // 如果全局管理器未初始化,默认启用缓存
r.logger.Warn("全局缓存配置管理器未初始化,默认启用缓存", r.logger.Warn("全局缓存配置管理器未初始化,默认启用缓存",
zap.String("table", r.tableName)) zap.String("table", r.tableName))
return true return true
} }
@@ -46,7 +46,7 @@ func (r *CachedBaseRepositoryImpl) isTableCacheEnabled() bool {
func (r *CachedBaseRepositoryImpl) shouldUseCacheForTable() bool { func (r *CachedBaseRepositoryImpl) shouldUseCacheForTable() bool {
// 检查表是否启用缓存 // 检查表是否启用缓存
if !r.isTableCacheEnabled() { if !r.isTableCacheEnabled() {
r.logger.Debug("表未启用缓存,跳过缓存操作", r.logger.Debug("表未启用缓存,跳过缓存操作",
zap.String("table", r.tableName)) zap.String("table", r.tableName))
return false return false
} }
@@ -58,17 +58,17 @@ func (r *CachedBaseRepositoryImpl) shouldUseCacheForTable() bool {
// GetWithCache 带缓存的单条查询(智能决策) // GetWithCache 带缓存的单条查询(智能决策)
func (r *CachedBaseRepositoryImpl) GetWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error { func (r *CachedBaseRepositoryImpl) GetWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error {
db := r.GetDB(ctx) db := r.GetDB(ctx)
// 智能决策:根据表配置决定是否使用缓存 // 智能决策:根据表配置决定是否使用缓存
if r.shouldUseCacheForTable() { if r.shouldUseCacheForTable() {
db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) db = db.Set("cache:enabled", true).Set("cache:ttl", ttl)
r.logger.Debug("执行带缓存查询", r.logger.Debug("执行带缓存查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.Duration("ttl", ttl), zap.Duration("ttl", ttl),
zap.String("where", where)) zap.String("where", where))
} else { } else {
db = db.Set("cache:disabled", true) db = db.Set("cache:disabled", true)
r.logger.Debug("执行无缓存查询", r.logger.Debug("执行无缓存查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.String("where", where)) zap.String("where", where))
} }
@@ -79,17 +79,17 @@ func (r *CachedBaseRepositoryImpl) GetWithCache(ctx context.Context, dest interf
// FindWithCache 带缓存的多条查询(智能决策) // FindWithCache 带缓存的多条查询(智能决策)
func (r *CachedBaseRepositoryImpl) FindWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error { func (r *CachedBaseRepositoryImpl) FindWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error {
db := r.GetDB(ctx) db := r.GetDB(ctx)
// 智能决策:根据表配置决定是否使用缓存 // 智能决策:根据表配置决定是否使用缓存
if r.shouldUseCacheForTable() { if r.shouldUseCacheForTable() {
db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) db = db.Set("cache:enabled", true).Set("cache:ttl", ttl)
r.logger.Debug("执行带缓存批量查询", r.logger.Debug("执行带缓存批量查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.Duration("ttl", ttl), zap.Duration("ttl", ttl),
zap.String("where", where)) zap.String("where", where))
} else { } else {
db = db.Set("cache:disabled", true) db = db.Set("cache:disabled", true)
r.logger.Debug("执行无缓存批量查询", r.logger.Debug("执行无缓存批量查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.String("where", where)) zap.String("where", where))
} }
@@ -100,17 +100,17 @@ func (r *CachedBaseRepositoryImpl) FindWithCache(ctx context.Context, dest inter
// CountWithCache 带缓存的计数查询(智能决策) // CountWithCache 带缓存的计数查询(智能决策)
func (r *CachedBaseRepositoryImpl) CountWithCache(ctx context.Context, count *int64, ttl time.Duration, entity interface{}, where string, args ...interface{}) error { func (r *CachedBaseRepositoryImpl) CountWithCache(ctx context.Context, count *int64, ttl time.Duration, entity interface{}, where string, args ...interface{}) error {
db := r.GetDB(ctx).Model(entity) db := r.GetDB(ctx).Model(entity)
// 智能决策:根据表配置决定是否使用缓存 // 智能决策:根据表配置决定是否使用缓存
if r.shouldUseCacheForTable() { if r.shouldUseCacheForTable() {
db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) db = db.Set("cache:enabled", true).Set("cache:ttl", ttl)
r.logger.Debug("执行带缓存计数查询", r.logger.Debug("执行带缓存计数查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.Duration("ttl", ttl), zap.Duration("ttl", ttl),
zap.String("where", where)) zap.String("where", where))
} else { } else {
db = db.Set("cache:disabled", true) db = db.Set("cache:disabled", true)
r.logger.Debug("执行无缓存计数查询", r.logger.Debug("执行无缓存计数查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.String("where", where)) zap.String("where", where))
} }
@@ -121,16 +121,16 @@ func (r *CachedBaseRepositoryImpl) CountWithCache(ctx context.Context, count *in
// ListWithCache 带缓存的列表查询(智能决策) // ListWithCache 带缓存的列表查询(智能决策)
func (r *CachedBaseRepositoryImpl) ListWithCache(ctx context.Context, dest interface{}, ttl time.Duration, options CacheListOptions) error { func (r *CachedBaseRepositoryImpl) ListWithCache(ctx context.Context, dest interface{}, ttl time.Duration, options CacheListOptions) error {
db := r.GetDB(ctx) db := r.GetDB(ctx)
// 智能决策:根据表配置决定是否使用缓存 // 智能决策:根据表配置决定是否使用缓存
if r.shouldUseCacheForTable() { if r.shouldUseCacheForTable() {
db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) db = db.Set("cache:enabled", true).Set("cache:ttl", ttl)
r.logger.Debug("执行带缓存列表查询", r.logger.Debug("执行带缓存列表查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.Duration("ttl", ttl)) zap.Duration("ttl", ttl))
} else { } else {
db = db.Set("cache:disabled", true) db = db.Set("cache:disabled", true)
r.logger.Debug("执行无缓存列表查询", r.logger.Debug("执行无缓存列表查询",
zap.String("table", r.tableName)) zap.String("table", r.tableName))
} }
@@ -214,10 +214,10 @@ func (r *CachedBaseRepositoryImpl) WithLongCache() *CachedBaseRepositoryImpl {
// SmartGetByID 智能ID查询自动缓存 // SmartGetByID 智能ID查询自动缓存
func (r *CachedBaseRepositoryImpl) SmartGetByID(ctx context.Context, id string, dest interface{}) error { func (r *CachedBaseRepositoryImpl) SmartGetByID(ctx context.Context, id string, dest interface{}) error {
r.logger.Debug("执行智能ID查询", r.logger.Debug("执行智能ID查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.String("id", id)) zap.String("id", id))
return r.GetWithCache(ctx, dest, 30*time.Minute, "id = ?", id) return r.GetWithCache(ctx, dest, 30*time.Minute, "id = ?", id)
} }
@@ -237,7 +237,7 @@ func (r *CachedBaseRepositoryImpl) SmartList(ctx context.Context, dest interface
cacheTTL := r.calculateCacheTTL(options) cacheTTL := r.calculateCacheTTL(options)
useCache := r.shouldUseCache(options) useCache := r.shouldUseCache(options)
r.logger.Debug("执行智能列表查询", r.logger.Debug("执行智能列表查询",
zap.String("table", r.tableName), zap.String("table", r.tableName),
zap.Bool("use_cache", useCache), zap.Bool("use_cache", useCache),
zap.Duration("cache_ttl", cacheTTL)) zap.Duration("cache_ttl", cacheTTL))