12 KiB
12 KiB
开票信息快照模式实现总结
概述
根据用户反馈,原来的实现存在数据一致性和业务逻辑问题。用户修改开票信息后,历史申请记录会显示新的信息,而不是申请时的信息。这不符合业务需求,因为:
- 数据一致性问题:历史申请记录应该保持申请时的信息不变
- 业务逻辑问题:用户可能针对不同业务场景需要不同的开票信息
- 审计追踪问题:无法准确追踪申请时的实际开票信息
因此,我们实现了快照模式来解决这些问题。
核心设计思路
1. 双表设计
user_invoice_info表:存储用户的开票信息模板,支持随时修改invoice_applications表:存储申请记录,包含开票信息快照
2. 快照机制
- 用户申请开票时,系统将当前的
user_invoice_info信息快照到invoice_applications表中 - 历史申请记录永远保持申请时的信息不变
- 支持用户修改开票信息模板,不影响历史记录
主要变更
1. 数据库表结构更新
user_invoice_info表(用户开票信息模板)
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表(发票申请记录)
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实体
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:"-"`
}
新增快照方法
// 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. 业务逻辑更新
申请开票流程
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
}
查询申请记录
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. 事件发布
- 使用快照信息发布事件
- 确保邮件发送到申请时的邮箱地址
- 保持事件数据的一致性
总结
通过实现开票信息快照模式,我们成功解决了以下问题:
- ✅ 数据一致性问题:历史申请记录保持申请时的信息不变
- ✅ 业务逻辑问题:支持用户修改开票信息模板,不影响历史记录
- ✅ 审计追踪问题:可以准确追踪每次申请时的实际开票信息
- ✅ 系统性能:查询申请记录时无需关联查询,提高性能
- ✅ 扩展性:支持未来功能扩展和字段添加
这种设计既满足了业务需求,又保证了系统的稳定性和可维护性,是一个优秀的DDD架构实践。