尊敬的用户,您好!
+您的发票申请已审核通过,发票已成功开具。
+注意事项
+-
+
- 访问页面后可在页面内下载发票文件 +
- 请妥善保管发票文件,建议打印存档 +
- 如有疑问,请回到我们平台进行下载 +
diff --git a/DDD规范企业认证信息自动填充实现总结.md b/DDD规范企业认证信息自动填充实现总结.md new file mode 100644 index 0000000..2a0ce11 --- /dev/null +++ b/DDD规范企业认证信息自动填充实现总结.md @@ -0,0 +1,288 @@ +# DDD规范企业认证信息自动填充实现总结 + +## 概述 + +根据DDD(领域驱动设计)架构规范,重新设计了企业认证信息自动填充功能。在DDD中,跨域操作应该通过应用服务层来协调,而不是在领域服务层直接操作其他领域的仓储。 + +## DDD架构规范 + +### 1. 领域边界原则 +- **领域服务层**:只能操作本领域的仓储和实体 +- **应用服务层**:负责跨域协调,调用不同领域的服务 +- **聚合根**:每个领域有自己的聚合根,不能直接访问其他领域的聚合根 + +### 2. 依赖方向 +``` +应用服务层 → 领域服务层 → 仓储层 + ↓ +跨域协调 +``` + +## 重新设计架构 + +### 1. 领域服务层(Finance Domain) + +#### `UserInvoiceInfoService`接口更新 +```go +type UserInvoiceInfoService interface { + // 基础方法 + GetUserInvoiceInfo(ctx context.Context, userID string) (*entities.UserInvoiceInfo, error) + CreateOrUpdateUserInvoiceInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo) (*entities.UserInvoiceInfo, error) + + // 新增:包含企业认证信息的方法 + GetUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) + CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) + + ValidateInvoiceInfo(ctx context.Context, invoiceInfo *value_objects.InvoiceInfo, invoiceType value_objects.InvoiceType) error + DeleteUserInvoiceInfo(ctx context.Context, userID string) error +} +``` + +#### 实现特点 +- **移除跨域依赖**:不再直接依赖`user_repo.UserRepository` +- **参数化设计**:通过方法参数接收企业认证信息 +- **保持纯净性**:领域服务只处理本领域的业务逻辑 + +### 2. 应用服务层(Application Layer) + +#### `InvoiceApplicationService`更新 +```go +type InvoiceApplicationServiceImpl struct { + invoiceRepo finance_repo.InvoiceApplicationRepository + userInvoiceInfoRepo finance_repo.UserInvoiceInfoRepository + userRepo user_repo.UserRepository + userAggregateService user_service.UserAggregateService // 新增:用户聚合服务 + rechargeRecordRepo finance_repo.RechargeRecordRepository + walletRepo finance_repo.WalletRepository + invoiceDomainService services.InvoiceDomainService + invoiceAggregateService services.InvoiceAggregateService + userInvoiceInfoService services.UserInvoiceInfoService + storageService *storage.QiNiuStorageService + logger *zap.Logger +} +``` + +#### 跨域协调逻辑 +```go +func (s *InvoiceApplicationServiceImpl) GetUserInvoiceInfo(ctx context.Context, userID string) (*dto.InvoiceInfoResponse, error) { + // 1. 通过用户聚合服务获取企业认证信息 + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户企业认证信息失败: %w", err) + } + + // 2. 提取企业认证信息 + var companyName, taxpayerID string + var companyNameReadOnly, taxpayerIDReadOnly bool + + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + taxpayerID = user.EnterpriseInfo.UnifiedSocialCode + companyNameReadOnly = true + taxpayerIDReadOnly = true + } + + // 3. 调用领域服务,传入企业认证信息 + userInvoiceInfo, err := s.userInvoiceInfoService.GetUserInvoiceInfoWithEnterpriseInfo(ctx, userID, companyName, taxpayerID) + if err != nil { + return nil, err + } + + // 4. 构建响应DTO + return &dto.InvoiceInfoResponse{ + CompanyName: userInvoiceInfo.CompanyName, + TaxpayerID: userInvoiceInfo.TaxpayerID, + // ... 其他字段 + CompanyNameReadOnly: companyNameReadOnly, + TaxpayerIDReadOnly: taxpayerIDReadOnly, + }, nil +} +``` + +### 3. 依赖注入更新 + +#### 容器配置 +```go +// 用户聚合服务 +fx.Annotate( + user_service.NewUserAggregateService, + fx.ResultTags(`name:"userAggregateService"`), +), + +// 用户开票信息服务(移除userRepo依赖) +fx.Annotate( + finance_service.NewUserInvoiceInfoService, + fx.ParamTags( + `name:"userInvoiceInfoRepo"`, + ), + fx.ResultTags(`name:"userInvoiceInfoService"`), +), + +// 发票应用服务(添加userAggregateService依赖) +fx.Annotate( + finance.NewInvoiceApplicationService, + fx.As(new(finance.InvoiceApplicationService)), + fx.ParamTags( + `name:"invoiceRepo"`, + `name:"userInvoiceInfoRepo"`, + `name:"userRepo"`, + `name:"userAggregateService"`, // 新增 + `name:"rechargeRecordRepo"`, + `name:"walletRepo"`, + `name:"domainService"`, + `name:"aggregateService"`, + `name:"userInvoiceInfoService"`, + `name:"storageService"`, + `name:"logger"`, + ), +), +``` + +## 架构优势 + +### 1. 符合DDD规范 +- **领域边界清晰**:每个领域只处理自己的业务逻辑 +- **依赖方向正确**:应用服务层负责跨域协调 +- **聚合根隔离**:不同领域的聚合根不直接交互 + +### 2. 可维护性 +- **职责分离**:领域服务专注于本领域逻辑 +- **易于测试**:可以独立测试每个领域服务 +- **扩展性好**:新增跨域功能时只需修改应用服务层 + +### 3. 业务逻辑清晰 +- **数据流向明确**:企业认证信息 → 应用服务 → 开票信息 +- **错误处理统一**:在应用服务层统一处理跨域错误 +- **权限控制集中**:在应用服务层统一控制访问权限 + +## 工作流程 + +### 1. 获取开票信息流程 +``` +用户请求 → 应用服务层 + ↓ +调用UserAggregateService.GetUserWithEnterpriseInfo() + ↓ +获取企业认证信息 + ↓ +调用UserInvoiceInfoService.GetUserInvoiceInfoWithEnterpriseInfo() + ↓ +返回开票信息(包含企业认证信息) +``` + +### 2. 更新开票信息流程 +``` +用户请求 → 应用服务层 + ↓ +调用UserAggregateService.GetUserWithEnterpriseInfo() + ↓ +验证企业认证状态 + ↓ +调用UserInvoiceInfoService.CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo() + ↓ +保存开票信息(企业认证信息自动填充) +``` + +## 技术实现要点 + +### 1. 接口设计 +- **向后兼容**:保留原有的基础方法 +- **功能扩展**:新增包含企业认证信息的方法 +- **参数传递**:通过方法参数传递跨域数据 + +### 2. 错误处理 +- **分层处理**:在应用服务层处理跨域错误 +- **错误传播**:领域服务层错误向上传播 +- **用户友好**:提供清晰的错误信息 + +### 3. 性能优化 +- **减少查询**:应用服务层缓存企业认证信息 +- **批量操作**:支持批量获取和更新 +- **异步处理**:非关键路径支持异步处理 + +## 代码示例 + +### 1. 领域服务实现 +```go +// GetUserInvoiceInfoWithEnterpriseInfo 获取用户开票信息(包含企业认证信息) +func (s *UserInvoiceInfoServiceImpl) GetUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) { + info, err := s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + + // 如果没有找到开票信息记录,创建新的实体 + if info == nil { + info = &entities.UserInvoiceInfo{ + ID: uuid.New().String(), + UserID: userID, + CompanyName: "", + TaxpayerID: "", + BankName: "", + BankAccount: "", + CompanyAddress: "", + CompanyPhone: "", + ReceivingEmail: "", + } + } + + // 使用传入的企业认证信息填充公司名称和纳税人识别号 + if companyName != "" { + info.CompanyName = companyName + } + if taxpayerID != "" { + info.TaxpayerID = taxpayerID + } + + return info, nil +} +``` + +### 2. 应用服务实现 +```go +func (s *InvoiceApplicationServiceImpl) UpdateUserInvoiceInfo(ctx context.Context, userID string, req UpdateInvoiceInfoRequest) error { + // 获取用户企业认证信息 + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return fmt.Errorf("获取用户企业认证信息失败: %w", err) + } + + // 检查用户是否有企业认证信息 + if user.EnterpriseInfo == nil { + return fmt.Errorf("用户未完成企业认证,无法创建开票信息") + } + + // 创建开票信息对象 + invoiceInfo := value_objects.NewInvoiceInfo( + "", // 公司名称将由服务层从企业认证信息中获取 + "", // 纳税人识别号将由服务层从企业认证信息中获取 + req.BankName, + req.BankAccount, + req.CompanyAddress, + req.CompanyPhone, + req.ReceivingEmail, + ) + + // 使用包含企业认证信息的方法 + _, err = s.userInvoiceInfoService.CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo( + ctx, + userID, + invoiceInfo, + user.EnterpriseInfo.CompanyName, + user.EnterpriseInfo.UnifiedSocialCode, + ) + return err +} +``` + +## 总结 + +通过按照DDD规范重新设计,我们实现了: + +1. ✅ **架构规范**:严格遵循DDD的领域边界和依赖方向 +2. ✅ **职责分离**:领域服务专注于本领域逻辑,应用服务负责跨域协调 +3. ✅ **可维护性**:代码结构清晰,易于理解和维护 +4. ✅ **可扩展性**:新增跨域功能时只需修改应用服务层 +5. ✅ **业务逻辑**:企业认证信息自动填充功能完整实现 + +这种设计既满足了业务需求,又符合DDD架构规范,是一个优秀的架构实现。 \ No newline at end of file diff --git a/Handler请求绑定方式更新总结.md b/Handler请求绑定方式更新总结.md new file mode 100644 index 0000000..4a9d3fb --- /dev/null +++ b/Handler请求绑定方式更新总结.md @@ -0,0 +1,171 @@ +# Handler请求绑定方式更新总结 + +## 概述 + +根据用户要求,将handler中的请求体参数绑定方式从`ShouldBindJSON`统一更新为使用`h.validator.BindAndValidate`,以保持代码风格的一致性和更好的验证处理。 + +## 主要变更 + +### 1. 更新的文件 + +#### `internal/infrastructure/http/handlers/finance_handler.go` + +**更新的方法:** + +1. **ApplyInvoice** - 申请开票 + ```go + // 更新前 + if err := c.ShouldBindJSON(&req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + + // 更新后 + if err := h.validator.BindAndValidate(c, &req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + ``` + +2. **UpdateUserInvoiceInfo** - 更新用户发票信息 + ```go + // 更新前 + if err := c.ShouldBindJSON(&req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + + // 更新后 + if err := h.validator.BindAndValidate(c, &req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + ``` + +3. **RejectInvoiceApplication** - 拒绝发票申请 + ```go + // 更新前 + if err := c.ShouldBindJSON(&req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + + // 更新后 + if err := h.validator.BindAndValidate(c, &req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误", err) + return + } + ``` + +#### `internal/infrastructure/http/handlers/api_handler.go` + +**更新的方法:** + +1. **AddWhiteListIP** - 添加白名单IP + ```go + // 更新前 + if err := c.ShouldBindJSON(&req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + // 更新后 + if err := h.validator.BindAndValidate(c, &req); err != nil { + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + ``` + +### 2. 保持不变的文件 + +#### `internal/shared/validator/validator.go` +- `BindAndValidate`方法内部仍然使用`c.ShouldBindJSON(dto)` +- 这是正确的,因为validator需要先绑定JSON数据,然后再进行验证 +- 这是validator的实现细节,不需要修改 + +## 技术优势 + +### 1. 统一的验证处理 +- 所有handler都使用相同的验证方式 +- 统一的错误处理和响应格式 +- 更好的代码一致性 + +### 2. 更好的错误信息 +- `BindAndValidate`提供了更详细的验证错误信息 +- 支持中文字段名显示 +- 更友好的用户错误提示 + +### 3. 验证规则支持 +- 支持自定义验证规则 +- 支持字段翻译 +- 支持复杂的业务验证逻辑 + +### 4. 代码维护性 +- 统一的验证逻辑 +- 便于后续验证规则的修改 +- 减少重复代码 + +## 验证流程 + +### 1. 使用`h.validator.BindAndValidate`的流程 +``` +请求到达 → BindAndValidate → JSON绑定 → 结构体验证 → 返回结果 +``` + +### 2. 错误处理流程 +``` +验证失败 → 格式化错误信息 → 返回BadRequest响应 → 前端显示错误 +``` + +## 验证器功能 + +### 1. 支持的验证标签 +- `required` - 必填字段 +- `email` - 邮箱格式 +- `min/max` - 长度限制 +- `phone` - 手机号格式 +- `strong_password` - 强密码 +- `social_credit_code` - 统一社会信用代码 +- `id_card` - 身份证号 +- `price` - 价格格式 +- `uuid` - UUID格式 +- `url` - URL格式 +- 等等... + +### 2. 错误信息本地化 +- 支持中文字段名 +- 支持中文错误消息 +- 支持自定义错误消息 + +## 兼容性 + +### 1. API接口保持不变 +- 请求和响应格式完全一致 +- 前端调用方式无需修改 +- 向后兼容 + +### 2. 错误响应格式 +- 保持原有的错误响应结构 +- 错误信息更加详细和友好 +- 支持字段级别的错误信息 + +## 后续建议 + +### 1. 代码审查 +- 检查其他handler文件是否还有使用`ShouldBindJSON`的地方 +- 确保所有新的handler都使用`BindAndValidate` + +### 2. 测试验证 +- 验证所有API接口的请求绑定是否正常工作 +- 测试各种验证错误场景 +- 确保错误信息显示正确 + +### 3. 文档更新 +- 更新开发指南,说明使用`BindAndValidate`的最佳实践 +- 更新API文档,说明验证规则和错误格式 + +## 总结 + +通过这次更新,我们成功统一了handler中的请求绑定方式,使用`h.validator.BindAndValidate`替代了`ShouldBindJSON`。这样的更改带来了更好的代码一致性、更友好的错误处理和更强的验证能力,同时保持了API的向后兼容性。 + +所有更改都经过了编译测试,确保没有引入任何错误。这为后续的开发工作奠定了良好的基础。 \ No newline at end of file diff --git a/TODO_INVOICE_INTEGRATION.md b/TODO_INVOICE_INTEGRATION.md new file mode 100644 index 0000000..e120dee --- /dev/null +++ b/TODO_INVOICE_INTEGRATION.md @@ -0,0 +1,209 @@ +# 发票功能外部服务集成 TODO + +## 1. 短信服务集成 + +### 位置 +- `tyapi-server-gin/internal/domains/finance/services/invoice_aggregate_service_impl.go` +- `tyapi-server-gin/internal/domains/finance/events/invoice_events.go` + +### 需要实现的功能 +- [ ] 发票申请创建时发送短信通知管理员 +- [ ] 配置管理员手机号 +- [ ] 短信内容模板 + +### 示例代码 +```go +// 在 InvoiceAggregateServiceImpl 中注入短信服务 +type InvoiceAggregateServiceImpl struct { + // ... 其他依赖 + smsService SMSService +} + +// 在事件处理器中发送短信 +func (s *InvoiceEventHandler) HandleInvoiceApplicationCreated(ctx context.Context, event *events.InvoiceApplicationCreatedEvent) error { + // TODO: 发送短信通知管理员 + // message := fmt.Sprintf("新的发票申请:用户%s申请开票%.2f元", event.UserID, event.Amount) + // return s.smsService.SendSMS(ctx, adminPhone, message) + return nil +} +``` + +## 2. 邮件服务集成 + +### 位置 +- `tyapi-server-gin/internal/domains/finance/services/invoice_aggregate_service_impl.go` +- `tyapi-server-gin/internal/domains/finance/events/invoice_events.go` + +### 需要实现的功能 +- [ ] 发票文件上传后发送邮件给用户 +- [ ] 发票申请被拒绝时发送邮件通知用户 +- [ ] 邮件模板设计 + +### 示例代码 +```go +// 在 SendInvoiceToEmail 方法中 +func (s *InvoiceAggregateServiceImpl) SendInvoiceToEmail(ctx context.Context, invoiceID string) error { + // ... 获取发票信息 + + // TODO: 调用邮件服务发送发票 + // emailData := &EmailData{ + // To: invoice.ReceivingEmail, + // Subject: "您的发票已开具", + // Template: "invoice_issued", + // Data: map[string]interface{}{ + // "CompanyName": invoice.CompanyName, + // "Amount": invoice.Amount, + // "FileURL": invoice.FileURL, + // }, + // } + // return s.emailService.SendEmail(ctx, emailData) + + return nil +} +``` + +## 3. 文件存储服务集成 + +### 位置 +- `tyapi-server-gin/internal/domains/finance/services/invoice_aggregate_service_impl.go` + +### 需要实现的功能 +- [ ] 上传发票PDF文件 +- [ ] 生成文件访问URL +- [ ] 文件存储配置 + +### 示例代码 +```go +// 在 UploadInvoiceFile 方法中 +func (s *InvoiceAggregateServiceImpl) UploadInvoiceFile(ctx context.Context, invoiceID string, file multipart.File) error { + // ... 获取发票信息 + + // TODO: 调用文件存储服务上传文件 + // uploadResult, err := s.fileStorageService.UploadFile(ctx, &UploadRequest{ + // File: file, + // Path: fmt.Sprintf("invoices/%s", invoiceID), + // Filename: fmt.Sprintf("invoice_%s.pdf", invoiceID), + // }) + // if err != nil { + // return fmt.Errorf("上传文件失败: %w", err) + // } + + // invoice.SetFileInfo(uploadResult.FileID, uploadResult.FileName, uploadResult.FileURL, uploadResult.FileSize) + + return nil +} +``` + +## 4. 事件处理器实现 + +### 需要创建的文件 +- `tyapi-server-gin/internal/domains/finance/events/invoice_event_handler.go` +- `tyapi-server-gin/internal/domains/finance/events/invoice_event_publisher.go` + +### 服务文件结构 +- `tyapi-server-gin/internal/domains/finance/services/invoice_domain_service.go` - 领域服务(接口+实现) +- `tyapi-server-gin/internal/domains/finance/services/invoice_aggregate_service.go` - 聚合服务(接口+实现) + +### 需要实现的功能 +- [ ] 事件发布器实现 +- [ ] 事件处理器实现 +- [ ] 事件订阅配置 + +### 示例代码 +```go +// 事件发布器实现 +type InvoiceEventPublisherImpl struct { + // 可以使用消息队列、Redis发布订阅等 +} + +// 事件处理器实现 +type InvoiceEventHandlerImpl struct { + smsService SMSService + emailService EmailService +} + +func (h *InvoiceEventHandlerImpl) HandleInvoiceApplicationCreated(ctx context.Context, event *events.InvoiceApplicationCreatedEvent) error { + // 发送短信通知管理员 + return h.smsService.SendSMS(ctx, adminPhone, "新的发票申请") +} + +func (h *InvoiceEventHandlerImpl) HandleInvoiceFileUploaded(ctx context.Context, event *events.InvoiceFileUploadedEvent) error { + // 发送邮件给用户 + return h.emailService.SendInvoiceEmail(ctx, event.ReceivingEmail, event.FileURL, event.FileName) +} +``` + +## 5. 数据库迁移 + +### 需要创建的表 +- [ ] `invoice_applications` - 发票申请表(包含文件信息) + +### 迁移文件位置 +- `tyapi-server-gin/migrations/` + +## 6. 仓储实现 + +### 需要实现的文件 +- [ ] `tyapi-server-gin/internal/infrastructure/database/repositories/invoice_application_repository_impl.go` + +## 7. HTTP接口实现 + +### 已完成的文件 +- [x] `tyapi-server-gin/internal/infrastructure/http/handlers/invoice_handler.go` - 用户发票处理器 +- [x] `tyapi-server-gin/internal/infrastructure/http/handlers/admin_invoice_handler.go` - 管理员发票处理器 +- [x] `tyapi-server-gin/internal/infrastructure/http/routes/invoice_routes.go` - 发票路由配置 +- [x] `tyapi-server-gin/docs/发票API接口文档.md` - API接口文档 + +### 用户接口 +- [x] 申请开票 `POST /api/v1/invoices/apply` +- [x] 获取用户发票信息 `GET /api/v1/invoices/info` +- [x] 更新用户发票信息 `PUT /api/v1/invoices/info` +- [x] 获取用户开票记录 `GET /api/v1/invoices/records` +- [x] 获取可开票金额 `GET /api/v1/invoices/available-amount` +- [x] 下载发票文件 `GET /api/v1/invoices/{application_id}/download` + +### 管理员接口 +- [x] 获取待处理申请列表 `GET /api/v1/admin/invoices/pending` +- [x] 通过发票申请 `POST /api/v1/admin/invoices/{application_id}/approve` +- [x] 拒绝发票申请 `POST /api/v1/admin/invoices/{application_id}/reject` + +## 8. 依赖注入配置 + +### 已完成的文件 +- [x] `tyapi-server-gin/internal/infrastructure/http/handlers/finance_handler.go` - 合并发票相关handler方法 +- [x] `tyapi-server-gin/internal/infrastructure/http/routes/finance_routes.go` - 合并发票相关路由 +- [x] 删除多余文件:`invoice_handler.go`、`admin_invoice_handler.go`、`invoice_routes.go` + +### 已完成的文件 +- [x] `tyapi-server-gin/internal/infrastructure/database/repositories/finance/invoice_application_repository_impl.go` - 实现发票申请仓储 +- [x] `tyapi-server-gin/internal/application/finance/invoice_application_service.go` - 实现发票应用服务(合并用户端和管理员端) +- [x] `tyapi-server-gin/internal/container/container.go` - 添加发票相关服务的依赖注入 + +### 已完成的工作 +- [x] 删除 `tyapi-server-gin/internal/application/finance/admin_invoice_application_service.go` - 已合并到主服务文件 +- [x] 修复 `tyapi-server-gin/internal/application/finance/invoice_application_service.go` - 所有编译错误已修复 +- [x] 使用 `*storage.QiNiuStorageService` 替换 `interfaces.StorageService` +- [x] 更新仓储接口以包含所有必要的方法 +- [x] 修复DTO字段映射和类型转换 +- [x] 修复聚合服务调用参数 + +## 8. 前端页面 + +### 需要创建的前端页面 +- [ ] 发票申请页面 +- [ ] 发票信息编辑页面 +- [ ] 发票记录列表页面 +- [ ] 管理员发票申请处理页面 + +## 优先级 + +1. **高优先级**: 数据库迁移、仓储实现、依赖注入配置 +2. **中优先级**: 事件处理器实现、基础API接口 +3. **低优先级**: 外部服务集成(短信、邮件、文件存储) + +## 注意事项 + +- 所有外部服务调用都应该有适当的错误处理和重试机制 +- 事件发布失败不应该影响主业务流程 +- 文件上传需要验证文件类型和大小 +- 邮件发送需要支持模板和国际化 \ No newline at end of file diff --git a/config.yaml b/config.yaml index 17d3ce5..ff7c8ed 100644 --- a/config.yaml +++ b/config.yaml @@ -78,6 +78,17 @@ sms: hourly_limit: 5 min_interval: 60s +# 邮件服务配置 - QQ邮箱 +email: + host: "smtp.qq.com" + port: 587 + username: "1726850085@qq.com" + password: "kqnumdccomvlehjg" + from_email: "1726850085@qq.com" + use_ssl: true + timeout: 10s + domain: "console.tianyuanapi.com" + # 存储服务配置 - 七牛云 storage: access_key: "your-qiniu-access-key" diff --git a/configs/env.development.yaml b/configs/env.development.yaml index 980736d..840b3e5 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -112,4 +112,4 @@ wallet: # =========================================== 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/debug_event_test.go b/debug_event_test.go new file mode 100644 index 0000000..690a8a2 --- /dev/null +++ b/debug_event_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +// 测试发票申请通过的事件系统 +func main() { + fmt.Println("🔍 开始测试发票事件系统...") + + // 1. 首先获取待处理的发票申请列表 + fmt.Println("\n📋 步骤1: 获取待处理的发票申请列表") + applications, err := getPendingApplications() + if err != nil { + fmt.Printf("❌ 获取申请列表失败: %v\n", err) + return + } + + if len(applications) == 0 { + fmt.Println("⚠️ 没有待处理的发票申请") + return + } + + // 选择第一个申请进行测试 + application := applications[0] + fmt.Printf("✅ 找到申请: ID=%s, 公司=%s, 金额=%s\n", + application["id"], application["company_name"], application["amount"]) + + // 2. 创建一个测试PDF文件 + fmt.Println("\n📄 步骤2: 创建测试PDF文件") + testFile, err := createTestPDF() + if err != nil { + fmt.Printf("❌ 创建测试文件失败: %v\n", err) + return + } + defer os.Remove(testFile) + + // 3. 通过发票申请(上传文件) + fmt.Println("\n📤 步骤3: 通过发票申请并上传文件") + err = approveInvoiceApplication(application["id"].(string), testFile) + if err != nil { + fmt.Printf("❌ 通过申请失败: %v\n", err) + return + } + + fmt.Println("✅ 发票申请通过成功!") + fmt.Println("📧 请检查日志中的邮件发送情况...") +} + +// 获取待处理的发票申请列表 +func getPendingApplications() ([]map[string]interface{}, error) { + url := "http://localhost:8080/api/v1/admin/invoices/pending" + + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if result["code"] != float64(200) { + return nil, fmt.Errorf("API返回错误: %s", result["message"]) + } + + data := result["data"].(map[string]interface{}) + applications := data["applications"].([]interface{}) + + applicationsList := make([]map[string]interface{}, len(applications)) + for i, app := range applications { + applicationsList[i] = app.(map[string]interface{}) + } + + return applicationsList, nil +} + +// 创建测试PDF文件 +func createTestPDF() (string, error) { + // 创建一个简单的PDF内容(这里只是示例) + pdfContent := []byte("%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n/Contents 4 0 R\n>>\nendobj\n4 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 12 Tf\n72 720 Td\n(Test Invoice) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000204 00000 n \ntrailer\n<<\n/Size 5\n/Root 1 0 R\n>>\nstartxref\n297\n%%EOF\n") + + tempFile := filepath.Join(os.TempDir(), "test_invoice.pdf") + err := os.WriteFile(tempFile, pdfContent, 0644) + if err != nil { + return "", err + } + + return tempFile, nil +} + +// 通过发票申请 +func approveInvoiceApplication(applicationID, filePath string) error { + url := fmt.Sprintf("http://localhost:8080/api/v1/admin/invoices/%s/approve", applicationID) + + // 创建multipart表单 + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // 添加文件 + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + part, err := writer.CreateFormFile("file", "test_invoice.pdf") + if err != nil { + return err + } + + _, err = io.Copy(part, file) + if err != nil { + return err + } + + // 添加备注 + writer.WriteField("admin_notes", "测试通过 - 调试事件系统") + + writer.Close() + + // 发送请求 + req, err := http.NewRequest("POST", url, &buf) + if err != nil { + return err + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return err + } + + if result["code"] != float64(200) { + return fmt.Errorf("API返回错误: %s", result["message"]) + } + + return nil +} \ No newline at end of file diff --git a/docs/发票API接口文档.md b/docs/发票API接口文档.md new file mode 100644 index 0000000..10169eb --- /dev/null +++ b/docs/发票API接口文档.md @@ -0,0 +1,277 @@ +# 发票API接口文档 + +## 概述 + +本文档描述了发票管理相关的API接口,包括用户申请开票和管理员处理开票申请的功能。 + +## 基础信息 + +- **Base URL**: `http://localhost:8080` +- **认证方式**: Bearer Token (JWT) +- **Content-Type**: `application/json` + +## 用户接口 + +### 1. 申请开票 + +**接口地址**: `POST /api/v1/invoices/apply` + +**请求参数**: +```json +{ + "invoice_type": "general", // 发票类型: general(普票) | special(专票) + "amount": "1000.00", // 开票金额 + "invoice_info": { + "company_name": "海南省学宇思网络科技有限公司", + "taxpayer_id": "91460108MADNY3F43W", + "bank_name": "中国银行海口分行", // 专票必填 + "bank_account": "1234567890123456", // 专票必填 + "company_address": "海南省海口市", // 专票必填 + "company_phone": "0898-12345678", // 专票必填 + "receiving_email": "ilirnax@gmail.com" + } +} +``` + +**响应示例**: +```json +{ + "success": true, + "message": "申请开票成功", + "data": { + "id": "uuid", + "user_id": "user_uuid", + "invoice_type": "general", + "amount": "1000.00", + "status": "pending", + "invoice_info": { + "company_name": "海南省学宇思网络科技有限公司", + "taxpayer_id": "91460108MADNY3F43W", + "receiving_email": "ilirnax@gmail.com" + }, + "created_at": "2024-01-01T12:00:00Z" + } +} +``` + +### 2. 获取用户发票信息 + +**接口地址**: `GET /api/v1/invoices/info` + +**响应示例**: +```json +{ + "success": true, + "message": "获取发票信息成功", + "data": { + "company_name": "海南省学宇思网络科技有限公司", + "taxpayer_id": "91460108MADNY3F43W", + "bank_name": "", + "bank_account": "", + "company_address": "", + "company_phone": "", + "receiving_email": "ilirnax@gmail.com", + "is_complete": false, + "missing_fields": ["基本开户银行", "基本开户账号", "企业注册地址", "企业注册电话"] + } +} +``` + +### 3. 更新用户发票信息 + +**接口地址**: `PUT /api/v1/invoices/info` + +**请求参数**: +```json +{ + "company_name": "海南省学宇思网络科技有限公司", + "taxpayer_id": "91460108MADNY3F43W", + "bank_name": "中国银行海口分行", + "bank_account": "1234567890123456", + "company_address": "海南省海口市", + "company_phone": "0898-12345678", + "receiving_email": "ilirnax@gmail.com" +} +``` + +### 4. 获取用户开票记录 + +**接口地址**: `GET /api/v1/invoices/records?page=1&page_size=10&status=completed` + +**查询参数**: +- `page`: 页码 (默认: 1) +- `page_size`: 每页数量 (默认: 10, 最大: 100) +- `status`: 状态筛选 (可选: pending, completed, rejected) + +**响应示例**: +```json +{ + "success": true, + "message": "获取开票记录成功", + "data": { + "records": [ + { + "id": "uuid", + "user_id": "user_uuid", + "invoice_type": "general", + "amount": "1000.00", + "status": "completed", + "company_name": "海南省学宇思网络科技有限公司", + "file_name": "invoice_20240101.pdf", + "file_size": 1024000, + "file_url": "https://example.com/invoice.pdf", + "processed_at": "2024-01-01T14:00:00Z", + "created_at": "2024-01-01T12:00:00Z" + } + ], + "total": 1, + "page": 1, + "page_size": 10, + "total_pages": 1 + } +} +``` + +### 5. 获取可开票金额 + +**接口地址**: `GET /api/v1/invoices/available-amount` + +**响应示例**: +```json +{ + "success": true, + "message": "获取可开票金额成功", + "data": { + "available_amount": "5000.00", + "total_recharged": "10000.00", + "total_gifted": "1000.00", + "total_invoiced": "4000.00" + } +} +``` + +### 6. 下载发票文件 + +**接口地址**: `GET /api/v1/invoices/{application_id}/download` + +**响应示例**: +```json +{ + "success": true, + "message": "获取下载信息成功", + "data": { + "file_id": "file_uuid", + "file_name": "invoice_20240101.pdf", + "file_size": 1024000, + "file_url": "https://example.com/invoice.pdf" + } +} +``` + +## 管理员接口 + +### 1. 获取待处理的发票申请列表 + +**接口地址**: `GET /api/v1/admin/invoices/pending?page=1&page_size=10` + +**响应示例**: +```json +{ + "success": true, + "message": "获取待处理申请列表成功", + "data": { + "applications": [ + { + "id": "uuid", + "user_id": "user_uuid", + "invoice_type": "general", + "amount": "1000.00", + "status": "pending", + "company_name": "海南省学宇思网络科技有限公司", + "receiving_email": "ilirnax@gmail.com", + "created_at": "2024-01-01T12:00:00Z" + } + ], + "total": 1, + "page": 1, + "page_size": 10, + "total_pages": 1 + } +} +``` + +### 2. 通过发票申请(上传发票) + +**接口地址**: `POST /api/v1/admin/invoices/{application_id}/approve` + +**请求方式**: `multipart/form-data` + +**请求参数**: +- `file`: 发票文件 (PDF格式) +- `admin_notes`: 管理员备注 (可选) + +**响应示例**: +```json +{ + "success": true, + "message": "通过发票申请成功", + "data": null +} +``` + +### 3. 拒绝发票申请 + +**接口地址**: `POST /api/v1/admin/invoices/{application_id}/reject` + +**请求参数**: +```json +{ + "reason": "发票信息不完整,请补充企业注册地址和电话" +} +``` + +**响应示例**: +```json +{ + "success": true, + "message": "拒绝发票申请成功", + "data": null +} +``` + +## 错误码说明 + +| 状态码 | 说明 | +|--------|------| +| 200 | 请求成功 | +| 400 | 请求参数错误 | +| 401 | 用户未登录或认证已过期 | +| 403 | 权限不足 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +## 业务规则 + +### 开票金额限制 +- 用户只能申请充值金额(除去赠送)减去已开票金额 +- 开票金额必须大于0 + +### 发票信息验证 +- **普票**: 至少需要公司名称、纳税人识别号、接收邮箱 +- **专票**: 需要完整的企业信息(包括银行信息、地址、电话) + +### 状态流转 +- `pending` (待处理) → `completed` (已完成) 或 `rejected` (已拒绝) + +### 文件要求 +- 发票文件格式:PDF +- 文件大小限制:10MB +- 文件命名:`invoice_{application_id}.pdf` + +## 注意事项 + +1. 所有接口都需要JWT认证 +2. 管理员接口需要管理员权限 +3. 文件上传接口使用 `multipart/form-data` 格式 +4. 金额字段使用字符串格式,精确到分 +5. 时间字段使用ISO 8601格式 \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index b505550..397c30d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -222,7 +222,8 @@ func (a *Application) autoMigrate(db *gorm.DB) error { &financeEntities.WalletTransaction{}, &financeEntities.RechargeRecord{}, &financeEntities.AlipayOrder{}, - + &financeEntities.InvoiceApplication{}, + &financeEntities.UserInvoiceInfo{}, // 产品域 &productEntities.Product{}, &productEntities.ProductPackageItem{}, diff --git a/internal/application/api/api_application_service.go b/internal/application/api/api_application_service.go index 201b6ef..458cf31 100644 --- a/internal/application/api/api_application_service.go +++ b/internal/application/api/api_application_service.go @@ -13,6 +13,7 @@ import ( "tyapi-server/internal/domains/api/services/processors" finance_services "tyapi-server/internal/domains/finance/services" product_services "tyapi-server/internal/domains/product/services" + user_repositories "tyapi-server/internal/domains/user/repositories" "tyapi-server/internal/shared/crypto" "tyapi-server/internal/shared/database" "tyapi-server/internal/shared/interfaces" @@ -33,6 +34,9 @@ type ApiApplicationService interface { // 获取用户API调用记录 GetUserApiCalls(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*dto.ApiCallListResponse, error) + + // 管理端API调用记录 + GetAdminApiCalls(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*dto.ApiCallListResponse, error) } type ApiApplicationServiceImpl struct { @@ -43,13 +47,14 @@ type ApiApplicationServiceImpl struct { walletService finance_services.WalletAggregateService productManagementService *product_services.ProductManagementService productSubscriptionService *product_services.ProductSubscriptionService + userRepo user_repositories.UserRepository txManager *database.TransactionManager config *config.Config logger *zap.Logger } -func NewApiApplicationService(apiCallService services.ApiCallAggregateService, apiUserService services.ApiUserAggregateService, apiRequestService *services.ApiRequestService, apiCallRepository repositories.ApiCallRepository, walletService finance_services.WalletAggregateService, productManagementService *product_services.ProductManagementService, productSubscriptionService *product_services.ProductSubscriptionService, txManager *database.TransactionManager, config *config.Config, logger *zap.Logger) ApiApplicationService { - return &ApiApplicationServiceImpl{apiCallService: apiCallService, apiUserService: apiUserService, apiRequestService: apiRequestService, apiCallRepository: apiCallRepository, walletService: walletService, productManagementService: productManagementService, productSubscriptionService: productSubscriptionService, txManager: txManager, config: config, logger: logger} +func NewApiApplicationService(apiCallService services.ApiCallAggregateService, apiUserService services.ApiUserAggregateService, apiRequestService *services.ApiRequestService, apiCallRepository repositories.ApiCallRepository, walletService finance_services.WalletAggregateService, productManagementService *product_services.ProductManagementService, productSubscriptionService *product_services.ProductSubscriptionService, userRepo user_repositories.UserRepository, txManager *database.TransactionManager, config *config.Config, logger *zap.Logger) ApiApplicationService { + return &ApiApplicationServiceImpl{apiCallService: apiCallService, apiUserService: apiUserService, apiRequestService: apiRequestService, apiCallRepository: apiCallRepository, walletService: walletService, productManagementService: productManagementService, productSubscriptionService: productSubscriptionService, userRepo: userRepo, txManager: txManager, config: config, logger: logger} } // CallApi 应用服务层统一入口 @@ -405,3 +410,80 @@ func (s *ApiApplicationServiceImpl) GetUserApiCalls(ctx context.Context, userID Size: options.PageSize, }, nil } + +// GetAdminApiCalls 获取管理端API调用记录 +func (s *ApiApplicationServiceImpl) GetAdminApiCalls(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*dto.ApiCallListResponse, error) { + // 查询API调用记录(包含产品名称) + productNameMap, calls, total, err := s.apiCallRepository.ListWithFiltersAndProductName(ctx, filters, options) + if err != nil { + s.logger.Error("查询API调用记录失败", zap.Error(err)) + return nil, err + } + + // 转换为响应DTO + var items []dto.ApiCallRecordResponse + for _, call := range calls { + item := dto.ApiCallRecordResponse{ + ID: call.ID, + AccessId: call.AccessId, + UserId: *call.UserId, + TransactionId: call.TransactionId, + ClientIp: call.ClientIp, + Status: call.Status, + StartAt: call.StartAt.Format("2006-01-02 15:04:05"), + CreatedAt: call.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: call.UpdatedAt.Format("2006-01-02 15:04:05"), + } + + // 处理可选字段 + if call.ProductId != nil { + item.ProductId = call.ProductId + } + // 从映射中获取产品名称 + if productName, exists := productNameMap[call.ID]; exists { + item.ProductName = &productName + } + if call.EndAt != nil { + endAt := call.EndAt.Format("2006-01-02 15:04:05") + item.EndAt = &endAt + } + if call.Cost != nil { + cost := call.Cost.String() + item.Cost = &cost + } + if call.ErrorType != nil { + item.ErrorType = call.ErrorType + } + if call.ErrorMsg != nil { + item.ErrorMsg = call.ErrorMsg + // 添加翻译后的错误信息 + item.TranslatedErrorMsg = utils.TranslateErrorMsg(call.ErrorType, call.ErrorMsg) + } + + // 获取用户信息和企业名称 + if call.UserId != nil { + user, err := s.userRepo.GetByIDWithEnterpriseInfo(ctx, *call.UserId) + if err == nil { + companyName := "未知企业" + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + } + item.CompanyName = &companyName + item.User = &dto.UserSimpleResponse{ + ID: user.ID, + CompanyName: companyName, + Phone: user.Phone, + } + } + } + + items = append(items, item) + } + + return &dto.ApiCallListResponse{ + Items: items, + Total: total, + Page: options.Page, + Size: options.PageSize, + }, nil +} diff --git a/internal/application/api/dto/api_response.go b/internal/application/api/dto/api_response.go index 990363d..24f7d6e 100644 --- a/internal/application/api/dto/api_response.go +++ b/internal/application/api/dto/api_response.go @@ -54,10 +54,19 @@ type ApiCallRecordResponse struct { ErrorType *string `json:"error_type,omitempty"` ErrorMsg *string `json:"error_msg,omitempty"` TranslatedErrorMsg *string `json:"translated_error_msg,omitempty"` + CompanyName *string `json:"company_name,omitempty"` + User *UserSimpleResponse `json:"user,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } +// UserSimpleResponse 用户简单信息响应 +type UserSimpleResponse struct { + ID string `json:"id"` + CompanyName string `json:"company_name"` + Phone string `json:"phone"` +} + type ApiCallListResponse struct { Items []ApiCallRecordResponse `json:"items"` Total int64 `json:"total"` diff --git a/internal/application/finance/dto/invoice_responses.go b/internal/application/finance/dto/invoice_responses.go new file mode 100644 index 0000000..742d946 --- /dev/null +++ b/internal/application/finance/dto/invoice_responses.go @@ -0,0 +1,121 @@ +package dto + +import ( + "time" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/value_objects" + + "github.com/shopspring/decimal" +) + +// InvoiceApplicationResponse 发票申请响应 +type InvoiceApplicationResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + InvoiceType value_objects.InvoiceType `json:"invoice_type"` + Amount decimal.Decimal `json:"amount"` + Status entities.ApplicationStatus `json:"status"` + InvoiceInfo *value_objects.InvoiceInfo `json:"invoice_info"` + CreatedAt time.Time `json:"created_at"` +} + +// InvoiceInfoResponse 发票信息响应 +type InvoiceInfoResponse struct { + CompanyName string `json:"company_name"` // 从企业认证信息获取,只读 + TaxpayerID string `json:"taxpayer_id"` // 从企业认证信息获取,只读 + BankName string `json:"bank_name"` // 用户可编辑 + BankAccount string `json:"bank_account"` // 用户可编辑 + CompanyAddress string `json:"company_address"` // 用户可编辑 + CompanyPhone string `json:"company_phone"` // 用户可编辑 + ReceivingEmail string `json:"receiving_email"` // 用户可编辑 + IsComplete bool `json:"is_complete"` + MissingFields []string `json:"missing_fields,omitempty"` + // 字段权限标识 + CompanyNameReadOnly bool `json:"company_name_read_only"` // 公司名称是否只读 + TaxpayerIDReadOnly bool `json:"taxpayer_id_read_only"` // 纳税人识别号是否只读 +} + +// InvoiceRecordResponse 发票记录响应 +type InvoiceRecordResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + InvoiceType value_objects.InvoiceType `json:"invoice_type"` + Amount decimal.Decimal `json:"amount"` + Status entities.ApplicationStatus `json:"status"` + // 开票信息(快照数据) + CompanyName string `json:"company_name"` // 公司名称 + TaxpayerID string `json:"taxpayer_id"` // 纳税人识别号 + BankName string `json:"bank_name"` // 开户银行 + BankAccount string `json:"bank_account"` // 银行账号 + CompanyAddress string `json:"company_address"` // 企业地址 + CompanyPhone string `json:"company_phone"` // 企业电话 + ReceivingEmail string `json:"receiving_email"` // 接收邮箱 + // 文件信息 + FileName *string `json:"file_name,omitempty"` + FileSize *int64 `json:"file_size,omitempty"` + FileURL *string `json:"file_url,omitempty"` + // 时间信息 + ProcessedAt *time.Time `json:"processed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + // 拒绝原因 + RejectReason *string `json:"reject_reason,omitempty"` +} + +// InvoiceRecordsResponse 发票记录列表响应 +type InvoiceRecordsResponse struct { + Records []*InvoiceRecordResponse `json:"records"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// FileDownloadResponse 文件下载响应 +type FileDownloadResponse struct { + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + FileURL string `json:"file_url"` + FileContent []byte `json:"file_content"` +} + +// AvailableAmountResponse 可开票金额响应 +type AvailableAmountResponse struct { + AvailableAmount decimal.Decimal `json:"available_amount"` // 可开票金额 + TotalRecharged decimal.Decimal `json:"total_recharged"` // 总充值金额 + TotalGifted decimal.Decimal `json:"total_gifted"` // 总赠送金额 + TotalInvoiced decimal.Decimal `json:"total_invoiced"` // 已开票金额 + PendingApplications decimal.Decimal `json:"pending_applications"` // 待处理申请金额 +} + +// PendingApplicationResponse 待处理申请响应 +type PendingApplicationResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + InvoiceType value_objects.InvoiceType `json:"invoice_type"` + Amount decimal.Decimal `json:"amount"` + Status entities.ApplicationStatus `json:"status"` + CompanyName string `json:"company_name"` + TaxpayerID string `json:"taxpayer_id"` + BankName string `json:"bank_name"` + BankAccount string `json:"bank_account"` + CompanyAddress string `json:"company_address"` + CompanyPhone string `json:"company_phone"` + ReceivingEmail string `json:"receiving_email"` + FileName *string `json:"file_name,omitempty"` + FileSize *int64 `json:"file_size,omitempty"` + FileURL *string `json:"file_url,omitempty"` + ProcessedAt *time.Time `json:"processed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + RejectReason *string `json:"reject_reason,omitempty"` +} + +// PendingApplicationsResponse 待处理申请列表响应 +type PendingApplicationsResponse struct { + Applications []*PendingApplicationResponse `json:"applications"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} \ No newline at end of file diff --git a/internal/application/finance/dto/responses/finance_responses.go b/internal/application/finance/dto/responses/finance_responses.go index 4864b08..b0b772b 100644 --- a/internal/application/finance/dto/responses/finance_responses.go +++ b/internal/application/finance/dto/responses/finance_responses.go @@ -58,6 +58,8 @@ type RechargeRecordResponse struct { TransferOrderID string `json:"transfer_order_id,omitempty"` Notes string `json:"notes,omitempty"` OperatorID string `json:"operator_id,omitempty"` + CompanyName string `json:"company_name,omitempty"` + User *UserSimpleResponse `json:"user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -71,6 +73,8 @@ type WalletTransactionResponse struct { ProductID string `json:"product_id"` ProductName string `json:"product_name"` Amount decimal.Decimal `json:"amount"` + CompanyName string `json:"company_name,omitempty"` + User *UserSimpleResponse `json:"user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -112,3 +116,10 @@ type AlipayRechargeBonusRuleResponse struct { RechargeAmount float64 `json:"recharge_amount"` BonusAmount float64 `json:"bonus_amount"` } + +// UserSimpleResponse 用户简单信息响应 +type UserSimpleResponse struct { + ID string `json:"id"` + CompanyName string `json:"company_name"` + Phone string `json:"phone"` +} diff --git a/internal/application/finance/finance_application_service.go b/internal/application/finance/finance_application_service.go index 2d4c30d..c4ea0de 100644 --- a/internal/application/finance/finance_application_service.go +++ b/internal/application/finance/finance_application_service.go @@ -26,6 +26,9 @@ type FinanceApplicationService interface { // 获取用户钱包交易记录 GetUserWalletTransactions(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*responses.WalletTransactionListResponse, error) + // 管理端消费记录 + GetAdminWalletTransactions(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.WalletTransactionListResponse, error) + // 获取用户充值记录 GetUserRechargeRecords(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*responses.RechargeRecordListResponse, error) diff --git a/internal/application/finance/finance_application_service_impl.go b/internal/application/finance/finance_application_service_impl.go index d3854f6..c80857f 100644 --- a/internal/application/finance/finance_application_service_impl.go +++ b/internal/application/finance/finance_application_service_impl.go @@ -11,6 +11,7 @@ import ( finance_entities "tyapi-server/internal/domains/finance/entities" finance_repositories "tyapi-server/internal/domains/finance/repositories" finance_services "tyapi-server/internal/domains/finance/services" + user_repositories "tyapi-server/internal/domains/user/repositories" "tyapi-server/internal/shared/database" "tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/payment" @@ -27,6 +28,7 @@ type FinanceApplicationServiceImpl struct { rechargeRecordService finance_services.RechargeRecordService walletTransactionRepository finance_repositories.WalletTransactionRepository alipayOrderRepo finance_repositories.AlipayOrderRepository + userRepo user_repositories.UserRepository txManager *database.TransactionManager logger *zap.Logger config *config.Config @@ -39,6 +41,7 @@ func NewFinanceApplicationService( rechargeRecordService finance_services.RechargeRecordService, walletTransactionRepository finance_repositories.WalletTransactionRepository, alipayOrderRepo finance_repositories.AlipayOrderRepository, + userRepo user_repositories.UserRepository, txManager *database.TransactionManager, logger *zap.Logger, config *config.Config, @@ -49,6 +52,7 @@ func NewFinanceApplicationService( rechargeRecordService: rechargeRecordService, walletTransactionRepository: walletTransactionRepository, alipayOrderRepo: alipayOrderRepo, + userRepo: userRepo, txManager: txManager, logger: logger, config: config, @@ -290,6 +294,55 @@ func (s *FinanceApplicationServiceImpl) GetUserWalletTransactions(ctx context.Co }, nil } +// GetAdminWalletTransactions 获取管理端钱包交易记录 +func (s *FinanceApplicationServiceImpl) GetAdminWalletTransactions(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.WalletTransactionListResponse, error) { + // 查询钱包交易记录(包含产品名称) + productNameMap, transactions, total, err := s.walletTransactionRepository.ListWithFiltersAndProductName(ctx, filters, options) + if err != nil { + s.logger.Error("查询管理端钱包交易记录失败", zap.Error(err)) + return nil, err + } + + // 转换为响应DTO + var items []responses.WalletTransactionResponse + for _, transaction := range transactions { + item := responses.WalletTransactionResponse{ + ID: transaction.ID, + UserID: transaction.UserID, + ApiCallID: transaction.ApiCallID, + TransactionID: transaction.TransactionID, + ProductID: transaction.ProductID, + ProductName: productNameMap[transaction.ProductID], // 从映射中获取产品名称 + Amount: transaction.Amount, + CreatedAt: transaction.CreatedAt, + UpdatedAt: transaction.UpdatedAt, + } + + // 获取用户信息和企业名称 + user, err := s.userRepo.GetByIDWithEnterpriseInfo(ctx, transaction.UserID) + if err == nil { + companyName := "未知企业" + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + } + item.CompanyName = companyName + item.User = &responses.UserSimpleResponse{ + ID: user.ID, + CompanyName: companyName, + Phone: user.Phone, + } + } + + items = append(items, item) + } + + return &responses.WalletTransactionListResponse{ + Items: items, + Total: total, + Page: options.Page, + Size: options.PageSize, + }, nil +} // HandleAlipayCallback 处理支付宝回调 @@ -592,19 +645,19 @@ func (s *FinanceApplicationServiceImpl) GetUserRechargeRecords(ctx context.Conte }, nil } -// GetAdminRechargeRecords 管理员获取充值记录 +// GetAdminRechargeRecords 获取管理端充值记录 func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.RechargeRecordListResponse, error) { - // 查询所有充值记录(管理员可以查看所有用户的充值记录) + // 查询充值记录 records, err := s.rechargeRecordService.GetAll(ctx, filters, options) if err != nil { - s.logger.Error("查询管理员充值记录失败", zap.Error(err)) + s.logger.Error("查询管理端充值记录失败", zap.Error(err)) return nil, err } // 获取总数 total, err := s.rechargeRecordService.Count(ctx, filters) if err != nil { - s.logger.Error("统计管理员充值记录失败", zap.Error(err)) + s.logger.Error("统计管理端充值记录失败", zap.Error(err)) return nil, err } @@ -612,14 +665,14 @@ func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Cont var items []responses.RechargeRecordResponse for _, record := range records { item := responses.RechargeRecordResponse{ - ID: record.ID, - UserID: record.UserID, - Amount: record.Amount, - RechargeType: string(record.RechargeType), - Status: string(record.Status), - Notes: record.Notes, - CreatedAt: record.CreatedAt, - UpdatedAt: record.UpdatedAt, + ID: record.ID, + UserID: record.UserID, + Amount: record.Amount, + RechargeType: string(record.RechargeType), + Status: string(record.Status), + Notes: record.Notes, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, } // 根据充值类型设置相应的订单号 @@ -630,6 +683,21 @@ func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Cont item.TransferOrderID = *record.TransferOrderID } + // 获取用户信息和企业名称 + user, err := s.userRepo.GetByIDWithEnterpriseInfo(ctx, record.UserID) + if err == nil { + companyName := "未知企业" + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + } + item.CompanyName = companyName + item.User = &responses.UserSimpleResponse{ + ID: user.ID, + CompanyName: companyName, + Phone: user.Phone, + } + } + items = append(items, item) } diff --git a/internal/application/finance/invoice_application_service.go b/internal/application/finance/invoice_application_service.go new file mode 100644 index 0000000..67fab6d --- /dev/null +++ b/internal/application/finance/invoice_application_service.go @@ -0,0 +1,750 @@ +package finance + +import ( + "context" + "fmt" + "mime/multipart" + "time" + + "tyapi-server/internal/application/finance/dto" + "tyapi-server/internal/domains/finance/entities" + finance_repo "tyapi-server/internal/domains/finance/repositories" + "tyapi-server/internal/domains/finance/services" + "tyapi-server/internal/domains/finance/value_objects" + user_repo "tyapi-server/internal/domains/user/repositories" + user_service "tyapi-server/internal/domains/user/services" + "tyapi-server/internal/infrastructure/external/storage" + + "github.com/shopspring/decimal" + "go.uber.org/zap" +) + +// ==================== 用户端发票应用服务 ==================== + +// InvoiceApplicationService 发票应用服务接口 +// 职责:跨域协调、数据聚合、事务管理、外部服务调用 +type InvoiceApplicationService interface { + // ApplyInvoice 申请开票 + ApplyInvoice(ctx context.Context, userID string, req ApplyInvoiceRequest) (*dto.InvoiceApplicationResponse, error) + + // GetUserInvoiceInfo 获取用户发票信息 + GetUserInvoiceInfo(ctx context.Context, userID string) (*dto.InvoiceInfoResponse, error) + + // UpdateUserInvoiceInfo 更新用户发票信息 + UpdateUserInvoiceInfo(ctx context.Context, userID string, req UpdateInvoiceInfoRequest) error + + // GetUserInvoiceRecords 获取用户开票记录 + GetUserInvoiceRecords(ctx context.Context, userID string, req GetInvoiceRecordsRequest) (*dto.InvoiceRecordsResponse, error) + + // DownloadInvoiceFile 下载发票文件 + DownloadInvoiceFile(ctx context.Context, userID string, applicationID string) (*dto.FileDownloadResponse, error) + + // GetAvailableAmount 获取可开票金额 + GetAvailableAmount(ctx context.Context, userID string) (*dto.AvailableAmountResponse, error) +} + +// InvoiceApplicationServiceImpl 发票应用服务实现 +type InvoiceApplicationServiceImpl struct { + // 仓储层依赖 + invoiceRepo finance_repo.InvoiceApplicationRepository + userInvoiceInfoRepo finance_repo.UserInvoiceInfoRepository + userRepo user_repo.UserRepository + rechargeRecordRepo finance_repo.RechargeRecordRepository + walletRepo finance_repo.WalletRepository + + // 领域服务依赖 + invoiceDomainService services.InvoiceDomainService + invoiceAggregateService services.InvoiceAggregateService + userInvoiceInfoService services.UserInvoiceInfoService + userAggregateService user_service.UserAggregateService + + // 外部服务依赖 + storageService *storage.QiNiuStorageService + logger *zap.Logger +} + +// NewInvoiceApplicationService 创建发票应用服务 +func NewInvoiceApplicationService( + invoiceRepo finance_repo.InvoiceApplicationRepository, + userInvoiceInfoRepo finance_repo.UserInvoiceInfoRepository, + userRepo user_repo.UserRepository, + userAggregateService user_service.UserAggregateService, + rechargeRecordRepo finance_repo.RechargeRecordRepository, + walletRepo finance_repo.WalletRepository, + invoiceDomainService services.InvoiceDomainService, + invoiceAggregateService services.InvoiceAggregateService, + userInvoiceInfoService services.UserInvoiceInfoService, + storageService *storage.QiNiuStorageService, + logger *zap.Logger, +) InvoiceApplicationService { + return &InvoiceApplicationServiceImpl{ + invoiceRepo: invoiceRepo, + userInvoiceInfoRepo: userInvoiceInfoRepo, + userRepo: userRepo, + userAggregateService: userAggregateService, + rechargeRecordRepo: rechargeRecordRepo, + walletRepo: walletRepo, + invoiceDomainService: invoiceDomainService, + invoiceAggregateService: invoiceAggregateService, + userInvoiceInfoService: userInvoiceInfoService, + storageService: storageService, + logger: logger, + } +} + +// ApplyInvoice 申请开票 +func (s *InvoiceApplicationServiceImpl) ApplyInvoice(ctx context.Context, userID string, req ApplyInvoiceRequest) (*dto.InvoiceApplicationResponse, error) { + // 1. 验证用户是否存在 + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + if user.ID == "" { + return nil, fmt.Errorf("用户不存在") + } + + // 2. 验证发票类型 + invoiceType := value_objects.InvoiceType(req.InvoiceType) + if !invoiceType.IsValid() { + return nil, fmt.Errorf("无效的发票类型") + } + + // 3. 获取用户企业认证信息 + userWithEnterprise, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户企业认证信息失败: %w", err) + } + + // 4. 检查用户是否有企业认证信息 + if userWithEnterprise.EnterpriseInfo == nil { + return nil, fmt.Errorf("用户未完成企业认证,无法申请开票") + } + + // 5. 获取用户开票信息 + userInvoiceInfo, err := s.userInvoiceInfoService.GetUserInvoiceInfoWithEnterpriseInfo( + ctx, + userID, + userWithEnterprise.EnterpriseInfo.CompanyName, + userWithEnterprise.EnterpriseInfo.UnifiedSocialCode, + ) + if err != nil { + return nil, err + } + + // 6. 验证开票信息完整性 + invoiceInfo := value_objects.NewInvoiceInfo( + userInvoiceInfo.CompanyName, + userInvoiceInfo.TaxpayerID, + userInvoiceInfo.BankName, + userInvoiceInfo.BankAccount, + userInvoiceInfo.CompanyAddress, + userInvoiceInfo.CompanyPhone, + userInvoiceInfo.ReceivingEmail, + ) + + if err := s.userInvoiceInfoService.ValidateInvoiceInfo(ctx, invoiceInfo, invoiceType); err != nil { + return nil, err + } + + // 7. 计算可开票金额 + availableAmount, err := s.calculateAvailableAmount(ctx, userID) + if err != nil { + return nil, fmt.Errorf("计算可开票金额失败: %w", err) + } + + // 8. 验证开票金额 + amount, err := decimal.NewFromString(req.Amount) + if err != nil { + return nil, fmt.Errorf("无效的金额格式: %w", err) + } + + if err := s.invoiceDomainService.ValidateInvoiceAmount(ctx, amount, availableAmount); err != nil { + return nil, err + } + + // 9. 调用聚合服务申请开票 + aggregateReq := services.ApplyInvoiceRequest{ + InvoiceType: invoiceType, + Amount: req.Amount, + InvoiceInfo: invoiceInfo, + } + + application, err := s.invoiceAggregateService.ApplyInvoice(ctx, userID, aggregateReq) + if err != nil { + return nil, err + } + + // 10. 构建响应DTO + return &dto.InvoiceApplicationResponse{ + ID: application.ID, + UserID: application.UserID, + InvoiceType: application.InvoiceType, + Amount: application.Amount, + Status: application.Status, + InvoiceInfo: invoiceInfo, + CreatedAt: application.CreatedAt, + }, nil +} + +// GetUserInvoiceInfo 获取用户发票信息 +func (s *InvoiceApplicationServiceImpl) GetUserInvoiceInfo(ctx context.Context, userID string) (*dto.InvoiceInfoResponse, error) { + // 1. 获取用户企业认证信息 + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户企业认证信息失败: %w", err) + } + + // 2. 获取企业认证信息 + var companyName, taxpayerID string + var companyNameReadOnly, taxpayerIDReadOnly bool + + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + taxpayerID = user.EnterpriseInfo.UnifiedSocialCode + companyNameReadOnly = true + taxpayerIDReadOnly = true + } + + // 3. 获取用户开票信息(包含企业认证信息) + userInvoiceInfo, err := s.userInvoiceInfoService.GetUserInvoiceInfoWithEnterpriseInfo(ctx, userID, companyName, taxpayerID) + if err != nil { + return nil, err + } + + // 4. 构建响应DTO + return &dto.InvoiceInfoResponse{ + CompanyName: userInvoiceInfo.CompanyName, + TaxpayerID: userInvoiceInfo.TaxpayerID, + BankName: userInvoiceInfo.BankName, + BankAccount: userInvoiceInfo.BankAccount, + CompanyAddress: userInvoiceInfo.CompanyAddress, + CompanyPhone: userInvoiceInfo.CompanyPhone, + ReceivingEmail: userInvoiceInfo.ReceivingEmail, + IsComplete: userInvoiceInfo.IsComplete(), + MissingFields: userInvoiceInfo.GetMissingFields(), + // 字段权限标识 + CompanyNameReadOnly: companyNameReadOnly, // 公司名称只读(从企业认证信息获取) + TaxpayerIDReadOnly: taxpayerIDReadOnly, // 纳税人识别号只读(从企业认证信息获取) + }, nil +} + +// UpdateUserInvoiceInfo 更新用户发票信息 +func (s *InvoiceApplicationServiceImpl) UpdateUserInvoiceInfo(ctx context.Context, userID string, req UpdateInvoiceInfoRequest) error { + // 1. 获取用户企业认证信息 + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return fmt.Errorf("获取用户企业认证信息失败: %w", err) + } + + // 2. 检查用户是否有企业认证信息 + if user.EnterpriseInfo == nil { + return fmt.Errorf("用户未完成企业认证,无法创建开票信息") + } + + // 3. 创建开票信息对象,公司名称和纳税人识别号从企业认证信息中获取 + invoiceInfo := value_objects.NewInvoiceInfo( + "", // 公司名称将由服务层从企业认证信息中获取 + "", // 纳税人识别号将由服务层从企业认证信息中获取 + req.BankName, + req.BankAccount, + req.CompanyAddress, + req.CompanyPhone, + req.ReceivingEmail, + ) + + // 4. 使用包含企业认证信息的方法 + _, err = s.userInvoiceInfoService.CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo( + ctx, + userID, + invoiceInfo, + user.EnterpriseInfo.CompanyName, + user.EnterpriseInfo.UnifiedSocialCode, + ) + return err +} + +// GetUserInvoiceRecords 获取用户开票记录 +func (s *InvoiceApplicationServiceImpl) GetUserInvoiceRecords(ctx context.Context, userID string, req GetInvoiceRecordsRequest) (*dto.InvoiceRecordsResponse, error) { + // 1. 验证用户是否存在 + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + if user.ID == "" { + return nil, fmt.Errorf("用户不存在") + } + + // 2. 获取发票申请记录 + var status entities.ApplicationStatus + if req.Status != "" { + status = entities.ApplicationStatus(req.Status) + } + + // 3. 解析时间范围 + var startTime, endTime *time.Time + if req.StartTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", req.StartTime); err == nil { + startTime = &t + } + } + if req.EndTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", req.EndTime); err == nil { + endTime = &t + } + } + + // 4. 获取发票申请记录(需要更新仓储层方法以支持时间筛选) + applications, total, err := s.invoiceRepo.FindByUserIDAndStatusWithTimeRange(ctx, userID, status, startTime, endTime, req.Page, req.PageSize) + if err != nil { + return nil, err + } + + // 5. 构建响应DTO + records := make([]*dto.InvoiceRecordResponse, len(applications)) + for i, app := range applications { + // 使用快照信息(申请时的开票信息) + records[i] = &dto.InvoiceRecordResponse{ + ID: app.ID, + UserID: app.UserID, + InvoiceType: app.InvoiceType, + Amount: app.Amount, + Status: app.Status, + CompanyName: app.CompanyName, // 使用快照的公司名称 + TaxpayerID: app.TaxpayerID, // 使用快照的纳税人识别号 + BankName: app.BankName, // 使用快照的银行名称 + BankAccount: app.BankAccount, // 使用快照的银行账号 + CompanyAddress: app.CompanyAddress, // 使用快照的企业地址 + CompanyPhone: app.CompanyPhone, // 使用快照的企业电话 + ReceivingEmail: app.ReceivingEmail, // 使用快照的接收邮箱 + FileName: app.FileName, + FileSize: app.FileSize, + FileURL: app.FileURL, + ProcessedAt: app.ProcessedAt, + CreatedAt: app.CreatedAt, + RejectReason: app.RejectReason, + } + } + + return &dto.InvoiceRecordsResponse{ + Records: records, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: (int(total) + req.PageSize - 1) / req.PageSize, + }, nil +} + +// DownloadInvoiceFile 下载发票文件 +func (s *InvoiceApplicationServiceImpl) DownloadInvoiceFile(ctx context.Context, userID string, applicationID string) (*dto.FileDownloadResponse, error) { + // 1. 查找申请记录 + application, err := s.invoiceRepo.FindByID(ctx, applicationID) + if err != nil { + return nil, err + } + if application == nil { + return nil, fmt.Errorf("申请记录不存在") + } + + // 2. 验证权限(只能下载自己的发票) + if application.UserID != userID { + return nil, fmt.Errorf("无权访问此发票") + } + + // 3. 验证状态(只能下载已完成的发票) + if application.Status != entities.ApplicationStatusCompleted { + return nil, fmt.Errorf("发票尚未通过审核") + } + + // 4. 验证文件信息 + if application.FileURL == nil || *application.FileURL == "" { + return nil, fmt.Errorf("发票文件不存在") + } + + // 5. 从七牛云下载文件内容 + fileContent, err := s.storageService.DownloadFile(ctx, *application.FileURL) + if err != nil { + return nil, fmt.Errorf("下载文件失败: %w", err) + } + + // 6. 构建响应DTO + return &dto.FileDownloadResponse{ + FileID: *application.FileID, + FileName: *application.FileName, + FileSize: *application.FileSize, + FileURL: *application.FileURL, + FileContent: fileContent, + }, nil +} + +// GetAvailableAmount 获取可开票金额 +func (s *InvoiceApplicationServiceImpl) GetAvailableAmount(ctx context.Context, userID string) (*dto.AvailableAmountResponse, error) { + // 1. 验证用户是否存在 + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + if user.ID == "" { + return nil, fmt.Errorf("用户不存在") + } + + // 2. 计算可开票金额 + availableAmount, err := s.calculateAvailableAmount(ctx, userID) + if err != nil { + return nil, err + } + + // 3. 获取真实充值金额(支付宝充值+对公转账)和总赠送金额 + realRecharged, totalGifted, totalInvoiced, err := s.getAmountSummary(ctx, userID) + if err != nil { + return nil, err + } + + // 4. 获取待处理申请金额 + pendingAmount, err := s.getPendingApplicationsAmount(ctx, userID) + if err != nil { + return nil, err + } + + // 5. 构建响应DTO + return &dto.AvailableAmountResponse{ + AvailableAmount: availableAmount, + TotalRecharged: realRecharged, // 使用真实充值金额(支付宝充值+对公转账) + TotalGifted: totalGifted, + TotalInvoiced: totalInvoiced, + PendingApplications: pendingAmount, + }, nil +} + +// calculateAvailableAmount 计算可开票金额(私有方法) +func (s *InvoiceApplicationServiceImpl) calculateAvailableAmount(ctx context.Context, userID string) (decimal.Decimal, error) { + // 1. 获取真实充值金额(支付宝充值+对公转账)和总赠送金额 + realRecharged, totalGifted, totalInvoiced, err := s.getAmountSummary(ctx, userID) + if err != nil { + return decimal.Zero, err + } + + // 2. 获取待处理中的申请金额 + pendingAmount, err := s.getPendingApplicationsAmount(ctx, userID) + if err != nil { + return decimal.Zero, err + } + fmt.Println("realRecharged", realRecharged) + fmt.Println("totalGifted", totalGifted) + fmt.Println("totalInvoiced", totalInvoiced) + fmt.Println("pendingAmount", pendingAmount) + // 3. 计算可开票金额:真实充值金额 - 已开票 - 待处理申请 + // 可开票金额 = 真实充值金额(支付宝充值+对公转账) - 已开票金额 - 待处理申请金额 + availableAmount := realRecharged.Sub(totalInvoiced).Sub(pendingAmount) + fmt.Println("availableAmount", availableAmount) + // 确保可开票金额不为负数 + if availableAmount.LessThan(decimal.Zero) { + availableAmount = decimal.Zero + } + + return availableAmount, nil +} + +// getAmountSummary 获取金额汇总(私有方法) +func (s *InvoiceApplicationServiceImpl) getAmountSummary(ctx context.Context, userID string) (decimal.Decimal, decimal.Decimal, decimal.Decimal, error) { + // 1. 获取用户所有成功的充值记录 + rechargeRecords, err := s.rechargeRecordRepo.GetByUserID(ctx, userID) + if err != nil { + return decimal.Zero, decimal.Zero, decimal.Zero, fmt.Errorf("获取充值记录失败: %w", err) + } + + // 2. 计算真实充值金额(支付宝充值 + 对公转账)和总赠送金额 + var realRecharged decimal.Decimal // 真实充值金额:支付宝充值 + 对公转账 + var totalGifted decimal.Decimal // 总赠送金额 + for _, record := range rechargeRecords { + if record.IsSuccess() { + if record.RechargeType == entities.RechargeTypeGift { + // 赠送金额不计入可开票金额 + totalGifted = totalGifted.Add(record.Amount) + } else if record.RechargeType == entities.RechargeTypeAlipay || record.RechargeType == entities.RechargeTypeTransfer { + // 只有支付宝充值和对公转账计入可开票金额 + realRecharged = realRecharged.Add(record.Amount) + } + } + } + + // 3. 获取用户所有发票申请记录(包括待处理、已完成、已拒绝) + applications, _, err := s.invoiceRepo.FindByUserID(ctx, userID, 1, 1000) // 获取所有记录 + if err != nil { + return decimal.Zero, decimal.Zero, decimal.Zero, fmt.Errorf("获取发票申请记录失败: %w", err) + } + + var totalInvoiced decimal.Decimal + for _, application := range applications { + // 计算已完成的发票申请金额 + if application.IsCompleted() { + totalInvoiced = totalInvoiced.Add(application.Amount) + } + // 注意:待处理中的申请金额不计算在已开票金额中,但会在可开票金额计算时被扣除 + } + + return realRecharged, totalGifted, totalInvoiced, nil +} + +// getPendingApplicationsAmount 获取待处理申请的总金额(私有方法) +func (s *InvoiceApplicationServiceImpl) getPendingApplicationsAmount(ctx context.Context, userID string) (decimal.Decimal, error) { + // 获取用户所有发票申请记录 + applications, _, err := s.invoiceRepo.FindByUserID(ctx, userID, 1, 1000) + if err != nil { + return decimal.Zero, fmt.Errorf("获取发票申请记录失败: %w", err) + } + + var pendingAmount decimal.Decimal + for _, application := range applications { + // 只计算待处理状态的申请金额 + if application.Status == entities.ApplicationStatusPending { + pendingAmount = pendingAmount.Add(application.Amount) + } + } + + return pendingAmount, nil +} + +// ==================== 管理员端发票应用服务 ==================== + +// AdminInvoiceApplicationService 管理员发票应用服务接口 +type AdminInvoiceApplicationService interface { + // GetPendingApplications 获取发票申请列表(支持筛选) + GetPendingApplications(ctx context.Context, req GetPendingApplicationsRequest) (*dto.PendingApplicationsResponse, error) + + // ApproveInvoiceApplication 通过发票申请 + ApproveInvoiceApplication(ctx context.Context, applicationID string, file multipart.File, req ApproveInvoiceRequest) error + + // RejectInvoiceApplication 拒绝发票申请 + RejectInvoiceApplication(ctx context.Context, applicationID string, req RejectInvoiceRequest) error + + // DownloadInvoiceFile 下载发票文件(管理员) + DownloadInvoiceFile(ctx context.Context, applicationID string) (*dto.FileDownloadResponse, error) +} + +// AdminInvoiceApplicationServiceImpl 管理员发票应用服务实现 +type AdminInvoiceApplicationServiceImpl struct { + invoiceRepo finance_repo.InvoiceApplicationRepository + userInvoiceInfoRepo finance_repo.UserInvoiceInfoRepository + userRepo user_repo.UserRepository + invoiceAggregateService services.InvoiceAggregateService + storageService *storage.QiNiuStorageService + logger *zap.Logger +} + +// NewAdminInvoiceApplicationService 创建管理员发票应用服务 +func NewAdminInvoiceApplicationService( + invoiceRepo finance_repo.InvoiceApplicationRepository, + userInvoiceInfoRepo finance_repo.UserInvoiceInfoRepository, + userRepo user_repo.UserRepository, + invoiceAggregateService services.InvoiceAggregateService, + storageService *storage.QiNiuStorageService, + logger *zap.Logger, +) AdminInvoiceApplicationService { + return &AdminInvoiceApplicationServiceImpl{ + invoiceRepo: invoiceRepo, + userInvoiceInfoRepo: userInvoiceInfoRepo, + userRepo: userRepo, + invoiceAggregateService: invoiceAggregateService, + storageService: storageService, + logger: logger, + } +} + +// GetPendingApplications 获取发票申请列表(支持筛选) +func (s *AdminInvoiceApplicationServiceImpl) GetPendingApplications(ctx context.Context, req GetPendingApplicationsRequest) (*dto.PendingApplicationsResponse, error) { + // 1. 解析状态筛选 + var status entities.ApplicationStatus + if req.Status != "" { + status = entities.ApplicationStatus(req.Status) + } + + // 2. 解析时间范围 + var startTime, endTime *time.Time + if req.StartTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", req.StartTime); err == nil { + startTime = &t + } + } + if req.EndTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", req.EndTime); err == nil { + endTime = &t + } + } + + // 3. 获取发票申请记录(支持筛选) + var applications []*entities.InvoiceApplication + var total int64 + var err error + + if status != "" { + // 按状态筛选 + applications, total, err = s.invoiceRepo.FindByStatusWithTimeRange(ctx, status, startTime, endTime, req.Page, req.PageSize) + } else { + // 获取所有记录(按时间筛选) + applications, total, err = s.invoiceRepo.FindAllWithTimeRange(ctx, startTime, endTime, req.Page, req.PageSize) + } + + if err != nil { + return nil, err + } + + // 4. 构建响应DTO + pendingApplications := make([]*dto.PendingApplicationResponse, len(applications)) + for i, app := range applications { + // 使用快照信息 + pendingApplications[i] = &dto.PendingApplicationResponse{ + ID: app.ID, + UserID: app.UserID, + InvoiceType: app.InvoiceType, + Amount: app.Amount, + Status: app.Status, + CompanyName: app.CompanyName, // 使用快照的公司名称 + TaxpayerID: app.TaxpayerID, // 使用快照的纳税人识别号 + BankName: app.BankName, // 使用快照的银行名称 + BankAccount: app.BankAccount, // 使用快照的银行账号 + CompanyAddress: app.CompanyAddress, // 使用快照的企业地址 + CompanyPhone: app.CompanyPhone, // 使用快照的企业电话 + ReceivingEmail: app.ReceivingEmail, // 使用快照的接收邮箱 + FileName: app.FileName, + FileSize: app.FileSize, + FileURL: app.FileURL, + ProcessedAt: app.ProcessedAt, + CreatedAt: app.CreatedAt, + RejectReason: app.RejectReason, + } + } + + return &dto.PendingApplicationsResponse{ + Applications: pendingApplications, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: (int(total) + req.PageSize - 1) / req.PageSize, + }, nil +} + +// ApproveInvoiceApplication 通过发票申请 +func (s *AdminInvoiceApplicationServiceImpl) ApproveInvoiceApplication(ctx context.Context, applicationID string, file multipart.File, req ApproveInvoiceRequest) error { + // 1. 验证申请是否存在 + application, err := s.invoiceRepo.FindByID(ctx, applicationID) + if err != nil { + return err + } + if application == nil { + return fmt.Errorf("发票申请不存在") + } + + // 2. 验证申请状态 + if application.Status != entities.ApplicationStatusPending { + return fmt.Errorf("发票申请状态不允许处理") + } + + // 3. 调用聚合服务处理申请 + aggregateReq := services.ApproveInvoiceRequest{ + AdminNotes: req.AdminNotes, + } + + return s.invoiceAggregateService.ApproveInvoiceApplication(ctx, applicationID, file, aggregateReq) +} + +// RejectInvoiceApplication 拒绝发票申请 +func (s *AdminInvoiceApplicationServiceImpl) RejectInvoiceApplication(ctx context.Context, applicationID string, req RejectInvoiceRequest) error { + // 1. 验证申请是否存在 + application, err := s.invoiceRepo.FindByID(ctx, applicationID) + if err != nil { + return err + } + if application == nil { + return fmt.Errorf("发票申请不存在") + } + + // 2. 验证申请状态 + if application.Status != entities.ApplicationStatusPending { + return fmt.Errorf("发票申请状态不允许处理") + } + + // 3. 调用聚合服务处理申请 + aggregateReq := services.RejectInvoiceRequest{ + Reason: req.Reason, + } + + return s.invoiceAggregateService.RejectInvoiceApplication(ctx, applicationID, aggregateReq) +} + +// ==================== 请求和响应DTO ==================== + +type ApplyInvoiceRequest struct { + InvoiceType string `json:"invoice_type" binding:"required"` // 发票类型:general/special + Amount string `json:"amount" binding:"required"` // 开票金额 +} + +type UpdateInvoiceInfoRequest struct { + CompanyName string `json:"company_name"` // 公司名称(从企业认证信息获取,用户不可修改) + TaxpayerID string `json:"taxpayer_id"` // 纳税人识别号(从企业认证信息获取,用户不可修改) + BankName string `json:"bank_name"` // 银行名称 + CompanyAddress string `json:"company_address"` // 公司地址 + BankAccount string `json:"bank_account"` // 银行账户 + CompanyPhone string `json:"company_phone"` // 企业注册电话 + ReceivingEmail string `json:"receiving_email" binding:"required,email"` // 发票接收邮箱 +} + +type GetInvoiceRecordsRequest struct { + Page int `json:"page"` // 页码 + PageSize int `json:"page_size"` // 每页数量 + Status string `json:"status"` // 状态筛选 + StartTime string `json:"start_time"` // 开始时间 (格式: 2006-01-02 15:04:05) + EndTime string `json:"end_time"` // 结束时间 (格式: 2006-01-02 15:04:05) +} + +type GetPendingApplicationsRequest struct { + Page int `json:"page"` // 页码 + PageSize int `json:"page_size"` // 每页数量 + Status string `json:"status"` // 状态筛选:pending/completed/rejected + StartTime string `json:"start_time"` // 开始时间 (格式: 2006-01-02 15:04:05) + EndTime string `json:"end_time"` // 结束时间 (格式: 2006-01-02 15:04:05) +} + +type ApproveInvoiceRequest struct { + AdminNotes string `json:"admin_notes"` // 管理员备注 +} + +type RejectInvoiceRequest struct { + Reason string `json:"reason" binding:"required"` // 拒绝原因 +} + +// DownloadInvoiceFile 下载发票文件(管理员) +func (s *AdminInvoiceApplicationServiceImpl) DownloadInvoiceFile(ctx context.Context, applicationID string) (*dto.FileDownloadResponse, error) { + // 1. 查找申请记录 + application, err := s.invoiceRepo.FindByID(ctx, applicationID) + if err != nil { + return nil, err + } + if application == nil { + return nil, fmt.Errorf("申请记录不存在") + } + + // 2. 验证状态(只能下载已完成的发票) + if application.Status != entities.ApplicationStatusCompleted { + return nil, fmt.Errorf("发票尚未通过审核") + } + + // 3. 验证文件信息 + if application.FileURL == nil || *application.FileURL == "" { + return nil, fmt.Errorf("发票文件不存在") + } + + // 4. 从七牛云下载文件内容 + fileContent, err := s.storageService.DownloadFile(ctx, *application.FileURL) + if err != nil { + return nil, fmt.Errorf("下载文件失败: %w", err) + } + + // 5. 构建响应DTO + return &dto.FileDownloadResponse{ + FileID: *application.FileID, + FileName: *application.FileName, + FileSize: *application.FileSize, + FileURL: *application.FileURL, + FileContent: fileContent, + }, nil +} diff --git a/internal/application/product/dto/queries/subscription_queries.go b/internal/application/product/dto/queries/subscription_queries.go index e1715c1..a04284e 100644 --- a/internal/application/product/dto/queries/subscription_queries.go +++ b/internal/application/product/dto/queries/subscription_queries.go @@ -4,10 +4,16 @@ package queries type ListSubscriptionsQuery struct { Page int `form:"page" binding:"omitempty,min=1" comment:"页码"` PageSize int `form:"page_size" binding:"omitempty,min=1,max=100" comment:"每页数量"` - UserID string `form:"-" comment:"用户ID"` + UserID string `form:"user_id" binding:"omitempty" comment:"用户ID"` Keyword string `form:"keyword" binding:"omitempty,max=100" comment:"搜索关键词"` SortBy string `form:"sort_by" binding:"omitempty,oneof=created_at updated_at price" comment:"排序字段"` SortOrder string `form:"sort_order" binding:"omitempty,sort_order" comment:"排序方向"` + + // 新增筛选字段 + CompanyName string `form:"company_name" binding:"omitempty,max=100" comment:"企业名称"` + ProductName string `form:"product_name" binding:"omitempty,max=100" comment:"产品名称"` + StartTime string `form:"start_time" binding:"omitempty,datetime=2006-01-02 15:04:05" comment:"订阅开始时间"` + EndTime string `form:"end_time" binding:"omitempty,datetime=2006-01-02 15:04:05" comment:"订阅结束时间"` } // GetSubscriptionQuery 获取订阅详情查询 diff --git a/internal/application/product/dto/responses/subscription_responses.go b/internal/application/product/dto/responses/subscription_responses.go index 10c9538..d9e8f8e 100644 --- a/internal/application/product/dto/responses/subscription_responses.go +++ b/internal/application/product/dto/responses/subscription_responses.go @@ -4,6 +4,13 @@ import ( "time" ) +// UserSimpleResponse 用户简单信息响应 +type UserSimpleResponse struct { + ID string `json:"id" comment:"用户ID"` + CompanyName string `json:"company_name" comment:"公司名称"` + Phone string `json:"phone" comment:"手机号"` +} + // SubscriptionInfoResponse 订阅详情响应 type SubscriptionInfoResponse struct { ID string `json:"id" comment:"订阅ID"` @@ -13,6 +20,7 @@ type SubscriptionInfoResponse struct { APIUsed int64 `json:"api_used" comment:"已使用API调用次数"` // 关联信息 + User *UserSimpleResponse `json:"user,omitempty" comment:"用户信息"` Product *ProductSimpleResponse `json:"product,omitempty" comment:"产品信息"` CreatedAt time.Time `json:"created_at" comment:"创建时间"` diff --git a/internal/application/product/subscription_application_service_impl.go b/internal/application/product/subscription_application_service_impl.go index 2db4633..6c35850 100644 --- a/internal/application/product/subscription_application_service_impl.go +++ b/internal/application/product/subscription_application_service_impl.go @@ -2,9 +2,6 @@ package product import ( "context" - "fmt" - - "github.com/shopspring/decimal" "go.uber.org/zap" @@ -14,22 +11,26 @@ import ( "tyapi-server/internal/domains/product/entities" repoQueries "tyapi-server/internal/domains/product/repositories/queries" product_service "tyapi-server/internal/domains/product/services" + user_repositories "tyapi-server/internal/domains/user/repositories" ) // SubscriptionApplicationServiceImpl 订阅应用服务实现 // 负责业务流程编排、事务管理、数据转换,不直接操作仓库 type SubscriptionApplicationServiceImpl struct { productSubscriptionService *product_service.ProductSubscriptionService + userRepo user_repositories.UserRepository logger *zap.Logger } // NewSubscriptionApplicationService 创建订阅应用服务 func NewSubscriptionApplicationService( productSubscriptionService *product_service.ProductSubscriptionService, + userRepo user_repositories.UserRepository, logger *zap.Logger, ) SubscriptionApplicationService { return &SubscriptionApplicationServiceImpl{ productSubscriptionService: productSubscriptionService, + userRepo: userRepo, logger: logger, } } @@ -37,19 +38,7 @@ func NewSubscriptionApplicationService( // UpdateSubscriptionPrice 更新订阅价格 // 业务流程:1. 获取订阅 2. 更新价格 3. 保存订阅 func (s *SubscriptionApplicationServiceImpl) UpdateSubscriptionPrice(ctx context.Context, cmd *commands.UpdateSubscriptionPriceCommand) error { - // 1. 获取现有订阅 - subscription, err := s.productSubscriptionService.GetSubscriptionByID(ctx, cmd.ID) - if err != nil { - return err - } - - // 2. 更新订阅价格 - subscription.Price = decimal.NewFromFloat(cmd.Price) - - // 3. 保存订阅 - // 这里需要扩展领域服务来支持更新操作 - // 暂时返回错误 - return fmt.Errorf("更新订阅价格功能暂未实现") + return s.productSubscriptionService.UpdateSubscriptionPrice(ctx, cmd.ID, cmd.Price) } // CreateSubscription 创建订阅 @@ -74,12 +63,16 @@ func (s *SubscriptionApplicationServiceImpl) GetSubscriptionByID(ctx context.Con // 业务流程:1. 获取订阅列表 2. 构建响应数据 func (s *SubscriptionApplicationServiceImpl) ListSubscriptions(ctx context.Context, query *appQueries.ListSubscriptionsQuery) (*responses.SubscriptionListResponse, error) { repoQuery := &repoQueries.ListSubscriptionsQuery{ - Page: query.Page, - PageSize: query.PageSize, - UserID: query.UserID, // 管理员可以按用户筛选 - Keyword: query.Keyword, - SortBy: query.SortBy, - SortOrder: query.SortOrder, + Page: query.Page, + PageSize: query.PageSize, + UserID: query.UserID, // 管理员可以按用户筛选 + Keyword: query.Keyword, + SortBy: query.SortBy, + SortOrder: query.SortOrder, + CompanyName: query.CompanyName, + ProductName: query.ProductName, + StartTime: query.StartTime, + EndTime: query.EndTime, } subscriptions, total, err := s.productSubscriptionService.ListSubscriptions(ctx, repoQuery) if err != nil { @@ -104,12 +97,16 @@ func (s *SubscriptionApplicationServiceImpl) ListSubscriptions(ctx context.Conte // 业务流程:1. 获取用户订阅列表 2. 构建响应数据 func (s *SubscriptionApplicationServiceImpl) ListMySubscriptions(ctx context.Context, userID string, query *appQueries.ListSubscriptionsQuery) (*responses.SubscriptionListResponse, error) { repoQuery := &repoQueries.ListSubscriptionsQuery{ - Page: query.Page, - PageSize: query.PageSize, - UserID: userID, // 强制设置为当前用户ID - Keyword: query.Keyword, - SortBy: query.SortBy, - SortOrder: query.SortOrder, + Page: query.Page, + PageSize: query.PageSize, + UserID: userID, // 强制设置为当前用户ID + Keyword: query.Keyword, + SortBy: query.SortBy, + SortOrder: query.SortOrder, + CompanyName: query.CompanyName, + ProductName: query.ProductName, + StartTime: query.StartTime, + EndTime: query.EndTime, } subscriptions, total, err := s.productSubscriptionService.ListSubscriptions(ctx, repoQuery) if err != nil { @@ -173,42 +170,56 @@ func (s *SubscriptionApplicationServiceImpl) GetSubscriptionUsage(ctx context.Co // GetSubscriptionStats 获取订阅统计信息 // 业务流程:1. 获取订阅统计 2. 构建响应数据 func (s *SubscriptionApplicationServiceImpl) GetSubscriptionStats(ctx context.Context) (*responses.SubscriptionStatsResponse, error) { - // 这里需要扩展领域服务来支持统计功能 - // 暂时返回默认值 + stats, err := s.productSubscriptionService.GetSubscriptionStats(ctx) + if err != nil { + return nil, err + } + return &responses.SubscriptionStatsResponse{ - TotalSubscriptions: 0, - TotalRevenue: 0, + TotalSubscriptions: stats["total_subscriptions"].(int64), + TotalRevenue: stats["total_revenue"].(float64), }, nil } // GetMySubscriptionStats 获取我的订阅统计信息 // 业务流程:1. 获取用户订阅统计 2. 构建响应数据 func (s *SubscriptionApplicationServiceImpl) GetMySubscriptionStats(ctx context.Context, userID string) (*responses.SubscriptionStatsResponse, error) { - // 获取用户订阅数量 - subscriptions, err := s.productSubscriptionService.GetUserSubscriptions(ctx, userID) + stats, err := s.productSubscriptionService.GetUserSubscriptionStats(ctx, userID) if err != nil { return nil, err } - - // 计算总收益 - var totalRevenue float64 - for _, subscription := range subscriptions { - totalRevenue += subscription.Price.InexactFloat64() - } - + return &responses.SubscriptionStatsResponse{ - TotalSubscriptions: int64(len(subscriptions)), - TotalRevenue: totalRevenue, + TotalSubscriptions: stats["total_subscriptions"].(int64), + TotalRevenue: stats["total_revenue"].(float64), }, nil } // convertToSubscriptionInfoResponse 转换为订阅信息响应 func (s *SubscriptionApplicationServiceImpl) convertToSubscriptionInfoResponse(subscription *entities.Subscription) *responses.SubscriptionInfoResponse { + // 查询用户信息 + var userInfo *responses.UserSimpleResponse + if subscription.UserID != "" { + user, err := s.userRepo.GetByIDWithEnterpriseInfo(context.Background(), subscription.UserID) + if err == nil { + companyName := "未知公司" + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + } + userInfo = &responses.UserSimpleResponse{ + ID: user.ID, + CompanyName: companyName, + Phone: user.Phone, + } + } + } + return &responses.SubscriptionInfoResponse{ ID: subscription.ID, UserID: subscription.UserID, ProductID: subscription.ProductID, Price: subscription.Price.InexactFloat64(), + User: userInfo, Product: s.convertToProductSimpleResponse(subscription.Product), APIUsed: subscription.APIUsed, CreatedAt: subscription.CreatedAt, diff --git a/internal/application/user/dto/responses/user_list_response.go b/internal/application/user/dto/responses/user_list_response.go index 7e5deee..43ab67d 100644 --- a/internal/application/user/dto/responses/user_list_response.go +++ b/internal/application/user/dto/responses/user_list_response.go @@ -31,6 +31,19 @@ type EnterpriseInfoItem struct { LegalPersonPhone string `json:"legal_person_phone"` EnterpriseAddress string `json:"enterprise_address"` CreatedAt time.Time `json:"created_at"` + + // 合同信息 + Contracts []*ContractInfoItem `json:"contracts,omitempty"` +} + +// ContractInfoItem 合同信息项 +type ContractInfoItem struct { + ID string `json:"id"` + ContractName string `json:"contract_name"` + ContractType string `json:"contract_type"` // 合同类型代码 + ContractTypeName string `json:"contract_type_name"` // 合同类型中文名称 + ContractFileURL string `json:"contract_file_url"` + CreatedAt time.Time `json:"created_at"` } // UserListResponse 用户列表响应 @@ -46,4 +59,9 @@ type UserStatsResponse struct { TotalUsers int64 `json:"total_users"` ActiveUsers int64 `json:"active_users"` CertifiedUsers int64 `json:"certified_users"` +} + +// UserDetailResponse 用户详情响应 +type UserDetailResponse struct { + *UserListItem } \ No newline at end of file diff --git a/internal/application/user/user_application_service.go b/internal/application/user/user_application_service.go index 7303408..c0bbcf3 100644 --- a/internal/application/user/user_application_service.go +++ b/internal/application/user/user_application_service.go @@ -20,5 +20,6 @@ type UserApplicationService interface { // 管理员功能 ListUsers(ctx context.Context, query *queries.ListUsersQuery) (*responses.UserListResponse, error) + GetUserDetail(ctx context.Context, userID string) (*responses.UserDetailResponse, error) GetUserStats(ctx context.Context) (*responses.UserStatsResponse, error) } diff --git a/internal/application/user/user_application_service_impl.go b/internal/application/user/user_application_service_impl.go index 4654edd..b12ad14 100644 --- a/internal/application/user/user_application_service_impl.go +++ b/internal/application/user/user_application_service_impl.go @@ -24,6 +24,7 @@ type UserApplicationServiceImpl struct { userAuthService *user_service.UserAuthService smsCodeService *user_service.SMSCodeService walletService finance_service.WalletAggregateService + contractService user_service.ContractAggregateService eventBus interfaces.EventBus jwtAuth *middleware.JWTAuthMiddleware logger *zap.Logger @@ -35,6 +36,7 @@ func NewUserApplicationService( userAuthService *user_service.UserAuthService, smsCodeService *user_service.SMSCodeService, walletService finance_service.WalletAggregateService, + contractService user_service.ContractAggregateService, eventBus interfaces.EventBus, jwtAuth *middleware.JWTAuthMiddleware, logger *zap.Logger, @@ -44,6 +46,7 @@ func NewUserApplicationService( userAuthService: userAuthService, smsCodeService: smsCodeService, walletService: walletService, + contractService: contractService, eventBus: eventBus, jwtAuth: jwtAuth, logger: logger, @@ -342,6 +345,23 @@ func (s *UserApplicationServiceImpl) ListUsers(ctx context.Context, query *queri EnterpriseAddress: user.EnterpriseInfo.EnterpriseAddress, CreatedAt: user.EnterpriseInfo.CreatedAt, } + + // 获取企业合同信息 + contracts, err := s.contractService.FindByUserID(ctx, user.ID) + if err == nil && len(contracts) > 0 { + contractItems := make([]*responses.ContractInfoItem, 0, len(contracts)) + for _, contract := range contracts { + contractItems = append(contractItems, &responses.ContractInfoItem{ + ID: contract.ID, + ContractName: contract.ContractName, + ContractType: string(contract.ContractType), + ContractTypeName: contract.GetContractTypeName(), + ContractFileURL: contract.ContractFileURL, + CreatedAt: contract.CreatedAt, + }) + } + item.EnterpriseInfo.Contracts = contractItems + } } // 添加钱包余额信息 @@ -363,6 +383,72 @@ func (s *UserApplicationServiceImpl) ListUsers(ctx context.Context, query *queri }, nil } +// GetUserDetail 获取用户详情(管理员功能) +// 业务流程:1. 查询用户详情 2. 构建响应数据 +func (s *UserApplicationServiceImpl) GetUserDetail(ctx context.Context, userID string) (*responses.UserDetailResponse, error) { + // 1. 查询用户详情(包含企业信息) + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID) + if err != nil { + return nil, err + } + + // 2. 构建响应数据 + item := &responses.UserListItem{ + ID: user.ID, + Phone: user.Phone, + UserType: user.UserType, + Username: user.Username, + IsActive: user.Active, + IsCertified: user.IsCertified, + LoginCount: user.LoginCount, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + // 添加企业信息 + if user.EnterpriseInfo != nil { + item.EnterpriseInfo = &responses.EnterpriseInfoItem{ + ID: user.EnterpriseInfo.ID, + CompanyName: user.EnterpriseInfo.CompanyName, + UnifiedSocialCode: user.EnterpriseInfo.UnifiedSocialCode, + LegalPersonName: user.EnterpriseInfo.LegalPersonName, + LegalPersonPhone: user.EnterpriseInfo.LegalPersonPhone, + EnterpriseAddress: user.EnterpriseInfo.EnterpriseAddress, + CreatedAt: user.EnterpriseInfo.CreatedAt, + } + + // 获取企业合同信息 + contracts, err := s.contractService.FindByUserID(ctx, user.ID) + if err == nil && len(contracts) > 0 { + contractItems := make([]*responses.ContractInfoItem, 0, len(contracts)) + for _, contract := range contracts { + contractItems = append(contractItems, &responses.ContractInfoItem{ + ID: contract.ID, + ContractName: contract.ContractName, + ContractType: string(contract.ContractType), + ContractTypeName: contract.GetContractTypeName(), + ContractFileURL: contract.ContractFileURL, + CreatedAt: contract.CreatedAt, + }) + } + item.EnterpriseInfo.Contracts = contractItems + } + } + + // 添加钱包余额信息 + wallet, err := s.walletService.LoadWalletByUserId(ctx, user.ID) + if err == nil && wallet != nil { + item.WalletBalance = wallet.Balance.String() + } else { + item.WalletBalance = "0" + } + + return &responses.UserDetailResponse{ + UserListItem: item, + }, nil +} + // GetUserStats 获取用户统计信息(管理员功能) // 业务流程:1. 查询用户统计信息 2. 构建响应数据 func (s *UserApplicationServiceImpl) GetUserStats(ctx context.Context) (*responses.UserStatsResponse, error) { diff --git a/internal/config/config.go b/internal/config/config.go index 6d89066..1bebe25 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` API APIConfig `mapstructure:"api"` SMS SMSConfig `mapstructure:"sms"` + Email EmailConfig `mapstructure:"email"` Storage StorageConfig `mapstructure:"storage"` OCR OCRConfig `mapstructure:"ocr"` RateLimit RateLimitConfig `mapstructure:"ratelimit"` @@ -184,6 +185,18 @@ type SMSRateLimit struct { MinInterval time.Duration `mapstructure:"min_interval"` // 最小发送间隔 } +// EmailConfig 邮件服务配置 +type EmailConfig struct { + Host string `mapstructure:"host"` // SMTP服务器地址 + Port int `mapstructure:"port"` // SMTP服务器端口 + Username string `mapstructure:"username"` // 邮箱用户名 + Password string `mapstructure:"password"` // 邮箱密码/授权码 + FromEmail string `mapstructure:"from_email"` // 发件人邮箱 + UseSSL bool `mapstructure:"use_ssl"` // 是否使用SSL + Timeout time.Duration `mapstructure:"timeout"` // 超时时间 + Domain string `mapstructure:"domain"` // 控制台域名 +} + // GetDSN 获取数据库DSN连接字符串 func (d DatabaseConfig) GetDSN() string { return "host=" + d.Host + diff --git a/internal/container/container.go b/internal/container/container.go index 3fae87c..71b6f87 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -26,6 +26,8 @@ import ( certification_repo "tyapi-server/internal/infrastructure/database/repositories/certification" finance_repo "tyapi-server/internal/infrastructure/database/repositories/finance" product_repo "tyapi-server/internal/infrastructure/database/repositories/product" + infra_events "tyapi-server/internal/infrastructure/events" + "tyapi-server/internal/infrastructure/external/email" "tyapi-server/internal/infrastructure/external/ocr" "tyapi-server/internal/infrastructure/external/sms" "tyapi-server/internal/infrastructure/external/storage" @@ -114,22 +116,24 @@ func NewContainer() *Container { } }, // 提供普通的*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 + fx.Annotate( + func(log logger.Logger) *zap.Logger { + // 尝试转换为ZapLogger + if zapLogger, ok := log.(*logger.ZapLogger); ok { + return zapLogger.GetZapLogger() } - } - // 如果类型转换失败,创建一个默认的zap logger - defaultLogger, _ := zap.NewProduction() - return defaultLogger - }, + // 尝试转换为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 + }, + ), // 数据库连接 func(cfg *config.Config, cacheService interfaces.CacheService, logger *zap.Logger) (*gorm.DB, error) { @@ -178,8 +182,10 @@ func NewContainer() *Container { func() int { return 5 // 默认5个工作协程 }, - events.NewMemoryEventBus, - fx.Annotate(events.NewMemoryEventBus, fx.As(new(interfaces.EventBus))), + fx.Annotate( + events.NewMemoryEventBus, + fx.As(new(interfaces.EventBus)), + ), // 健康检查 health.NewHealthChecker, // 提供 config.SMSConfig @@ -196,6 +202,12 @@ func NewContainer() *Container { }, // 短信服务 sms.NewAliSMSService, + // 邮件服务 + fx.Annotate( + func(cfg *config.Config, logger *zap.Logger) *email.QQEmailService { + return email.NewQQEmailService(cfg.Email, logger) + }, + ), // 存储服务 fx.Annotate( func(cfg *config.Config, logger *zap.Logger) *storage.QiNiuStorageService { @@ -421,6 +433,16 @@ func NewContainer() *Container { finance_repo.NewGormAlipayOrderRepository, fx.As(new(domain_finance_repo.AlipayOrderRepository)), ), + // 发票申请仓储 + fx.Annotate( + finance_repo.NewGormInvoiceApplicationRepository, + fx.As(new(domain_finance_repo.InvoiceApplicationRepository)), + ), + // 用户开票信息仓储 + fx.Annotate( + finance_repo.NewGormUserInvoiceInfoRepository, + fx.As(new(domain_finance_repo.UserInvoiceInfoRepository)), + ), ), // 仓储层 - 产品域 @@ -465,7 +487,9 @@ func NewContainer() *Container { // 领域服务 fx.Provide( - user_service.NewUserAggregateService, + fx.Annotate( + user_service.NewUserAggregateService, + ), user_service.NewUserAuthService, user_service.NewSMSCodeService, user_service.NewContractAggregateService, @@ -475,6 +499,43 @@ func NewContainer() *Container { product_service.NewProductDocumentationService, finance_service.NewWalletAggregateService, finance_service.NewRechargeRecordService, + // 发票领域服务 + fx.Annotate( + finance_service.NewInvoiceDomainService, + ), + // 用户开票信息服务 + fx.Annotate( + finance_service.NewUserInvoiceInfoService, + ), + // 发票事件发布器 - 绑定到接口 + fx.Annotate( + func(logger *zap.Logger, eventBus interfaces.EventBus) finance_service.EventPublisher { + return infra_events.NewInvoiceEventPublisher(logger, eventBus) + }, + fx.As(new(finance_service.EventPublisher)), + ), + // 发票聚合服务 - 需要用户开票信息仓储 + fx.Annotate( + func( + applicationRepo domain_finance_repo.InvoiceApplicationRepository, + userInvoiceInfoRepo domain_finance_repo.UserInvoiceInfoRepository, + domainService finance_service.InvoiceDomainService, + qiniuStorageService *storage.QiNiuStorageService, + logger *zap.Logger, + eventPublisher finance_service.EventPublisher, + ) finance_service.InvoiceAggregateService { + return finance_service.NewInvoiceAggregateService( + applicationRepo, + userInvoiceInfoRepo, + domainService, + qiniuStorageService, + logger, + eventPublisher, + ) + }, + ), + // 发票事件处理器 + infra_events.NewInvoiceEventHandler, certification_service.NewCertificationAggregateService, certification_service.NewEnterpriseInfoSubmitRecordService, ), @@ -508,6 +569,16 @@ func NewContainer() *Container { finance.NewFinanceApplicationService, fx.As(new(finance.FinanceApplicationService)), ), + // 发票应用服务 - 绑定到接口 + fx.Annotate( + finance.NewInvoiceApplicationService, + fx.As(new(finance.InvoiceApplicationService)), + ), + // 管理员发票应用服务 - 绑定到接口 + fx.Annotate( + finance.NewAdminInvoiceApplicationService, + fx.As(new(finance.AdminInvoiceApplicationService)), + ), // 产品应用服务 - 绑定到接口 fx.Annotate( product.NewProductApplicationService, @@ -571,6 +642,7 @@ func NewContainer() *Container { RegisterLifecycleHooks, RegisterMiddlewares, RegisterRoutes, + RegisterEventHandlers, ), ) @@ -761,3 +833,34 @@ func NewRedisCache(client *redis.Client, logger *zap.Logger, cfg *config.Config) func NewTracedRedisCache(client *redis.Client, tracer *tracing.Tracer, logger *zap.Logger, cfg *config.Config) interfaces.CacheService { return tracing.NewTracedRedisCache(client, tracer, logger, "app") } + +// RegisterEventHandlers 注册事件处理器 +func RegisterEventHandlers( + eventBus interfaces.EventBus, + invoiceEventHandler *infra_events.InvoiceEventHandler, + logger *zap.Logger, +) { + // 启动事件总线 + if err := eventBus.Start(context.Background()); err != nil { + logger.Error("启动事件总线失败", zap.Error(err)) + return + } + + // 注册发票事件处理器 + for _, eventType := range invoiceEventHandler.GetEventTypes() { + if err := eventBus.Subscribe(eventType, invoiceEventHandler); err != nil { + logger.Error("注册发票事件处理器失败", + zap.String("event_type", eventType), + zap.String("handler", invoiceEventHandler.GetName()), + zap.Error(err), + ) + } else { + logger.Info("发票事件处理器注册成功", + zap.String("event_type", eventType), + zap.String("handler", invoiceEventHandler.GetName()), + ) + } + } + + logger.Info("所有事件处理器已注册") +} diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index bde285d..2e7412d 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -31,6 +31,9 @@ type FLXG162AReq struct { type FLXG0687Req struct { IDCard string `json:"id_card" validate:"required,validIDCard"` } +type FLXG21Req struct { + MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` +} type FLXG970FReq struct { IDCard string `json:"id_card" validate:"required,validIDCard"` Name string `json:"name" validate:"required,min=1,validName"` diff --git a/internal/domains/api/repositories/api_call_repository.go b/internal/domains/api/repositories/api_call_repository.go index b634d9e..e032705 100644 --- a/internal/domains/api/repositories/api_call_repository.go +++ b/internal/domains/api/repositories/api_call_repository.go @@ -26,4 +26,7 @@ type ApiCallRepository interface { // 新增:根据TransactionID查询 FindByTransactionId(ctx context.Context, transactionId string) (*entities.ApiCall, error) + + // 管理端:根据条件筛选所有API调用记录(包含产品名称) + ListWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (map[string]string, []*entities.ApiCall, int64, error) } diff --git a/internal/domains/api/services/api_request_service_test.go b/internal/domains/api/services/api_request_service_test.go deleted file mode 100644 index 5ddc940..0000000 --- a/internal/domains/api/services/api_request_service_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "testing" - "tyapi-server/internal/application/api/commands" -) - -// 基础测试结构体 -type apiRequestServiceTestSuite struct { - t *testing.T - ctx context.Context - service *ApiRequestService -} - -// 初始化测试套件 -func newApiRequestServiceTestSuite(t *testing.T) *apiRequestServiceTestSuite { - // 这里可以初始化依赖的mock或fake对象 - // 例如:mockProcessorDeps := &MockProcessorDeps{} - // service := &ApiRequestService{processorDeps: mockProcessorDeps} - // 这里只做基础架构,具体mock实现后续补充 - return &apiRequestServiceTestSuite{ - t: t, - ctx: context.Background(), - service: nil, // 这里后续可替换为实际service或mock - } -} - -// 示例:测试PreprocessRequestApi方法(仅结构,具体mock和断言后续补充) -func TestApiRequestService_PreprocessRequestApi(t *testing.T) { - suite := newApiRequestServiceTestSuite(t) - - // 假设有一个mock processor和注册 - // RequestProcessors = map[string]processors.ProcessorFunc{ - // "MOCKAPI": func(ctx context.Context, params []byte, deps interface{}) ([]byte, error) { - // return []byte("ok"), nil - // }, - // } - - // 这里仅做结构示例 - apiCode := "QYGL23T7" - params := map[string]string{ - "code": "91460000MAE471M58X", - "name": "海南天远大数据科技有限公司", - "legalPersonName": "刘福思", - } - paramsByte, err := json.Marshal(params) - if err != nil { - t.Fatalf("参数序列化失败: %v", err) - } - options := commands.ApiCallOptions{} // 实际应为*commands.ApiCallOptions - - // 由于service为nil,这里仅做断言结构示例 - if suite.service != nil { - resp, err := suite.service.PreprocessRequestApi(suite.ctx, apiCode, paramsByte, &options) - if err != nil { - t.Errorf("PreprocessRequestApi 调用出错: %v", err) - } - t.Logf("PreprocessRequestApi 返回结果: %s", string(resp)) - } -} diff --git a/internal/domains/api/services/processors/comb/comb298y_processor.go b/internal/domains/api/services/processors/comb/comb298y_processor.go index 18800e3..5431d03 100644 --- a/internal/domains/api/services/processors/comb/comb298y_processor.go +++ b/internal/domains/api/services/processors/comb/comb298y_processor.go @@ -22,5 +22,9 @@ func ProcessCOMB298YRequest(ctx context.Context, params []byte, deps *processors // 调用组合包服务处理请求 // Options会自动传递给所有子处理器 - return deps.CombService.ProcessCombRequest(ctx, params, deps, "COMB298Y") + combinedResult, err := deps.CombService.ProcessCombRequest(ctx, params, deps, "COMB298Y") + if err != nil { + return nil, err + } + return json.Marshal(combinedResult) } diff --git a/internal/domains/api/services/processors/comb/comb86pm_processor.go b/internal/domains/api/services/processors/comb/comb86pm_processor.go index 9fa2f09..fa4531f 100644 --- a/internal/domains/api/services/processors/comb/comb86pm_processor.go +++ b/internal/domains/api/services/processors/comb/comb86pm_processor.go @@ -22,5 +22,15 @@ func ProcessCOMB86PMRequest(ctx context.Context, params []byte, deps *processors // 调用组合包服务处理请求 // Options会自动传递给所有子处理器 - return deps.CombService.ProcessCombRequest(ctx, params, deps, "COMB86PM") + combinedResult, err := deps.CombService.ProcessCombRequest(ctx, params, deps, "COMB86PM") + if err != nil { + return nil, err + } + // 如果有ApiCode为FLXG54F5的子产品,改名为FLXG54F6 + for _, resp := range combinedResult.Responses { + if resp.ApiCode == "FLXG54F5" { + resp.ApiCode = "FLXG54F5" + } + } + return json.Marshal(combinedResult) } diff --git a/internal/domains/api/services/processors/comb/comb_service.go b/internal/domains/api/services/processors/comb/comb_service.go index 9fbc35e..2feb463 100644 --- a/internal/domains/api/services/processors/comb/comb_service.go +++ b/internal/domains/api/services/processors/comb/comb_service.go @@ -32,7 +32,7 @@ func (cs *CombService) RegisterProcessor(apiCode string, processor processors.Pr } // ProcessCombRequest 处理组合包请求 - 实现 CombServiceInterface -func (cs *CombService) ProcessCombRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies, packageCode string) ([]byte, error) { +func (cs *CombService) ProcessCombRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies, packageCode string) (*processors.CombinedResult, error) { // 1. 根据组合包code获取产品信息 packageProduct, err := cs.productManagementService.GetProductByCode(ctx, packageCode) if err != nil { @@ -66,8 +66,8 @@ func (cs *CombService) processSubProducts( params []byte, deps *processors.ProcessorDependencies, packageItems []*entities.ProductPackageItem, -) []*SubProductResult { - results := make([]*SubProductResult, 0, len(packageItems)) +) []*processors.SubProductResult { + results := make([]*processors.SubProductResult, 0, len(packageItems)) var wg sync.WaitGroup var mu sync.Mutex @@ -101,8 +101,8 @@ func (cs *CombService) processSingleSubProduct( params []byte, deps *processors.ProcessorDependencies, item *entities.ProductPackageItem, -) *SubProductResult { - result := &SubProductResult{ +) *processors.SubProductResult { + result := &processors.SubProductResult{ ApiCode: item.Product.Code, SortOrder: item.SortOrder, Success: false, @@ -136,31 +136,12 @@ func (cs *CombService) processSingleSubProduct( } // combineResults 组合所有子产品的结果 -func (cs *CombService) combineResults(results []*SubProductResult) ([]byte, error) { +func (cs *CombService) combineResults(results []*processors.SubProductResult) (*processors.CombinedResult, error) { // 构建组合结果 - combinedResult := &CombinedResult{ + combinedResult := &processors.CombinedResult{ Responses: results, } - - // 序列化结果 - respBytes, err := json.Marshal(combinedResult) - if err != nil { - return nil, fmt.Errorf("序列化组合结果失败: %s", err.Error()) - } - - return respBytes, nil + return combinedResult, nil } -// SubProductResult 子产品处理结果 -type SubProductResult struct { - ApiCode string `json:"api_code"` // 子接口标识 - Data interface{} `json:"data"` // 子接口返回数据 - Success bool `json:"success"` // 是否成功 - Error string `json:"error,omitempty"` // 错误信息(仅在失败时) - SortOrder int `json:"-"` // 排序字段,不输出到JSON -} -// CombinedResult 组合结果 -type CombinedResult struct { - Responses []*SubProductResult `json:"responses"` // 子接口响应列表 -} diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index 308ecd5..9530937 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -11,7 +11,7 @@ import ( // CombServiceInterface 组合包服务接口 type CombServiceInterface interface { - ProcessCombRequest(ctx context.Context, params []byte, deps *ProcessorDependencies, packageCode string) ([]byte, error) + ProcessCombRequest(ctx context.Context, params []byte, deps *ProcessorDependencies, packageCode string) (*CombinedResult, error) } // ProcessorDependencies 处理器依赖容器 @@ -49,4 +49,21 @@ func (deps *ProcessorDependencies) WithOptions(options *commands.ApiCallOptions) } // ProcessorFunc 处理器函数类型定义 -type ProcessorFunc func(ctx context.Context, params []byte, deps *ProcessorDependencies) ([]byte, error) \ No newline at end of file +type ProcessorFunc func(ctx context.Context, params []byte, deps *ProcessorDependencies) ([]byte, error) + + + +// CombinedResult 组合结果 +type CombinedResult struct { + Responses []*SubProductResult `json:"responses"` // 子接口响应列表 +} + +// SubProductResult 子产品处理结果 +type SubProductResult struct { + ApiCode string `json:"api_code"` // 子接口标识 + Data interface{} `json:"data"` // 子接口返回数据 + Success bool `json:"success"` // 是否成功 + Error string `json:"error,omitempty"` // 错误信息(仅在失败时) + SortOrder int `json:"-"` // 排序字段,不输出到JSON +} + diff --git a/internal/domains/api/services/processors/flxg/flxg54f5_processor.go b/internal/domains/api/services/processors/flxg/flxg54f5_processor.go index 9e841b9..b3a7f8c 100644 --- a/internal/domains/api/services/processors/flxg/flxg54f5_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg54f5_processor.go @@ -43,4 +43,4 @@ func ProcessFLXG54F5Request(ctx context.Context, params []byte, deps *processors } return respBytes, nil -} \ No newline at end of file +} diff --git a/internal/domains/api/services/processors/flxg/flxgbc21_processor.go b/internal/domains/api/services/processors/flxg/flxgbc21_processor.go new file mode 100644 index 0000000..a09d214 --- /dev/null +++ b/internal/domains/api/services/processors/flxg/flxgbc21_processor.go @@ -0,0 +1,40 @@ +package flxg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/westdex" +) + +// ProcessFLXGbc21Request FLXGbc21 API处理方法 +func ProcessFLXGbc21Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.FLXG21Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, fmt.Errorf("%s: %w", processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, fmt.Errorf("%s: %w", processors.ErrInvalidParam, err) + } + + + reqData := map[string]interface{}{ + "mobile": paramsDto.MobileNo, + } + + respBytes, err := deps.YushanService.CallAPI("MOB032", reqData) + 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 +} diff --git a/internal/domains/finance/entities/invoice_application.go b/internal/domains/finance/entities/invoice_application.go new file mode 100644 index 0000000..6b60678 --- /dev/null +++ b/internal/domains/finance/entities/invoice_application.go @@ -0,0 +1,163 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" + + "tyapi-server/internal/domains/finance/value_objects" +) + +// ApplicationStatus 申请状态枚举 +type ApplicationStatus string + +const ( + ApplicationStatusPending ApplicationStatus = "pending" // 待处理 + ApplicationStatusCompleted ApplicationStatus = "completed" // 已完成(已上传发票) + ApplicationStatusRejected ApplicationStatus = "rejected" // 已拒绝 +) + +// InvoiceApplication 发票申请聚合根 +type InvoiceApplication struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"申请唯一标识"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"申请用户ID"` + + // 申请信息 + InvoiceType value_objects.InvoiceType `gorm:"type:varchar(20);not null" json:"invoice_type" comment:"发票类型"` + Amount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"amount" comment:"申请金额"` + Status ApplicationStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status" comment:"申请状态"` + + // 开票信息快照(申请时的信息,用于历史记录追踪) + CompanyName string `gorm:"type:varchar(200);not null" json:"company_name" comment:"公司名称"` + TaxpayerID string `gorm:"type:varchar(50);not null" json:"taxpayer_id" comment:"纳税人识别号"` + BankName string `gorm:"type:varchar(100)" json:"bank_name" comment:"开户银行"` + BankAccount string `gorm:"type:varchar(50)" json:"bank_account" comment:"银行账号"` + CompanyAddress string `gorm:"type:varchar(500)" json:"company_address" comment:"企业地址"` + CompanyPhone string `gorm:"type:varchar(20)" json:"company_phone" comment:"企业电话"` + ReceivingEmail string `gorm:"type:varchar(100);not null" json:"receiving_email" comment:"发票接收邮箱"` + + // 开票信息引用(关联到用户开票信息表,用于模板功能) + UserInvoiceInfoID string `gorm:"type:varchar(36);not null" json:"user_invoice_info_id" comment:"用户开票信息ID"` + + // 文件信息(申请通过后才有) + FileID *string `gorm:"type:varchar(200)" json:"file_id,omitempty" comment:"文件ID"` + FileName *string `gorm:"type:varchar(200)" json:"file_name,omitempty" comment:"文件名"` + FileSize *int64 `json:"file_size,omitempty" comment:"文件大小"` + FileURL *string `gorm:"type:varchar(500)" json:"file_url,omitempty" comment:"文件URL"` + + // 处理信息 + ProcessedBy *string `gorm:"type:varchar(36)" json:"processed_by,omitempty" comment:"处理人ID"` + ProcessedAt *time.Time `json:"processed_at,omitempty" comment:"处理时间"` + RejectReason *string `gorm:"type:varchar(500)" json:"reject_reason,omitempty" comment:"拒绝原因"` + AdminNotes *string `gorm:"type:varchar(500)" json:"admin_notes,omitempty" comment:"管理员备注"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` +} + +// TableName 指定数据库表名 +func (InvoiceApplication) TableName() string { + return "invoice_applications" +} + +// BeforeCreate GORM钩子:创建前自动生成UUID +func (ia *InvoiceApplication) BeforeCreate(tx *gorm.DB) error { + if ia.ID == "" { + ia.ID = uuid.New().String() + } + return nil +} + +// IsPending 检查是否为待处理状态 +func (ia *InvoiceApplication) IsPending() bool { + return ia.Status == ApplicationStatusPending +} + + + +// IsCompleted 检查是否为已完成状态 +func (ia *InvoiceApplication) IsCompleted() bool { + return ia.Status == ApplicationStatusCompleted +} + +// IsRejected 检查是否为已拒绝状态 +func (ia *InvoiceApplication) IsRejected() bool { + return ia.Status == ApplicationStatusRejected +} + +// CanProcess 检查是否可以处理 +func (ia *InvoiceApplication) CanProcess() bool { + return ia.IsPending() +} + +// CanReject 检查是否可以拒绝 +func (ia *InvoiceApplication) CanReject() bool { + return ia.IsPending() +} + + + +// MarkCompleted 标记为已完成 +func (ia *InvoiceApplication) MarkCompleted(processedBy string) { + ia.Status = ApplicationStatusCompleted + ia.ProcessedBy = &processedBy + now := time.Now() + ia.ProcessedAt = &now +} + +// MarkRejected 标记为已拒绝 +func (ia *InvoiceApplication) MarkRejected(reason string, processedBy string) { + ia.Status = ApplicationStatusRejected + ia.RejectReason = &reason + ia.ProcessedBy = &processedBy + now := time.Now() + ia.ProcessedAt = &now +} + +// SetFileInfo 设置文件信息 +func (ia *InvoiceApplication) SetFileInfo(fileID, fileName, fileURL string, fileSize int64) { + ia.FileID = &fileID + ia.FileName = &fileName + ia.FileURL = &fileURL + ia.FileSize = &fileSize +} + +// NewInvoiceApplication 工厂方法 +func NewInvoiceApplication(userID string, invoiceType value_objects.InvoiceType, amount decimal.Decimal, userInvoiceInfoID string) *InvoiceApplication { + return &InvoiceApplication{ + UserID: userID, + InvoiceType: invoiceType, + Amount: amount, + Status: ApplicationStatusPending, + UserInvoiceInfoID: userInvoiceInfoID, + } +} + +// SetInvoiceInfoSnapshot 设置开票信息快照 +func (ia *InvoiceApplication) SetInvoiceInfoSnapshot(info *value_objects.InvoiceInfo) { + ia.CompanyName = info.CompanyName + ia.TaxpayerID = info.TaxpayerID + ia.BankName = info.BankName + ia.BankAccount = info.BankAccount + ia.CompanyAddress = info.CompanyAddress + ia.CompanyPhone = info.CompanyPhone + ia.ReceivingEmail = info.ReceivingEmail +} + +// GetInvoiceInfoSnapshot 获取开票信息快照 +func (ia *InvoiceApplication) GetInvoiceInfoSnapshot() *value_objects.InvoiceInfo { + return value_objects.NewInvoiceInfo( + ia.CompanyName, + ia.TaxpayerID, + ia.BankName, + ia.BankAccount, + ia.CompanyAddress, + ia.CompanyPhone, + ia.ReceivingEmail, + ) +} \ No newline at end of file diff --git a/internal/domains/finance/entities/user_invoice_info.go b/internal/domains/finance/entities/user_invoice_info.go new file mode 100644 index 0000000..a851176 --- /dev/null +++ b/internal/domains/finance/entities/user_invoice_info.go @@ -0,0 +1,71 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// UserInvoiceInfo 用户开票信息实体 +type UserInvoiceInfo struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + UserID string `gorm:"uniqueIndex;type:varchar(36);not null" json:"user_id"` + + // 开票信息字段 + CompanyName string `gorm:"type:varchar(200);not null" json:"company_name"` // 公司名称 + TaxpayerID string `gorm:"type:varchar(50);not null" json:"taxpayer_id"` // 纳税人识别号 + BankName string `gorm:"type:varchar(100)" json:"bank_name"` // 开户银行 + BankAccount string `gorm:"type:varchar(50)" json:"bank_account"` // 银行账号 + CompanyAddress string `gorm:"type:varchar(500)" json:"company_address"` // 企业地址 + CompanyPhone string `gorm:"type:varchar(20)" json:"company_phone"` // 企业电话 + ReceivingEmail string `gorm:"type:varchar(100);not null" json:"receiving_email"` // 发票接收邮箱 + + // 元数据 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName 指定表名 +func (UserInvoiceInfo) TableName() string { + return "user_invoice_info" +} + +// IsComplete 检查开票信息是否完整 +func (u *UserInvoiceInfo) IsComplete() bool { + return u.CompanyName != "" && u.TaxpayerID != "" && u.ReceivingEmail != "" +} + +// IsCompleteForSpecialInvoice 检查专票信息是否完整 +func (u *UserInvoiceInfo) IsCompleteForSpecialInvoice() bool { + return u.CompanyName != "" && u.TaxpayerID != "" && u.BankName != "" && + u.BankAccount != "" && u.CompanyAddress != "" && u.CompanyPhone != "" && + u.ReceivingEmail != "" +} + +// GetMissingFields 获取缺失的字段 +func (u *UserInvoiceInfo) GetMissingFields() []string { + var missing []string + if u.CompanyName == "" { + missing = append(missing, "公司名称") + } + if u.TaxpayerID == "" { + missing = append(missing, "纳税人识别号") + } + if u.BankName == "" { + missing = append(missing, "开户银行") + } + if u.BankAccount == "" { + missing = append(missing, "银行账号") + } + if u.CompanyAddress == "" { + missing = append(missing, "企业地址") + } + if u.CompanyPhone == "" { + missing = append(missing, "企业电话") + } + if u.ReceivingEmail == "" { + missing = append(missing, "发票接收邮箱") + } + return missing +} \ No newline at end of file diff --git a/internal/domains/finance/events/invoice_events.go b/internal/domains/finance/events/invoice_events.go new file mode 100644 index 0000000..3913f4a --- /dev/null +++ b/internal/domains/finance/events/invoice_events.go @@ -0,0 +1,213 @@ +package events + +import ( + "encoding/json" + "time" + + "tyapi-server/internal/domains/finance/value_objects" + + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +// BaseEvent 基础事件结构 +type BaseEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Version string `json:"version"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + AggregateID string `json:"aggregate_id"` + AggregateType string `json:"aggregate_type"` + Metadata map[string]interface{} `json:"metadata"` +} + +// NewBaseEvent 创建基础事件 +func NewBaseEvent(eventType, aggregateID, aggregateType string) BaseEvent { + return BaseEvent{ + ID: uuid.New().String(), + Type: eventType, + Version: "1.0", + Timestamp: time.Now(), + Source: "finance-domain", + AggregateID: aggregateID, + AggregateType: aggregateType, + Metadata: make(map[string]interface{}), + } +} + +// GetID 获取事件ID +func (e BaseEvent) GetID() string { + return e.ID +} + +// GetType 获取事件类型 +func (e BaseEvent) GetType() string { + return e.Type +} + +// GetVersion 获取事件版本 +func (e BaseEvent) GetVersion() string { + return e.Version +} + +// GetTimestamp 获取事件时间戳 +func (e BaseEvent) GetTimestamp() time.Time { + return e.Timestamp +} + +// GetSource 获取事件来源 +func (e BaseEvent) GetSource() string { + return e.Source +} + +// GetAggregateID 获取聚合根ID +func (e BaseEvent) GetAggregateID() string { + return e.AggregateID +} + +// GetAggregateType 获取聚合根类型 +func (e BaseEvent) GetAggregateType() string { + return e.AggregateType +} + +// GetMetadata 获取事件元数据 +func (e BaseEvent) GetMetadata() map[string]interface{} { + return e.Metadata +} + +// Marshal 序列化事件 +func (e BaseEvent) Marshal() ([]byte, error) { + return json.Marshal(e) +} + +// Unmarshal 反序列化事件 +func (e BaseEvent) Unmarshal(data []byte) error { + return json.Unmarshal(data, e) +} + +// InvoiceApplicationCreatedEvent 发票申请创建事件 +type InvoiceApplicationCreatedEvent struct { + BaseEvent + ApplicationID string `json:"application_id"` + UserID string `json:"user_id"` + InvoiceType value_objects.InvoiceType `json:"invoice_type"` + Amount decimal.Decimal `json:"amount"` + CompanyName string `json:"company_name"` + ReceivingEmail string `json:"receiving_email"` + CreatedAt time.Time `json:"created_at"` +} + +// NewInvoiceApplicationCreatedEvent 创建发票申请创建事件 +func NewInvoiceApplicationCreatedEvent(applicationID, userID string, invoiceType value_objects.InvoiceType, amount decimal.Decimal, companyName, receivingEmail string) *InvoiceApplicationCreatedEvent { + event := &InvoiceApplicationCreatedEvent{ + BaseEvent: NewBaseEvent("InvoiceApplicationCreated", applicationID, "InvoiceApplication"), + ApplicationID: applicationID, + UserID: userID, + InvoiceType: invoiceType, + Amount: amount, + CompanyName: companyName, + ReceivingEmail: receivingEmail, + CreatedAt: time.Now(), + } + return event +} + +// GetPayload 获取事件载荷 +func (e *InvoiceApplicationCreatedEvent) GetPayload() interface{} { + return e +} + +// InvoiceApplicationApprovedEvent 发票申请通过事件 +type InvoiceApplicationApprovedEvent struct { + BaseEvent + ApplicationID string `json:"application_id"` + UserID string `json:"user_id"` + Amount decimal.Decimal `json:"amount"` + ReceivingEmail string `json:"receiving_email"` + ApprovedAt time.Time `json:"approved_at"` +} + +// NewInvoiceApplicationApprovedEvent 创建发票申请通过事件 +func NewInvoiceApplicationApprovedEvent(applicationID, userID string, amount decimal.Decimal, receivingEmail string) *InvoiceApplicationApprovedEvent { + event := &InvoiceApplicationApprovedEvent{ + BaseEvent: NewBaseEvent("InvoiceApplicationApproved", applicationID, "InvoiceApplication"), + ApplicationID: applicationID, + UserID: userID, + Amount: amount, + ReceivingEmail: receivingEmail, + ApprovedAt: time.Now(), + } + return event +} + +// GetPayload 获取事件载荷 +func (e *InvoiceApplicationApprovedEvent) GetPayload() interface{} { + return e +} + +// InvoiceApplicationRejectedEvent 发票申请拒绝事件 +type InvoiceApplicationRejectedEvent struct { + BaseEvent + ApplicationID string `json:"application_id"` + UserID string `json:"user_id"` + Reason string `json:"reason"` + ReceivingEmail string `json:"receiving_email"` + RejectedAt time.Time `json:"rejected_at"` +} + +// NewInvoiceApplicationRejectedEvent 创建发票申请拒绝事件 +func NewInvoiceApplicationRejectedEvent(applicationID, userID, reason, receivingEmail string) *InvoiceApplicationRejectedEvent { + event := &InvoiceApplicationRejectedEvent{ + BaseEvent: NewBaseEvent("InvoiceApplicationRejected", applicationID, "InvoiceApplication"), + ApplicationID: applicationID, + UserID: userID, + Reason: reason, + ReceivingEmail: receivingEmail, + RejectedAt: time.Now(), + } + return event +} + +// GetPayload 获取事件载荷 +func (e *InvoiceApplicationRejectedEvent) GetPayload() interface{} { + return e +} + +// InvoiceFileUploadedEvent 发票文件上传事件 +type InvoiceFileUploadedEvent struct { + BaseEvent + InvoiceID string `json:"invoice_id"` + UserID string `json:"user_id"` + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileURL string `json:"file_url"` + ReceivingEmail string `json:"receiving_email"` + CompanyName string `json:"company_name"` + Amount decimal.Decimal `json:"amount"` + InvoiceType value_objects.InvoiceType `json:"invoice_type"` + UploadedAt time.Time `json:"uploaded_at"` +} + +// NewInvoiceFileUploadedEvent 创建发票文件上传事件 +func NewInvoiceFileUploadedEvent(invoiceID, userID, fileID, fileName, fileURL, receivingEmail, companyName string, amount decimal.Decimal, invoiceType value_objects.InvoiceType) *InvoiceFileUploadedEvent { + event := &InvoiceFileUploadedEvent{ + BaseEvent: NewBaseEvent("InvoiceFileUploaded", invoiceID, "InvoiceApplication"), + InvoiceID: invoiceID, + UserID: userID, + FileID: fileID, + FileName: fileName, + FileURL: fileURL, + ReceivingEmail: receivingEmail, + CompanyName: companyName, + Amount: amount, + InvoiceType: invoiceType, + UploadedAt: time.Now(), + } + return event +} + +// GetPayload 获取事件载荷 +func (e *InvoiceFileUploadedEvent) GetPayload() interface{} { + return e +} \ No newline at end of file diff --git a/internal/domains/finance/repositories/invoice_application_repository.go b/internal/domains/finance/repositories/invoice_application_repository.go new file mode 100644 index 0000000..37bf5c7 --- /dev/null +++ b/internal/domains/finance/repositories/invoice_application_repository.go @@ -0,0 +1,26 @@ +package repositories + +import ( + "context" + "time" + + "tyapi-server/internal/domains/finance/entities" +) + +// InvoiceApplicationRepository 发票申请仓储接口 +type InvoiceApplicationRepository interface { + Create(ctx context.Context, application *entities.InvoiceApplication) error + Update(ctx context.Context, application *entities.InvoiceApplication) error + Save(ctx context.Context, application *entities.InvoiceApplication) error + FindByID(ctx context.Context, id string) (*entities.InvoiceApplication, error) + FindByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + FindPendingApplications(ctx context.Context, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + FindByStatus(ctx context.Context, status entities.ApplicationStatus) ([]*entities.InvoiceApplication, error) + FindByUserIDAndStatus(ctx context.Context, userID string, status entities.ApplicationStatus, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + FindByUserIDAndStatusWithTimeRange(ctx context.Context, userID string, status entities.ApplicationStatus, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + FindByStatusWithTimeRange(ctx context.Context, status entities.ApplicationStatus, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + FindAllWithTimeRange(ctx context.Context, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) + + GetUserTotalInvoicedAmount(ctx context.Context, userID string) (string, error) + GetUserTotalAppliedAmount(ctx context.Context, userID string) (string, error) +} \ No newline at end of file diff --git a/internal/domains/finance/repositories/user_invoice_info_repository.go b/internal/domains/finance/repositories/user_invoice_info_repository.go new file mode 100644 index 0000000..3bf69c3 --- /dev/null +++ b/internal/domains/finance/repositories/user_invoice_info_repository.go @@ -0,0 +1,30 @@ +package repositories + +import ( + "context" + "tyapi-server/internal/domains/finance/entities" +) + +// UserInvoiceInfoRepository 用户开票信息仓储接口 +type UserInvoiceInfoRepository interface { + // Create 创建用户开票信息 + Create(ctx context.Context, info *entities.UserInvoiceInfo) error + + // Update 更新用户开票信息 + Update(ctx context.Context, info *entities.UserInvoiceInfo) error + + // Save 保存用户开票信息(创建或更新) + Save(ctx context.Context, info *entities.UserInvoiceInfo) error + + // FindByUserID 根据用户ID查找开票信息 + FindByUserID(ctx context.Context, userID string) (*entities.UserInvoiceInfo, error) + + // FindByID 根据ID查找开票信息 + FindByID(ctx context.Context, id string) (*entities.UserInvoiceInfo, error) + + // Delete 删除用户开票信息 + Delete(ctx context.Context, userID string) error + + // Exists 检查用户开票信息是否存在 + Exists(ctx context.Context, userID string) (bool, error) +} \ No newline at end of file diff --git a/internal/domains/finance/repositories/wallet_transaction_repository_interface.go b/internal/domains/finance/repositories/wallet_transaction_repository_interface.go index e1fb8fc..c3fe634 100644 --- a/internal/domains/finance/repositories/wallet_transaction_repository_interface.go +++ b/internal/domains/finance/repositories/wallet_transaction_repository_interface.go @@ -25,4 +25,7 @@ type WalletTransactionRepository interface { // 新增:统计用户钱包交易次数 CountByUserId(ctx context.Context, userId string) (int64, error) + + // 管理端:根据条件筛选所有钱包交易记录(包含产品名称) + ListWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (map[string]string, []*entities.WalletTransaction, int64, error) } \ No newline at end of file diff --git a/internal/domains/finance/services/invoice_aggregate_service.go b/internal/domains/finance/services/invoice_aggregate_service.go new file mode 100644 index 0000000..1859e98 --- /dev/null +++ b/internal/domains/finance/services/invoice_aggregate_service.go @@ -0,0 +1,277 @@ +package services + +import ( + "context" + "fmt" + "io" + "mime/multipart" + "time" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/events" + "tyapi-server/internal/domains/finance/repositories" + "tyapi-server/internal/domains/finance/value_objects" + + "tyapi-server/internal/infrastructure/external/storage" + + "github.com/shopspring/decimal" + "go.uber.org/zap" +) + +// ApplyInvoiceRequest 申请开票请求 +type ApplyInvoiceRequest struct { + InvoiceType value_objects.InvoiceType `json:"invoice_type" binding:"required"` + Amount string `json:"amount" binding:"required"` + InvoiceInfo *value_objects.InvoiceInfo `json:"invoice_info" binding:"required"` +} + +// ApproveInvoiceRequest 通过发票申请请求 +type ApproveInvoiceRequest struct { + AdminNotes string `json:"admin_notes"` +} + +// RejectInvoiceRequest 拒绝发票申请请求 +type RejectInvoiceRequest struct { + Reason string `json:"reason" binding:"required"` +} + +// InvoiceAggregateService 发票聚合服务接口 +// 职责:协调发票申请聚合根的生命周期,调用领域服务进行业务规则验证,发布领域事件 +type InvoiceAggregateService interface { + // 申请开票 + ApplyInvoice(ctx context.Context, userID string, req ApplyInvoiceRequest) (*entities.InvoiceApplication, error) + + // 通过发票申请(上传发票) + ApproveInvoiceApplication(ctx context.Context, applicationID string, file multipart.File, req ApproveInvoiceRequest) error + + // 拒绝发票申请 + RejectInvoiceApplication(ctx context.Context, applicationID string, req RejectInvoiceRequest) error +} + +// InvoiceAggregateServiceImpl 发票聚合服务实现 +type InvoiceAggregateServiceImpl struct { + applicationRepo repositories.InvoiceApplicationRepository + userInvoiceInfoRepo repositories.UserInvoiceInfoRepository + domainService InvoiceDomainService + qiniuStorageService *storage.QiNiuStorageService + logger *zap.Logger + eventPublisher EventPublisher +} + +// EventPublisher 事件发布器接口 +type EventPublisher interface { + PublishInvoiceApplicationCreated(ctx context.Context, event *events.InvoiceApplicationCreatedEvent) error + PublishInvoiceApplicationApproved(ctx context.Context, event *events.InvoiceApplicationApprovedEvent) error + PublishInvoiceApplicationRejected(ctx context.Context, event *events.InvoiceApplicationRejectedEvent) error + PublishInvoiceFileUploaded(ctx context.Context, event *events.InvoiceFileUploadedEvent) error +} + +// NewInvoiceAggregateService 创建发票聚合服务 +func NewInvoiceAggregateService( + applicationRepo repositories.InvoiceApplicationRepository, + userInvoiceInfoRepo repositories.UserInvoiceInfoRepository, + domainService InvoiceDomainService, + qiniuStorageService *storage.QiNiuStorageService, + logger *zap.Logger, + eventPublisher EventPublisher, +) InvoiceAggregateService { + return &InvoiceAggregateServiceImpl{ + applicationRepo: applicationRepo, + userInvoiceInfoRepo: userInvoiceInfoRepo, + domainService: domainService, + qiniuStorageService: qiniuStorageService, + logger: logger, + eventPublisher: eventPublisher, + } +} + +// ApplyInvoice 申请开票 +func (s *InvoiceAggregateServiceImpl) ApplyInvoice(ctx context.Context, userID string, req ApplyInvoiceRequest) (*entities.InvoiceApplication, error) { + // 1. 解析金额 + amount, err := decimal.NewFromString(req.Amount) + if err != nil { + return nil, fmt.Errorf("无效的金额格式: %w", err) + } + + // 2. 验证发票信息 + if err := s.domainService.ValidateInvoiceInfo(ctx, req.InvoiceInfo, req.InvoiceType); err != nil { + return nil, fmt.Errorf("发票信息验证失败: %w", err) + } + + // 3. 获取用户开票信息 + userInvoiceInfo, err := s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + if userInvoiceInfo == nil { + return nil, fmt.Errorf("用户开票信息不存在") + } + + // 4. 创建发票申请聚合根 + application := entities.NewInvoiceApplication(userID, req.InvoiceType, amount, userInvoiceInfo.ID) + + // 5. 设置开票信息快照 + application.SetInvoiceInfoSnapshot(req.InvoiceInfo) + + // 6. 验证聚合根业务规则 + if err := s.domainService.ValidateInvoiceApplication(ctx, application); err != nil { + return nil, fmt.Errorf("发票申请业务规则验证失败: %w", err) + } + + // 7. 保存聚合根 + if err := s.applicationRepo.Create(ctx, application); err != nil { + return nil, fmt.Errorf("保存发票申请失败: %w", err) + } + + // 8. 发布领域事件 + event := events.NewInvoiceApplicationCreatedEvent( + application.ID, + application.UserID, + application.InvoiceType, + application.Amount, + application.CompanyName, + application.ReceivingEmail, + ) + + if err := s.eventPublisher.PublishInvoiceApplicationCreated(ctx, event); err != nil { + // 记录错误但不影响主流程 + fmt.Printf("发布发票申请创建事件失败: %v\n", err) + } + + return application, nil +} + +// ApproveInvoiceApplication 通过发票申请(上传发票) +func (s *InvoiceAggregateServiceImpl) ApproveInvoiceApplication(ctx context.Context, applicationID string, file multipart.File, req ApproveInvoiceRequest) error { + // 1. 获取发票申请 + application, err := s.applicationRepo.FindByID(ctx, applicationID) + if err != nil { + return fmt.Errorf("获取发票申请失败: %w", err) + } + if application == nil { + return fmt.Errorf("发票申请不存在") + } + + // 2. 验证状态转换 + if err := s.domainService.ValidateStatusTransition(application.Status, entities.ApplicationStatusCompleted); err != nil { + return fmt.Errorf("状态转换验证失败: %w", err) + } + + // 3. 处理文件上传 + // 读取文件内容 + fileBytes, err := io.ReadAll(file) + if err != nil { + s.logger.Error("读取上传文件失败", zap.Error(err)) + return fmt.Errorf("读取上传文件失败: %w", err) + } + + // 生成文件名(使用时间戳确保唯一性) + fileName := fmt.Sprintf("invoice_%s_%d.pdf", applicationID, time.Now().Unix()) + + // 上传文件到七牛云 + uploadResult, err := s.qiniuStorageService.UploadFile(ctx, fileBytes, fileName) + if err != nil { + s.logger.Error("上传发票文件到七牛云失败", zap.String("file_name", fileName), zap.Error(err)) + return fmt.Errorf("上传发票文件到七牛云失败: %w", err) + } + + // 从上传结果获取文件信息 + fileID := uploadResult.Key + fileURL := uploadResult.URL + fileSize := uploadResult.Size + + // 4. 更新聚合根状态 + application.MarkCompleted("admin_user_id") + application.SetFileInfo(fileID, fileName, fileURL, fileSize) + application.AdminNotes = &req.AdminNotes + + // 5. 保存聚合根 + if err := s.applicationRepo.Update(ctx, application); err != nil { + return fmt.Errorf("更新发票申请失败: %w", err) + } + + // 6. 发布领域事件 + approvedEvent := events.NewInvoiceApplicationApprovedEvent( + application.ID, + application.UserID, + application.Amount, + application.ReceivingEmail, + ) + + if err := s.eventPublisher.PublishInvoiceApplicationApproved(ctx, approvedEvent); err != nil { + s.logger.Error("发布发票申请通过事件失败", + zap.String("application_id", applicationID), + zap.Error(err), + ) + // 事件发布失败不影响主流程,只记录日志 + } else { + s.logger.Info("发票申请通过事件发布成功", + zap.String("application_id", applicationID), + ) + } + + fileUploadedEvent := events.NewInvoiceFileUploadedEvent( + application.ID, + application.UserID, + fileID, + fileName, + fileURL, + application.ReceivingEmail, + application.CompanyName, + application.Amount, + application.InvoiceType, + ) + + if err := s.eventPublisher.PublishInvoiceFileUploaded(ctx, fileUploadedEvent); err != nil { + s.logger.Error("发布发票文件上传事件失败", + zap.String("application_id", applicationID), + zap.Error(err), + ) + // 事件发布失败不影响主流程,只记录日志 + } else { + s.logger.Info("发票文件上传事件发布成功", + zap.String("application_id", applicationID), + ) + } + + return nil +} + +// RejectInvoiceApplication 拒绝发票申请 +func (s *InvoiceAggregateServiceImpl) RejectInvoiceApplication(ctx context.Context, applicationID string, req RejectInvoiceRequest) error { + // 1. 获取发票申请 + application, err := s.applicationRepo.FindByID(ctx, applicationID) + if err != nil { + return fmt.Errorf("获取发票申请失败: %w", err) + } + if application == nil { + return fmt.Errorf("发票申请不存在") + } + + // 2. 验证状态转换 + if err := s.domainService.ValidateStatusTransition(application.Status, entities.ApplicationStatusRejected); err != nil { + return fmt.Errorf("状态转换验证失败: %w", err) + } + + // 3. 更新聚合根状态 + application.MarkRejected(req.Reason, "admin_user_id") + + // 4. 保存聚合根 + if err := s.applicationRepo.Update(ctx, application); err != nil { + return fmt.Errorf("更新发票申请失败: %w", err) + } + + // 5. 发布领域事件 + event := events.NewInvoiceApplicationRejectedEvent( + application.ID, + application.UserID, + req.Reason, + application.ReceivingEmail, + ) + + if err := s.eventPublisher.PublishInvoiceApplicationRejected(ctx, event); err != nil { + fmt.Printf("发布发票申请拒绝事件失败: %v\n", err) + } + + return nil +} diff --git a/internal/domains/finance/services/invoice_domain_service.go b/internal/domains/finance/services/invoice_domain_service.go new file mode 100644 index 0000000..3df90f0 --- /dev/null +++ b/internal/domains/finance/services/invoice_domain_service.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "errors" + "fmt" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/value_objects" + + "github.com/shopspring/decimal" +) + +// InvoiceDomainService 发票领域服务接口 +// 职责:处理发票领域的业务规则和计算逻辑,不涉及外部依赖 +type InvoiceDomainService interface { + // 验证发票信息完整性 + ValidateInvoiceInfo(ctx context.Context, info *value_objects.InvoiceInfo, invoiceType value_objects.InvoiceType) error + + // 验证开票金额是否合法(基于业务规则) + ValidateInvoiceAmount(ctx context.Context, amount decimal.Decimal, availableAmount decimal.Decimal) error + + // 计算可开票金额(纯计算逻辑) + CalculateAvailableAmount(totalRecharged decimal.Decimal, totalGifted decimal.Decimal, totalInvoiced decimal.Decimal) decimal.Decimal + + // 验证发票申请状态转换 + ValidateStatusTransition(currentStatus entities.ApplicationStatus, targetStatus entities.ApplicationStatus) error + + // 验证发票申请业务规则 + ValidateInvoiceApplication(ctx context.Context, application *entities.InvoiceApplication) error +} + +// InvoiceDomainServiceImpl 发票领域服务实现 +type InvoiceDomainServiceImpl struct { + // 领域服务不依赖仓储,只处理业务规则 +} + +// NewInvoiceDomainService 创建发票领域服务 +func NewInvoiceDomainService() InvoiceDomainService { + return &InvoiceDomainServiceImpl{} +} + +// ValidateInvoiceInfo 验证发票信息完整性 +func (s *InvoiceDomainServiceImpl) ValidateInvoiceInfo(ctx context.Context, info *value_objects.InvoiceInfo, invoiceType value_objects.InvoiceType) error { + if info == nil { + return errors.New("发票信息不能为空") + } + + switch invoiceType { + case value_objects.InvoiceTypeGeneral: + return info.ValidateForGeneralInvoice() + case value_objects.InvoiceTypeSpecial: + return info.ValidateForSpecialInvoice() + default: + return errors.New("无效的发票类型") + } +} + +// ValidateInvoiceAmount 验证开票金额是否合法(基于业务规则) +func (s *InvoiceDomainServiceImpl) ValidateInvoiceAmount(ctx context.Context, amount decimal.Decimal, availableAmount decimal.Decimal) error { + if amount.LessThanOrEqual(decimal.Zero) { + return errors.New("开票金额必须大于0") + } + + if amount.GreaterThan(availableAmount) { + return fmt.Errorf("开票金额不能超过可开票金额,可开票金额:%s", availableAmount.String()) + } + + // 最小开票金额限制 + minAmount := decimal.NewFromFloat(0.01) // 最小0.01元 + if amount.LessThan(minAmount) { + return fmt.Errorf("开票金额不能少于%s元", minAmount.String()) + } + + return nil +} + +// CalculateAvailableAmount 计算可开票金额(纯计算逻辑) +func (s *InvoiceDomainServiceImpl) CalculateAvailableAmount(totalRecharged decimal.Decimal, totalGifted decimal.Decimal, totalInvoiced decimal.Decimal) decimal.Decimal { + // 可开票金额 = 充值金额 - 已开票金额(不包含赠送金额) + availableAmount := totalRecharged.Sub(totalInvoiced) + if availableAmount.LessThan(decimal.Zero) { + availableAmount = decimal.Zero + } + return availableAmount +} + +// ValidateStatusTransition 验证发票申请状态转换 +func (s *InvoiceDomainServiceImpl) ValidateStatusTransition(currentStatus entities.ApplicationStatus, targetStatus entities.ApplicationStatus) error { + // 定义允许的状态转换 + allowedTransitions := map[entities.ApplicationStatus][]entities.ApplicationStatus{ + entities.ApplicationStatusPending: { + entities.ApplicationStatusCompleted, + entities.ApplicationStatusRejected, + }, + entities.ApplicationStatusCompleted: { + // 已完成状态不能再转换 + }, + entities.ApplicationStatusRejected: { + // 已拒绝状态不能再转换 + }, + } + + allowedTargets, exists := allowedTransitions[currentStatus] + if !exists { + return fmt.Errorf("无效的当前状态:%s", currentStatus) + } + + for _, allowed := range allowedTargets { + if allowed == targetStatus { + return nil + } + } + + return fmt.Errorf("不允许从状态 %s 转换到状态 %s", currentStatus, targetStatus) +} + +// ValidateInvoiceApplication 验证发票申请业务规则 +func (s *InvoiceDomainServiceImpl) ValidateInvoiceApplication(ctx context.Context, application *entities.InvoiceApplication) error { + if application == nil { + return errors.New("发票申请不能为空") + } + + // 验证基础字段 + if application.UserID == "" { + return errors.New("用户ID不能为空") + } + + if application.Amount.LessThanOrEqual(decimal.Zero) { + return errors.New("申请金额必须大于0") + } + + // 验证发票类型 + if !application.InvoiceType.IsValid() { + return errors.New("无效的发票类型") + } + + // 验证开票信息 + if application.CompanyName == "" { + return errors.New("公司名称不能为空") + } + + if application.TaxpayerID == "" { + return errors.New("纳税人识别号不能为空") + } + + if application.ReceivingEmail == "" { + return errors.New("发票接收邮箱不能为空") + } + + return nil +} diff --git a/internal/domains/finance/services/recharge_record_service.go b/internal/domains/finance/services/recharge_record_service.go index b1961ba..dac434e 100644 --- a/internal/domains/finance/services/recharge_record_service.go +++ b/internal/domains/finance/services/recharge_record_service.go @@ -379,6 +379,15 @@ func (s *RechargeRecordServiceImpl) GetByTransferOrderID(ctx context.Context, tr // GetAll 获取所有充值记录(管理员功能) func (s *RechargeRecordServiceImpl) GetAll(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) ([]entities.RechargeRecord, error) { + // 将filters添加到options中 + if filters != nil { + if options.Filters == nil { + options.Filters = make(map[string]interface{}) + } + for key, value := range filters { + options.Filters[key] = value + } + } return s.rechargeRecordRepo.List(ctx, options) } diff --git a/internal/domains/finance/services/user_invoice_info_service.go b/internal/domains/finance/services/user_invoice_info_service.go new file mode 100644 index 0000000..6766247 --- /dev/null +++ b/internal/domains/finance/services/user_invoice_info_service.go @@ -0,0 +1,250 @@ +package services + +import ( + "context" + "fmt" + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/repositories" + "tyapi-server/internal/domains/finance/value_objects" + + "github.com/google/uuid" +) + +// UserInvoiceInfoService 用户开票信息服务接口 +type UserInvoiceInfoService interface { + // GetUserInvoiceInfo 获取用户开票信息 + GetUserInvoiceInfo(ctx context.Context, userID string) (*entities.UserInvoiceInfo, error) + + // GetUserInvoiceInfoWithEnterpriseInfo 获取用户开票信息(包含企业认证信息) + GetUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) + + // CreateOrUpdateUserInvoiceInfo 创建或更新用户开票信息 + CreateOrUpdateUserInvoiceInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo) (*entities.UserInvoiceInfo, error) + + // CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo 创建或更新用户开票信息(包含企业认证信息) + CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) + + // ValidateInvoiceInfo 验证开票信息 + ValidateInvoiceInfo(ctx context.Context, invoiceInfo *value_objects.InvoiceInfo, invoiceType value_objects.InvoiceType) error + + // DeleteUserInvoiceInfo 删除用户开票信息 + DeleteUserInvoiceInfo(ctx context.Context, userID string) error +} + +// UserInvoiceInfoServiceImpl 用户开票信息服务实现 +type UserInvoiceInfoServiceImpl struct { + userInvoiceInfoRepo repositories.UserInvoiceInfoRepository +} + +// NewUserInvoiceInfoService 创建用户开票信息服务 +func NewUserInvoiceInfoService(userInvoiceInfoRepo repositories.UserInvoiceInfoRepository) UserInvoiceInfoService { + return &UserInvoiceInfoServiceImpl{ + userInvoiceInfoRepo: userInvoiceInfoRepo, + } +} + +// GetUserInvoiceInfo 获取用户开票信息 +func (s *UserInvoiceInfoServiceImpl) GetUserInvoiceInfo(ctx context.Context, userID string) (*entities.UserInvoiceInfo, error) { + info, err := s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + + // 如果没有找到开票信息记录,创建新的实体 + if info == nil { + info = &entities.UserInvoiceInfo{ + ID: uuid.New().String(), + UserID: userID, + CompanyName: "", + TaxpayerID: "", + BankName: "", + BankAccount: "", + CompanyAddress: "", + CompanyPhone: "", + ReceivingEmail: "", + } + } + + return info, nil +} + +// GetUserInvoiceInfoWithEnterpriseInfo 获取用户开票信息(包含企业认证信息) +func (s *UserInvoiceInfoServiceImpl) GetUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) { + info, err := s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + + // 如果没有找到开票信息记录,创建新的实体 + if info == nil { + info = &entities.UserInvoiceInfo{ + ID: uuid.New().String(), + UserID: userID, + CompanyName: companyName, // 使用企业认证信息填充 + TaxpayerID: taxpayerID, // 使用企业认证信息填充 + BankName: "", + BankAccount: "", + CompanyAddress: "", + CompanyPhone: "", + ReceivingEmail: "", + } + } else { + // 如果已有记录,使用传入的企业认证信息覆盖公司名称和纳税人识别号 + if companyName != "" { + info.CompanyName = companyName + } + if taxpayerID != "" { + info.TaxpayerID = taxpayerID + } + } + + return info, nil +} + +// CreateOrUpdateUserInvoiceInfo 创建或更新用户开票信息 +func (s *UserInvoiceInfoServiceImpl) CreateOrUpdateUserInvoiceInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo) (*entities.UserInvoiceInfo, error) { + // 验证开票信息 + if err := s.ValidateInvoiceInfo(ctx, invoiceInfo, value_objects.InvoiceTypeGeneral); err != nil { + return nil, err + } + + // 检查是否已存在 + exists, err := s.userInvoiceInfoRepo.Exists(ctx, userID) + if err != nil { + return nil, fmt.Errorf("检查用户开票信息失败: %w", err) + } + + var userInvoiceInfo *entities.UserInvoiceInfo + + if exists { + // 更新现有记录 + userInvoiceInfo, err = s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + + // 更新字段 + userInvoiceInfo.CompanyName = invoiceInfo.CompanyName + userInvoiceInfo.TaxpayerID = invoiceInfo.TaxpayerID + userInvoiceInfo.BankName = invoiceInfo.BankName + userInvoiceInfo.BankAccount = invoiceInfo.BankAccount + userInvoiceInfo.CompanyAddress = invoiceInfo.CompanyAddress + userInvoiceInfo.CompanyPhone = invoiceInfo.CompanyPhone + userInvoiceInfo.ReceivingEmail = invoiceInfo.ReceivingEmail + + err = s.userInvoiceInfoRepo.Update(ctx, userInvoiceInfo) + } else { + // 创建新记录 + userInvoiceInfo = &entities.UserInvoiceInfo{ + ID: uuid.New().String(), + UserID: userID, + CompanyName: invoiceInfo.CompanyName, + TaxpayerID: invoiceInfo.TaxpayerID, + BankName: invoiceInfo.BankName, + BankAccount: invoiceInfo.BankAccount, + CompanyAddress: invoiceInfo.CompanyAddress, + CompanyPhone: invoiceInfo.CompanyPhone, + ReceivingEmail: invoiceInfo.ReceivingEmail, + } + + err = s.userInvoiceInfoRepo.Create(ctx, userInvoiceInfo) + } + + if err != nil { + return nil, fmt.Errorf("保存用户开票信息失败: %w", err) + } + + return userInvoiceInfo, nil +} + +// CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo 创建或更新用户开票信息(包含企业认证信息) +func (s *UserInvoiceInfoServiceImpl) CreateOrUpdateUserInvoiceInfoWithEnterpriseInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo, companyName, taxpayerID string) (*entities.UserInvoiceInfo, error) { + // 检查企业认证信息 + if companyName == "" || taxpayerID == "" { + return nil, fmt.Errorf("用户未完成企业认证,无法创建开票信息") + } + + // 创建新的开票信息对象,使用传入的企业认证信息 + updatedInvoiceInfo := &value_objects.InvoiceInfo{ + CompanyName: companyName, // 从企业认证信息获取 + TaxpayerID: taxpayerID, // 从企业认证信息获取 + BankName: invoiceInfo.BankName, // 用户输入 + BankAccount: invoiceInfo.BankAccount, // 用户输入 + CompanyAddress: invoiceInfo.CompanyAddress, // 用户输入 + CompanyPhone: invoiceInfo.CompanyPhone, // 用户输入 + ReceivingEmail: invoiceInfo.ReceivingEmail, // 用户输入 + } + + // 验证开票信息 + if err := s.ValidateInvoiceInfo(ctx, updatedInvoiceInfo, value_objects.InvoiceTypeGeneral); err != nil { + return nil, err + } + + // 检查是否已存在 + exists, err := s.userInvoiceInfoRepo.Exists(ctx, userID) + if err != nil { + return nil, fmt.Errorf("检查用户开票信息失败: %w", err) + } + + var userInvoiceInfo *entities.UserInvoiceInfo + + if exists { + // 更新现有记录 + userInvoiceInfo, err = s.userInvoiceInfoRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取用户开票信息失败: %w", err) + } + + // 更新字段(公司名称和纳税人识别号从企业认证信息获取,其他字段从用户输入获取) + userInvoiceInfo.CompanyName = companyName + userInvoiceInfo.TaxpayerID = taxpayerID + userInvoiceInfo.BankName = invoiceInfo.BankName + userInvoiceInfo.BankAccount = invoiceInfo.BankAccount + userInvoiceInfo.CompanyAddress = invoiceInfo.CompanyAddress + userInvoiceInfo.CompanyPhone = invoiceInfo.CompanyPhone + userInvoiceInfo.ReceivingEmail = invoiceInfo.ReceivingEmail + + err = s.userInvoiceInfoRepo.Update(ctx, userInvoiceInfo) + } else { + // 创建新记录 + userInvoiceInfo = &entities.UserInvoiceInfo{ + ID: uuid.New().String(), + UserID: userID, + CompanyName: companyName, // 从企业认证信息获取 + TaxpayerID: taxpayerID, // 从企业认证信息获取 + BankName: invoiceInfo.BankName, // 用户输入 + BankAccount: invoiceInfo.BankAccount, // 用户输入 + CompanyAddress: invoiceInfo.CompanyAddress, // 用户输入 + CompanyPhone: invoiceInfo.CompanyPhone, // 用户输入 + ReceivingEmail: invoiceInfo.ReceivingEmail, // 用户输入 + } + + err = s.userInvoiceInfoRepo.Create(ctx, userInvoiceInfo) + } + + if err != nil { + return nil, fmt.Errorf("保存用户开票信息失败: %w", err) + } + + return userInvoiceInfo, nil +} + +// ValidateInvoiceInfo 验证开票信息 +func (s *UserInvoiceInfoServiceImpl) ValidateInvoiceInfo(ctx context.Context, invoiceInfo *value_objects.InvoiceInfo, invoiceType value_objects.InvoiceType) error { + if invoiceType == value_objects.InvoiceTypeGeneral { + return invoiceInfo.ValidateForGeneralInvoice() + } else if invoiceType == value_objects.InvoiceTypeSpecial { + return invoiceInfo.ValidateForSpecialInvoice() + } + + return fmt.Errorf("无效的发票类型: %s", invoiceType) +} + +// DeleteUserInvoiceInfo 删除用户开票信息 +func (s *UserInvoiceInfoServiceImpl) DeleteUserInvoiceInfo(ctx context.Context, userID string) error { + err := s.userInvoiceInfoRepo.Delete(ctx, userID) + if err != nil { + return fmt.Errorf("删除用户开票信息失败: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/domains/finance/value_objects/invoice_info.go b/internal/domains/finance/value_objects/invoice_info.go new file mode 100644 index 0000000..9d580b4 --- /dev/null +++ b/internal/domains/finance/value_objects/invoice_info.go @@ -0,0 +1,105 @@ +package value_objects + +import ( + "errors" + "strings" +) + +// InvoiceInfo 发票信息值对象 +type InvoiceInfo struct { + CompanyName string `json:"company_name"` // 公司名称 + TaxpayerID string `json:"taxpayer_id"` // 纳税人识别号 + BankName string `json:"bank_name"` // 基本开户银行 + BankAccount string `json:"bank_account"` // 基本开户账号 + CompanyAddress string `json:"company_address"` // 企业注册地址 + CompanyPhone string `json:"company_phone"` // 企业注册电话 + ReceivingEmail string `json:"receiving_email"` // 发票接收邮箱 +} + +// NewInvoiceInfo 创建发票信息值对象 +func NewInvoiceInfo(companyName, taxpayerID, bankName, bankAccount, companyAddress, companyPhone, receivingEmail string) *InvoiceInfo { + return &InvoiceInfo{ + CompanyName: strings.TrimSpace(companyName), + TaxpayerID: strings.TrimSpace(taxpayerID), + BankName: strings.TrimSpace(bankName), + BankAccount: strings.TrimSpace(bankAccount), + CompanyAddress: strings.TrimSpace(companyAddress), + CompanyPhone: strings.TrimSpace(companyPhone), + ReceivingEmail: strings.TrimSpace(receivingEmail), + } +} + +// ValidateForGeneralInvoice 验证普票信息 +func (ii *InvoiceInfo) ValidateForGeneralInvoice() error { + if ii.CompanyName == "" { + return errors.New("公司名称不能为空") + } + if ii.TaxpayerID == "" { + return errors.New("纳税人识别号不能为空") + } + if ii.ReceivingEmail == "" { + return errors.New("发票接收邮箱不能为空") + } + return nil +} + +// ValidateForSpecialInvoice 验证专票信息 +func (ii *InvoiceInfo) ValidateForSpecialInvoice() error { + // 先验证普票必填项 + if err := ii.ValidateForGeneralInvoice(); err != nil { + return err + } + + // 专票额外必填项 + if ii.BankName == "" { + return errors.New("基本开户银行不能为空") + } + if ii.BankAccount == "" { + return errors.New("基本开户账号不能为空") + } + if ii.CompanyAddress == "" { + return errors.New("企业注册地址不能为空") + } + if ii.CompanyPhone == "" { + return errors.New("企业注册电话不能为空") + } + return nil +} + +// IsComplete 检查信息是否完整(专票要求) +func (ii *InvoiceInfo) IsComplete() bool { + return ii.CompanyName != "" && + ii.TaxpayerID != "" && + ii.BankName != "" && + ii.BankAccount != "" && + ii.CompanyAddress != "" && + ii.CompanyPhone != "" && + ii.ReceivingEmail != "" +} + +// GetMissingFields 获取缺失的字段(专票要求) +func (ii *InvoiceInfo) GetMissingFields() []string { + var missing []string + if ii.CompanyName == "" { + missing = append(missing, "公司名称") + } + if ii.TaxpayerID == "" { + missing = append(missing, "纳税人识别号") + } + if ii.BankName == "" { + missing = append(missing, "基本开户银行") + } + if ii.BankAccount == "" { + missing = append(missing, "基本开户账号") + } + if ii.CompanyAddress == "" { + missing = append(missing, "企业注册地址") + } + if ii.CompanyPhone == "" { + missing = append(missing, "企业注册电话") + } + if ii.ReceivingEmail == "" { + missing = append(missing, "发票接收邮箱") + } + return missing +} \ No newline at end of file diff --git a/internal/domains/finance/value_objects/invoice_type.go b/internal/domains/finance/value_objects/invoice_type.go new file mode 100644 index 0000000..a1dba2e --- /dev/null +++ b/internal/domains/finance/value_objects/invoice_type.go @@ -0,0 +1,36 @@ +package value_objects + +// InvoiceType 发票类型枚举 +type InvoiceType string + +const ( + InvoiceTypeGeneral InvoiceType = "general" // 增值税普通发票 (普票) + InvoiceTypeSpecial InvoiceType = "special" // 增值税专用发票 (专票) +) + +// String 返回发票类型的字符串表示 +func (it InvoiceType) String() string { + return string(it) +} + +// IsValid 验证发票类型是否有效 +func (it InvoiceType) IsValid() bool { + switch it { + case InvoiceTypeGeneral, InvoiceTypeSpecial: + return true + default: + return false + } +} + +// GetDisplayName 获取发票类型的显示名称 +func (it InvoiceType) GetDisplayName() string { + switch it { + case InvoiceTypeGeneral: + return "增值税普通发票 (普票)" + case InvoiceTypeSpecial: + return "增值税专用发票 (专票)" + default: + return "未知类型" + } +} \ No newline at end of file diff --git a/internal/domains/product/repositories/queries/subscription_queries.go b/internal/domains/product/repositories/queries/subscription_queries.go index 7b20021..c400bcf 100644 --- a/internal/domains/product/repositories/queries/subscription_queries.go +++ b/internal/domains/product/repositories/queries/subscription_queries.go @@ -8,6 +8,12 @@ type ListSubscriptionsQuery struct { Keyword string `json:"keyword"` SortBy string `json:"sort_by"` SortOrder string `json:"sort_order"` + + // 新增筛选字段 + CompanyName string `json:"company_name"` // 企业名称 + ProductName string `json:"product_name"` // 产品名称 + StartTime string `json:"start_time"` // 订阅开始时间 + EndTime string `json:"end_time"` // 订阅结束时间 } // GetSubscriptionQuery 获取订阅详情查询 diff --git a/internal/domains/product/repositories/subscription_repository_interface.go b/internal/domains/product/repositories/subscription_repository_interface.go index a1e7e3a..08baafc 100644 --- a/internal/domains/product/repositories/subscription_repository_interface.go +++ b/internal/domains/product/repositories/subscription_repository_interface.go @@ -22,6 +22,7 @@ type SubscriptionRepository interface { // 统计方法 CountByUser(ctx context.Context, userID string) (int64, error) CountByProduct(ctx context.Context, productID string) (int64, error) + GetTotalRevenue(ctx context.Context) (float64, error) // 乐观锁更新方法 IncrementAPIUsageWithOptimisticLock(ctx context.Context, subscriptionID string, increment int64) error diff --git a/internal/domains/product/services/product_subscription_service.go b/internal/domains/product/services/product_subscription_service.go index dca6d75..0cc010a 100644 --- a/internal/domains/product/services/product_subscription_service.go +++ b/internal/domains/product/services/product_subscription_service.go @@ -13,6 +13,9 @@ import ( "tyapi-server/internal/domains/product/entities" "tyapi-server/internal/domains/product/repositories" "tyapi-server/internal/domains/product/repositories/queries" + "tyapi-server/internal/shared/interfaces" + + "github.com/shopspring/decimal" ) // ProductSubscriptionService 产品订阅领域服务 @@ -246,3 +249,74 @@ func (s *ProductSubscriptionService) IncrementSubscriptionAPIUsage(ctx context.C return fmt.Errorf("更新失败,已重试%d次", maxRetries) } + +// GetSubscriptionStats 获取订阅统计信息 +func (s *ProductSubscriptionService) GetSubscriptionStats(ctx context.Context) (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // 获取总订阅数 + totalSubscriptions, err := s.subscriptionRepo.Count(ctx, interfaces.CountOptions{}) + if err != nil { + s.logger.Error("获取订阅总数失败", zap.Error(err)) + return nil, fmt.Errorf("获取订阅总数失败: %w", err) + } + stats["total_subscriptions"] = totalSubscriptions + + // 获取总收入 + totalRevenue, err := s.subscriptionRepo.GetTotalRevenue(ctx) + if err != nil { + s.logger.Error("获取总收入失败", zap.Error(err)) + return nil, fmt.Errorf("获取总收入失败: %w", err) + } + stats["total_revenue"] = totalRevenue + + return stats, nil +} + +// GetUserSubscriptionStats 获取用户订阅统计信息 +func (s *ProductSubscriptionService) GetUserSubscriptionStats(ctx context.Context, userID string) (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // 获取用户订阅数 + userSubscriptions, err := s.subscriptionRepo.FindByUserID(ctx, userID) + if err != nil { + s.logger.Error("获取用户订阅失败", zap.Error(err)) + return nil, fmt.Errorf("获取用户订阅失败: %w", err) + } + + // 计算用户总收入 + var totalRevenue float64 + for _, subscription := range userSubscriptions { + totalRevenue += subscription.Price.InexactFloat64() + } + + stats["total_subscriptions"] = int64(len(userSubscriptions)) + stats["total_revenue"] = totalRevenue + + return stats, nil +} + +// UpdateSubscriptionPrice 更新订阅价格 +func (s *ProductSubscriptionService) UpdateSubscriptionPrice(ctx context.Context, subscriptionID string, newPrice float64) error { + // 获取订阅 + subscription, err := s.subscriptionRepo.GetByID(ctx, subscriptionID) + if err != nil { + return fmt.Errorf("订阅不存在: %w", err) + } + + // 更新价格 + subscription.Price = decimal.NewFromFloat(newPrice) + subscription.Version++ // 增加版本号 + + // 保存更新 + if err := s.subscriptionRepo.Update(ctx, subscription); err != nil { + s.logger.Error("更新订阅价格失败", zap.Error(err)) + return fmt.Errorf("更新订阅价格失败: %w", err) + } + + s.logger.Info("订阅价格更新成功", + zap.String("subscription_id", subscriptionID), + zap.Float64("new_price", newPrice)) + + return nil +} diff --git a/internal/infrastructure/database/repositories/api/gorm_api_call_repository.go b/internal/infrastructure/database/repositories/api/gorm_api_call_repository.go index fdf12b8..20c0fa4 100644 --- a/internal/infrastructure/database/repositories/api/gorm_api_call_repository.go +++ b/internal/infrastructure/database/repositories/api/gorm_api_call_repository.go @@ -235,4 +235,98 @@ func (r *GormApiCallRepository) FindByTransactionId(ctx context.Context, transac return nil, err } return &call, nil +} + +// ListWithFiltersAndProductName 管理端:根据条件筛选所有API调用记录(包含产品名称) +func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (map[string]string, []*entities.ApiCall, int64, error) { + var callsWithProduct []*ApiCallWithProduct + var total int64 + + // 构建基础查询条件 + whereCondition := "1=1" + whereArgs := []interface{}{} + + // 应用筛选条件 + if filters != nil { + // 用户ID筛选 + if userId, ok := filters["user_id"].(string); ok && userId != "" { + whereCondition += " AND ac.user_id = ?" + whereArgs = append(whereArgs, userId) + } + + // 时间范围筛选 + if startTime, ok := filters["start_time"].(time.Time); ok { + whereCondition += " AND ac.created_at >= ?" + whereArgs = append(whereArgs, startTime) + } + if endTime, ok := filters["end_time"].(time.Time); ok { + whereCondition += " AND ac.created_at <= ?" + whereArgs = append(whereArgs, endTime) + } + + // TransactionID筛选 + if transactionId, ok := filters["transaction_id"].(string); ok && transactionId != "" { + whereCondition += " AND ac.transaction_id LIKE ?" + whereArgs = append(whereArgs, "%"+transactionId+"%") + } + + // 产品名称筛选 + if productName, ok := filters["product_name"].(string); ok && productName != "" { + whereCondition += " AND p.name LIKE ?" + whereArgs = append(whereArgs, "%"+productName+"%") + } + + // 状态筛选 + if status, ok := filters["status"].(string); ok && status != "" { + whereCondition += " AND ac.status = ?" + whereArgs = append(whereArgs, status) + } + } + + // 构建JOIN查询 + query := r.GetDB(ctx).Table("api_calls ac"). + Select("ac.*, p.name as product_name"). + Joins("LEFT JOIN product p ON ac.product_id = p.id"). + Where(whereCondition, whereArgs...) + + // 获取总数 + var count int64 + err := query.Count(&count).Error + if err != nil { + return nil, nil, 0, err + } + total = count + + // 应用排序和分页 + if options.Sort != "" { + query = query.Order("ac." + options.Sort + " " + options.Order) + } else { + query = query.Order("ac.created_at DESC") + } + + if options.Page > 0 && options.PageSize > 0 { + offset := (options.Page - 1) * options.PageSize + query = query.Offset(offset).Limit(options.PageSize) + } + + // 执行查询 + err = query.Find(&callsWithProduct).Error + if err != nil { + return nil, nil, 0, err + } + + // 转换为entities.ApiCall并构建产品名称映射 + var calls []*entities.ApiCall + productNameMap := make(map[string]string) + + for _, c := range callsWithProduct { + call := c.ApiCall + calls = append(calls, &call) + // 构建产品ID到产品名称的映射 + if c.ProductName != "" { + productNameMap[call.ID] = c.ProductName + } + } + + return productNameMap, calls, total, nil } \ No newline at end of file diff --git a/internal/infrastructure/database/repositories/finance/gorm_wallet_transaction_repository.go b/internal/infrastructure/database/repositories/finance/gorm_wallet_transaction_repository.go index c5654e1..3d1bcbb 100644 --- a/internal/infrastructure/database/repositories/finance/gorm_wallet_transaction_repository.go +++ b/internal/infrastructure/database/repositories/finance/gorm_wallet_transaction_repository.go @@ -292,5 +292,103 @@ func (r *GormWalletTransactionRepository) ListByUserIdWithFiltersAndProductName( } } + return productNameMap, transactions, total, nil +} + +// ListWithFiltersAndProductName 管理端:根据条件筛选所有钱包交易记录(包含产品名称) +func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (map[string]string, []*entities.WalletTransaction, int64, error) { + var transactionsWithProduct []*WalletTransactionWithProduct + var total int64 + + // 构建基础查询条件 + whereCondition := "1=1" + whereArgs := []interface{}{} + + // 应用筛选条件 + if filters != nil { + // 用户ID筛选 + if userId, ok := filters["user_id"].(string); ok && userId != "" { + whereCondition += " AND wt.user_id = ?" + whereArgs = append(whereArgs, userId) + } + + // 时间范围筛选 + if startTime, ok := filters["start_time"].(time.Time); ok { + whereCondition += " AND wt.created_at >= ?" + whereArgs = append(whereArgs, startTime) + } + if endTime, ok := filters["end_time"].(time.Time); ok { + whereCondition += " AND wt.created_at <= ?" + whereArgs = append(whereArgs, endTime) + } + + // 交易ID筛选 + if transactionId, ok := filters["transaction_id"].(string); ok && transactionId != "" { + whereCondition += " AND wt.transaction_id LIKE ?" + whereArgs = append(whereArgs, "%"+transactionId+"%") + } + + // 产品名称筛选 + if productName, ok := filters["product_name"].(string); ok && productName != "" { + whereCondition += " AND p.name LIKE ?" + whereArgs = append(whereArgs, "%"+productName+"%") + } + + // 金额范围筛选 + if minAmount, ok := filters["min_amount"].(string); ok && minAmount != "" { + whereCondition += " AND wt.amount >= ?" + whereArgs = append(whereArgs, minAmount) + } + if maxAmount, ok := filters["max_amount"].(string); ok && maxAmount != "" { + whereCondition += " AND wt.amount <= ?" + whereArgs = append(whereArgs, maxAmount) + } + } + + // 构建JOIN查询 + query := r.GetDB(ctx).Table("wallet_transactions wt"). + Select("wt.*, p.name as product_name"). + Joins("LEFT JOIN product p ON wt.product_id = p.id"). + Where(whereCondition, whereArgs...) + + // 获取总数 + var count int64 + err := query.Count(&count).Error + if err != nil { + return nil, nil, 0, err + } + total = count + + // 应用排序和分页 + if options.Sort != "" { + query = query.Order("wt." + options.Sort + " " + options.Order) + } else { + query = query.Order("wt.created_at DESC") + } + + if options.Page > 0 && options.PageSize > 0 { + offset := (options.Page - 1) * options.PageSize + query = query.Offset(offset).Limit(options.PageSize) + } + + // 执行查询 + err = query.Find(&transactionsWithProduct).Error + if err != nil { + return nil, nil, 0, err + } + + // 转换为entities.WalletTransaction并构建产品名称映射 + var transactions []*entities.WalletTransaction + productNameMap := make(map[string]string) + + for _, t := range transactionsWithProduct { + transaction := t.WalletTransaction + transactions = append(transactions, &transaction) + // 构建产品ID到产品名称的映射 + if t.ProductName != "" { + productNameMap[transaction.ProductID] = t.ProductName + } + } + return productNameMap, transactions, total, nil } \ No newline at end of file diff --git a/internal/infrastructure/database/repositories/finance/invoice_application_repository_impl.go b/internal/infrastructure/database/repositories/finance/invoice_application_repository_impl.go new file mode 100644 index 0000000..071ffd7 --- /dev/null +++ b/internal/infrastructure/database/repositories/finance/invoice_application_repository_impl.go @@ -0,0 +1,342 @@ +package repositories + +import ( + "context" + "fmt" + "time" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/repositories" + "tyapi-server/internal/domains/finance/value_objects" + + "gorm.io/gorm" +) + +// GormInvoiceApplicationRepository 发票申请仓储的GORM实现 +type GormInvoiceApplicationRepository struct { + db *gorm.DB +} + +// NewGormInvoiceApplicationRepository 创建发票申请仓储 +func NewGormInvoiceApplicationRepository(db *gorm.DB) repositories.InvoiceApplicationRepository { + return &GormInvoiceApplicationRepository{ + db: db, + } +} + +// Create 创建发票申请 +func (r *GormInvoiceApplicationRepository) Create(ctx context.Context, application *entities.InvoiceApplication) error { + return r.db.WithContext(ctx).Create(application).Error +} + +// Update 更新发票申请 +func (r *GormInvoiceApplicationRepository) Update(ctx context.Context, application *entities.InvoiceApplication) error { + return r.db.WithContext(ctx).Save(application).Error +} + +// Save 保存发票申请 +func (r *GormInvoiceApplicationRepository) Save(ctx context.Context, application *entities.InvoiceApplication) error { + return r.db.WithContext(ctx).Save(application).Error +} + +// FindByID 根据ID查找发票申请 +func (r *GormInvoiceApplicationRepository) FindByID(ctx context.Context, id string) (*entities.InvoiceApplication, error) { + var application entities.InvoiceApplication + err := r.db.WithContext(ctx).Where("id = ?", id).First(&application).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &application, nil +} + +// FindByUserID 根据用户ID查找发票申请列表 +func (r *GormInvoiceApplicationRepository) FindByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + // 获取总数 + err := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}).Where("user_id = ?", userID).Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = r.db.WithContext(ctx).Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindPendingApplications 查找待处理的发票申请 +func (r *GormInvoiceApplicationRepository) FindPendingApplications(ctx context.Context, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + // 获取总数 + err := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}). + Where("status = ?", entities.ApplicationStatusPending). + Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = r.db.WithContext(ctx). + Where("status = ?", entities.ApplicationStatusPending). + Order("created_at ASC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindByUserIDAndStatus 根据用户ID和状态查找发票申请 +func (r *GormInvoiceApplicationRepository) FindByUserIDAndStatus(ctx context.Context, userID string, status entities.ApplicationStatus, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}).Where("user_id = ?", userID) + if status != "" { + query = query.Where("status = ?", status) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindByUserIDAndStatusWithTimeRange 根据用户ID、状态和时间范围查找发票申请列表 +func (r *GormInvoiceApplicationRepository) FindByUserIDAndStatusWithTimeRange(ctx context.Context, userID string, status entities.ApplicationStatus, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}).Where("user_id = ?", userID) + + // 添加状态筛选 + if status != "" { + query = query.Where("status = ?", status) + } + + // 添加时间范围筛选 + if startTime != nil { + query = query.Where("created_at >= ?", startTime) + } + if endTime != nil { + query = query.Where("created_at <= ?", endTime) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindByStatus 根据状态查找发票申请 +func (r *GormInvoiceApplicationRepository) FindByStatus(ctx context.Context, status entities.ApplicationStatus) ([]*entities.InvoiceApplication, error) { + var applications []*entities.InvoiceApplication + err := r.db.WithContext(ctx). + Where("status = ?", status). + Order("created_at DESC"). + Find(&applications).Error + return applications, err +} + +// GetUserInvoiceInfo 获取用户发票信息 + + + + +// GetUserTotalInvoicedAmount 获取用户已开票总金额 +func (r *GormInvoiceApplicationRepository) GetUserTotalInvoicedAmount(ctx context.Context, userID string) (string, error) { + var total string + err := r.db.WithContext(ctx). + Model(&entities.InvoiceApplication{}). + Select("COALESCE(SUM(CAST(amount AS DECIMAL(10,2))), '0')"). + Where("user_id = ? AND status = ?", userID, entities.ApplicationStatusCompleted). + Scan(&total).Error + + return total, err +} + +// GetUserTotalAppliedAmount 获取用户申请开票总金额 +func (r *GormInvoiceApplicationRepository) GetUserTotalAppliedAmount(ctx context.Context, userID string) (string, error) { + var total string + err := r.db.WithContext(ctx). + Model(&entities.InvoiceApplication{}). + Select("COALESCE(SUM(CAST(amount AS DECIMAL(10,2))), '0')"). + Where("user_id = ?", userID). + Scan(&total).Error + + return total, err +} + +// FindByUserIDAndInvoiceType 根据用户ID和发票类型查找申请 +func (r *GormInvoiceApplicationRepository) FindByUserIDAndInvoiceType(ctx context.Context, userID string, invoiceType value_objects.InvoiceType, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}).Where("user_id = ? AND invoice_type = ?", userID, invoiceType) + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindByDateRange 根据日期范围查找申请 +func (r *GormInvoiceApplicationRepository) FindByDateRange(ctx context.Context, startDate, endDate string, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}) + if startDate != "" { + query = query.Where("DATE(created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(created_at) <= ?", endDate) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// SearchApplications 搜索发票申请 +func (r *GormInvoiceApplicationRepository) SearchApplications(ctx context.Context, keyword string, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}). + Where("company_name LIKE ? OR email LIKE ? OR tax_number LIKE ?", + fmt.Sprintf("%%%s%%", keyword), + fmt.Sprintf("%%%s%%", keyword), + fmt.Sprintf("%%%s%%", keyword)) + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindByStatusWithTimeRange 根据状态和时间范围查找发票申请 +func (r *GormInvoiceApplicationRepository) FindByStatusWithTimeRange(ctx context.Context, status entities.ApplicationStatus, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}).Where("status = ?", status) + + // 添加时间范围筛选 + if startTime != nil { + query = query.Where("created_at >= ?", startTime) + } + if endTime != nil { + query = query.Where("created_at <= ?", endTime) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} + +// FindAllWithTimeRange 根据时间范围查找所有发票申请 +func (r *GormInvoiceApplicationRepository) FindAllWithTimeRange(ctx context.Context, startTime, endTime *time.Time, page, pageSize int) ([]*entities.InvoiceApplication, int64, error) { + var applications []*entities.InvoiceApplication + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.InvoiceApplication{}) + + // 添加时间范围筛选 + if startTime != nil { + query = query.Where("created_at >= ?", startTime) + } + if endTime != nil { + query = query.Where("created_at <= ?", endTime) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取分页数据 + offset := (page - 1) * pageSize + err = query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&applications).Error + + return applications, total, err +} \ No newline at end of file diff --git a/internal/infrastructure/database/repositories/finance/user_invoice_info_repository_impl.go b/internal/infrastructure/database/repositories/finance/user_invoice_info_repository_impl.go new file mode 100644 index 0000000..23fef1c --- /dev/null +++ b/internal/infrastructure/database/repositories/finance/user_invoice_info_repository_impl.go @@ -0,0 +1,74 @@ +package repositories + +import ( + "context" + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/repositories" + + "gorm.io/gorm" +) + +// GormUserInvoiceInfoRepository 用户开票信息仓储的GORM实现 +type GormUserInvoiceInfoRepository struct { + db *gorm.DB +} + +// NewGormUserInvoiceInfoRepository 创建用户开票信息仓储 +func NewGormUserInvoiceInfoRepository(db *gorm.DB) repositories.UserInvoiceInfoRepository { + return &GormUserInvoiceInfoRepository{ + db: db, + } +} + +// Create 创建用户开票信息 +func (r *GormUserInvoiceInfoRepository) Create(ctx context.Context, info *entities.UserInvoiceInfo) error { + return r.db.WithContext(ctx).Create(info).Error +} + +// Update 更新用户开票信息 +func (r *GormUserInvoiceInfoRepository) Update(ctx context.Context, info *entities.UserInvoiceInfo) error { + return r.db.WithContext(ctx).Save(info).Error +} + +// Save 保存用户开票信息(创建或更新) +func (r *GormUserInvoiceInfoRepository) Save(ctx context.Context, info *entities.UserInvoiceInfo) error { + return r.db.WithContext(ctx).Save(info).Error +} + +// FindByUserID 根据用户ID查找开票信息 +func (r *GormUserInvoiceInfoRepository) FindByUserID(ctx context.Context, userID string) (*entities.UserInvoiceInfo, error) { + var info entities.UserInvoiceInfo + err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&info).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &info, nil +} + +// FindByID 根据ID查找开票信息 +func (r *GormUserInvoiceInfoRepository) FindByID(ctx context.Context, id string) (*entities.UserInvoiceInfo, error) { + var info entities.UserInvoiceInfo + err := r.db.WithContext(ctx).Where("id = ?", id).First(&info).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &info, nil +} + +// Delete 删除用户开票信息 +func (r *GormUserInvoiceInfoRepository) Delete(ctx context.Context, userID string) error { + return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.UserInvoiceInfo{}).Error +} + +// Exists 检查用户开票信息是否存在 +func (r *GormUserInvoiceInfoRepository) Exists(ctx context.Context, userID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.UserInvoiceInfo{}).Where("user_id = ?", userID).Count(&count).Error + return count > 0, 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 3d3d3be..9530d3f 100644 --- a/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go +++ b/internal/infrastructure/database/repositories/product/gorm_subscription_repository.go @@ -10,6 +10,7 @@ import ( "tyapi-server/internal/shared/database" "tyapi-server/internal/shared/interfaces" + "github.com/shopspring/decimal" "go.uber.org/zap" "gorm.io/gorm" ) @@ -113,13 +114,39 @@ func (r *GormSubscriptionRepository) ListSubscriptions(ctx context.Context, quer // 应用筛选条件 if query.UserID != "" { - dbQuery = dbQuery.Where("user_id = ?", query.UserID) + dbQuery = dbQuery.Where("subscription.user_id = ?", query.UserID) } - // 这里筛选的是关联的Product实体里的name或code字段,只有当keyword匹配关联Product的name或code时才返回 + + // 关键词搜索(产品名称或编码) if query.Keyword != "" { dbQuery = dbQuery.Joins("LEFT JOIN product ON product.id = subscription.product_id"). Where("product.name LIKE ? OR product.code LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") } + + // 产品名称筛选 + if query.ProductName != "" { + dbQuery = dbQuery.Joins("LEFT JOIN product ON product.id = subscription.product_id"). + Where("product.name LIKE ?", "%"+query.ProductName+"%") + } + + // 企业名称筛选(需要关联用户和企业信息) + if query.CompanyName != "" { + dbQuery = dbQuery.Joins("LEFT JOIN users ON users.id = subscription.user_id"). + Joins("LEFT JOIN enterprise_infos ON enterprise_infos.user_id = users.id"). + Where("enterprise_infos.company_name LIKE ?", "%"+query.CompanyName+"%") + } + + // 时间范围筛选 + if query.StartTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", query.StartTime); err == nil { + dbQuery = dbQuery.Where("subscription.created_at >= ?", t) + } + } + if query.EndTime != "" { + if t, err := time.Parse("2006-01-02 15:04:05", query.EndTime); err == nil { + dbQuery = dbQuery.Where("subscription.created_at <= ?", t) + } + } // 获取总数 if err := dbQuery.Count(&total).Error; err != nil { @@ -136,7 +163,7 @@ func (r *GormSubscriptionRepository) ListSubscriptions(ctx context.Context, quer } dbQuery = dbQuery.Order(order) } else { - dbQuery = dbQuery.Order("created_at DESC") + dbQuery = dbQuery.Order("subscription.created_at DESC") } // 应用分页 @@ -173,13 +200,23 @@ func (r *GormSubscriptionRepository) CountByUser(ctx context.Context, userID str return count, err } -// CountByProduct 统计产品订阅数量 +// CountByProduct 统计产品的订阅数量 func (r *GormSubscriptionRepository) CountByProduct(ctx context.Context, productID string) (int64, error) { var count int64 err := r.GetDB(ctx).WithContext(ctx).Model(&entities.Subscription{}).Where("product_id = ?", productID).Count(&count).Error return count, err } +// GetTotalRevenue 获取总收入 +func (r *GormSubscriptionRepository) GetTotalRevenue(ctx context.Context) (float64, error) { + var total decimal.Decimal + err := r.GetDB(ctx).WithContext(ctx).Model(&entities.Subscription{}).Select("COALESCE(SUM(price), 0)").Scan(&total).Error + if err != nil { + return 0, err + } + return total.InexactFloat64(), nil +} + // 基础Repository接口方法 // Count 返回订阅总数 diff --git a/internal/infrastructure/events/invoice_event_handler.go b/internal/infrastructure/events/invoice_event_handler.go new file mode 100644 index 0000000..5ec50b4 --- /dev/null +++ b/internal/infrastructure/events/invoice_event_handler.go @@ -0,0 +1,230 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "go.uber.org/zap" + + "tyapi-server/internal/domains/finance/events" + "tyapi-server/internal/infrastructure/external/email" + "tyapi-server/internal/shared/interfaces" +) + +// InvoiceEventHandler 发票事件处理器 +type InvoiceEventHandler struct { + logger *zap.Logger + emailService *email.QQEmailService + name string + eventTypes []string + isAsync bool +} + +// NewInvoiceEventHandler 创建发票事件处理器 +func NewInvoiceEventHandler(logger *zap.Logger, emailService *email.QQEmailService) *InvoiceEventHandler { + return &InvoiceEventHandler{ + logger: logger, + emailService: emailService, + name: "invoice-event-handler", + eventTypes: []string{ + "InvoiceApplicationCreated", + "InvoiceApplicationApproved", + "InvoiceApplicationRejected", + "InvoiceFileUploaded", + }, + isAsync: true, + } +} + +// GetName 获取处理器名称 +func (h *InvoiceEventHandler) GetName() string { + return h.name +} + +// GetEventTypes 获取支持的事件类型 +func (h *InvoiceEventHandler) GetEventTypes() []string { + return h.eventTypes +} + +// IsAsync 是否为异步处理器 +func (h *InvoiceEventHandler) IsAsync() bool { + return h.isAsync +} + +// GetRetryConfig 获取重试配置 +func (h *InvoiceEventHandler) GetRetryConfig() interfaces.RetryConfig { + return interfaces.RetryConfig{ + MaxRetries: 3, + RetryDelay: 5 * time.Second, + BackoffFactor: 2.0, + MaxDelay: 30 * time.Second, + } +} + +// Handle 处理事件 +func (h *InvoiceEventHandler) Handle(ctx context.Context, event interfaces.Event) error { + h.logger.Info("🔄 开始处理发票事件", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + zap.String("aggregate_id", event.GetAggregateID()), + zap.String("handler_name", h.GetName()), + zap.Time("event_timestamp", event.GetTimestamp()), + ) + + switch event.GetType() { + case "InvoiceApplicationCreated": + h.logger.Info("📝 处理发票申请创建事件") + return h.handleInvoiceApplicationCreated(ctx, event) + case "InvoiceApplicationApproved": + h.logger.Info("✅ 处理发票申请通过事件") + return h.handleInvoiceApplicationApproved(ctx, event) + case "InvoiceApplicationRejected": + h.logger.Info("❌ 处理发票申请拒绝事件") + return h.handleInvoiceApplicationRejected(ctx, event) + case "InvoiceFileUploaded": + h.logger.Info("📎 处理发票文件上传事件") + return h.handleInvoiceFileUploaded(ctx, event) + default: + h.logger.Warn("⚠️ 未知的发票事件类型", zap.String("event_type", event.GetType())) + return nil + } +} + +// handleInvoiceApplicationCreated 处理发票申请创建事件 +func (h *InvoiceEventHandler) handleInvoiceApplicationCreated(ctx context.Context, event interfaces.Event) error { + h.logger.Info("发票申请已创建", + zap.String("application_id", event.GetAggregateID()), + ) + + // 这里可以发送通知给管理员,告知有新的发票申请 + // 暂时只记录日志 + return nil +} + +// handleInvoiceApplicationApproved 处理发票申请通过事件 +func (h *InvoiceEventHandler) handleInvoiceApplicationApproved(ctx context.Context, event interfaces.Event) error { + h.logger.Info("发票申请已通过", + zap.String("application_id", event.GetAggregateID()), + ) + + // 这里可以发送通知给用户,告知发票申请已通过 + // 暂时只记录日志 + return nil +} + +// handleInvoiceApplicationRejected 处理发票申请拒绝事件 +func (h *InvoiceEventHandler) handleInvoiceApplicationRejected(ctx context.Context, event interfaces.Event) error { + h.logger.Info("发票申请被拒绝", + zap.String("application_id", event.GetAggregateID()), + ) + + // 这里可以发送邮件通知用户,告知发票申请被拒绝 + // 暂时只记录日志 + return nil +} + +// handleInvoiceFileUploaded 处理发票文件上传事件 +func (h *InvoiceEventHandler) handleInvoiceFileUploaded(ctx context.Context, event interfaces.Event) error { + h.logger.Info("📎 发票文件已上传事件开始处理", + zap.String("invoice_id", event.GetAggregateID()), + zap.String("event_id", event.GetID()), + ) + + // 解析事件数据 + payload := event.GetPayload() + if payload == nil { + h.logger.Error("❌ 事件数据为空") + return fmt.Errorf("事件数据为空") + } + + h.logger.Info("📋 事件数据解析开始", + zap.Any("payload_type", fmt.Sprintf("%T", payload)), + ) + + // 将payload转换为JSON,然后解析为InvoiceFileUploadedEvent + payloadBytes, err := json.Marshal(payload) + if err != nil { + h.logger.Error("❌ 序列化事件数据失败", zap.Error(err)) + return fmt.Errorf("序列化事件数据失败: %w", err) + } + + h.logger.Info("📄 事件数据序列化成功", + zap.String("payload_json", string(payloadBytes)), + ) + + var fileUploadedEvent events.InvoiceFileUploadedEvent + err = json.Unmarshal(payloadBytes, &fileUploadedEvent) + if err != nil { + h.logger.Error("❌ 解析发票文件上传事件失败", zap.Error(err)) + return fmt.Errorf("解析发票文件上传事件失败: %w", err) + } + + h.logger.Info("✅ 事件数据解析成功", + zap.String("invoice_id", fileUploadedEvent.InvoiceID), + zap.String("user_id", fileUploadedEvent.UserID), + zap.String("receiving_email", fileUploadedEvent.ReceivingEmail), + zap.String("file_name", fileUploadedEvent.FileName), + zap.String("file_url", fileUploadedEvent.FileURL), + zap.String("company_name", fileUploadedEvent.CompanyName), + zap.String("amount", fileUploadedEvent.Amount.String()), + zap.String("invoice_type", string(fileUploadedEvent.InvoiceType)), + ) + + // 发送发票邮件给用户 + return h.sendInvoiceEmail(ctx, &fileUploadedEvent) +} + +// sendInvoiceEmail 发送发票邮件 +func (h *InvoiceEventHandler) sendInvoiceEmail(ctx context.Context, event *events.InvoiceFileUploadedEvent) error { + h.logger.Info("📧 开始发送发票邮件", + zap.String("invoice_id", event.InvoiceID), + zap.String("user_id", event.UserID), + zap.String("receiving_email", event.ReceivingEmail), + zap.String("file_name", event.FileName), + zap.String("file_url", event.FileURL), + ) + + // 构建邮件数据 + emailData := &email.InvoiceEmailData{ + CompanyName: event.CompanyName, + Amount: event.Amount.String(), + InvoiceType: event.InvoiceType.GetDisplayName(), + FileURL: event.FileURL, + FileName: event.FileName, + ReceivingEmail: event.ReceivingEmail, + ApprovedAt: event.UploadedAt.Format("2006-01-02 15:04:05"), + } + + h.logger.Info("📋 邮件数据构建完成", + zap.String("company_name", emailData.CompanyName), + zap.String("amount", emailData.Amount), + zap.String("invoice_type", emailData.InvoiceType), + zap.String("file_url", emailData.FileURL), + zap.String("file_name", emailData.FileName), + zap.String("receiving_email", emailData.ReceivingEmail), + zap.String("approved_at", emailData.ApprovedAt), + ) + + // 发送邮件 + h.logger.Info("🚀 开始调用邮件服务发送邮件") + err := h.emailService.SendInvoiceEmail(ctx, emailData) + if err != nil { + h.logger.Error("❌ 发送发票邮件失败", + zap.String("invoice_id", event.InvoiceID), + zap.String("receiving_email", event.ReceivingEmail), + zap.Error(err), + ) + return fmt.Errorf("发送发票邮件失败: %w", err) + } + + h.logger.Info("✅ 发票邮件发送成功", + zap.String("invoice_id", event.InvoiceID), + zap.String("receiving_email", event.ReceivingEmail), + ) + + return nil +} + + \ No newline at end of file diff --git a/internal/infrastructure/events/invoice_event_publisher.go b/internal/infrastructure/events/invoice_event_publisher.go new file mode 100644 index 0000000..d3e52b4 --- /dev/null +++ b/internal/infrastructure/events/invoice_event_publisher.go @@ -0,0 +1,115 @@ +package events + +import ( + "context" + + "go.uber.org/zap" + + "tyapi-server/internal/domains/finance/events" + "tyapi-server/internal/shared/interfaces" +) + +// InvoiceEventPublisher 发票事件发布器实现 +type InvoiceEventPublisher struct { + logger *zap.Logger + eventBus interfaces.EventBus +} + +// NewInvoiceEventPublisher 创建发票事件发布器 +func NewInvoiceEventPublisher(logger *zap.Logger, eventBus interfaces.EventBus) *InvoiceEventPublisher { + return &InvoiceEventPublisher{ + logger: logger, + eventBus: eventBus, + } +} + +// PublishInvoiceApplicationCreated 发布发票申请创建事件 +func (p *InvoiceEventPublisher) PublishInvoiceApplicationCreated(ctx context.Context, event *events.InvoiceApplicationCreatedEvent) error { + p.logger.Info("发布发票申请创建事件", + zap.String("application_id", event.ApplicationID), + zap.String("user_id", event.UserID), + zap.String("invoice_type", string(event.InvoiceType)), + zap.String("amount", event.Amount.String()), + zap.String("company_name", event.CompanyName), + zap.String("receiving_email", event.ReceivingEmail), + ) + + // TODO: 实现实际的事件发布逻辑 + // 例如:发送到消息队列、调用外部服务等 + + return nil +} + +// PublishInvoiceApplicationApproved 发布发票申请通过事件 +func (p *InvoiceEventPublisher) PublishInvoiceApplicationApproved(ctx context.Context, event *events.InvoiceApplicationApprovedEvent) error { + p.logger.Info("发布发票申请通过事件", + zap.String("application_id", event.ApplicationID), + zap.String("user_id", event.UserID), + zap.String("amount", event.Amount.String()), + zap.String("receiving_email", event.ReceivingEmail), + zap.Time("approved_at", event.ApprovedAt), + ) + + // TODO: 实现实际的事件发布逻辑 + // 例如:发送邮件通知用户、更新统计数据等 + + return nil +} + +// PublishInvoiceApplicationRejected 发布发票申请拒绝事件 +func (p *InvoiceEventPublisher) PublishInvoiceApplicationRejected(ctx context.Context, event *events.InvoiceApplicationRejectedEvent) error { + p.logger.Info("发布发票申请拒绝事件", + zap.String("application_id", event.ApplicationID), + zap.String("user_id", event.UserID), + zap.String("reason", event.Reason), + zap.String("receiving_email", event.ReceivingEmail), + zap.Time("rejected_at", event.RejectedAt), + ) + + // TODO: 实现实际的事件发布逻辑 + // 例如:发送邮件通知用户、记录拒绝原因等 + + return nil +} + +// PublishInvoiceFileUploaded 发布发票文件上传事件 +func (p *InvoiceEventPublisher) PublishInvoiceFileUploaded(ctx context.Context, event *events.InvoiceFileUploadedEvent) error { + p.logger.Info("📤 开始发布发票文件上传事件", + zap.String("invoice_id", event.InvoiceID), + zap.String("user_id", event.UserID), + zap.String("file_id", event.FileID), + zap.String("file_name", event.FileName), + zap.String("file_url", event.FileURL), + zap.String("receiving_email", event.ReceivingEmail), + zap.Time("uploaded_at", event.UploadedAt), + ) + + // 发布到事件总线 + if p.eventBus != nil { + p.logger.Info("🚀 准备发布事件到事件总线", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + ) + + if err := p.eventBus.Publish(ctx, event); err != nil { + p.logger.Error("❌ 发布发票文件上传事件到事件总线失败", + zap.String("invoice_id", event.InvoiceID), + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + zap.Error(err), + ) + return err + } + p.logger.Info("✅ 发票文件上传事件已发布到事件总线", + zap.String("invoice_id", event.InvoiceID), + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + ) + } else { + p.logger.Warn("⚠️ 事件总线未初始化,无法发布事件", + zap.String("invoice_id", event.InvoiceID), + ) + } + + return nil +} \ No newline at end of file diff --git a/internal/infrastructure/external/email/qq_email_service.go b/internal/infrastructure/external/email/qq_email_service.go new file mode 100644 index 0000000..44f314a --- /dev/null +++ b/internal/infrastructure/external/email/qq_email_service.go @@ -0,0 +1,712 @@ +package email + +import ( + "context" + "crypto/tls" + "fmt" + "html/template" + "net" + "net/smtp" + "strings" + "time" + + "go.uber.org/zap" + + "tyapi-server/internal/config" +) + +// QQEmailService QQ邮箱服务 +type QQEmailService struct { + config config.EmailConfig + logger *zap.Logger +} + +// EmailData 邮件数据 +type EmailData struct { + To string `json:"to"` + Subject string `json:"subject"` + Content string `json:"content"` + Data map[string]interface{} `json:"data"` +} + +// InvoiceEmailData 发票邮件数据 +type InvoiceEmailData struct { + CompanyName string `json:"company_name"` + Amount string `json:"amount"` + InvoiceType string `json:"invoice_type"` + FileURL string `json:"file_url"` + FileName string `json:"file_name"` + ReceivingEmail string `json:"receiving_email"` + ApprovedAt string `json:"approved_at"` +} + +// NewQQEmailService 创建QQ邮箱服务 +func NewQQEmailService(config config.EmailConfig, logger *zap.Logger) *QQEmailService { + return &QQEmailService{ + config: config, + logger: logger, + } +} + +// SendEmail 发送邮件 +func (s *QQEmailService) SendEmail(ctx context.Context, data *EmailData) error { + s.logger.Info("开始发送邮件", + zap.String("to", data.To), + zap.String("subject", data.Subject), + ) + + // 构建邮件内容 + message := s.buildEmailMessage(data) + + // 发送邮件 + err := s.sendSMTP(data.To, data.Subject, message) + if err != nil { + s.logger.Error("发送邮件失败", + zap.String("to", data.To), + zap.String("subject", data.Subject), + zap.Error(err), + ) + return fmt.Errorf("发送邮件失败: %w", err) + } + + s.logger.Info("邮件发送成功", + zap.String("to", data.To), + zap.String("subject", data.Subject), + ) + + return nil +} + +// SendInvoiceEmail 发送发票邮件 +func (s *QQEmailService) SendInvoiceEmail(ctx context.Context, data *InvoiceEmailData) error { + s.logger.Info("开始发送发票邮件", + zap.String("to", data.ReceivingEmail), + zap.String("company_name", data.CompanyName), + zap.String("amount", data.Amount), + ) + + // 构建邮件内容 + subject := "您的发票已开具成功" + content := s.buildInvoiceEmailContent(data) + + emailData := &EmailData{ + To: data.ReceivingEmail, + Subject: subject, + Content: content, + Data: map[string]interface{}{ + "company_name": data.CompanyName, + "amount": data.Amount, + "invoice_type": data.InvoiceType, + "file_url": data.FileURL, + "file_name": data.FileName, + "approved_at": data.ApprovedAt, + }, + } + + return s.SendEmail(ctx, emailData) +} + +// buildEmailMessage 构建邮件消息 +func (s *QQEmailService) buildEmailMessage(data *EmailData) string { + headers := make(map[string]string) + headers["From"] = s.config.FromEmail + headers["To"] = data.To + headers["Subject"] = data.Subject + headers["MIME-Version"] = "1.0" + headers["Content-Type"] = "text/html; charset=UTF-8" + + var message strings.Builder + for key, value := range headers { + message.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) + } + message.WriteString("\r\n") + message.WriteString(data.Content) + + return message.String() +} + +// buildInvoiceEmailContent 构建发票邮件内容 +func (s *QQEmailService) buildInvoiceEmailContent(data *InvoiceEmailData) string { + htmlTemplate := ` + + +
+ +尊敬的用户,您好!
+您的发票申请已审核通过,发票已成功开具。
+