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"
secret_key: "your-baidu-secret-key"
# 充值配置
recharge:
min_amount: "100.00" # 生产环境最低充值金额
max_amount: "100000.00" # 单次最高充值金额
ratelimit:
requests: 5000
window: 60s
@@ -161,6 +156,16 @@ esign:
# ===========================================
wallet:
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,13 +195,6 @@ alipay:
notify_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/callback"
return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return"
# ===========================================
# 🌐 域名配置
# ===========================================
domain:
api: "" # 开发环境不限制域名,生产环境为 "api.tianyuancha.com"
# ===========================================
# 🔍 天眼查配置
# ===========================================

View File

@@ -92,11 +92,20 @@ alipay:
return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return"
# ===========================================
# 💰 充值配置
# 💰 钱包配置
# ===========================================
recharge:
min_amount: "0.01" # 开发环境最低充值金额
wallet:
default_credit_limit: 0.01
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:
secret: JwT8xR4mN9vP2sL7kH3oB6yC1zA5uF0qE9tW
api:
domain: "apitest.tianyuanapi.com"
# ===========================================
# 📁 存储服务配置 - 七牛云
# ===========================================
@@ -155,8 +158,18 @@ alipay:
return_url: "https://consoletest.tianyuanapi.com/api/v1/finance/alipay/return"
# ===========================================
# 💰 充值配置
# 💰 钱包配置
# ===========================================
recharge:
min_amount: "0.01" # 开发环境最低充值金额
wallet:
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元

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
// 8. 更新订阅使用次数
subscription.IncrementAPIUsage(1)
err = s.productSubscriptionService.SaveSubscription(txCtx, subscription)
if err != nil {
s.logger.Error("保存订阅失败", zap.Error(err))
businessError = ErrSystem
return ErrSystem
}
// 8. 更新订阅使用次数(使用乐观锁)
// err = s.productSubscriptionService.IncrementSubscriptionAPIUsage(txCtx, subscription.ID, 1)
// if err != nil {
// s.logger.Error("更新订阅使用次数失败", zap.Error(err))
// businessError = ErrSystem
// return ErrSystem
// }
// 9. 扣钱
err = s.walletService.Deduct(txCtx, apiUser.UserId, subscription.Price, apiCall.ID, transactionId, product.ID)

View File

@@ -104,4 +104,11 @@ type AlipayRechargeOrderResponse struct {
type RechargeConfigResponse struct {
MinAmount string `json:"min_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 {
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)
}
maxAmount, err := decimal.NewFromString(s.config.Recharge.MaxAmount)
maxAmount, err := decimal.NewFromString(s.config.Wallet.MaxAmount)
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)
}
@@ -643,8 +643,16 @@ func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Cont
// GetRechargeConfig 获取充值配置
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{
MinAmount: s.config.Recharge.MinAmount,
MaxAmount: s.config.Recharge.MaxAmount,
MinAmount: s.config.Wallet.MinAmount,
MaxAmount: s.config.Wallet.MaxAmount,
AlipayRechargeBonus: bonus,
}, 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:"产品编号"`
}
// 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列表获取产品查询
type GetProductsByIDsQuery struct {
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:"组合包项目列表"`
// 文档信息
Documentation *DocumentationResponse `json:"documentation,omitempty" comment:"产品文档"`
CreatedAt time.Time `json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// ProductInfoWithDocumentResponse 包含文档的产品详情响应
type ProductInfoWithDocumentResponse struct {
ProductInfoResponse
Documentation *DocumentationResponse `json:"documentation,omitempty" comment:"产品文档"`
}
// ProductAdminListResponse 管理员产品列表响应
type ProductAdminListResponse struct {
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)
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)

View File

@@ -21,6 +21,7 @@ type ProductApplicationServiceImpl struct {
productManagementService *product_service.ProductManagementService
productSubscriptionService *product_service.ProductSubscriptionService
productApiConfigAppService ProductApiConfigApplicationService
documentationAppService DocumentationApplicationServiceInterface
logger *zap.Logger
}
@@ -29,12 +30,14 @@ func NewProductApplicationService(
productManagementService *product_service.ProductManagementService,
productSubscriptionService *product_service.ProductSubscriptionService,
productApiConfigAppService ProductApiConfigApplicationService,
documentationAppService DocumentationApplicationServiceInterface,
logger *zap.Logger,
) ProductApplicationService {
return &ProductApplicationServiceImpl{
productManagementService: productManagementService,
productSubscriptionService: productSubscriptionService,
productApiConfigAppService: productApiConfigAppService,
documentationAppService: documentationAppService,
logger: logger,
}
}
@@ -411,18 +414,28 @@ func (s *ProductApplicationServiceImpl) ListProductsForAdmin(ctx context.Context
// GetProductByIDForAdmin 根据ID获取产品管理员专用
// 业务流程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)
if err != nil {
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获取产品用户端专用
// 业务流程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)
if err != nil {
return nil, err
@@ -433,7 +446,19 @@ func (s *ProductApplicationServiceImpl) GetProductByIDForUser(ctx context.Contex
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 转换为产品信息响应

View File

@@ -27,10 +27,8 @@ type Config struct {
Wallet WalletConfig `mapstructure:"wallet"`
WestDex WestDexConfig `mapstructure:"westdex"`
AliPay AliPayConfig `mapstructure:"alipay"`
Recharge RechargeConfig `mapstructure:"recharge"`
Yushan YushanConfig `mapstructure:"yushan"`
TianYanCha TianYanChaConfig `mapstructure:"tianyancha"`
Domain DomainConfig `mapstructure:"domain"`
}
// ServerConfig HTTP服务器配置
@@ -276,6 +274,15 @@ type SignConfig struct {
// WalletConfig 钱包配置
type WalletConfig struct {
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配置
@@ -296,12 +303,6 @@ type AliPayConfig struct {
ReturnURL string `mapstructure:"return_url"`
}
// RechargeConfig 充值配置
type RechargeConfig struct {
MinAmount string `mapstructure:"min_amount"` // 最低充值金额
MaxAmount string `mapstructure:"max_amount"` // 最高充值金额
}
// YushanConfig 羽山配置
type YushanConfig struct {
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缓存插件
func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *config.Config, logger *zap.Logger) error {
// 缓存功能已完全禁用
logger.Info("GORM缓存插件已禁用 - 所有查询将直接访问数据库")
return nil
// 以下是原有的缓存配置代码,已注释掉
/*
// 创建缓存配置
cacheConfig := cache.CacheConfig{
DefaultTTL: 30 * time.Minute,
@@ -29,16 +35,16 @@ func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *conf
// 配置启用缓存的表
EnabledTables: []string{
"product",
"product_category",
"enterprise_info_submit_records",
"sms_codes",
"wallets",
"subscription",
"product_category",
"product_documentation",
"enterprise_infos",
"api_users",
// "product",
// "product_category",
// "enterprise_info_submit_records",
// "sms_codes",
// "wallets",
// "subscription",
// "product_category",
// "product_documentation",
// "enterprise_infos",
// "api_users",
// 添加更多需要缓存的表
},
@@ -68,8 +74,9 @@ func SetupGormCache(db *gorm.DB, cacheService interfaces.CacheService, cfg *conf
zap.Strings("enabled_tables", cacheConfig.EnabledTables),
zap.Strings("disabled_tables", cacheConfig.DisabledTables),
)
*/
return nil
// return nil
}
// GetCacheConfig 根据环境获取缓存配置
@@ -89,10 +96,10 @@ func GetCacheConfig(cfg *config.Config) cache.CacheConfig {
InvalidateDelay: 50 * time.Millisecond,
EnabledTables: []string{
"product",
"product_category",
"enterprise_info_submit_records",
"sms_codes",
// "product",
// "product_category",
// "enterprise_info_submit_records",
// "sms_codes",
},
DisabledTables: []string{
@@ -120,10 +127,10 @@ func GetCacheConfig(cfg *config.Config) cache.CacheConfig {
InvalidateDelay: 200 * time.Millisecond,
EnabledTables: []string{
"product",
"product_category",
"enterprise_info_submit_records",
"sms_codes",
// "product",
// "product_category",
// "enterprise_info_submit_records",
// "sms_codes",
},
DisabledTables: []string{

View File

@@ -445,6 +445,10 @@ func NewContainer() *Container {
product_repo.NewGormProductApiConfigRepository,
fx.As(new(domain_product_repo.ProductApiConfigRepository)),
),
fx.Annotate(
product_repo.NewGormProductDocumentationRepository,
fx.As(new(domain_product_repo.ProductDocumentationRepository)),
),
),
// API域仓储层
@@ -468,6 +472,7 @@ func NewContainer() *Container {
product_service.NewProductManagementService,
product_service.NewProductSubscriptionService,
product_service.NewProductApiConfigService,
product_service.NewProductDocumentationService,
finance_service.NewWalletAggregateService,
finance_service.NewRechargeRecordService,
certification_service.NewCertificationAggregateService,
@@ -518,6 +523,10 @@ func NewContainer() *Container {
product.NewCategoryApplicationService,
fx.As(new(product.CategoryApplicationService)),
),
fx.Annotate(
product.NewDocumentationApplicationService,
fx.As(new(product.DocumentationApplicationServiceInterface)),
),
// 订阅应用服务 - 绑定到接口
fx.Annotate(
product.NewSubscriptionApplicationService,

View File

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

View File

@@ -7,12 +7,31 @@ import (
"github.com/shopspring/decimal"
"go.uber.org/zap"
"tyapi-server/internal/config"
"tyapi-server/internal/domains/finance/entities"
"tyapi-server/internal/domains/finance/repositories"
"tyapi-server/internal/shared/database"
"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 充值记录服务接口
type RechargeRecordService interface {
// 对公转账充值
@@ -47,6 +66,7 @@ type RechargeRecordServiceImpl struct {
walletService WalletAggregateService
txManager *database.TransactionManager
logger *zap.Logger
cfg *config.Config
}
func NewRechargeRecordService(
@@ -56,6 +76,7 @@ func NewRechargeRecordService(
walletService WalletAggregateService,
txManager *database.TransactionManager,
logger *zap.Logger,
cfg *config.Config,
) RechargeRecordService {
return &RechargeRecordServiceImpl{
rechargeRecordRepo: rechargeRecordRepo,
@@ -64,6 +85,7 @@ func NewRechargeRecordService(
walletService: walletService,
txManager: txManager,
logger: logger,
cfg: cfg,
}
}
@@ -273,6 +295,10 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
return nil
}
// 计算充值赠送金额
bonusAmount := calculateAlipayRechargeBonus(amount, &s.cfg.Wallet)
totalAmount := amount.Add(bonusAmount)
// 在事务中执行所有更新操作
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
// 更新支付宝订单状态为成功
@@ -291,8 +317,22 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
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 {
s.logger.Error("更新钱包余额失败", zap.String("user_id", rechargeRecord.UserID), zap.Error(err))
return err
@@ -307,7 +347,9 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
s.logger.Info("支付宝支付成功回调处理成功",
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("trade_no", tradeNo),
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

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

View File

@@ -1,6 +1,9 @@
package entities
import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -11,14 +14,14 @@ import (
type ProductDocumentation struct {
ID string `gorm:"primaryKey;type:varchar(36)" comment:"文档ID"`
ProductID string `gorm:"type:varchar(36);not null;uniqueIndex" comment:"产品ID"`
Title string `gorm:"type:varchar(200);not null" comment:"文档标题"`
Content string `gorm:"type:text;not null" comment:"文档内容"`
UsageGuide string `gorm:"type:text" comment:"使用指南"`
APIDocs string `gorm:"type:text" comment:"API文档"`
Examples string `gorm:"type:text" comment:"使用示例"`
FAQ string `gorm:"type:text" comment:"常见问题"`
RequestURL string `gorm:"type:varchar(500);not null" comment:"请求链接"`
RequestMethod string `gorm:"type:varchar(20);not null" comment:"请求方法"`
BasicInfo string `gorm:"type:text" comment:"基础说明(请求头配置、参数加密等)"`
RequestParams string `gorm:"type:text" comment:"请求参数"`
ResponseFields string `gorm:"type:text" comment:"返回字段说明"`
ResponseExample string `gorm:"type:text" comment:"响应示例"`
ErrorCodes string `gorm:"type:text" comment:"错误代码"`
Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"`
Published bool `gorm:"default:false" comment:"是否已发布"`
// 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
@@ -41,29 +44,16 @@ func (pd *ProductDocumentation) IsValid() bool {
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 更新文档内容
func (pd *ProductDocumentation) UpdateContent(title, content, usageGuide, apiDocs, examples, faq string) {
pd.Title = title
pd.Content = content
pd.UsageGuide = usageGuide
pd.APIDocs = apiDocs
pd.Examples = examples
pd.FAQ = faq
func (pd *ProductDocumentation) UpdateContent(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes string) {
pd.RequestURL = requestURL
pd.RequestMethod = requestMethod
pd.BasicInfo = basicInfo
pd.RequestParams = requestParams
pd.ResponseFields = responseFields
pd.ResponseExample = responseExample
pd.ErrorCodes = errorCodes
}
// IncrementVersion 增加版本号
@@ -76,3 +66,156 @@ func (pd *ProductDocumentation) IncrementVersion() {
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"`
Price decimal.Decimal `gorm:"type:decimal(10,2);not null" comment:"订阅价格"`
APIUsed int64 `gorm:"default:0" comment:"已使用API调用次数"`
Version int64 `gorm:"default:1" comment:"乐观锁版本号"`
// 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
@@ -40,9 +41,11 @@ func (s *Subscription) IsValid() bool {
// IncrementAPIUsage 增加API使用次数
func (s *Subscription) IncrementAPIUsage(count int64) {
s.APIUsed += count
s.Version++ // 增加版本号
}
// ResetAPIUsage 重置API使用次数
func (s *Subscription) ResetAPIUsage() {
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)
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"
"errors"
"fmt"
"time"
"gorm.io/gorm"
@@ -191,3 +192,57 @@ func (s *ProductSubscriptionService) SaveSubscription(ctx context.Context, subsc
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
}
// 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

@@ -17,6 +17,7 @@ type ProductAdminHandler struct {
productAppService product.ProductApplicationService
categoryAppService product.CategoryApplicationService
subscriptionAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
@@ -27,6 +28,7 @@ func NewProductAdminHandler(
productAppService product.ProductApplicationService,
categoryAppService product.CategoryApplicationService,
subscriptionAppService product.SubscriptionApplicationService,
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
@@ -35,6 +37,7 @@ func NewProductAdminHandler(
productAppService: productAppService,
categoryAppService: categoryAppService,
subscriptionAppService: subscriptionAppService,
documentationAppService: documentationAppService,
responseBuilder: responseBuilder,
validator: validator,
logger: logger,
@@ -357,14 +360,15 @@ func (h *ProductAdminHandler) getIntQuery(c *gin.Context, key string, defaultVal
return defaultValue
}
// GetProductDetail 获取产品详情(管理员)
// GetProductDetail 获取产品详情
// @Summary 获取产品详情
// @Description 管理员获取产品详细信息,包含可见状态
// @Description 管理员获取产品详细信息
// @Tags 产品管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "产品ID"
// @Param with_document query bool false "是否包含文档信息"
// @Success 200 {object} responses.ProductAdminInfoResponse "获取产品详情成功"
// @Failure 400 {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{} "服务器内部错误"
// @Router /api/v1/admin/products/{id} [get]
func (h *ProductAdminHandler) GetProductDetail(c *gin.Context) {
var query queries.GetProductQuery
var query queries.GetProductDetailQuery
query.ID = c.Param("id")
if query.ID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
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)
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, "产品不存在")
return
}
@@ -893,7 +902,7 @@ func (h *ProductAdminHandler) UpdateProductApiConfig(c *gin.Context) {
// @Success 200 {object} map[string]interface{} "API配置删除成功"
// @Failure 400 {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{} "服务器内部错误"
// @Router /api/v1/admin/products/{id}/api-config [delete]
func (h *ProductAdminHandler) DeleteProductApiConfig(c *gin.Context) {
@@ -903,19 +912,144 @@ func (h *ProductAdminHandler) DeleteProductApiConfig(c *gin.Context) {
return
}
// 先获取现有配置以获取配置ID
existingConfig, err := h.productAppService.GetProductApiConfig(c.Request.Context(), productID)
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))
if err := h.productAppService.DeleteProductApiConfig(c.Request.Context(), productID); err != nil {
h.logger.Error("删除产品API配置失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
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
categoryService product.CategoryApplicationService
subAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
@@ -28,6 +29,7 @@ func NewProductHandler(
apiConfigService product.ProductApiConfigApplicationService,
categoryService product.CategoryApplicationService,
subAppService product.SubscriptionApplicationService,
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
@@ -37,6 +39,7 @@ func NewProductHandler(
apiConfigService: apiConfigService,
categoryService: categoryService,
subAppService: subAppService,
documentationAppService: documentationAppService,
responseBuilder: responseBuilder,
validator: validator,
logger: logger,
@@ -171,30 +174,36 @@ func (h *ProductHandler) getCurrentUserID(c *gin.Context) string {
// GetProductDetail 获取产品详情
// @Summary 获取产品详情
// @Description 根据产品ID获取产品详细信息,只能获取可见的产品
// @Description 获取产品详细信息,用户端只能查看可见的产品
// @Tags 数据大厅
// @Accept json
// @Produce json
// @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 404 {object} map[string]interface{} "产品不存在"
// @Failure 404 {object} map[string]interface{} "产品不存在或不可见"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/products/{id} [get]
func (h *ProductHandler) GetProductDetail(c *gin.Context) {
var query queries.GetProductQuery
var query queries.GetProductDetailQuery
query.ID = c.Param("id")
if query.ID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
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)
if err != nil {
h.logger.Error("获取产品详情失败", zap.Error(err), zap.String("product_id", query.ID))
h.responseBuilder.NotFound(c, "产品不存在")
h.logger.Error("获取产品详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "产品不存在或不可见")
return
}
@@ -523,31 +532,61 @@ func (h *ProductHandler) GetMySubscriptionDetail(c *gin.Context) {
// @Produce json
// @Security Bearer
// @Param id path string true "订阅ID"
// @Success 200 {object} responses.SubscriptionUsageResponse "获取使用情况成功"
// @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/my/subscriptions/{id}/usage [get]
func (h *ProductHandler) GetMySubscriptionUsage(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
subscriptionID := c.Param("id")
if subscriptionID == "" {
h.responseBuilder.BadRequest(c, "订阅ID不能为空")
return
}
result, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取我的订阅使用情况失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
h.responseBuilder.NotFound(c, "订阅不存在")
// 获取当前用户ID
userID := h.getCurrentUserID(c)
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未认证")
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.PUT("/:id/api-config", r.handler.UpdateProductApiConfig)
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配置 - 使用具体路径避免冲突
products.GET("/:id", r.productHandler.GetProductDetail)
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)