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