diff --git a/config.yaml b/config.yaml index 1e620cc..17d3ce5 100644 --- a/config.yaml +++ b/config.yaml @@ -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,16 +195,9 @@ 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" - - # =========================================== # 🔍 天眼查配置 # =========================================== tianyancha: base_url: http://open.api.tianyancha.com/services - api_key: e6a43dc9-786e-4a16-bb12-392b8201d8e2 \ No newline at end of file + api_key: e6a43dc9-786e-4a16-bb12-392b8201d8e2 diff --git a/configs/env.development.yaml b/configs/env.development.yaml index 255d019..980736d 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -92,11 +92,20 @@ alipay: return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return" # =========================================== -# 💰 充值配置 +# 💰 钱包配置 # =========================================== -recharge: - min_amount: "0.01" # 开发环境最低充值金额 - max_amount: "100000.00" # 单次最高充值金额 +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元 # =========================================== # 🔍 天眼查配置 diff --git a/configs/env.production.yaml b/configs/env.production.yaml index 9072b3d..021ee4a 100644 --- a/configs/env.production.yaml +++ b/configs/env.production.yaml @@ -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元 + diff --git a/docs/支付宝充值赠送功能说明.md b/docs/支付宝充值赠送功能说明.md new file mode 100644 index 0000000..bb9ad54 --- /dev/null +++ b/docs/支付宝充值赠送功能说明.md @@ -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. **配置热更新**:修改配置后需要重启服务才能生效 \ No newline at end of file diff --git a/internal/application/api/api_application_service.go b/internal/application/api/api_application_service.go index 510fb2d..201b6ef 100644 --- a/internal/application/api/api_application_service.go +++ b/internal/application/api/api_application_service.go @@ -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) diff --git a/internal/application/finance/dto/responses/finance_responses.go b/internal/application/finance/dto/responses/finance_responses.go index a1b6dda..4864b08 100644 --- a/internal/application/finance/dto/responses/finance_responses.go +++ b/internal/application/finance/dto/responses/finance_responses.go @@ -102,6 +102,13 @@ type AlipayRechargeOrderResponse struct { // RechargeConfigResponse 充值配置响应 type RechargeConfigResponse struct { - MinAmount string `json:"min_amount"` // 最低充值金额 - MaxAmount string `json:"max_amount"` // 最高充值金额 + 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"` } diff --git a/internal/application/finance/finance_application_service_impl.go b/internal/application/finance/finance_application_service_impl.go index 9562f81..d3854f6 100644 --- a/internal/application/finance/finance_application_service_impl.go +++ b/internal/application/finance/finance_application_service_impl.go @@ -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 } diff --git a/internal/application/product/documentation_application_service.go b/internal/application/product/documentation_application_service.go new file mode 100644 index 0000000..50385c0 --- /dev/null +++ b/internal/application/product/documentation_application_service.go @@ -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 +} diff --git a/internal/application/product/dto/commands/documentation_commands.go b/internal/application/product/dto/commands/documentation_commands.go new file mode 100644 index 0000000..d70f90b --- /dev/null +++ b/internal/application/product/dto/commands/documentation_commands.go @@ -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"` +} diff --git a/internal/application/product/dto/queries/product_queries.go b/internal/application/product/dto/queries/product_queries.go index cac46fe..24e7604 100644 --- a/internal/application/product/dto/queries/product_queries.go +++ b/internal/application/product/dto/queries/product_queries.go @@ -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列表"` diff --git a/internal/application/product/dto/responses/documentation_responses.go b/internal/application/product/dto/responses/documentation_responses.go new file mode 100644 index 0000000..cc9efd5 --- /dev/null +++ b/internal/application/product/dto/responses/documentation_responses.go @@ -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, + } +} diff --git a/internal/application/product/dto/responses/product_responses.go b/internal/application/product/dto/responses/product_responses.go index 6ec0edf..963fb2d 100644 --- a/internal/application/product/dto/responses/product_responses.go +++ b/internal/application/product/dto/responses/product_responses.go @@ -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:"总数"` diff --git a/internal/application/product/product_application_service.go b/internal/application/product/product_application_service.go index 0f5cac8..19085f7 100644 --- a/internal/application/product/product_application_service.go +++ b/internal/application/product/product_application_service.go @@ -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) diff --git a/internal/application/product/product_application_service_impl.go b/internal/application/product/product_application_service_impl.go index dc46bf2..4ebccdf 100644 --- a/internal/application/product/product_application_service_impl.go +++ b/internal/application/product/product_application_service_impl.go @@ -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 转换为产品信息响应 diff --git a/internal/config/config.go b/internal/config/config.go index 7fc04ab..6d89066 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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服务器配置 @@ -275,7 +273,16 @@ type SignConfig struct { // WalletConfig 钱包配置 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配置 @@ -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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..162e9a8 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/container/cache_setup.go b/internal/container/cache_setup.go index b58e320..0d33ad7 100644 --- a/internal/container/cache_setup.go +++ b/internal/container/cache_setup.go @@ -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{ diff --git a/internal/container/container.go b/internal/container/container.go index 4e63f21..3fae87c 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -77,12 +77,29 @@ func NewContainer() *Container { // 基础设施模块 fx.Provide( - // 日志器 - 提供自定义Logger和*zap.Logger - func(cfg *config.Config) (logger.Logger, error) { - if cfg.Logger.EnableLevelSeparation { - // 使用按级别分文件的日志器 - levelConfig := logger.LevelLoggerConfig{ - BaseConfig: logger.Config{ + // 日志器 - 提供自定义Logger和*zap.Logger + func(cfg *config.Config) (logger.Logger, error) { + if cfg.Logger.EnableLevelSeparation { + // 使用按级别分文件的日志器 + levelConfig := logger.LevelLoggerConfig{ + 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, Format: cfg.Logger.Format, Output: cfg.Logger.Output, @@ -92,44 +109,27 @@ func NewContainer() *Container { MaxAge: cfg.Logger.MaxAge, Compress: cfg.Logger.Compress, UseDaily: cfg.Logger.UseDaily, - }, - EnableLevelSeparation: true, - LevelConfigs: convertLevelConfigs(cfg.Logger.LevelConfigs), + } + return logger.NewLogger(logCfg) } - return logger.NewLevelLogger(levelConfig) - } else { - // 使用普通日志器 - logCfg := 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, + }, + // 提供普通的*zap.Logger(用于大多数场景) + func(log logger.Logger) *zap.Logger { + // 尝试转换为ZapLogger + if zapLogger, ok := log.(*logger.ZapLogger); ok { + return zapLogger.GetZapLogger() } - return logger.NewLogger(logCfg) - } - }, - // 提供普通的*zap.Logger(用于大多数场景) - func(log logger.Logger) *zap.Logger { - // 尝试转换为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 + // 尝试转换为LevelLogger + if levelLogger, ok := log.(*logger.LevelLogger); ok { + // 获取Info级别的日志器作为默认 + if infoLogger := levelLogger.GetLevelLogger(zapcore.InfoLevel); infoLogger != nil { + return infoLogger + } } - } - // 如果类型转换失败,创建一个默认的zap logger - defaultLogger, _ := zap.NewProduction() - return defaultLogger - }, + // 如果类型转换失败,创建一个默认的zap logger + defaultLogger, _ := zap.NewProduction() + return defaultLogger + }, // 数据库连接 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) if err != nil { - logger.Error("数据库连接失败", + logger.Error("数据库连接失败", zap.String("host", cfg.Database.Host), zap.String("port", cfg.Database.Port), zap.String("database", cfg.Database.Name), @@ -155,8 +155,8 @@ func NewContainer() *Container { zap.Error(err)) return nil, err } - - logger.Info("数据库连接成功", + + logger.Info("数据库连接成功", zap.String("host", cfg.Database.Host), zap.String("port", cfg.Database.Port), zap.String("database", cfg.Database.Name)) @@ -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, @@ -670,7 +679,7 @@ func RegisterRoutes( logger.Info("HTTP服务器启动成功", zap.String("addr", addr)) } }() - + logger.Info("路由注册完成,HTTP服务器启动中...") } @@ -691,7 +700,7 @@ func NewRequestBodyLoggerMiddlewareWrapper(logger *zap.Logger, cfg *config.Confi // convertLevelConfigs 转换级别配置 func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore.Level]logger.LevelFileConfig { result := make(map[zapcore.Level]logger.LevelFileConfig) - + levelMap := map[string]zapcore.Level{ "debug": zapcore.DebugLevel, "info": zapcore.InfoLevel, @@ -700,7 +709,7 @@ func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore. "fatal": zapcore.FatalLevel, "panic": zapcore.PanicLevel, } - + for levelStr, config := range configs { if level, exists := levelMap[levelStr]; exists { result[level] = logger.LevelFileConfig{ @@ -711,7 +720,7 @@ func convertLevelConfigs(configs map[string]config.LevelFileConfig) map[zapcore. } } } - + return result } diff --git a/internal/domains/api/services/processors/flxg/flxg0v3b_processor.go b/internal/domains/api/services/processors/flxg/flxg0v3b_processor.go index 53166a6..e5b95f3 100644 --- a/internal/domains/api/services/processors/flxg/flxg0v3b_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg0v3b_processor.go @@ -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,20 +31,27 @@ func ProcessFLXG0V3Bequest(ctx context.Context, params []byte, deps *processors. return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err) } - reqData := map[string]interface{}{ - "data": map[string]interface{}{ - "name": encryptedName, - "id_card": encryptedIDCard, - }, + // mock 1秒,不用真实请求 + // 模拟耗时 + select { + 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 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 nil, fmt.Errorf("%s: %w", processors.ErrSystem, err) } return respBytes, nil diff --git a/internal/domains/finance/services/recharge_record_service.go b/internal/domains/finance/services/recharge_record_service.go index a6caf09..b1961ba 100644 --- a/internal/domains/finance/services/recharge_record_service.go +++ b/internal/domains/finance/services/recharge_record_service.go @@ -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), diff --git a/internal/domains/finance/services/recharge_record_service_test.go b/internal/domains/finance/services/recharge_record_service_test.go new file mode 100644 index 0000000..489f322 --- /dev/null +++ b/internal/domains/finance/services/recharge_record_service_test.go @@ -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配置应该返回零赠送金额") +} + + \ No newline at end of file diff --git a/internal/domains/product/entities/product.go b/internal/domains/product/entities/product.go index 0865350..646f7bd 100644 --- a/internal/domains/product/entities/product.go +++ b/internal/domains/product/entities/product.go @@ -28,7 +28,8 @@ type Product struct { 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:"创建时间"` UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` diff --git a/internal/domains/product/entities/product_documentation.go b/internal/domains/product/entities/product_documentation.go index 3da41ef..fe4da35 100644 --- a/internal/domains/product/entities/product_documentation.go +++ b/internal/domains/product/entities/product_documentation.go @@ -1,6 +1,9 @@ package entities import ( + "errors" + "fmt" + "strings" "time" "github.com/google/uuid" @@ -9,20 +12,20 @@ import ( // ProductDocumentation 产品文档实体 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:"常见问题"` - Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"` - Published bool `gorm:"default:false" comment:"是否已发布"` - + ID string `gorm:"primaryKey;type:varchar(36)" comment:"文档ID"` + ProductID string `gorm:"type:varchar(36);not null;uniqueIndex" comment:"产品ID"` + 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:"文档版本"` + // 关联关系 Product *Product `gorm:"foreignKey:ProductID" comment:"产品"` - + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` DeletedAt gorm.DeletedAt `gorm:"index" 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 增加版本号 @@ -75,4 +65,157 @@ func (pd *ProductDocumentation) IncrementVersion() { // 这里可以实现更复杂的版本号递增逻辑 pd.Version = pd.Version + ".1" } -} \ No newline at end of file +} + +// 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 +} diff --git a/internal/domains/product/entities/subscription.go b/internal/domains/product/entities/subscription.go index 72d3b65..5b25e59 100644 --- a/internal/domains/product/entities/subscription.go +++ b/internal/domains/product/entities/subscription.go @@ -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++ // 增加版本号 } diff --git a/internal/domains/product/repositories/product_documentation_repository_interface.go b/internal/domains/product/repositories/product_documentation_repository_interface.go new file mode 100644 index 0000000..9f53830 --- /dev/null +++ b/internal/domains/product/repositories/product_documentation_repository_interface.go @@ -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) +} diff --git a/internal/domains/product/repositories/subscription_repository_interface.go b/internal/domains/product/repositories/subscription_repository_interface.go index 6b6c142..a1e7e3a 100644 --- a/internal/domains/product/repositories/subscription_repository_interface.go +++ b/internal/domains/product/repositories/subscription_repository_interface.go @@ -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 } diff --git a/internal/domains/product/services/product_documentation_service.go b/internal/domains/product/services/product_documentation_service.go new file mode 100644 index 0000000..b837947 --- /dev/null +++ b/internal/domains/product/services/product_documentation_service.go @@ -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) +} diff --git a/internal/domains/product/services/product_subscription_service.go b/internal/domains/product/services/product_subscription_service.go index e1b4fcb..dca6d75 100644 --- a/internal/domains/product/services/product_subscription_service.go +++ b/internal/domains/product/services/product_subscription_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "gorm.io/gorm" @@ -190,4 +191,58 @@ func (s *ProductSubscriptionService) SaveSubscription(ctx context.Context, subsc } return nil } -} \ No newline at end of file +} + +// 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) +} diff --git a/internal/infrastructure/database/repositories/product/gorm_product_documentation_repository.go b/internal/infrastructure/database/repositories/product/gorm_product_documentation_repository.go new file mode 100644 index 0000000..c63eebc --- /dev/null +++ b/internal/infrastructure/database/repositories/product/gorm_product_documentation_repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go b/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go index 994563a..3d3d3be 100644 --- a/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go +++ b/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go @@ -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 +} diff --git a/internal/infrastructure/http/handlers/product_admin_handler.go b/internal/infrastructure/http/handlers/product_admin_handler.go index a9e1cf9..e646f60 100644 --- a/internal/infrastructure/http/handlers/product_admin_handler.go +++ b/internal/infrastructure/http/handlers/product_admin_handler.go @@ -14,12 +14,13 @@ import ( // ProductAdminHandler 产品管理员HTTP处理器 type ProductAdminHandler struct { - productAppService product.ProductApplicationService - categoryAppService product.CategoryApplicationService - subscriptionAppService product.SubscriptionApplicationService - responseBuilder interfaces.ResponseBuilder - validator interfaces.RequestValidator - logger *zap.Logger + productAppService product.ProductApplicationService + categoryAppService product.CategoryApplicationService + subscriptionAppService product.SubscriptionApplicationService + documentationAppService product.DocumentationApplicationServiceInterface + responseBuilder interfaces.ResponseBuilder + validator interfaces.RequestValidator + logger *zap.Logger } // NewProductAdminHandler 创建产品管理员HTTP处理器 @@ -27,17 +28,19 @@ func NewProductAdminHandler( productAppService product.ProductApplicationService, categoryAppService product.CategoryApplicationService, subscriptionAppService product.SubscriptionApplicationService, + documentationAppService product.DocumentationApplicationServiceInterface, responseBuilder interfaces.ResponseBuilder, validator interfaces.RequestValidator, logger *zap.Logger, ) *ProductAdminHandler { return &ProductAdminHandler{ - productAppService: productAppService, - categoryAppService: categoryAppService, - subscriptionAppService: subscriptionAppService, - responseBuilder: responseBuilder, - validator: validator, - logger: logger, + 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 } @@ -793,7 +802,7 @@ func (h *ProductAdminHandler) GetProductApiConfig(c *gin.Context) { h.responseBuilder.Success(c, emptyConfig, "获取API配置成功") return } - + h.logger.Error("获取产品API配置失败", zap.Error(err), zap.String("product_id", productID)) 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, "文档删除成功") +} diff --git a/internal/infrastructure/http/handlers/product_handler.go b/internal/infrastructure/http/handlers/product_handler.go index 75eac34..5550f7b 100644 --- a/internal/infrastructure/http/handlers/product_handler.go +++ b/internal/infrastructure/http/handlers/product_handler.go @@ -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, "获取文档成功") } diff --git a/internal/infrastructure/http/routes/product_admin_routes.go b/internal/infrastructure/http/routes/product_admin_routes.go index 563f67d..b6bc491 100644 --- a/internal/infrastructure/http/routes/product_admin_routes.go +++ b/internal/infrastructure/http/routes/product_admin_routes.go @@ -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) } // 分类管理 diff --git a/internal/infrastructure/http/routes/product_routes.go b/internal/infrastructure/http/routes/product_routes.go index ee5b84f..43a6b74 100644 --- a/internal/infrastructure/http/routes/product_routes.go +++ b/internal/infrastructure/http/routes/product_routes.go @@ -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) diff --git a/internal/shared/cache/gorm_cache_plugin.go b/internal/shared/cache/gorm_cache_plugin.go index b2b06fd..250ba32 100644 --- a/internal/shared/cache/gorm_cache_plugin.go +++ b/internal/shared/cache/gorm_cache_plugin.go @@ -22,7 +22,7 @@ type GormCachePlugin struct { cache interfaces.CacheService logger *zap.Logger config CacheConfig - + // 缓存失效去重机制 invalidationQueue map[string]*time.Timer queueMutex sync.RWMutex @@ -64,7 +64,7 @@ func DefaultCacheConfig() CacheConfig { BloomFilter: false, 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{ - cache: cache, - logger: logger, - config: cfg, + cache: cache, + logger: logger, + config: cfg, invalidationQueue: make(map[string]*time.Timer), } } @@ -105,16 +105,16 @@ func (p *GormCachePlugin) Initialize(db *gorm.DB) error { func (p *GormCachePlugin) Shutdown() { p.queueMutex.Lock() defer p.queueMutex.Unlock() - + // 停止所有定时器 for table, timer := range p.invalidationQueue { timer.Stop() p.logger.Debug("停止缓存失效定时器", zap.String("table", table)) } - + // 清空队列 p.invalidationQueue = make(map[string]*time.Timer) - + p.logger.Info("GORM缓存插件已关闭") } @@ -174,13 +174,13 @@ func (p *GormCachePlugin) beforeQuery(db *gorm.DB) { } return } else { - p.logger.Warn("缓存数据恢复失败,将执行数据库查询", - zap.String("cache_key", cacheKey), + p.logger.Warn("缓存数据恢复失败,将执行数据库查询", + zap.String("cache_key", cacheKey), zap.Error(err)) } } else { - p.logger.Debug("缓存未命中", - zap.String("cache_key", cacheKey), + p.logger.Debug("缓存未命中", + zap.String("cache_key", cacheKey), zap.Error(err)) } @@ -421,7 +421,7 @@ func (p *GormCachePlugin) saveToCache(ctx context.Context, cacheKey string, db * // 修复:改进缓存数据保存逻辑 var dataToCache interface{} - + // 如果dest是切片,需要特殊处理 destValue := reflect.ValueOf(dest) 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) - + // 如果类型完全匹配,直接赋值 if cachedValue.Type().AssignableTo(destValue.Elem().Type()) { 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 { - p.logger.Error("反序列化缓存数据失败", - zap.String("json_data", string(jsonData)), + p.logger.Error("反序列化缓存数据失败", + zap.String("json_data", string(jsonData)), zap.Error(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 - p.logger.Debug("从缓存恢复数据成功", + p.logger.Debug("从缓存恢复数据成功", zap.Int64("rows", cachedResult.RowCount), zap.Time("timestamp", cachedResult.Timestamp)) @@ -521,34 +521,34 @@ func (p *GormCachePlugin) invalidateTableCache(ctx context.Context, table string // 使用去重机制,避免重复的缓存失效操作 p.queueMutex.Lock() defer p.queueMutex.Unlock() - + // 如果已经有相同的失效操作在队列中,取消之前的定时器 if timer, exists := p.invalidationQueue[table]; exists { timer.Stop() delete(p.invalidationQueue, table) } - + // 创建独立的上下文,避免受到原始请求上下文的影响 // 设置合理的超时时间,避免缓存失效操作阻塞 cacheCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - + // 创建新的定时器 timer := time.AfterFunc(p.config.InvalidateDelay, func() { // 执行缓存失效 p.doInvalidateTableCache(cacheCtx, table) - + // 清理定时器引用 p.queueMutex.Lock() delete(p.invalidationQueue, table) p.queueMutex.Unlock() - + // 取消上下文 cancel() }) - + // 将定时器加入队列 p.invalidationQueue[table] = timer - + p.logger.Debug("缓存失效操作已加入队列", zap.String("table", table), 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) continue } - + p.logger.Warn("失效表缓存失败", zap.String("table", table), zap.String("pattern", pattern), @@ -583,7 +583,7 @@ func (p *GormCachePlugin) doInvalidateTableCache(ctx context.Context, table stri ) return } - + // 成功删除,记录日志并退出 p.logger.Debug("表缓存已失效", zap.String("table", table), diff --git a/internal/shared/database/cached_base_repository.go b/internal/shared/database/cached_base_repository.go index 541b7e9..4d88d01 100644 --- a/internal/shared/database/cached_base_repository.go +++ b/internal/shared/database/cached_base_repository.go @@ -35,9 +35,9 @@ func (r *CachedBaseRepositoryImpl) isTableCacheEnabled() bool { if cache.GlobalCacheConfigManager != nil { return cache.GlobalCacheConfigManager.IsTableCacheEnabled(r.tableName) } - + // 如果全局管理器未初始化,默认启用缓存 - r.logger.Warn("全局缓存配置管理器未初始化,默认启用缓存", + r.logger.Warn("全局缓存配置管理器未初始化,默认启用缓存", zap.String("table", r.tableName)) return true } @@ -46,7 +46,7 @@ func (r *CachedBaseRepositoryImpl) isTableCacheEnabled() bool { func (r *CachedBaseRepositoryImpl) shouldUseCacheForTable() bool { // 检查表是否启用缓存 if !r.isTableCacheEnabled() { - r.logger.Debug("表未启用缓存,跳过缓存操作", + r.logger.Debug("表未启用缓存,跳过缓存操作", zap.String("table", r.tableName)) return false } @@ -58,17 +58,17 @@ func (r *CachedBaseRepositoryImpl) shouldUseCacheForTable() bool { // GetWithCache 带缓存的单条查询(智能决策) func (r *CachedBaseRepositoryImpl) GetWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error { db := r.GetDB(ctx) - + // 智能决策:根据表配置决定是否使用缓存 if r.shouldUseCacheForTable() { db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) - r.logger.Debug("执行带缓存查询", + r.logger.Debug("执行带缓存查询", zap.String("table", r.tableName), zap.Duration("ttl", ttl), zap.String("where", where)) } else { db = db.Set("cache:disabled", true) - r.logger.Debug("执行无缓存查询", + r.logger.Debug("执行无缓存查询", zap.String("table", r.tableName), zap.String("where", where)) } @@ -79,17 +79,17 @@ func (r *CachedBaseRepositoryImpl) GetWithCache(ctx context.Context, dest interf // FindWithCache 带缓存的多条查询(智能决策) func (r *CachedBaseRepositoryImpl) FindWithCache(ctx context.Context, dest interface{}, ttl time.Duration, where string, args ...interface{}) error { db := r.GetDB(ctx) - + // 智能决策:根据表配置决定是否使用缓存 if r.shouldUseCacheForTable() { db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) - r.logger.Debug("执行带缓存批量查询", + r.logger.Debug("执行带缓存批量查询", zap.String("table", r.tableName), zap.Duration("ttl", ttl), zap.String("where", where)) } else { db = db.Set("cache:disabled", true) - r.logger.Debug("执行无缓存批量查询", + r.logger.Debug("执行无缓存批量查询", zap.String("table", r.tableName), zap.String("where", where)) } @@ -100,17 +100,17 @@ func (r *CachedBaseRepositoryImpl) FindWithCache(ctx context.Context, dest inter // CountWithCache 带缓存的计数查询(智能决策) 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) - + // 智能决策:根据表配置决定是否使用缓存 if r.shouldUseCacheForTable() { db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) - r.logger.Debug("执行带缓存计数查询", + r.logger.Debug("执行带缓存计数查询", zap.String("table", r.tableName), zap.Duration("ttl", ttl), zap.String("where", where)) } else { db = db.Set("cache:disabled", true) - r.logger.Debug("执行无缓存计数查询", + r.logger.Debug("执行无缓存计数查询", zap.String("table", r.tableName), zap.String("where", where)) } @@ -121,16 +121,16 @@ func (r *CachedBaseRepositoryImpl) CountWithCache(ctx context.Context, count *in // ListWithCache 带缓存的列表查询(智能决策) func (r *CachedBaseRepositoryImpl) ListWithCache(ctx context.Context, dest interface{}, ttl time.Duration, options CacheListOptions) error { db := r.GetDB(ctx) - + // 智能决策:根据表配置决定是否使用缓存 if r.shouldUseCacheForTable() { db = db.Set("cache:enabled", true).Set("cache:ttl", ttl) - r.logger.Debug("执行带缓存列表查询", + r.logger.Debug("执行带缓存列表查询", zap.String("table", r.tableName), zap.Duration("ttl", ttl)) } else { db = db.Set("cache:disabled", true) - r.logger.Debug("执行无缓存列表查询", + r.logger.Debug("执行无缓存列表查询", zap.String("table", r.tableName)) } @@ -214,10 +214,10 @@ func (r *CachedBaseRepositoryImpl) WithLongCache() *CachedBaseRepositoryImpl { // SmartGetByID 智能ID查询(自动缓存) 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("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) useCache := r.shouldUseCache(options) - r.logger.Debug("执行智能列表查询", + r.logger.Debug("执行智能列表查询", zap.String("table", r.tableName), zap.Bool("use_cache", useCache), zap.Duration("cache_ttl", cacheTTL))