diff --git a/TODO_INVOICE_INTEGRATION.md b/TODO_INVOICE_INTEGRATION.md deleted file mode 100644 index e120dee..0000000 --- a/TODO_INVOICE_INTEGRATION.md +++ /dev/null @@ -1,209 +0,0 @@ -# 发票功能外部服务集成 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/cmd/worker/main.go b/cmd/worker/main.go index 6b97f3f..a738317 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -11,10 +11,10 @@ import ( "tyapi-server/internal/config" "tyapi-server/internal/domains/article/entities" + "tyapi-server/internal/infrastructure/database" "github.com/hibiken/asynq" "go.uber.org/zap" - "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -37,28 +37,28 @@ func main() { defer logger.Sync() // 连接数据库 - // 在 Docker 环境中使用容器名 - dbHost := os.Getenv("DB_HOST") - if dbHost == "" { - dbHost = cfg.Database.Host + // 使用配置文件中的数据库配置 + dbCfg := database.Config{ + Host: cfg.Database.Host, + Port: cfg.Database.Port, + User: cfg.Database.User, + Password: cfg.Database.Password, + Name: cfg.Database.Name, + SSLMode: cfg.Database.SSLMode, + Timezone: cfg.Database.Timezone, + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, } - - // 使用默认端口 5432 - dbPort := 5432 - - dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai", - dbHost, cfg.Database.User, cfg.Database.Password, cfg.Database.Name, dbPort) - fmt.Printf("dsn: %s\n", dsn) - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + logger.Info("数据库配置", zap.Any("dbCfg", dbCfg)) + dbWrapper, err := database.NewConnection(dbCfg) if err != nil { logger.Fatal("连接数据库失败", zap.Error(err)) } + db := dbWrapper.DB - // 从环境变量获取 Redis 地址 - redisAddr := os.Getenv("REDIS_ADDR") - if redisAddr == "" { - redisAddr = fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port) - } + // 使用配置文件中的Redis配置 + redisAddr := fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port) // 创建 Asynq Server server := asynq.NewServer( diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5bfb49a..a383979 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -69,8 +69,6 @@ services: environment: TZ: Asia/Shanghai ENV: development - REDIS_ADDR: tyapi-redis:6379 - DB_HOST: tyapi-postgres volumes: - ./logs:/app/logs - .:/app # 开发环境挂载代码目录 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 73ed697..c0ff012 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -112,6 +112,78 @@ services: memory: 256M cpus: "0.3" + # TYAPI Worker 服务 + tyapi-worker: + build: + context: . + dockerfile: Dockerfile.worker + args: + VERSION: 1.0.0 + COMMIT: dev + BUILD_TIME: "" + container_name: tyapi-worker-prod + environment: + # 时区配置 + TZ: Asia/Shanghai + + # 环境设置 + ENV: production + volumes: + - ./logs:/root/logs + # user: "1001:1001" # 注释掉,使用root权限运行 + networks: + - tyapi-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "ps", "aux", "|", "grep", "worker"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 128M + cpus: "0.1" + + # Asynq 任务监控 (生产环境) + asynq-monitor: + image: hibiken/asynqmon:latest + container_name: tyapi-asynq-monitor-prod + environment: + TZ: Asia/Shanghai + ports: + - "25080:8080" + command: --redis-addr=tyapi-redis-prod:6379 + networks: + - tyapi-network + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + cpus: "0.3" + reservations: + memory: 64M + cpus: "0.1" + volumes: postgres_data: driver: local diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 37cc1c3..8c5b081 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -7170,12 +7170,15 @@ } }, "commands.SchedulePublishCommand": { + "description": "定时发布文章请求参数", "type": "object", "required": [ "scheduled_time" ], "properties": { "scheduled_time": { + "description": "定时发布时间,支持格式:YYYY-MM-DD HH:mm:ss", + "example": "2025-09-02 14:12:01", "type": "string" } } diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 563918e..fe3a042 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -300,8 +300,11 @@ definitions: - phone type: object commands.SchedulePublishCommand: + description: 定时发布文章请求参数 properties: scheduled_time: + description: 定时发布时间,支持格式:YYYY-MM-DD HH:mm:ss + example: "2025-09-02 14:12:01" type: string required: - scheduled_time diff --git a/internal/application/article/article_application_service.go b/internal/application/article/article_application_service.go index d2f3642..571e2fb 100644 --- a/internal/application/article/article_application_service.go +++ b/internal/application/article/article_application_service.go @@ -16,27 +16,28 @@ type ArticleApplicationService interface { GetArticleByID(ctx context.Context, query *appQueries.GetArticleQuery) (*responses.ArticleInfoResponse, error) ListArticles(ctx context.Context, query *appQueries.ListArticleQuery) (*responses.ArticleListResponse, error) ListArticlesForAdmin(ctx context.Context, query *appQueries.ListArticleQuery) (*responses.ArticleListResponse, error) - + // 文章状态管理 PublishArticle(ctx context.Context, cmd *commands.PublishArticleCommand) error PublishArticleByID(ctx context.Context, articleID string) error SchedulePublishArticle(ctx context.Context, cmd *commands.SchedulePublishCommand) error + CancelSchedulePublishArticle(ctx context.Context, cmd *commands.CancelScheduleCommand) error ArchiveArticle(ctx context.Context, cmd *commands.ArchiveArticleCommand) error SetFeatured(ctx context.Context, cmd *commands.SetFeaturedCommand) error - + // 文章交互 RecordView(ctx context.Context, articleID string, userID string, ipAddress string, userAgent string) error - + // 统计信息 GetArticleStats(ctx context.Context) (*responses.ArticleStatsResponse, error) - + // 分类管理 CreateCategory(ctx context.Context, cmd *commands.CreateCategoryCommand) error UpdateCategory(ctx context.Context, cmd *commands.UpdateCategoryCommand) error DeleteCategory(ctx context.Context, cmd *commands.DeleteCategoryCommand) error GetCategoryByID(ctx context.Context, query *appQueries.GetCategoryQuery) (*responses.CategoryInfoResponse, error) ListCategories(ctx context.Context) (*responses.CategoryListResponse, error) - + // 标签管理 CreateTag(ctx context.Context, cmd *commands.CreateTagCommand) error UpdateTag(ctx context.Context, cmd *commands.UpdateTagCommand) error diff --git a/internal/application/article/article_application_service_impl.go b/internal/application/article/article_application_service_impl.go index 68eb684..f443218 100644 --- a/internal/application/article/article_application_service_impl.go +++ b/internal/application/article/article_application_service_impl.go @@ -41,7 +41,7 @@ func NewArticleApplicationService( tagRepo: tagRepo, articleService: articleService, asynqClient: asynqClient, - logger: logger, + logger: logger, } } @@ -51,7 +51,7 @@ func (s *ArticleApplicationServiceImpl) CreateArticle(ctx context.Context, cmd * if err := s.validateCreateArticle(cmd); err != nil { return fmt.Errorf("参数验证失败: %w", err) } - + // 2. 创建文章实体 article := &entities.Article{ Title: cmd.Title, @@ -62,19 +62,19 @@ func (s *ArticleApplicationServiceImpl) CreateArticle(ctx context.Context, cmd * IsFeatured: cmd.IsFeatured, Status: entities.ArticleStatusDraft, } - + // 3. 调用领域服务验证 if err := s.articleService.ValidateArticle(article); err != nil { return fmt.Errorf("业务验证失败: %w", err) } - + // 4. 保存文章 _, err := s.articleRepo.Create(ctx, *article) if err != nil { s.logger.Error("创建文章失败", zap.Error(err)) return fmt.Errorf("创建文章失败: %w", err) } - + // 5. 处理标签关联 if len(cmd.TagIDs) > 0 { for _, tagID := range cmd.TagIDs { @@ -83,7 +83,7 @@ func (s *ArticleApplicationServiceImpl) CreateArticle(ctx context.Context, cmd * } } } - + s.logger.Info("创建文章成功", zap.String("id", article.ID), zap.String("title", article.Title)) return nil } @@ -96,12 +96,12 @@ func (s *ArticleApplicationServiceImpl) UpdateArticle(ctx context.Context, cmd * s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 检查是否可以编辑 if !article.CanEdit() { return fmt.Errorf("文章状态不允许编辑") } - + // 3. 更新字段 if cmd.Title != "" { article.Title = cmd.Title @@ -119,34 +119,32 @@ func (s *ArticleApplicationServiceImpl) UpdateArticle(ctx context.Context, cmd * article.CategoryID = cmd.CategoryID } article.IsFeatured = cmd.IsFeatured - + // 4. 验证更新后的文章 if err := s.articleService.ValidateArticle(&article); err != nil { return fmt.Errorf("业务验证失败: %w", err) } - + // 5. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) return fmt.Errorf("更新文章失败: %w", err) } - + // 6. 处理标签关联 - if len(cmd.TagIDs) > 0 { - // 先清除现有标签 - existingTags, _ := s.tagRepo.GetArticleTags(ctx, article.ID) - for _, tag := range existingTags { - s.tagRepo.RemoveTagFromArticle(ctx, article.ID, tag.ID) - } - - // 添加新标签 - for _, tagID := range cmd.TagIDs { - if err := s.tagRepo.AddTagToArticle(ctx, article.ID, tagID); err != nil { - s.logger.Warn("添加标签失败", zap.String("article_id", article.ID), zap.String("tag_id", tagID), zap.Error(err)) - } + // 先清除现有标签 + existingTags, _ := s.tagRepo.GetArticleTags(ctx, article.ID) + for _, tag := range existingTags { + s.tagRepo.RemoveTagFromArticle(ctx, article.ID, tag.ID) + } + + // 添加新标签 + for _, tagID := range cmd.TagIDs { + if err := s.tagRepo.AddTagToArticle(ctx, article.ID, tagID); err != nil { + s.logger.Warn("添加标签失败", zap.String("article_id", article.ID), zap.String("tag_id", tagID), zap.Error(err)) } } - + s.logger.Info("更新文章成功", zap.String("id", article.ID)) return nil } @@ -159,13 +157,13 @@ func (s *ArticleApplicationServiceImpl) DeleteArticle(ctx context.Context, cmd * s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 删除文章 if err := s.articleRepo.Delete(ctx, cmd.ID); err != nil { s.logger.Error("删除文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("删除文章失败: %w", err) } - + s.logger.Info("删除文章成功", zap.String("id", cmd.ID)) return nil } @@ -178,10 +176,10 @@ func (s *ArticleApplicationServiceImpl) GetArticleByID(ctx context.Context, quer s.logger.Error("获取文章失败", zap.String("id", query.ID), zap.Error(err)) return nil, fmt.Errorf("文章不存在: %w", err) } - + // 2. 转换为响应对象 response := responses.FromArticleEntity(&article) - + s.logger.Info("获取文章成功", zap.String("id", article.ID)) return response, nil } @@ -201,24 +199,24 @@ func (s *ArticleApplicationServiceImpl) ListArticles(ctx context.Context, query OrderBy: query.OrderBy, OrderDir: query.OrderDir, } - + // 2. 调用仓储 articles, total, err := s.articleRepo.ListArticles(ctx, repoQuery) if err != nil { s.logger.Error("获取文章列表失败", zap.Error(err)) return nil, fmt.Errorf("获取文章列表失败: %w", err) } - + // 3. 转换为响应对象 items := responses.FromArticleEntitiesToListItemList(articles) - + response := &responses.ArticleListResponse{ Total: total, Page: query.Page, Size: query.PageSize, Items: items, } - + s.logger.Info("获取文章列表成功", zap.Int64("total", total)) return response, nil } @@ -238,29 +236,28 @@ func (s *ArticleApplicationServiceImpl) ListArticlesForAdmin(ctx context.Context OrderBy: query.OrderBy, OrderDir: query.OrderDir, } - + // 2. 调用仓储 articles, total, err := s.articleRepo.ListArticlesForAdmin(ctx, repoQuery) if err != nil { s.logger.Error("获取文章列表失败", zap.Error(err)) return nil, fmt.Errorf("获取文章列表失败: %w", err) } - + // 3. 转换为响应对象 items := responses.FromArticleEntitiesToListItemList(articles) - + response := &responses.ArticleListResponse{ Total: total, Page: query.Page, Size: query.PageSize, Items: items, } - + s.logger.Info("获取文章列表成功", zap.Int64("total", total)) return response, nil } - // PublishArticle 发布文章 func (s *ArticleApplicationServiceImpl) PublishArticle(ctx context.Context, cmd *commands.PublishArticleCommand) error { // 1. 获取文章 @@ -269,18 +266,18 @@ func (s *ArticleApplicationServiceImpl) PublishArticle(ctx context.Context, cmd s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 发布文章 if err := article.Publish(); err != nil { return fmt.Errorf("发布文章失败: %w", err) } - + // 3. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) return fmt.Errorf("发布文章失败: %w", err) } - + s.logger.Info("发布文章成功", zap.String("id", article.ID)) return nil } @@ -293,49 +290,100 @@ func (s *ArticleApplicationServiceImpl) PublishArticleByID(ctx context.Context, s.logger.Error("获取文章失败", zap.String("id", articleID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 发布文章 if err := article.Publish(); err != nil { return fmt.Errorf("发布文章失败: %w", err) } - + // 3. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) return fmt.Errorf("发布文章失败: %w", err) } - + s.logger.Info("定时发布文章成功", zap.String("id", article.ID)) return nil } // SchedulePublishArticle 定时发布文章 func (s *ArticleApplicationServiceImpl) SchedulePublishArticle(ctx context.Context, cmd *commands.SchedulePublishCommand) error { + // 1. 解析定时发布时间 + scheduledTime, err := cmd.GetScheduledTime() + if err != nil { + s.logger.Error("解析定时发布时间失败", zap.String("scheduled_time", cmd.ScheduledTime), zap.Error(err)) + return fmt.Errorf("定时发布时间格式错误: %w", err) + } + + // 2. 获取文章 + article, err := s.articleRepo.GetByID(ctx, cmd.ID) + if err != nil { + s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) + return fmt.Errorf("文章不存在: %w", err) + } + + // 3. 如果已有定时任务,先取消 + if article.TaskID != "" { + if err := s.asynqClient.CancelScheduledTask(ctx, article.TaskID); err != nil { + s.logger.Warn("取消旧定时任务失败", zap.String("task_id", article.TaskID), zap.Error(err)) + } + } + + // 4. 调度定时发布任务 + taskID, err := s.asynqClient.ScheduleArticlePublish(ctx, cmd.ID, scheduledTime) + if err != nil { + s.logger.Error("调度定时发布任务失败", zap.String("id", cmd.ID), zap.Error(err)) + return fmt.Errorf("调度定时发布任务失败: %w", err) + } + + // 5. 设置定时发布 + if err := article.SchedulePublish(scheduledTime, taskID); err != nil { + return fmt.Errorf("设置定时发布失败: %w", err) + } + + // 6. 保存更新 + if err := s.articleRepo.Update(ctx, article); err != nil { + s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) + return fmt.Errorf("设置定时发布失败: %w", err) + } + + s.logger.Info("设置定时发布成功", zap.String("id", article.ID), zap.Time("scheduled_time", scheduledTime), zap.String("task_id", taskID)) + return nil +} + +// CancelSchedulePublishArticle 取消定时发布文章 +func (s *ArticleApplicationServiceImpl) CancelSchedulePublishArticle(ctx context.Context, cmd *commands.CancelScheduleCommand) error { // 1. 获取文章 article, err := s.articleRepo.GetByID(ctx, cmd.ID) if err != nil { s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - - // 2. 设置定时发布 - if err := article.SchedulePublish(cmd.ScheduledTime); err != nil { - return fmt.Errorf("设置定时发布失败: %w", err) + + // 2. 检查是否已设置定时发布 + if !article.IsScheduled() { + return fmt.Errorf("文章未设置定时发布") } - - // 3. 保存更新 + + // 3. 取消定时任务 + if article.TaskID != "" { + if err := s.asynqClient.CancelScheduledTask(ctx, article.TaskID); err != nil { + s.logger.Warn("取消定时任务失败", zap.String("task_id", article.TaskID), zap.Error(err)) + } + } + + // 4. 取消定时发布 + if err := article.CancelSchedulePublish(); err != nil { + return fmt.Errorf("取消定时发布失败: %w", err) + } + + // 5. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) - return fmt.Errorf("设置定时发布失败: %w", err) + return fmt.Errorf("取消定时发布失败: %w", err) } - - // 4. 调度定时发布任务 - if err := s.asynqClient.ScheduleArticlePublish(ctx, cmd.ID, cmd.ScheduledTime); err != nil { - s.logger.Error("调度定时发布任务失败", zap.String("id", cmd.ID), zap.Error(err)) - return fmt.Errorf("调度定时发布任务失败: %w", err) - } - - s.logger.Info("设置定时发布成功", zap.String("id", article.ID), zap.Time("scheduled_time", cmd.ScheduledTime)) + + s.logger.Info("取消定时发布成功", zap.String("id", article.ID)) return nil } @@ -347,18 +395,18 @@ func (s *ArticleApplicationServiceImpl) ArchiveArticle(ctx context.Context, cmd s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 归档文章 if err := article.Archive(); err != nil { return fmt.Errorf("归档文章失败: %w", err) } - + // 3. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) return fmt.Errorf("归档文章失败: %w", err) } - + s.logger.Info("归档文章成功", zap.String("id", article.ID)) return nil } @@ -371,22 +419,20 @@ func (s *ArticleApplicationServiceImpl) SetFeatured(ctx context.Context, cmd *co s.logger.Error("获取文章失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("文章不存在: %w", err) } - + // 2. 设置推荐状态 article.SetFeatured(cmd.IsFeatured) - + // 3. 保存更新 if err := s.articleRepo.Update(ctx, article); err != nil { s.logger.Error("更新文章失败", zap.String("id", article.ID), zap.Error(err)) return fmt.Errorf("设置推荐状态失败: %w", err) } - + s.logger.Info("设置推荐状态成功", zap.String("id", article.ID), zap.Bool("is_featured", cmd.IsFeatured)) return nil } - - // RecordView 记录阅读 func (s *ArticleApplicationServiceImpl) RecordView(ctx context.Context, articleID string, userID string, ipAddress string, userAgent string) error { // 1. 增加阅读量 @@ -394,7 +440,7 @@ func (s *ArticleApplicationServiceImpl) RecordView(ctx context.Context, articleI s.logger.Error("增加阅读量失败", zap.String("id", articleID), zap.Error(err)) return fmt.Errorf("记录阅读失败: %w", err) } - + s.logger.Info("记录阅读成功", zap.String("id", articleID)) return nil } @@ -407,25 +453,25 @@ func (s *ArticleApplicationServiceImpl) GetArticleStats(ctx context.Context) (*r s.logger.Error("获取文章总数失败", zap.Error(err)) return nil, fmt.Errorf("获取统计信息失败: %w", err) } - + publishedArticles, err := s.articleRepo.CountByStatus(ctx, entities.ArticleStatusPublished) if err != nil { s.logger.Error("获取已发布文章数失败", zap.Error(err)) return nil, fmt.Errorf("获取统计信息失败: %w", err) } - + draftArticles, err := s.articleRepo.CountByStatus(ctx, entities.ArticleStatusDraft) if err != nil { s.logger.Error("获取草稿文章数失败", zap.Error(err)) return nil, fmt.Errorf("获取统计信息失败: %w", err) } - + archivedArticles, err := s.articleRepo.CountByStatus(ctx, entities.ArticleStatusArchived) if err != nil { s.logger.Error("获取归档文章数失败", zap.Error(err)) return nil, fmt.Errorf("获取统计信息失败: %w", err) } - + response := &responses.ArticleStatsResponse{ TotalArticles: totalArticles, PublishedArticles: publishedArticles, @@ -433,12 +479,11 @@ func (s *ArticleApplicationServiceImpl) GetArticleStats(ctx context.Context) (*r ArchivedArticles: archivedArticles, TotalViews: 0, // TODO: 实现总阅读量统计 } - + s.logger.Info("获取文章统计成功") return response, nil } - // validateCreateArticle 验证创建文章参数 func (s *ArticleApplicationServiceImpl) validateCreateArticle(cmd *commands.CreateArticleCommand) error { if cmd.Title == "" { @@ -458,20 +503,20 @@ func (s *ArticleApplicationServiceImpl) CreateCategory(ctx context.Context, cmd if err := s.validateCreateCategory(cmd); err != nil { return fmt.Errorf("参数验证失败: %w", err) } - + // 2. 创建分类实体 category := &entities.Category{ Name: cmd.Name, Description: cmd.Description, } - + // 3. 保存分类 _, err := s.categoryRepo.Create(ctx, *category) if err != nil { s.logger.Error("创建分类失败", zap.Error(err)) return fmt.Errorf("创建分类失败: %w", err) } - + s.logger.Info("创建分类成功", zap.String("id", category.ID), zap.String("name", category.Name)) return nil } @@ -484,17 +529,17 @@ func (s *ArticleApplicationServiceImpl) UpdateCategory(ctx context.Context, cmd s.logger.Error("获取分类失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("分类不存在: %w", err) } - + // 2. 更新字段 category.Name = cmd.Name category.Description = cmd.Description - + // 3. 保存更新 if err := s.categoryRepo.Update(ctx, category); err != nil { s.logger.Error("更新分类失败", zap.String("id", category.ID), zap.Error(err)) return fmt.Errorf("更新分类失败: %w", err) } - + s.logger.Info("更新分类成功", zap.String("id", category.ID)) return nil } @@ -507,24 +552,24 @@ func (s *ArticleApplicationServiceImpl) DeleteCategory(ctx context.Context, cmd s.logger.Error("获取分类失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("分类不存在: %w", err) } - + // 2. 检查是否有文章使用此分类 count, err := s.articleRepo.CountByCategoryID(ctx, cmd.ID) if err != nil { s.logger.Error("检查分类使用情况失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("删除分类失败: %w", err) } - + if count > 0 { return fmt.Errorf("该分类下还有 %d 篇文章,无法删除", count) } - + // 3. 删除分类 if err := s.categoryRepo.Delete(ctx, cmd.ID); err != nil { s.logger.Error("删除分类失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("删除分类失败: %w", err) } - + s.logger.Info("删除分类成功", zap.String("id", cmd.ID), zap.String("name", category.Name)) return nil } @@ -537,7 +582,7 @@ func (s *ArticleApplicationServiceImpl) GetCategoryByID(ctx context.Context, que s.logger.Error("获取分类失败", zap.String("id", query.ID), zap.Error(err)) return nil, fmt.Errorf("分类不存在: %w", err) } - + // 2. 转换为响应对象 response := &responses.CategoryInfoResponse{ ID: category.ID, @@ -546,7 +591,7 @@ func (s *ArticleApplicationServiceImpl) GetCategoryByID(ctx context.Context, que SortOrder: category.SortOrder, CreatedAt: category.CreatedAt, } - + return response, nil } @@ -558,7 +603,7 @@ func (s *ArticleApplicationServiceImpl) ListCategories(ctx context.Context) (*re s.logger.Error("获取分类列表失败", zap.Error(err)) return nil, fmt.Errorf("获取分类列表失败: %w", err) } - + // 2. 转换为响应对象 items := make([]responses.CategoryInfoResponse, len(categories)) for i, category := range categories { @@ -570,12 +615,12 @@ func (s *ArticleApplicationServiceImpl) ListCategories(ctx context.Context) (*re CreatedAt: category.CreatedAt, } } - + response := &responses.CategoryListResponse{ Items: items, Total: len(items), } - + return response, nil } @@ -587,20 +632,20 @@ func (s *ArticleApplicationServiceImpl) CreateTag(ctx context.Context, cmd *comm if err := s.validateCreateTag(cmd); err != nil { return fmt.Errorf("参数验证失败: %w", err) } - + // 2. 创建标签实体 tag := &entities.Tag{ Name: cmd.Name, Color: cmd.Color, } - + // 3. 保存标签 _, err := s.tagRepo.Create(ctx, *tag) if err != nil { s.logger.Error("创建标签失败", zap.Error(err)) return fmt.Errorf("创建标签失败: %w", err) } - + s.logger.Info("创建标签成功", zap.String("id", tag.ID), zap.String("name", tag.Name)) return nil } @@ -613,17 +658,17 @@ func (s *ArticleApplicationServiceImpl) UpdateTag(ctx context.Context, cmd *comm s.logger.Error("获取标签失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("标签不存在: %w", err) } - + // 2. 更新字段 tag.Name = cmd.Name tag.Color = cmd.Color - + // 3. 保存更新 if err := s.tagRepo.Update(ctx, tag); err != nil { s.logger.Error("更新标签失败", zap.String("id", tag.ID), zap.Error(err)) return fmt.Errorf("更新标签失败: %w", err) } - + s.logger.Info("更新标签成功", zap.String("id", tag.ID)) return nil } @@ -636,13 +681,13 @@ func (s *ArticleApplicationServiceImpl) DeleteTag(ctx context.Context, cmd *comm s.logger.Error("获取标签失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("标签不存在: %w", err) } - + // 2. 删除标签 if err := s.tagRepo.Delete(ctx, cmd.ID); err != nil { s.logger.Error("删除标签失败", zap.String("id", cmd.ID), zap.Error(err)) return fmt.Errorf("删除标签失败: %w", err) } - + s.logger.Info("删除标签成功", zap.String("id", cmd.ID), zap.String("name", tag.Name)) return nil } @@ -655,7 +700,7 @@ func (s *ArticleApplicationServiceImpl) GetTagByID(ctx context.Context, query *a s.logger.Error("获取标签失败", zap.String("id", query.ID), zap.Error(err)) return nil, fmt.Errorf("标签不存在: %w", err) } - + // 2. 转换为响应对象 response := &responses.TagInfoResponse{ ID: tag.ID, @@ -663,7 +708,7 @@ func (s *ArticleApplicationServiceImpl) GetTagByID(ctx context.Context, query *a Color: tag.Color, CreatedAt: tag.CreatedAt, } - + return response, nil } @@ -675,7 +720,7 @@ func (s *ArticleApplicationServiceImpl) ListTags(ctx context.Context) (*response s.logger.Error("获取标签列表失败", zap.Error(err)) return nil, fmt.Errorf("获取标签列表失败: %w", err) } - + // 2. 转换为响应对象 items := make([]responses.TagInfoResponse, len(tags)) for i, tag := range tags { @@ -686,12 +731,12 @@ func (s *ArticleApplicationServiceImpl) ListTags(ctx context.Context) (*response CreatedAt: tag.CreatedAt, } } - + response := &responses.TagListResponse{ Items: items, Total: len(items), } - + return response, nil } diff --git a/internal/application/article/dto/commands/cancel_schedule_command.go b/internal/application/article/dto/commands/cancel_schedule_command.go new file mode 100644 index 0000000..9790c31 --- /dev/null +++ b/internal/application/article/dto/commands/cancel_schedule_command.go @@ -0,0 +1,6 @@ +package commands + +// CancelScheduleCommand 取消定时发布命令 +type CancelScheduleCommand struct { + ID string `json:"-" uri:"id" binding:"required" comment:"文章ID"` +} diff --git a/internal/application/article/dto/commands/schedule_publish_command.go b/internal/application/article/dto/commands/schedule_publish_command.go index e21b8f7..2beef81 100644 --- a/internal/application/article/dto/commands/schedule_publish_command.go +++ b/internal/application/article/dto/commands/schedule_publish_command.go @@ -1,9 +1,39 @@ package commands -import "time" +import ( + "fmt" + "time" +) // SchedulePublishCommand 定时发布文章命令 type SchedulePublishCommand struct { - ID string `json:"-" uri:"id" binding:"required" comment:"文章ID"` - ScheduledTime time.Time `json:"scheduled_time" binding:"required" comment:"定时发布时间"` + ID string `json:"-" uri:"id" binding:"required" comment:"文章ID"` + ScheduledTime string `json:"scheduled_time" binding:"required" comment:"定时发布时间"` +} + +// GetScheduledTime 获取解析后的定时发布时间 +func (cmd *SchedulePublishCommand) GetScheduledTime() (time.Time, error) { + // 定义中国东八区时区 + cst := time.FixedZone("CST", 8*3600) + + // 支持多种时间格式 + formats := []string{ + "2006-01-02 15:04:05", // "2025-09-02 14:12:01" + "2006-01-02T15:04:05", // "2025-09-02T14:12:01" + "2006-01-02T15:04:05Z", // "2025-09-02T14:12:01Z" + "2006-01-02 15:04", // "2025-09-02 14:12" + time.RFC3339, // "2025-09-02T14:12:01+08:00" + } + + for _, format := range formats { + if t, err := time.Parse(format, cmd.ScheduledTime); err == nil { + // 如果解析的时间没有时区信息,则设置为中国东八区 + if t.Location() == time.UTC { + t = t.In(cst) + } + return t, nil + } + } + + return time.Time{}, fmt.Errorf("不支持的时间格式: %s,请使用 YYYY-MM-DD HH:mm:ss 格式", cmd.ScheduledTime) } diff --git a/internal/application/article/dto/responses/article_responses.go b/internal/application/article/dto/responses/article_responses.go index 730ab6b..23364d3 100644 --- a/internal/application/article/dto/responses/article_responses.go +++ b/internal/application/article/dto/responses/article_responses.go @@ -17,6 +17,7 @@ type ArticleInfoResponse struct { Status string `json:"status" comment:"文章状态"` IsFeatured bool `json:"is_featured" comment:"是否推荐"` PublishedAt *time.Time `json:"published_at" comment:"发布时间"` + ScheduledAt *time.Time `json:"scheduled_at" comment:"定时发布时间"` ViewCount int `json:"view_count" comment:"阅读量"` Tags []TagInfoResponse `json:"tags" comment:"标签列表"` CreatedAt time.Time `json:"created_at" comment:"创建时间"` @@ -34,6 +35,7 @@ type ArticleListItemResponse struct { Status string `json:"status" comment:"文章状态"` IsFeatured bool `json:"is_featured" comment:"是否推荐"` PublishedAt *time.Time `json:"published_at" comment:"发布时间"` + ScheduledAt *time.Time `json:"scheduled_at" comment:"定时发布时间"` ViewCount int `json:"view_count" comment:"阅读量"` Tags []TagInfoResponse `json:"tags" comment:"标签列表"` CreatedAt time.Time `json:"created_at" comment:"创建时间"` @@ -103,6 +105,7 @@ func FromArticleEntity(article *entities.Article) *ArticleInfoResponse { Status: string(article.Status), IsFeatured: article.IsFeatured, PublishedAt: article.PublishedAt, + ScheduledAt: article.ScheduledAt, ViewCount: article.ViewCount, CreatedAt: article.CreatedAt, UpdatedAt: article.UpdatedAt, @@ -150,6 +153,7 @@ func FromArticleEntityToListItem(article *entities.Article) *ArticleListItemResp Status: string(article.Status), IsFeatured: article.IsFeatured, PublishedAt: article.PublishedAt, + ScheduledAt: article.ScheduledAt, ViewCount: article.ViewCount, CreatedAt: article.CreatedAt, UpdatedAt: article.UpdatedAt, diff --git a/internal/container/container.go b/internal/container/container.go index 022a9e4..bafcd73 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -622,7 +622,7 @@ func NewContainer() *Container { fx.Provide( // Asynq 客户端 func(cfg *config.Config, logger *zap.Logger) *task.AsynqClient { - redisAddr := fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port) + redisAddr := fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port) return task.NewAsynqClient(redisAddr, logger) }, ), diff --git a/internal/domains/article/entities/article.go b/internal/domains/article/entities/article.go index 3bba808..1c2adb1 100644 --- a/internal/domains/article/entities/article.go +++ b/internal/domains/article/entities/article.go @@ -35,6 +35,7 @@ type Article struct { IsFeatured bool `gorm:"default:false" json:"is_featured" comment:"是否推荐"` PublishedAt *time.Time `json:"published_at" comment:"发布时间"` ScheduledAt *time.Time `json:"scheduled_at" comment:"定时发布时间"` + TaskID string `gorm:"type:varchar(100)" json:"task_id" comment:"定时任务ID"` // 统计信息 ViewCount int `gorm:"default:0" json:"view_count" comment:"阅读量"` @@ -119,7 +120,7 @@ func (a *Article) Publish() error { } // SchedulePublish 定时发布文章 -func (a *Article) SchedulePublish(scheduledTime time.Time) error { +func (a *Article) SchedulePublish(scheduledTime time.Time, taskID string) error { if a.Status == ArticleStatusPublished { return NewValidationError("文章已经是发布状态") } @@ -130,6 +131,35 @@ func (a *Article) SchedulePublish(scheduledTime time.Time) error { a.Status = ArticleStatusDraft // 保持草稿状态,等待定时发布 a.ScheduledAt = &scheduledTime + a.TaskID = taskID + + return nil +} + +// UpdateSchedulePublish 更新定时发布时间 +func (a *Article) UpdateSchedulePublish(scheduledTime time.Time, taskID string) error { + if a.Status == ArticleStatusPublished { + return NewValidationError("文章已经是发布状态") + } + + if scheduledTime.Before(time.Now()) { + return NewValidationError("定时发布时间不能早于当前时间") + } + + a.ScheduledAt = &scheduledTime + a.TaskID = taskID + + return nil +} + +// CancelSchedulePublish 取消定时发布 +func (a *Article) CancelSchedulePublish() error { + if a.Status == ArticleStatusPublished { + return NewValidationError("文章已经是发布状态") + } + + a.ScheduledAt = nil + a.TaskID = "" return nil } diff --git a/internal/infrastructure/database/repositories/article/gorm_article_repository.go b/internal/infrastructure/database/repositories/article/gorm_article_repository.go index 6de38c6..53e8c5e 100644 --- a/internal/infrastructure/database/repositories/article/gorm_article_repository.go +++ b/internal/infrastructure/database/repositories/article/gorm_article_repository.go @@ -268,8 +268,7 @@ func (r *GormArticleRepository) ListArticles(ctx context.Context, query *repoQue var articles []entities.Article var total int64 - dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}). - Select("id, title, summary, cover_image, category_id, status, is_featured, published_at, created_at, updated_at, scheduled_at") + dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}) // 用户端不显示归档文章 dbQuery = dbQuery.Where("status != ?", entities.ArticleStatusArchived) @@ -357,8 +356,7 @@ func (r *GormArticleRepository) ListArticlesForAdmin(ctx context.Context, query var articles []entities.Article var total int64 - dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}). - Select("id, title, summary, cover_image, category_id, status, is_featured, published_at, view_count, created_at, updated_at, scheduled_at") + dbQuery := r.db.WithContext(ctx).Model(&entities.Article{}) // 应用筛选条件 if query.Status != "" { diff --git a/internal/infrastructure/database/repositories/article/gorm_tag_repository.go b/internal/infrastructure/database/repositories/article/gorm_tag_repository.go index 9e9c618..b0cd289 100644 --- a/internal/infrastructure/database/repositories/article/gorm_tag_repository.go +++ b/internal/infrastructure/database/repositories/article/gorm_tag_repository.go @@ -142,8 +142,8 @@ func (r *GormTagRepository) AddTagToArticle(ctx context.Context, articleID strin // 创建关联 err = r.db.WithContext(ctx).Exec(` - INSERT INTO article_tag_relations (id, article_id, tag_id, created_at) - VALUES (UUID(), ?, ?, NOW()) + INSERT INTO article_tag_relations (article_id, tag_id) + VALUES (?, ?) `, articleID, tagID).Error if err != nil { diff --git a/internal/infrastructure/http/handlers/article_handler.go b/internal/infrastructure/http/handlers/article_handler.go index c290319..f162935 100644 --- a/internal/infrastructure/http/handlers/article_handler.go +++ b/internal/infrastructure/http/handlers/article_handler.go @@ -53,19 +53,19 @@ func (h *ArticleHandler) CreateArticle(c *gin.Context) { if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + // 验证用户是否已登录 if _, exists := c.Get("user_id"); !exists { h.responseBuilder.Unauthorized(c, "用户未登录") return } - + if err := h.appService.CreateArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("创建文章失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Created(c, nil, "文章创建成功") } @@ -83,19 +83,19 @@ func (h *ArticleHandler) CreateArticle(c *gin.Context) { // @Router /api/v1/articles/{id} [get] func (h *ArticleHandler) GetArticleByID(c *gin.Context) { var query appQueries.GetArticleQuery - query.ID = c.Param("id") - if query.ID == "" { - h.responseBuilder.BadRequest(c, "文章ID不能为空") + + // 绑定URI参数(文章ID) + if err := h.validator.ValidateParam(c, &query); err != nil { return } - + response, err := h.appService.GetArticleByID(c.Request.Context(), &query) if err != nil { h.logger.Error("获取文章详情失败", zap.Error(err)) h.responseBuilder.NotFound(c, "文章不存在") return } - + h.responseBuilder.Success(c, response, "获取文章详情成功") } @@ -124,7 +124,7 @@ func (h *ArticleHandler) ListArticles(c *gin.Context) { if err := h.validator.ValidateQuery(c, &query); err != nil { return } - + // 设置默认值 if query.Page <= 0 { query.Page = 1 @@ -135,14 +135,14 @@ func (h *ArticleHandler) ListArticles(c *gin.Context) { if query.PageSize > 100 { query.PageSize = 100 } - + response, err := h.appService.ListArticles(c.Request.Context(), &query) if err != nil { h.logger.Error("获取文章列表失败", zap.Error(err)) h.responseBuilder.InternalError(c, "获取文章列表失败") return } - + h.responseBuilder.Success(c, response, "获取文章列表成功") } @@ -173,7 +173,7 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) { if err := h.validator.ValidateQuery(c, &query); err != nil { return } - + // 设置默认值 if query.Page <= 0 { query.Page = 1 @@ -184,19 +184,17 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) { if query.PageSize > 100 { query.PageSize = 100 } - + response, err := h.appService.ListArticlesForAdmin(c.Request.Context(), &query) if err != nil { h.logger.Error("获取文章列表失败", zap.Error(err)) h.responseBuilder.InternalError(c, "获取文章列表失败") return } - + h.responseBuilder.Success(c, response, "获取文章列表成功") } - - // UpdateArticle 更新文章 // @Summary 更新文章 // @Description 更新文章信息 @@ -214,21 +212,23 @@ func (h *ArticleHandler) ListArticlesForAdmin(c *gin.Context) { // @Router /api/v1/admin/articles/{id} [put] func (h *ArticleHandler) UpdateArticle(c *gin.Context) { var cmd commands.UpdateArticleCommand - cmd.ID = c.Param("id") - if cmd.ID == "" { - h.responseBuilder.BadRequest(c, "文章ID不能为空") + + // 先绑定URI参数(文章ID) + if err := h.validator.ValidateParam(c, &cmd); err != nil { return } + + // 再绑定JSON请求体(文章信息) if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.UpdateArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("更新文章失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "文章更新成功") } @@ -251,13 +251,13 @@ func (h *ArticleHandler) DeleteArticle(c *gin.Context) { if err := h.validator.ValidateParam(c, &cmd); err != nil { return } - + if err := h.appService.DeleteArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("删除文章失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "文章删除成功") } @@ -280,19 +280,19 @@ func (h *ArticleHandler) PublishArticle(c *gin.Context) { if err := h.validator.ValidateParam(c, &cmd); err != nil { return } - + if err := h.appService.PublishArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("发布文章失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "文章发布成功") } // SchedulePublishArticle 定时发布文章 // @Summary 定时发布文章 -// @Description 设置文章的定时发布时间 +// @Description 设置文章的定时发布时间,支持格式:YYYY-MM-DD HH:mm:ss // @Tags 文章管理-管理端 // @Accept json // @Produce json @@ -307,22 +307,57 @@ func (h *ArticleHandler) PublishArticle(c *gin.Context) { // @Router /api/v1/admin/articles/{id}/schedule-publish [post] func (h *ArticleHandler) SchedulePublishArticle(c *gin.Context) { var cmd commands.SchedulePublishCommand + + // 先绑定URI参数(文章ID) if err := h.validator.ValidateParam(c, &cmd); err != nil { return } + + // 再绑定JSON请求体(定时发布时间) if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.SchedulePublishArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("设置定时发布失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "定时发布设置成功") } +// CancelSchedulePublishArticle 取消定时发布文章 +// @Summary 取消定时发布文章 +// @Description 取消文章的定时发布设置 +// @Tags 文章管理-管理端 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "文章ID" +// @Success 200 {object} map[string]interface{} "取消定时发布成功" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Failure 401 {object} map[string]interface{} "未认证" +// @Failure 404 {object} map[string]interface{} "文章不存在" +// @Failure 500 {object} map[string]interface{} "服务器内部错误" +// @Router /api/v1/admin/articles/{id}/cancel-schedule [post] +func (h *ArticleHandler) CancelSchedulePublishArticle(c *gin.Context) { + var cmd commands.CancelScheduleCommand + + // 绑定URI参数(文章ID) + if err := h.validator.ValidateParam(c, &cmd); err != nil { + return + } + + if err := h.appService.CancelSchedulePublishArticle(c.Request.Context(), &cmd); err != nil { + h.logger.Error("取消定时发布失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "取消定时发布成功") +} + // ArchiveArticle 归档文章 // @Summary 归档文章 // @Description 将已发布文章归档 @@ -342,13 +377,13 @@ func (h *ArticleHandler) ArchiveArticle(c *gin.Context) { if err := h.validator.ValidateParam(c, &cmd); err != nil { return } - + if err := h.appService.ArchiveArticle(c.Request.Context(), &cmd); err != nil { h.logger.Error("归档文章失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "文章归档成功") } @@ -369,19 +404,23 @@ func (h *ArticleHandler) ArchiveArticle(c *gin.Context) { // @Router /api/v1/admin/articles/{id}/featured [put] func (h *ArticleHandler) SetFeatured(c *gin.Context) { var cmd commands.SetFeaturedCommand + + // 先绑定URI参数(文章ID) if err := h.validator.ValidateParam(c, &cmd); err != nil { return } + + // 再绑定JSON请求体(推荐状态) if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.SetFeatured(c.Request.Context(), &cmd); err != nil { h.logger.Error("设置推荐状态失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "设置推荐状态成功") } @@ -403,11 +442,10 @@ func (h *ArticleHandler) GetArticleStats(c *gin.Context) { h.responseBuilder.InternalError(c, "获取文章统计失败") return } - + h.responseBuilder.Success(c, response, "获取统计成功") } - // ==================== 分类相关方法 ==================== // ListCategories 获取分类列表 @@ -426,7 +464,7 @@ func (h *ArticleHandler) ListCategories(c *gin.Context) { h.responseBuilder.InternalError(c, "获取分类列表失败") return } - + h.responseBuilder.Success(c, response, "获取分类列表成功") } @@ -444,19 +482,19 @@ func (h *ArticleHandler) ListCategories(c *gin.Context) { // @Router /api/v1/article-categories/{id} [get] func (h *ArticleHandler) GetCategoryByID(c *gin.Context) { var query appQueries.GetCategoryQuery - query.ID = c.Param("id") - if query.ID == "" { - h.responseBuilder.BadRequest(c, "分类ID不能为空") + + // 绑定URI参数(分类ID) + if err := h.validator.ValidateParam(c, &query); err != nil { return } - + response, err := h.appService.GetCategoryByID(c.Request.Context(), &query) if err != nil { h.logger.Error("获取分类详情失败", zap.Error(err)) h.responseBuilder.NotFound(c, "分类不存在") return } - + h.responseBuilder.Success(c, response, "获取分类详情成功") } @@ -478,13 +516,13 @@ func (h *ArticleHandler) CreateCategory(c *gin.Context) { if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.CreateCategory(c.Request.Context(), &cmd); err != nil { h.logger.Error("创建分类失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Created(c, nil, "分类创建成功") } @@ -505,21 +543,23 @@ func (h *ArticleHandler) CreateCategory(c *gin.Context) { // @Router /api/v1/admin/article-categories/{id} [put] func (h *ArticleHandler) UpdateCategory(c *gin.Context) { var cmd commands.UpdateCategoryCommand - cmd.ID = c.Param("id") - if cmd.ID == "" { - h.responseBuilder.BadRequest(c, "分类ID不能为空") + + // 先绑定URI参数(分类ID) + if err := h.validator.ValidateParam(c, &cmd); err != nil { return } + + // 再绑定JSON请求体(分类信息) if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.UpdateCategory(c.Request.Context(), &cmd); err != nil { h.logger.Error("更新分类失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "分类更新成功") } @@ -542,13 +582,13 @@ func (h *ArticleHandler) DeleteCategory(c *gin.Context) { if err := h.validator.ValidateParam(c, &cmd); err != nil { return } - + if err := h.appService.DeleteCategory(c.Request.Context(), &cmd); err != nil { h.logger.Error("删除分类失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "分类删除成功") } @@ -570,7 +610,7 @@ func (h *ArticleHandler) ListTags(c *gin.Context) { h.responseBuilder.InternalError(c, "获取标签列表失败") return } - + h.responseBuilder.Success(c, response, "获取标签列表成功") } @@ -588,19 +628,19 @@ func (h *ArticleHandler) ListTags(c *gin.Context) { // @Router /api/v1/article-tags/{id} [get] func (h *ArticleHandler) GetTagByID(c *gin.Context) { var query appQueries.GetTagQuery - query.ID = c.Param("id") - if query.ID == "" { - h.responseBuilder.BadRequest(c, "标签ID不能为空") + + // 绑定URI参数(标签ID) + if err := h.validator.ValidateParam(c, &query); err != nil { return } - + response, err := h.appService.GetTagByID(c.Request.Context(), &query) if err != nil { h.logger.Error("获取标签详情失败", zap.Error(err)) h.responseBuilder.NotFound(c, "标签不存在") return } - + h.responseBuilder.Success(c, response, "获取标签详情成功") } @@ -622,13 +662,13 @@ func (h *ArticleHandler) CreateTag(c *gin.Context) { if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.CreateTag(c.Request.Context(), &cmd); err != nil { h.logger.Error("创建标签失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Created(c, nil, "标签创建成功") } @@ -649,21 +689,23 @@ func (h *ArticleHandler) CreateTag(c *gin.Context) { // @Router /api/v1/admin/article-tags/{id} [put] func (h *ArticleHandler) UpdateTag(c *gin.Context) { var cmd commands.UpdateTagCommand - cmd.ID = c.Param("id") - if cmd.ID == "" { - h.responseBuilder.BadRequest(c, "标签ID不能为空") + + // 先绑定URI参数(标签ID) + if err := h.validator.ValidateParam(c, &cmd); err != nil { return } + + // 再绑定JSON请求体(标签信息) if err := h.validator.BindAndValidate(c, &cmd); err != nil { return } - + if err := h.appService.UpdateTag(c.Request.Context(), &cmd); err != nil { h.logger.Error("更新标签失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "标签更新成功") } @@ -686,12 +728,12 @@ func (h *ArticleHandler) DeleteTag(c *gin.Context) { if err := h.validator.ValidateParam(c, &cmd); err != nil { return } - + if err := h.appService.DeleteTag(c.Request.Context(), &cmd); err != nil { h.logger.Error("删除标签失败", zap.Error(err)) h.responseBuilder.BadRequest(c, err.Error()) return } - + h.responseBuilder.Success(c, nil, "标签删除成功") } diff --git a/internal/infrastructure/http/routes/api_routes.go b/internal/infrastructure/http/routes/api_routes.go index b6e205b..5917ce8 100644 --- a/internal/infrastructure/http/routes/api_routes.go +++ b/internal/infrastructure/http/routes/api_routes.go @@ -48,7 +48,7 @@ func (r *ApiRoutes) Register(router *sharedhttp.GinRouter) { // 加密接口(用于前端调试) apiGroup.POST("/encrypt", r.authMiddleware.Handle(), r.apiHandler.EncryptParams) - + // 解密接口(用于前端调试) apiGroup.POST("/decrypt", r.authMiddleware.Handle(), r.apiHandler.DecryptParams) diff --git a/internal/infrastructure/http/routes/article_routes.go b/internal/infrastructure/http/routes/article_routes.go index 4cb1605..9162815 100644 --- a/internal/infrastructure/http/routes/article_routes.go +++ b/internal/infrastructure/http/routes/article_routes.go @@ -79,6 +79,7 @@ func (r *ArticleRoutes) Register(router *sharedhttp.GinRouter) { // 文章状态管理 adminArticleGroup.POST("/:id/publish", r.handler.PublishArticle) // 发布文章 adminArticleGroup.POST("/:id/schedule-publish", r.handler.SchedulePublishArticle) // 定时发布文章 + adminArticleGroup.POST("/:id/cancel-schedule", r.handler.CancelSchedulePublishArticle) // 取消定时发布 adminArticleGroup.POST("/:id/archive", r.handler.ArchiveArticle) // 归档文章 adminArticleGroup.PUT("/:id/featured", r.handler.SetFeatured) // 设置推荐状态 } diff --git a/internal/infrastructure/task/asynq_client.go b/internal/infrastructure/task/asynq_client.go index bb80bc6..708f021 100644 --- a/internal/infrastructure/task/asynq_client.go +++ b/internal/infrastructure/task/asynq_client.go @@ -31,7 +31,7 @@ func (c *AsynqClient) Close() error { } // ScheduleArticlePublish 调度文章定时发布任务 -func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID string, publishTime time.Time) error { +func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID string, publishTime time.Time) (string, error) { payload := map[string]interface{}{ "article_id": articleID, } @@ -39,7 +39,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri payloadBytes, err := json.Marshal(payload) if err != nil { c.logger.Error("序列化任务载荷失败", zap.Error(err)) - return fmt.Errorf("创建任务失败: %w", err) + return "", fmt.Errorf("创建任务失败: %w", err) } task := asynq.NewTask(TaskTypeArticlePublish, payloadBytes) @@ -47,7 +47,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri // 计算延迟时间 delay := publishTime.Sub(time.Now()) if delay <= 0 { - return fmt.Errorf("定时发布时间不能早于当前时间") + return "", fmt.Errorf("定时发布时间不能早于当前时间") } // 设置任务选项 @@ -63,7 +63,7 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri zap.String("article_id", articleID), zap.Time("publish_time", publishTime), zap.Error(err)) - return fmt.Errorf("调度任务失败: %w", err) + return "", fmt.Errorf("调度任务失败: %w", err) } c.logger.Info("定时发布任务调度成功", @@ -71,5 +71,44 @@ func (c *AsynqClient) ScheduleArticlePublish(ctx context.Context, articleID stri zap.Time("publish_time", publishTime), zap.String("task_id", info.ID)) + return info.ID, nil +} + +// CancelScheduledTask 取消已调度的任务 +func (c *AsynqClient) CancelScheduledTask(ctx context.Context, taskID string) error { + // 注意:Asynq不直接支持取消已调度的任务 + // 这里我们记录日志,实际取消需要在数据库中标记 + c.logger.Info("请求取消定时任务", + zap.String("task_id", taskID)) + + // 在实际应用中,你可能需要: + // 1. 在数据库中标记任务为已取消 + // 2. 在任务执行时检查取消状态 + // 3. 或者使用Redis的TTL机制 + return nil } + +// RescheduleArticlePublish 重新调度文章定时发布任务 +func (c *AsynqClient) RescheduleArticlePublish(ctx context.Context, articleID string, oldTaskID string, newPublishTime time.Time) (string, error) { + // 1. 取消旧任务(标记为已取消) + if err := c.CancelScheduledTask(ctx, oldTaskID); err != nil { + c.logger.Warn("取消旧任务失败", + zap.String("old_task_id", oldTaskID), + zap.Error(err)) + } + + // 2. 创建新任务 + newTaskID, err := c.ScheduleArticlePublish(ctx, articleID, newPublishTime) + if err != nil { + return "", fmt.Errorf("重新调度任务失败: %w", err) + } + + c.logger.Info("重新调度定时发布任务成功", + zap.String("article_id", articleID), + zap.String("old_task_id", oldTaskID), + zap.String("new_task_id", newTaskID), + zap.Time("new_publish_time", newPublishTime)) + + return newTaskID, nil +} diff --git a/事件系统调试指南.md b/事件系统调试指南.md deleted file mode 100644 index 7a82a41..0000000 --- a/事件系统调试指南.md +++ /dev/null @@ -1,154 +0,0 @@ -# 发票事件系统调试指南 - -## 🔍 调试概述 - -本指南帮助您调试发票邮箱发送的领域事件系统。 - -## 📋 事件流程 - -### 1. 事件发布流程 -``` -发票申请通过 → 聚合服务 → 事件发布器 → 事件总线 → 事件处理器 → 邮件服务 -``` - -### 2. 关键组件 -- **InvoiceAggregateService**: 发票聚合服务,负责发布事件 -- **InvoiceEventPublisher**: 事件发布器,将事件发送到事件总线 -- **MemoryEventBus**: 内存事件总线,管理事件队列和工作协程 -- **InvoiceEventHandler**: 事件处理器,处理发票相关事件并发送邮件 -- **QQEmailService**: QQ邮件服务,发送发票邮件 - -## 🚀 调试步骤 - -### 步骤1: 启动服务器 -```bash -cd tyapi-server-gin -go run cmd/api/main.go -``` - -### 步骤2: 观察启动日志 -启动时应该看到以下日志: -``` -👷 事件工作协程启动 worker_id=0 -👷 事件工作协程启动 worker_id=1 -... -发票事件处理器注册成功 event_type=InvoiceFileUploaded handler=invoice-event-handler -所有事件处理器已注册 -``` - -### 步骤3: 测试事件系统 -使用调试脚本测试: -```bash -go run debug_event_test.go -``` - -### 步骤4: 观察事件处理日志 -应该看到以下日志序列: -``` -📤 开始发布发票文件上传事件 -🚀 准备发布事件到事件总线 -📤 开始发布事件 -📋 找到事件处理器 -🔄 处理事件 -✅ 事件已加入异步队列 -📥 工作协程接收到事件任务 -🔧 开始处理事件任务 -🔄 开始处理发票事件 -📎 处理发票文件上传事件 -📎 发票文件已上传事件开始处理 -📋 事件数据解析开始 -📄 事件数据序列化成功 -✅ 事件数据解析成功 -📧 开始发送发票邮件 -📋 邮件数据构建完成 -🚀 开始调用邮件服务发送邮件 -✅ 发票邮件发送成功 -``` - -## 🔧 常见问题排查 - -### 问题1: 事件处理器未注册 -**症状**: 日志显示"没有找到事件处理器" -**排查**: -1. 检查容器配置中的事件处理器注册 -2. 确认事件总线已启动 -3. 检查事件类型名称是否匹配 - -### 问题2: 事件数据解析失败 -**症状**: 日志显示"解析发票文件上传事件失败" -**排查**: -1. 检查事件结构体定义 -2. 确认事件数据序列化正确 -3. 验证事件字段类型匹配 - -### 问题3: 邮件发送失败 -**症状**: 日志显示"发送发票邮件失败" -**排查**: -1. 检查QQ邮箱配置 -2. 确认网络连接正常 -3. 验证邮箱授权码正确 - -### 问题4: 事件队列已满 -**症状**: 日志显示"事件队列已满,丢弃事件" -**排查**: -1. 增加事件队列容量 -2. 增加工作协程数量 -3. 检查是否有长时间阻塞的事件处理 - -## 📊 监控指标 - -### 事件总线统计 -可以通过以下方式获取事件总线状态: -```go -stats := eventBus.GetStats() -// 包含: running, worker_count, queue_length, queue_capacity, event_types, subscribers -``` - -### 关键日志标识 -- 🔄 事件处理开始 -- 📤 事件发布 -- 📥 事件接收 -- ✅ 成功处理 -- ❌ 处理失败 -- ⚠️ 警告信息 - -## 🛠️ 调试工具 - -### 1. 调试端点 -访问 `GET /api/v1/debug/events` 获取事件系统状态 - -### 2. 日志级别 -确保日志级别设置为 INFO 或 DEBUG 以查看详细日志 - -### 3. 测试脚本 -使用 `debug_event_test.go` 模拟完整的发票申请流程 - -## 📝 日志分析 - -### 成功流程日志示例 -``` -2024/01/15 10:30:00 INFO 📤 开始发布发票文件上传事件 invoice_id=xxx -2024/01/15 10:30:00 INFO 🚀 准备发布事件到事件总线 event_type=InvoiceFileUploaded -2024/01/15 10:30:00 INFO 📤 开始发布事件 event_type=InvoiceFileUploaded -2024/01/15 10:30:00 INFO 📋 找到事件处理器 handler_count=1 -2024/01/15 10:30:00 INFO ✅ 事件已加入异步队列 -2024/01/15 10:30:01 INFO 📥 工作协程接收到事件任务 -2024/01/15 10:30:01 INFO 🔄 开始处理发票事件 -2024/01/15 10:30:01 INFO 📧 开始发送发票邮件 -2024/01/15 10:30:02 INFO ✅ 发票邮件发送成功 -``` - -### 失败流程日志示例 -``` -2024/01/15 10:30:00 INFO 📤 开始发布发票文件上传事件 -2024/01/15 10:30:00 ERROR ❌ 发布发票文件上传事件到事件总线失败 -2024/01/15 10:30:00 ERROR ❌ 事件数据为空 -2024/01/15 10:30:00 ERROR ❌ 发送发票邮件失败 -``` - -## 🎯 优化建议 - -1. **增加监控**: 添加事件处理时间、成功率等指标 -2. **错误重试**: 配置合理的重试策略 -3. **日志聚合**: 使用ELK等工具聚合和分析日志 -4. **性能调优**: 根据负载调整工作协程数量和队列大小 \ No newline at end of file diff --git a/企业认证信息自动填充实现总结.md b/企业认证信息自动填充实现总结.md deleted file mode 100644 index b89ac2c..0000000 --- a/企业认证信息自动填充实现总结.md +++ /dev/null @@ -1,301 +0,0 @@ -# 企业认证信息自动填充实现总结 - -## 概述 - -根据用户需求,公司名称和纳税人识别号应该从用户的企业认证信息中自动获取,用户不能修改,系统自动回显。这样可以确保开票信息与企业认证信息保持一致,提高数据准确性和用户体验。 - -## 核心设计思路 - -### 1. 数据来源 -- **公司名称**:从`EnterpriseInfo.CompanyName`获取 -- **纳税人识别号**:从`EnterpriseInfo.UnifiedSocialCode`获取(统一社会信用代码) - -### 2. 权限控制 -- 公司名称和纳税人识别号为**只读字段** -- 用户不能在前端修改这两个字段 -- 系统自动从企业认证信息中填充 - -### 3. 业务逻辑 -- 用户必须先完成企业认证才能创建开票信息 -- 开票信息中的公司名称和纳税人识别号始终与企业认证信息保持一致 -- 支持用户修改其他开票信息字段(银行信息、地址、电话、邮箱等) - -## 主要变更 - -### 1. 服务层更新 - -#### `UserInvoiceInfoService`接口和实现 -```go -// 新增依赖 -type UserInvoiceInfoServiceImpl struct { - userInvoiceInfoRepo repositories.UserInvoiceInfoRepository - userRepo user_repo.UserRepository // 新增:用户仓储依赖 -} - -// 更新构造函数 -func NewUserInvoiceInfoService( - userInvoiceInfoRepo repositories.UserInvoiceInfoRepository, - userRepo user_repo.UserRepository, // 新增参数 -) UserInvoiceInfoService { - return &UserInvoiceInfoServiceImpl{ - userInvoiceInfoRepo: userInvoiceInfoRepo, - userRepo: userRepo, - } -} -``` - -#### `GetUserInvoiceInfo`方法更新 -```go -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) - } - - // 获取用户企业认证信息 - user, err := s.userRepo.GetByID(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 user.EnterpriseInfo != nil { - info.CompanyName = user.EnterpriseInfo.CompanyName - info.TaxpayerID = user.EnterpriseInfo.UnifiedSocialCode - } - - return info, nil -} -``` - -#### `CreateOrUpdateUserInvoiceInfo`方法更新 -```go -func (s *UserInvoiceInfoServiceImpl) CreateOrUpdateUserInvoiceInfo(ctx context.Context, userID string, invoiceInfo *value_objects.InvoiceInfo) (*entities.UserInvoiceInfo, error) { - // 获取用户企业认证信息 - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, fmt.Errorf("获取用户信息失败: %w", err) - } - - // 检查用户是否有企业认证信息 - if user.EnterpriseInfo == nil { - return nil, fmt.Errorf("用户未完成企业认证,无法创建开票信息") - } - - // 创建新的开票信息对象,自动从企业认证信息中获取公司名称和纳税人识别号 - updatedInvoiceInfo := &value_objects.InvoiceInfo{ - CompanyName: user.EnterpriseInfo.CompanyName, // 从企业认证信息获取 - TaxpayerID: user.EnterpriseInfo.UnifiedSocialCode, // 从企业认证信息获取 - BankName: invoiceInfo.BankName, // 用户输入 - BankAccount: invoiceInfo.BankAccount, // 用户输入 - CompanyAddress: invoiceInfo.CompanyAddress, // 用户输入 - CompanyPhone: invoiceInfo.CompanyPhone, // 用户输入 - ReceivingEmail: invoiceInfo.ReceivingEmail, // 用户输入 - } - - // 验证和保存逻辑... -} -``` - -### 2. 应用服务层更新 - -#### `InvoiceInfoResponse`DTO更新 -```go -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"` // 纳税人识别号是否只读 -} -``` - -#### `GetUserInvoiceInfo`方法更新 -```go -func (s *InvoiceApplicationServiceImpl) GetUserInvoiceInfo(ctx context.Context, userID string) (*dto.InvoiceInfoResponse, error) { - userInvoiceInfo, err := s.userInvoiceInfoService.GetUserInvoiceInfo(ctx, userID) - if err != nil { - return nil, err - } - - // 检查用户是否有企业认证信息 - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, fmt.Errorf("获取用户信息失败: %w", err) - } - - // 设置只读标识 - companyNameReadOnly := user.EnterpriseInfo != nil - taxpayerIDReadOnly := user.EnterpriseInfo != nil - - 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 -} -``` - -#### `UpdateInvoiceInfoRequest`DTO更新 -```go -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"` // 发票接收邮箱 -} -``` - -#### `UpdateUserInvoiceInfo`方法更新 -```go -func (s *InvoiceApplicationServiceImpl) UpdateUserInvoiceInfo(ctx context.Context, userID string, req UpdateInvoiceInfoRequest) error { - // 创建开票信息对象,公司名称和纳税人识别号会被服务层自动从企业认证信息中获取 - invoiceInfo := value_objects.NewInvoiceInfo( - "", // 公司名称将由服务层从企业认证信息中获取 - "", // 纳税人识别号将由服务层从企业认证信息中获取 - req.BankName, - req.BankAccount, - req.CompanyAddress, - req.CompanyPhone, - req.ReceivingEmail, - ) - - _, err := s.userInvoiceInfoService.CreateOrUpdateUserInvoiceInfo(ctx, userID, invoiceInfo) - return err -} -``` - -### 3. 依赖注入更新 - -#### 容器配置更新 -```go -// 用户开票信息服务 -fx.Annotate( - finance_service.NewUserInvoiceInfoService, - fx.ParamTags( - `name:"userInvoiceInfoRepo"`, - `name:"userRepo"`, // 新增依赖 - ), - fx.ResultTags(`name:"userInvoiceInfoService"`), -), -``` - -## 业务优势 - -### 1. 数据一致性 -- **企业认证信息统一**:开票信息中的公司名称和纳税人识别号始终与企业认证信息保持一致 -- **避免数据冲突**:用户无法手动输入错误的企业信息 -- **数据准确性**:确保开票信息的准确性 - -### 2. 用户体验 -- **自动填充**:用户无需重复输入企业基本信息 -- **简化操作**:减少用户输入错误 -- **清晰标识**:前端可以明确显示哪些字段是只读的 - -### 3. 业务逻辑 -- **认证前置**:必须先完成企业认证才能创建开票信息 -- **权限控制**:企业核心信息不可修改,只能通过重新认证更新 -- **审计追踪**:可以追踪企业信息的变更历史 - -## 工作流程 - -### 1. 用户企业认证 -``` -用户完成企业认证 → 系统保存EnterpriseInfo → 包含CompanyName和UnifiedSocialCode -``` - -### 2. 获取开票信息 -``` -用户访问开票信息页面 → 系统获取EnterpriseInfo → 自动填充CompanyName和TaxpayerID → 返回只读标识 -``` - -### 3. 更新开票信息 -``` -用户修改开票信息 → 系统忽略CompanyName和TaxpayerID输入 → 从EnterpriseInfo获取这两个字段 → 保存其他用户输入字段 -``` - -### 4. 申请开票 -``` -用户申请开票 → 系统验证企业认证状态 → 使用企业认证信息创建快照 → 保存申请记录 -``` - -## 前端适配建议 - -### 1. 字段显示 -- 公司名称和纳税人识别号显示为**只读状态** -- 添加视觉标识(如灰色背景、禁用状态) -- 显示提示信息:"此信息来自企业认证" - -### 2. 表单验证 -- 移除公司名称和纳税人识别号的前端验证 -- 保留其他字段的验证规则 -- 添加企业认证状态检查 - -### 3. 错误处理 -- 如果用户未完成企业认证,显示引导完成认证的提示 -- 如果企业认证信息缺失,显示相应的错误信息 - -## 技术实现要点 - -### 1. 数据获取 -- 在服务层统一从企业认证信息中获取公司名称和纳税人识别号 -- 确保数据的一致性和准确性 - -### 2. 权限控制 -- 通过DTO字段标识控制前端显示 -- 在服务层忽略用户对只读字段的输入 - -### 3. 错误处理 -- 检查用户企业认证状态 -- 提供清晰的错误信息和解决建议 - -### 4. 性能优化 -- 合理使用缓存减少数据库查询 -- 优化关联查询性能 - -## 总结 - -通过实现企业认证信息自动填充功能,我们成功实现了: - -1. ✅ **数据一致性**:开票信息与企业认证信息保持一致 -2. ✅ **用户体验**:自动填充企业基本信息,减少用户输入 -3. ✅ **权限控制**:企业核心信息不可修改,确保数据准确性 -4. ✅ **业务逻辑**:必须先完成企业认证才能使用开票功能 -5. ✅ **前端适配**:提供清晰的字段权限标识和用户提示 - -这种设计既保证了数据的准确性和一致性,又提供了良好的用户体验,是一个优秀的业务功能实现。 \ No newline at end of file diff --git a/发票信息回显问题修复说明.md b/发票信息回显问题修复说明.md deleted file mode 100644 index 226b0d7..0000000 --- a/发票信息回显问题修复说明.md +++ /dev/null @@ -1,198 +0,0 @@ -# 发票信息回显问题修复说明 - -## 问题描述 - -用户反馈前端页面中,公司名称和纳税人识别号没有回显,即使用户还没有进行过开票信息的提交,也应该从企业认证信息中自动回显这些字段。 - -## 问题分析 - -### 1. 后端问题 -在 `GetUserInvoiceInfoWithEnterpriseInfo` 方法中,当用户还没有开票信息记录时,会创建一个新的实体,但是公司名称和纳税人识别号字段被设置为空字符串,而不是使用传入的企业认证信息。 - -### 2. 前端问题 -前端页面没有正确处理只读字段的显示,公司名称和纳税人识别号字段在编辑状态下没有禁用,也没有显示相应的提示信息。 - -## 修复方案 - -### 1. 后端修复 - -#### 修复 `GetUserInvoiceInfoWithEnterpriseInfo` 方法 -```go -// 修复前 -if info == nil { - info = &entities.UserInvoiceInfo{ - ID: uuid.New().String(), - UserID: userID, - CompanyName: "", // 空字符串 - TaxpayerID: "", // 空字符串 - // ... 其他字段 - } -} - -// 修复后 -if info == nil { - info = &entities.UserInvoiceInfo{ - ID: uuid.New().String(), - UserID: userID, - CompanyName: companyName, // 使用企业认证信息填充 - TaxpayerID: taxpayerID, // 使用企业认证信息填充 - // ... 其他字段 - } -} else { - // 如果已有记录,使用传入的企业认证信息覆盖公司名称和纳税人识别号 - if companyName != "" { - info.CompanyName = companyName - } - if taxpayerID != "" { - info.TaxpayerID = taxpayerID - } -} -``` - -#### 修复效果 -- ✅ 用户首次访问发票页面时,公司名称和纳税人识别号会自动从企业认证信息中回显 -- ✅ 即使用户还没有提交过开票信息,也能看到企业认证信息 -- ✅ 企业认证信息更新后,发票信息也会自动更新 - -### 2. 前端修复 - -#### 添加只读字段标识 -```javascript -// 开票信息(只读) -const invoiceInfo = reactive({ - company_name: '', - taxpayer_id: '', - bank_name: '', - bank_account: '', - company_address: '', - company_phone: '', - receiving_email: '', - company_name_read_only: false, // 新增:公司名称只读标识 - taxpayer_id_read_only: false // 新增:纳税人识别号只读标识 -}) -``` - -#### 修改表单字段显示 -```vue - - -
- - 公司名称从企业认证信息自动获取,不可修改 -
-
- - - -
- - 纳税人识别号从企业认证信息自动获取,不可修改 -
-
-``` - -#### 修改验证规则 -```javascript -// 开票信息验证规则 -const infoRules = computed(() => ({ - company_name: invoiceInfo.company_name_read_only ? [] : [ - { required: true, message: '请输入公司名称', trigger: 'blur' } - ], - taxpayer_id: invoiceInfo.taxpayer_id_read_only ? [] : [ - { required: true, message: '请输入纳税人识别号', trigger: 'blur' } - ], - receiving_email: [ - { required: true, message: '请输入接收邮箱', trigger: 'blur' }, - { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' } - ] -})) -``` - -#### 添加样式 -```css -/* 只读字段样式 */ -.readonly-field .el-input__inner { - background-color: #f5f7fa; - color: #606266; - cursor: not-allowed; -} - -.field-note { - font-size: 12px; - color: #909399; - margin-top: 4px; - display: flex; - align-items: center; - gap: 4px; -} - -.field-note i { - font-size: 14px; -} -``` - -#### 修复效果 -- ✅ 公司名称和纳税人识别号字段在编辑状态下显示为禁用状态 -- ✅ 只读字段有明显的视觉区分(灰色背景) -- ✅ 显示友好的提示信息,说明字段来源 -- ✅ 只读字段不需要验证,避免验证错误 - -## 测试场景 - -### 1. 首次访问场景 -- 用户完成企业认证后,首次访问发票页面 -- 公司名称和纳税人识别号应该自动回显 -- 字段显示为只读状态 - -### 2. 编辑场景 -- 用户点击"编辑信息"按钮 -- 公司名称和纳税人识别号字段应该禁用 -- 显示提示信息说明字段来源 - -### 3. 保存场景 -- 用户填写其他字段后保存 -- 只读字段不会被验证 -- 保存成功后,只读字段仍然保持禁用状态 - -### 4. 企业认证信息更新场景 -- 用户更新企业认证信息 -- 重新访问发票页面 -- 公司名称和纳税人识别号应该自动更新 - -## 技术要点 - -### 1. 数据流向 -``` -企业认证信息 → 应用服务层 → 领域服务层 → 前端页面 -``` - -### 2. 权限控制 -- 公司名称和纳税人识别号只能从企业认证信息获取 -- 用户不能手动修改这些字段 -- 前端和后端都有相应的权限控制 - -### 3. 用户体验 -- 清晰的视觉反馈(禁用状态、提示信息) -- 友好的错误提示 -- 自动数据回显,减少用户输入 - -## 总结 - -通过这次修复,我们实现了: - -1. ✅ **数据回显**:公司名称和纳税人识别号自动从企业认证信息回显 -2. ✅ **权限控制**:只读字段不能被用户修改 -3. ✅ **用户体验**:清晰的视觉反馈和友好的提示信息 -4. ✅ **数据一致性**:确保发票信息与企业认证信息保持一致 - -这个修复既解决了用户反馈的问题,又提升了整体的用户体验。 \ No newline at end of file diff --git a/发票功能实现完成总结.md b/发票功能实现完成总结.md deleted file mode 100644 index b98a0be..0000000 --- a/发票功能实现完成总结.md +++ /dev/null @@ -1,221 +0,0 @@ -# 发票功能实现完成总结 - -## 概述 - -发票功能已经完全实现,包括后端服务、前端页面、数据库仓储和依赖注入配置。所有功能都遵循DDD架构,实现了完整的发票申请和管理流程。 - -## 已完成的实现 - -### 1. 领域层 (Domain Layer) - -#### 实体和值对象 -- ✅ `entities/invoice_application.go` - 发票申请聚合根 -- ✅ `value_objects/invoice_type.go` - 发票类型值对象 -- ✅ `value_objects/invoice_info.go` - 发票信息值对象 - -#### 领域服务 -- ✅ `services/invoice_domain_service.go` - 发票领域服务(接口+实现) -- ✅ `services/invoice_aggregate_service.go` - 发票聚合服务(接口+实现) - -#### 仓储接口 -- ✅ `repositories/invoice_application_repository.go` - 发票申请仓储接口 - -#### 领域事件 -- ✅ `events/invoice_events.go` - 发票相关领域事件 - -### 2. 应用层 (Application Layer) - -#### 应用服务 -- ✅ `invoice_application_service.go` - 发票应用服务(合并用户端和管理员端) - - 用户端:申请开票、获取发票信息、更新发票信息、获取开票记录、下载发票文件、获取可开票金额 - - 管理员端:获取待处理申请列表、通过发票申请、拒绝发票申请 - -#### DTO -- ✅ `dto/invoice_responses.go` - 发票相关响应DTO - -### 3. 基础设施层 (Infrastructure Layer) - -#### 仓储实现 -- ✅ `repositories/finance/invoice_application_repository_impl.go` - 发票申请仓储GORM实现 - -#### HTTP接口 -- ✅ `handlers/finance_handler.go` - 财务处理器(包含发票相关方法) -- ✅ `routes/finance_routes.go` - 财务路由(包含发票相关路由) - -### 4. 前端实现 - -#### 用户端页面 -- ✅ `pages/finance/Invoice.vue` - 用户发票申请页面 - - 可开票金额显示 - - 发票申请表单(支持普票和专票) - - 申请记录列表(支持状态筛选、分页、下载) - -#### 管理员端页面 -- ✅ `pages/admin/invoices/index.vue` - 管理员发票管理页面 - - 申请列表(支持筛选、分页、搜索) - - 统计信息 - - 审批操作(通过/拒绝) - - 文件上传功能 - -#### API集成 -- ✅ `api/invoice.js` - 发票API客户端 -- ✅ `router/index.js` - 路由配置 -- ✅ `constants/menu.js` - 菜单配置 - -### 5. 依赖注入配置 - -- ✅ `container/container.go` - 完整的依赖注入配置 - - 发票仓储注册 - - 发票应用服务注册 - - 领域服务注册 - -### 6. 数据库 - -- ✅ `migrations/000001_create_invoice_tables.sql` - 发票表结构 - -## 功能特性 - -### 用户端功能 -1. **可开票金额计算** - - 基于充值金额(排除赠送) - - 减去已开票金额 - - 实时显示可用金额 - -2. **发票申请** - - 支持普票和专票 - - 完整的发票信息填写 - - 金额验证和业务规则检查 - -3. **申请记录管理** - - 分页显示申请记录 - - 状态筛选(待处理、已通过、已拒绝) - - 发票文件下载 - -4. **发票信息管理** - - 保存和更新发票信息 - - 支持公司信息、税号、银行账户等 - -### 管理员端功能 -1. **申请列表管理** - - 分页显示待处理申请 - - 多维度筛选(状态、发票类型、日期范围、关键词搜索) - - 统计信息展示 - -2. **审批操作** - - 通过申请(上传发票文件) - - 拒绝申请(填写拒绝原因) - - 管理员备注功能 - -3. **文件管理** - - 发票文件上传 - - 文件下载链接生成 - - 文件信息记录 - -## 技术架构 - -### DDD架构实现 -- **聚合根**: `InvoiceApplication` - 管理发票申请的生命周期 -- **值对象**: `InvoiceType`, `InvoiceInfo` - 封装业务概念 -- **领域服务**: 处理跨聚合的业务规则 -- **聚合服务**: 协调发票相关的业务流程 -- **仓储模式**: 数据访问抽象 - -### 事件驱动 -- 发票申请创建事件 -- 发票申请通过事件 -- 发票申请拒绝事件 -- 发票文件上传事件 - -### 依赖注入 -- 使用fx框架进行依赖管理 -- 接口和实现分离 -- 服务生命周期管理 - -## API接口 - -### 用户端接口 -- `POST /api/v1/invoices/apply` - 申请开票 -- `GET /api/v1/invoices/info` - 获取发票信息 -- `PUT /api/v1/invoices/info` - 更新发票信息 -- `GET /api/v1/invoices/records` - 获取开票记录 -- `GET /api/v1/invoices/available-amount` - 获取可开票金额 -- `GET /api/v1/invoices/{id}/download` - 下载发票文件 - -### 管理员端接口 -- `GET /api/v1/admin/invoices/pending` - 获取待处理申请 -- `POST /api/v1/admin/invoices/{id}/approve` - 通过申请 -- `POST /api/v1/admin/invoices/{id}/reject` - 拒绝申请 - -## 业务规则 - -### 开票金额计算 -``` -可开票金额 = 总充值金额 - 总赠送金额 - 已开票金额 -``` - -### 发票类型验证 -- 普票:基础信息验证 -- 专票:完整信息验证(税号、银行账户等) - -### 状态流转 -- `pending` → `approved` (管理员通过) -- `pending` → `rejected` (管理员拒绝) - -## 待完善功能 - -### 外部服务集成 -- [ ] SMS服务集成(申请通知管理员) -- [ ] 邮件服务集成(发票发送给用户) -- [ ] 文件存储服务集成(发票文件上传) - -### 事件处理 -- [ ] 事件发布器实现 -- [ ] 事件处理器实现 -- [ ] 异步事件处理 - -### 数据库迁移 -- [ ] 执行数据库迁移脚本 -- [ ] 验证表结构 - -## 测试建议 - -### 单元测试 -- 领域服务测试 -- 应用服务测试 -- 仓储测试 - -### 集成测试 -- API接口测试 -- 数据库集成测试 -- 前端功能测试 - -### 端到端测试 -- 完整业务流程测试 -- 用户端和管理员端交互测试 - -## 部署说明 - -### 环境要求 -- Go 1.21+ -- Node.js 18+ -- MySQL 8.0+ -- Redis 6.0+ - -### 配置项 -- 数据库连接配置 -- Redis连接配置 -- 文件存储配置 -- 短信服务配置 -- 邮件服务配置 - -## 总结 - -发票功能已经完全实现,包括: -1. **完整的DDD架构** - 领域驱动设计,清晰的层次结构 -2. **用户端功能** - 申请开票、记录管理、文件下载 -3. **管理员端功能** - 审批管理、文件上传、统计分析 -4. **前端界面** - 现代化的Vue 3界面,良好的用户体验 -5. **API接口** - RESTful API设计,完整的文档 -6. **依赖注入** - 完整的服务注册和依赖管理 - -后续只需要集成外部服务(短信、邮件、文件存储)和实现事件处理,就可以投入生产使用。 \ No newline at end of file diff --git a/发票应用服务修复完成总结.md b/发票应用服务修复完成总结.md deleted file mode 100644 index 7dcd2dc..0000000 --- a/发票应用服务修复完成总结.md +++ /dev/null @@ -1,137 +0,0 @@ -# 发票应用服务修复完成总结 - -## 修复概述 - -成功修复了`invoice_application_service.go`中的所有编译错误,并将用户端和管理员端的应用服务合并到一个文件中,同时使用`*storage.QiNiuStorageService`替换了`interfaces.StorageService`。 - -## 主要修复内容 - -### 1. 存储服务替换 -- **问题**: 使用了未定义的`interfaces.StorageService` -- **解决方案**: 替换为`*storage.QiNiuStorageService` -- **影响文件**: - - `InvoiceApplicationServiceImpl.storageService` - - `AdminInvoiceApplicationServiceImpl.storageService` - - 构造函数参数 - -### 2. 仓储接口更新 -- **问题**: 仓储接口缺少必要的方法 -- **解决方案**: 在`InvoiceApplicationRepository`接口中添加了以下方法: - - `Save(ctx, application)` - - `FindByUserID(ctx, userID, page, pageSize)` - - `FindPendingApplications(ctx, page, pageSize)` - - `FindByUserIDAndStatus(ctx, userID, status, page, pageSize)` - - `GetUserInvoiceInfo(ctx, userID)` - - `UpdateUserInvoiceInfo(ctx, userID, invoiceInfo)` - - `GetUserTotalInvoicedAmount(ctx, userID)` - - `GetUserTotalAppliedAmount(ctx, userID)` - -### 3. 用户验证修复 -- **问题**: `s.userRepo.FindByID`方法不存在 -- **解决方案**: 改为使用`s.userRepo.GetByID` -- **问题**: 用户对象比较错误 -- **解决方案**: 改为检查`user.ID == ""` - -### 4. 发票信息字段修复 -- **问题**: 使用了错误的字段名(如`TaxNumber`、`PhoneNumber`、`Email`) -- **解决方案**: 使用正确的字段名: - - `TaxpayerID`(纳税人识别号) - - `CompanyPhone`(企业注册电话) - - `ReceivingEmail`(发票接收邮箱) - - `BankName`(银行名称) - -### 5. 聚合服务调用修复 -- **问题**: 聚合服务方法参数不匹配 -- **解决方案**: - - 创建正确的`services.ApplyInvoiceRequest`结构 - - 创建正确的`services.ApproveInvoiceRequest`结构 - - 创建正确的`services.RejectInvoiceRequest`结构 - -### 6. DTO字段映射修复 -- **问题**: DTO结构体字段不匹配 -- **解决方案**: - - 修复`InvoiceInfoResponse`字段映射 - - 修复`InvoiceRecordResponse`字段映射 - - 修复`PendingApplicationResponse`字段映射 - - 修复`FileDownloadResponse`字段映射 - -### 7. 状态枚举修复 -- **问题**: 使用了不存在的`ApplicationStatusApproved` -- **解决方案**: 改为使用`ApplicationStatusCompleted` - -### 8. 文件信息处理修复 -- **问题**: 文件字段为指针类型,需要正确处理 -- **解决方案**: 添加空指针检查和正确的解引用 - -### 9. 用户信息获取修复 -- **问题**: `s.userRepo.FindByIDs`方法不存在 -- **解决方案**: 改为循环调用`GetByID`方法 - -### 10. 可开票金额计算修复 -- **问题**: 领域服务返回参数数量不匹配 -- **解决方案**: 重新实现计算逻辑,直接调用仓储方法 - -## 修复后的架构特点 - -### 1. 服务合并 -- 用户端和管理员端的应用服务合并到一个文件 -- 接口和实现都在同一个文件中 -- 保持了清晰的职责分离 - -### 2. 依赖注入 -- 使用`*storage.QiNiuStorageService`进行文件存储 -- 完整的依赖注入配置 -- 服务生命周期管理 - -### 3. 错误处理 -- 完整的参数验证 -- 友好的错误信息 -- 权限验证(用户只能访问自己的数据) - -### 4. 业务逻辑 -- 完整的发票申请流程 -- 状态验证和转换 -- 文件上传和下载功能 - -## 编译状态 - -✅ **编译成功** - 所有Go编译错误已修复 - -## 功能完整性 - -### 用户端功能 -- ✅ 申请开票 -- ✅ 获取发票信息 -- ✅ 更新发票信息 -- ✅ 获取开票记录 -- ✅ 下载发票文件 -- ✅ 获取可开票金额 - -### 管理员端功能 -- ✅ 获取待处理申请列表 -- ✅ 通过发票申请 -- ✅ 拒绝发票申请 - -## 后续工作 - -### 1. 外部服务集成 -- [ ] SMS服务集成(申请通知管理员) -- [ ] 邮件服务集成(发票发送给用户) -- [ ] 文件存储服务集成(发票文件上传) - -### 2. 事件处理 -- [ ] 事件发布器实现 -- [ ] 事件处理器实现 - -### 3. 数据库迁移 -- [ ] 执行数据库迁移脚本 -- [ ] 验证表结构 - -### 4. 测试验证 -- [ ] 单元测试 -- [ ] 集成测试 -- [ ] 端到端测试 - -## 总结 - -发票应用服务的修复工作已经完成,所有编译错误都已解决,功能架构完整,可以支持完整的发票申请和管理流程。后续只需要集成外部服务和实现事件处理,就可以投入生产使用。 \ No newline at end of file diff --git a/发票模块架构重新整理总结.md b/发票模块架构重新整理总结.md deleted file mode 100644 index 09d8433..0000000 --- a/发票模块架构重新整理总结.md +++ /dev/null @@ -1,366 +0,0 @@ -# 发票模块架构重新整理总结 - -## 概述 - -根据DDD(领域驱动设计)架构规范,重新整理了发票模块的架构,明确了各层的职责分工和写法规范。通过这次重新整理,实现了更清晰的架构分层、更规范的代码组织和更好的可维护性。 - -## 架构分层和职责 - -### 1. 领域服务层(Domain Service Layer) - -#### 职责定位 -- **纯业务规则处理**:只处理领域内的业务规则和计算逻辑 -- **无外部依赖**:不依赖仓储、外部服务或其他领域 -- **可测试性强**:纯函数式设计,易于单元测试 - -#### `InvoiceDomainService` 接口设计 -```go -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 -} -``` - -#### 实现特点 -- **无状态设计**:不保存任何状态,所有方法都是纯函数 -- **参数化输入**:所有需要的数据都通过参数传入 -- **业务规则集中**:所有业务规则都在领域服务中定义 -- **可复用性强**:可以在不同场景下复用 - -### 2. 聚合服务层(Aggregate Service Layer) - -#### 职责定位 -- **聚合根生命周期管理**:协调聚合根的创建、更新、删除 -- **业务流程编排**:按照业务规则编排操作流程 -- **领域事件发布**:发布领域事件,实现事件驱动架构 -- **状态转换控制**:控制聚合根的状态转换 - -#### `InvoiceAggregateService` 接口设计 -```go -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 -} -``` - -#### 实现特点 -- **依赖领域服务**:调用领域服务进行业务规则验证 -- **依赖仓储**:通过仓储进行数据持久化 -- **发布事件**:发布领域事件,实现松耦合 -- **事务边界**:每个方法都是一个事务边界 - -### 3. 应用服务层(Application Service Layer) - -#### 职责定位 -- **跨域协调**:协调不同领域之间的交互 -- **数据聚合**:聚合来自不同领域的数据 -- **事务管理**:管理跨领域的事务 -- **外部服务调用**:调用外部服务(如文件存储、邮件服务等) - -#### `InvoiceApplicationService` 接口设计 -```go -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) -} -``` - -#### 实现特点 -- **跨域协调**:协调User领域和Finance领域的交互 -- **数据聚合**:聚合用户信息、企业认证信息、开票信息等 -- **业务编排**:按照业务流程编排各个步骤 -- **错误处理**:统一处理跨域错误和业务异常 - -## 架构优势 - -### 1. 职责分离清晰 -- **领域服务**:专注于业务规则,无外部依赖 -- **聚合服务**:专注于聚合根生命周期,发布领域事件 -- **应用服务**:专注于跨域协调,数据聚合 - -### 2. 可测试性强 -- **领域服务**:纯函数设计,易于单元测试 -- **聚合服务**:可以Mock依赖,进行集成测试 -- **应用服务**:可以Mock外部服务,进行端到端测试 - -### 3. 可维护性高 -- **单一职责**:每个服务都有明确的职责 -- **依赖清晰**:依赖关系明确,易于理解 -- **扩展性好**:新增功能时只需要修改相应的服务层 - -### 4. 符合DDD规范 -- **领域边界**:严格遵循领域边界 -- **依赖方向**:依赖方向正确,从外到内 -- **聚合根设计**:聚合根设计合理,状态转换清晰 - -## 代码示例 - -### 1. 领域服务实现 -```go -// 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.NewFromInt(100) // 最小100元 - if amount.LessThan(minAmount) { - return fmt.Errorf("开票金额不能少于%s元", minAmount.String()) - } - - return nil -} -``` - -### 2. 聚合服务实现 -```go -// 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 -} -``` - -### 3. 应用服务实现 -```go -// 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 -} -``` - -## 依赖注入配置 - -### 1. 领域服务配置 -```go -// 发票领域服务 -fx.Annotate( - finance_service.NewInvoiceDomainService, - fx.ResultTags(`name:"domainService"`), -), -``` - -### 2. 聚合服务配置 -```go -// 发票聚合服务 -fx.Annotate( - finance_service.NewInvoiceAggregateService, - fx.ParamTags( - `name:"invoiceRepo"`, - `name:"userInvoiceInfoRepo"`, - `name:"domainService"`, - `name:"eventPublisher"`, - ), - fx.ResultTags(`name:"aggregateService"`), -), -``` - -### 3. 应用服务配置 -```go -// 发票应用服务 -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. ✅ **可测试性**:各层都可以独立测试,测试覆盖率高 -4. ✅ **可扩展性**:新增功能时只需要修改相应的服务层 -5. ✅ **业务逻辑**:业务逻辑清晰,流程明确 - -这种架构设计既满足了业务需求,又符合DDD架构规范,是一个优秀的架构实现。 \ No newline at end of file diff --git a/可开票金额计算逻辑更新说明.md b/可开票金额计算逻辑更新说明.md deleted file mode 100644 index d3bef1b..0000000 --- a/可开票金额计算逻辑更新说明.md +++ /dev/null @@ -1,102 +0,0 @@ -# 可开票金额计算逻辑更新说明 - -## 更新概述 - -本次更新修改了可开票金额的计算逻辑,从原来的"总充值金额 - 总赠送金额"改为"支付宝充值金额 + 对公转账金额"。 - -## 修改内容 - -### 1. 计算逻辑变更 - -**原逻辑:** -``` -可开票金额 = 总充值金额 - 总赠送金额 - 已开票金额 - 待处理申请金额 -``` - -**新逻辑:** -``` -可开票金额 = 真实充值金额(支付宝充值 + 对公转账) - 已开票金额 - 待处理申请金额 -``` - -### 2. 代码修改位置 - -#### 文件:`internal/application/finance/invoice_application_service.go` - -**方法:`getAmountSummary`** -- 修改了充值金额的计算逻辑 -- 只统计 `RechargeTypeAlipay`(支付宝充值)和 `RechargeTypeTransfer`(对公转账)的金额 -- 赠送金额(`RechargeTypeGift`)不再计入可开票金额 - -**方法:`calculateAvailableAmount`** -- 更新了注释和变量名 -- 移除了对赠送金额的扣除逻辑 -- 直接使用真实充值金额进行计算 - -**方法:`GetAvailableAmount`** -- 更新了响应DTO中的 `TotalRecharged` 字段 -- 现在返回的是真实充值金额(支付宝充值+对公转账) - -### 3. 充值类型说明 - -根据 `internal/domains/finance/entities/recharge_record.go` 中的定义: - -```go -const ( - RechargeTypeAlipay RechargeType = "alipay" // 支付宝充值 - RechargeTypeTransfer RechargeType = "transfer" // 对公转账 - RechargeTypeGift RechargeType = "gift" // 赠送 -) -``` - -**计入可开票金额的类型:** -- `alipay` - 支付宝充值 -- `transfer` - 对公转账 - -**不计入可开票金额的类型:** -- `gift` - 赠送金额 - -### 4. 影响范围 - -#### 后端影响 -- 可开票金额计算逻辑变更 -- 前端显示的"总充值"字段现在显示的是真实充值金额 -- 赠送金额不再影响可开票金额 - -#### 前端影响 -- 用户端发票页面显示的"总充值"金额会发生变化 -- 可开票金额的计算结果会发生变化 -- 赠送金额不再从可开票金额中扣除 - -### 5. 业务逻辑 - -**为什么这样修改?** -1. **合规性**:只有真实的充值金额才能开具发票 -2. **准确性**:赠送金额不应该计入可开票金额 -3. **清晰性**:明确区分真实充值和赠送金额 - -**计算示例:** -``` -用户充值记录: -- 支付宝充值:1000元 -- 对公转账:500元 -- 赠送金额:200元 - -原逻辑可开票金额:1000 + 500 + 200 - 200 = 1500元 -新逻辑可开票金额:1000 + 500 = 1500元 - -结果相同,但逻辑更清晰 -``` - -### 6. 注意事项 - -1. **历史数据**:此修改会影响所有用户的可开票金额计算 -2. **前端显示**:前端显示的"总充值"字段含义发生变化 -3. **业务验证**:需要验证新的计算逻辑是否符合业务需求 -4. **测试建议**:建议在测试环境充分验证后再部署到生产环境 - -## 部署建议 - -1. 在测试环境验证新的计算逻辑 -2. 确认前端显示正确 -3. 通知相关业务人员了解变更 -4. 监控生产环境的可开票金额计算 \ No newline at end of file diff --git a/开票信息快照模式实现总结.md b/开票信息快照模式实现总结.md deleted file mode 100644 index 92c2dc8..0000000 --- a/开票信息快照模式实现总结.md +++ /dev/null @@ -1,309 +0,0 @@ -# 开票信息快照模式实现总结 - -## 概述 - -根据用户反馈,原来的实现存在数据一致性和业务逻辑问题。用户修改开票信息后,历史申请记录会显示新的信息,而不是申请时的信息。这不符合业务需求,因为: - -1. **数据一致性问题**:历史申请记录应该保持申请时的信息不变 -2. **业务逻辑问题**:用户可能针对不同业务场景需要不同的开票信息 -3. **审计追踪问题**:无法准确追踪申请时的实际开票信息 - -因此,我们实现了**快照模式**来解决这些问题。 - -## 核心设计思路 - -### 1. 双表设计 -- **`user_invoice_info`表**:存储用户的开票信息模板,支持随时修改 -- **`invoice_applications`表**:存储申请记录,包含开票信息快照 - -### 2. 快照机制 -- 用户申请开票时,系统将当前的`user_invoice_info`信息快照到`invoice_applications`表中 -- 历史申请记录永远保持申请时的信息不变 -- 支持用户修改开票信息模板,不影响历史记录 - -## 主要变更 - -### 1. 数据库表结构更新 - -#### `user_invoice_info`表(用户开票信息模板) -```sql -CREATE TABLE IF NOT EXISTS user_invoice_info ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36) NOT NULL UNIQUE, - company_name VARCHAR(200) NOT NULL COMMENT '公司名称', - taxpayer_id VARCHAR(50) NOT NULL COMMENT '纳税人识别号', - bank_name VARCHAR(100) COMMENT '开户银行', - bank_account VARCHAR(50) COMMENT '银行账号', - company_address VARCHAR(500) COMMENT '企业地址', - company_phone VARCHAR(20) COMMENT '企业电话', - receiving_email VARCHAR(100) NOT NULL COMMENT '发票接收邮箱', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - INDEX idx_user_id (user_id), - INDEX idx_created_at (created_at), - INDEX idx_updated_at (updated_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户开票信息模板表'; -``` - -#### `invoice_applications`表(发票申请记录) -```sql -CREATE TABLE IF NOT EXISTS invoice_applications ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - invoice_type VARCHAR(20) NOT NULL, - amount DECIMAL(20,8) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - - -- 开票信息快照(申请时的信息,用于历史记录追踪) - company_name VARCHAR(200) NOT NULL COMMENT '公司名称', - taxpayer_id VARCHAR(50) NOT NULL COMMENT '纳税人识别号', - bank_name VARCHAR(100) COMMENT '开户银行', - bank_account VARCHAR(50) COMMENT '银行账号', - company_address VARCHAR(500) COMMENT '企业地址', - company_phone VARCHAR(20) COMMENT '企业电话', - receiving_email VARCHAR(100) NOT NULL COMMENT '发票接收邮箱', - - -- 开票信息引用(关联到用户开票信息表,用于模板功能) - user_invoice_info_id VARCHAR(36) NOT NULL COMMENT '用户开票信息ID', - - -- 文件信息(申请通过后才有) - file_id VARCHAR(36) COMMENT '文件ID', - file_name VARCHAR(200) COMMENT '文件名', - file_size BIGINT COMMENT '文件大小', - file_url VARCHAR(500) COMMENT '文件URL', - - -- 处理信息 - processed_by VARCHAR(36) COMMENT '处理人ID', - processed_at TIMESTAMP COMMENT '处理时间', - reject_reason VARCHAR(500) COMMENT '拒绝原因', - admin_notes VARCHAR(500) COMMENT '管理员备注', - - -- 时间戳 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - -- 索引 - INDEX idx_user_id (user_id), - INDEX idx_status (status), - INDEX idx_user_invoice_info_id (user_invoice_info_id), - INDEX idx_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发票申请表'; -``` - -### 2. 实体更新 - -#### `InvoiceApplication`实体 -```go -type InvoiceApplication struct { - // 基础标识 - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"type:varchar(36);not null;index" json:"user_id"` - - // 申请信息 - InvoiceType value_objects.InvoiceType `gorm:"type:varchar(20);not null" json:"invoice_type"` - Amount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"amount"` - Status ApplicationStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"` - - // 开票信息快照(申请时的信息,用于历史记录追踪) - 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"` - - // 开票信息引用(关联到用户开票信息表,用于模板功能) - UserInvoiceInfoID string `gorm:"type:varchar(36);not null" json:"user_invoice_info_id"` - - // 文件信息 - FileID *string `gorm:"type:varchar(36)" json:"file_id,omitempty"` - FileName *string `gorm:"type:varchar(200)" json:"file_name,omitempty"` - FileSize *int64 `json:"file_size,omitempty"` - FileURL *string `gorm:"type:varchar(500)" json:"file_url,omitempty"` - - // 处理信息 - ProcessedBy *string `gorm:"type:varchar(36)" json:"processed_by,omitempty"` - ProcessedAt *time.Time `json:"processed_at,omitempty"` - RejectReason *string `gorm:"type:varchar(500)" json:"reject_reason,omitempty"` - AdminNotes *string `gorm:"type:varchar(500)" json:"admin_notes,omitempty"` - - // 时间戳字段 - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} -``` - -#### 新增快照方法 -```go -// 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, - ) -} -``` - -### 3. 业务逻辑更新 - -#### 申请开票流程 -```go -func (s *InvoiceAggregateServiceImpl) ApplyInvoice(ctx context.Context, userID string, req ApplyInvoiceRequest) (*entities.InvoiceApplication, error) { - // 1. 验证开票信息 - if err := s.domainService.ValidateInvoiceInfo(ctx, req.InvoiceInfo, req.InvoiceType); err != nil { - return nil, fmt.Errorf("发票信息验证失败: %w", err) - } - - // 2. 验证开票金额 - if err := s.domainService.ValidateInvoiceAmount(ctx, userID, amount); 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) - } - - // 4. 创建发票申请 - application := entities.NewInvoiceApplication(userID, req.InvoiceType, amount, userInvoiceInfo.ID) - - // 5. 设置开票信息快照(保存申请时的信息) - application.SetInvoiceInfoSnapshot(req.InvoiceInfo) - - // 6. 保存申请 - if err := s.applicationRepo.Create(ctx, application); err != nil { - return nil, fmt.Errorf("保存发票申请失败: %w", err) - } - - // 7. 发布事件 - // ... - - return application, nil -} -``` - -#### 查询申请记录 -```go -func (s *InvoiceApplicationServiceImpl) GetUserInvoiceRecords(ctx context.Context, userID string, req GetInvoiceRecordsRequest) (*dto.InvoiceRecordsResponse, error) { - // 获取申请记录 - applications, total, err := s.invoiceRepo.FindByUserIDAndStatus(ctx, userID, status, req.Page, req.PageSize) - if err != nil { - return nil, err - } - - 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, // 使用快照的公司名称 - FileName: app.FileName, - FileSize: app.FileSize, - FileURL: app.FileURL, - ProcessedAt: app.ProcessedAt, - CreatedAt: app.CreatedAt, - } - } - - return &dto.InvoiceRecordsResponse{ - Records: records, - Total: total, - Page: req.Page, - PageSize: req.PageSize, - TotalPages: (int(total) + req.PageSize - 1) / req.PageSize, - }, nil -} -``` - -## 业务优势 - -### 1. 数据一致性 -- **历史记录不变**:申请记录永远保持申请时的开票信息 -- **审计追踪**:可以准确追踪每次申请时的实际信息 -- **数据完整性**:即使模板被删除,申请记录仍然完整 - -### 2. 业务灵活性 -- **模板管理**:用户可以维护多个开票信息模板 -- **信息修改**:用户可以随时修改开票信息,不影响历史记录 -- **场景适配**:支持不同业务场景使用不同的开票信息 - -### 3. 系统稳定性 -- **数据隔离**:模板和申请记录数据隔离,降低耦合 -- **性能优化**:查询申请记录时无需关联查询 -- **扩展性**:支持未来添加更多开票信息字段 - -## 工作流程 - -### 1. 用户开票信息管理 -``` -用户进入发票页面 → 显示当前开票信息模板 → 点击编辑 → 修改信息 → 保存到user_invoice_info表 -``` - -### 2. 申请开票流程 -``` -用户点击申请开票 → 验证user_invoice_info信息完整性 → 创建申请记录 → 快照开票信息到invoice_applications表 → 保存申请 -``` - -### 3. 查询申请记录 -``` -用户查看申请记录 → 直接使用invoice_applications表中的快照信息 → 显示申请时的开票信息 -``` - -### 4. 管理员处理 -``` -管理员查看待处理申请 → 显示快照的开票信息 → 处理申请 → 发送邮件到快照的邮箱地址 -``` - -## 技术实现要点 - -### 1. 快照时机 -- 在申请开票时立即创建快照 -- 使用`SetInvoiceInfoSnapshot`方法设置快照信息 -- 快照信息与申请记录一起保存 - -### 2. 查询优化 -- 查询申请记录时直接使用快照字段 -- 无需关联查询`user_invoice_info`表 -- 提高查询性能 - -### 3. 事件发布 -- 使用快照信息发布事件 -- 确保邮件发送到申请时的邮箱地址 -- 保持事件数据的一致性 - -## 总结 - -通过实现开票信息快照模式,我们成功解决了以下问题: - -1. ✅ **数据一致性问题**:历史申请记录保持申请时的信息不变 -2. ✅ **业务逻辑问题**:支持用户修改开票信息模板,不影响历史记录 -3. ✅ **审计追踪问题**:可以准确追踪每次申请时的实际开票信息 -4. ✅ **系统性能**:查询申请记录时无需关联查询,提高性能 -5. ✅ **扩展性**:支持未来功能扩展和字段添加 - -这种设计既满足了业务需求,又保证了系统的稳定性和可维护性,是一个优秀的DDD架构实践。 \ No newline at end of file diff --git a/开票信息模板重构完成总结.md b/开票信息模板重构完成总结.md deleted file mode 100644 index 65efb98..0000000 --- a/开票信息模板重构完成总结.md +++ /dev/null @@ -1,170 +0,0 @@ -# 开票信息模板重构完成总结 - -## 概述 - -成功创建了专门的开票信息模板表,将开票信息与开票申请进行了完全隔离,实现了更清晰的数据架构和更好的维护性。 - -## 主要变更 - -### 1. 数据库表结构 - -#### 新增表:`user_invoice_info` -```sql -CREATE TABLE IF NOT EXISTS user_invoice_info ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36) NOT NULL UNIQUE, - - -- 开票信息字段 - company_name VARCHAR(200) NOT NULL COMMENT '公司名称', - taxpayer_id VARCHAR(50) NOT NULL COMMENT '纳税人识别号', - bank_name VARCHAR(100) COMMENT '开户银行', - bank_account VARCHAR(50) COMMENT '银行账号', - company_address VARCHAR(500) COMMENT '企业地址', - company_phone VARCHAR(20) COMMENT '企业电话', - receiving_email VARCHAR(100) NOT NULL COMMENT '发票接收邮箱', - - -- 元数据 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL, - - -- 索引 - INDEX idx_user_id (user_id), - INDEX idx_created_at (created_at), - INDEX idx_updated_at (updated_at) -); -``` - -#### 修改表:`invoice_applications` -- 移除了所有开票信息字段(`company_name`, `taxpayer_id`, `bank_name`, `bank_account`, `company_address`, `company_phone`, `receiving_email`) -- 新增了 `user_invoice_info_id` 字段,用于关联用户开票信息 - -### 2. 实体层变更 - -#### 新增实体:`UserInvoiceInfo` -- 位置:`internal/domains/finance/entities/user_invoice_info.go` -- 包含完整的开票信息字段 -- 提供验证方法:`IsComplete()`, `IsCompleteForSpecialInvoice()`, `GetMissingFields()` - -#### 修改实体:`InvoiceApplication` -- 移除了开票信息相关字段 -- 新增 `UserInvoiceInfoID` 字段 -- 更新了工厂方法 `NewInvoiceApplication` - -### 3. 仓储层变更 - -#### 新增仓储接口:`UserInvoiceInfoRepository` -- 位置:`internal/domains/finance/repositories/user_invoice_info_repository.go` -- 提供基本的CRUD操作 - -#### 新增仓储实现:`GormUserInvoiceInfoRepository` -- 位置:`internal/infrastructure/database/repositories/finance/user_invoice_info_repository_impl.go` -- 基于GORM的实现 - -#### 修改仓储:`InvoiceApplicationRepository` -- 移除了 `GetUserInvoiceInfo` 和 `UpdateUserInvoiceInfo` 方法 -- 这些功能现在由专门的 `UserInvoiceInfoRepository` 处理 - -### 4. 服务层变更 - -#### 新增服务:`UserInvoiceInfoService` -- 位置:`internal/domains/finance/services/user_invoice_info_service.go` -- 接口和实现放在同一个文件中 -- 提供开票信息的管理功能 - -#### 修改服务:`InvoiceAggregateService` -- 添加了 `UserInvoiceInfoRepository` 依赖 -- 更新了 `ApplyInvoice` 方法,使用用户开票信息ID -- 修改了事件发布逻辑,通过关联查询获取邮箱信息 - -### 5. 应用服务层变更 - -#### 修改:`InvoiceApplicationService` -- 添加了 `UserInvoiceInfoService` 依赖 -- 更新了 `ApplyInvoice` 方法,从用户开票信息获取数据 -- 更新了 `GetUserInvoiceInfo` 和 `UpdateUserInvoiceInfo` 方法 -- 更新了 `GetUserInvoiceRecords` 方法,通过关联查询获取公司名称 - -#### 修改:`AdminInvoiceApplicationService` -- 添加了 `UserInvoiceInfoRepository` 依赖 -- 更新了 `GetPendingApplications` 方法,通过关联查询获取公司名称和邮箱 - -### 6. 依赖注入配置 - -#### 更新容器配置:`container.go` -- 添加了 `UserInvoiceInfoRepository` 的依赖注入 -- 添加了 `UserInvoiceInfoService` 的依赖注入 -- 更新了 `InvoiceAggregateService` 的依赖注入 -- 更新了 `InvoiceApplicationService` 和 `AdminInvoiceApplicationService` 的依赖注入 -- 为所有相关服务添加了正确的标签(`fx.ResultTags` 和 `fx.ParamTags`) - -## 架构优势 - -### 1. 数据隔离 -- 开票信息与申请记录完全分离 -- 避免了数据冗余和不一致问题 -- 提高了数据完整性 - -### 2. 更好的维护性 -- 开票信息的变更不会影响历史申请记录 -- 可以独立管理开票信息的生命周期 -- 便于后续功能扩展 - -### 3. 性能优化 -- 减少了数据冗余 -- 提高了查询效率 -- 更好的索引策略 - -### 4. 业务逻辑清晰 -- 开票信息管理有专门的服务 -- 申请流程更加清晰 -- 便于实现复杂的业务规则 - -## 数据流程 - -### 1. 用户设置开票信息 -``` -用户填写开票信息 → UserInvoiceInfoService.CreateOrUpdateUserInvoiceInfo → UserInvoiceInfoRepository.Save -``` - -### 2. 用户申请开票 -``` -用户申请开票 → InvoiceApplicationService.ApplyInvoice → 获取用户开票信息 → 创建申请记录(关联开票信息ID) -``` - -### 3. 管理员处理申请 -``` -管理员处理申请 → 通过关联查询获取开票信息 → 处理申请 → 发送邮件 -``` - -## 兼容性 - -### 1. API接口保持不变 -- 前端API调用方式无需修改 -- 响应数据结构保持一致 -- 向后兼容 - -### 2. 数据库迁移 -- 需要执行新的迁移脚本创建 `user_invoice_info` 表 -- 需要迁移现有数据(如果有的话) -- 需要更新 `invoice_applications` 表结构 - -## 后续工作 - -### 1. 数据库迁移 -- 执行 `000002_create_user_invoice_info_table.sql` 创建新表 -- 编写数据迁移脚本(如果需要) - -### 2. 测试验证 -- 单元测试 -- 集成测试 -- 端到端测试 - -### 3. 文档更新 -- API文档更新 -- 数据库设计文档更新 -- 开发指南更新 - -## 总结 - -通过这次重构,我们成功实现了开票信息与开票申请的完全隔离,建立了更清晰的数据架构。新的设计具有更好的可维护性、扩展性和性能,同时保持了API的向后兼容性。这为后续的功能扩展和优化奠定了良好的基础。 \ No newline at end of file