Files
tyapi-server/开票信息快照模式实现总结.md
2025-08-02 02:54:21 +08:00

12 KiB
Raw Blame History

开票信息快照模式实现总结

概述

根据用户反馈,原来的实现存在数据一致性和业务逻辑问题。用户修改开票信息后,历史申请记录会显示新的信息,而不是申请时的信息。这不符合业务需求,因为:

  1. 数据一致性问题:历史申请记录应该保持申请时的信息不变
  2. 业务逻辑问题:用户可能针对不同业务场景需要不同的开票信息
  3. 审计追踪问题:无法准确追踪申请时的实际开票信息

因此,我们实现了快照模式来解决这些问题。

核心设计思路

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. 事件发布

  • 使用快照信息发布事件
  • 确保邮件发送到申请时的邮箱地址
  • 保持事件数据的一致性

总结

通过实现开票信息快照模式,我们成功解决了以下问题:

  1. 数据一致性问题:历史申请记录保持申请时的信息不变
  2. 业务逻辑问题:支持用户修改开票信息模板,不影响历史记录
  3. 审计追踪问题:可以准确追踪每次申请时的实际开票信息
  4. 系统性能:查询申请记录时无需关联查询,提高性能
  5. 扩展性:支持未来功能扩展和字段添加

这种设计既满足了业务需求又保证了系统的稳定性和可维护性是一个优秀的DDD架构实践。