# 开票信息快照模式实现总结 ## 概述 根据用户反馈,原来的实现存在数据一致性和业务逻辑问题。用户修改开票信息后,历史申请记录会显示新的信息,而不是申请时的信息。这不符合业务需求,因为: 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架构实践。