Skip to content

09:业务逻辑:多表事务、日志、权限与远程调用

1. 为什么单表 CRUD 还不算业务开发

单表 CRUD 只解决“数据能存进去”。真实业务通常要同时修改多张表、校验状态、记录日志、判断权限,甚至调用其他服务。

正式 CRM 新增一个客户会涉及:

text
客户主表
  -> 地区
  -> 工商信息
  -> 跟进产品
  -> 跟进记录和附件
  -> 开票信息
  -> 银行信息
  -> 变更日志
  -> 跟进期限规则

这也是 CustomerInfoServiceImpl.saveCustomerInfo 需要加 @Transactional 的原因。

2. 先在 Demo 做“客户 + 联系人”事务

新增 Form:

java
@Data
public class CustomerContactSaveForm {
    @NotBlank(message = "联系人姓名不能为空")
    private String contactName;

    @NotBlank(message = "联系电话不能为空")
    private String mobile;

    private String positionName;
}

CustomerSaveForm 增加:

java
@Valid
private List<CustomerContactSaveForm> contacts;

完整保存顺序:

mermaid
sequenceDiagram
    participant C as Controller
    participant S as CustomerServiceImpl
    participant M as CustomerMapper
    participant CM as ContactMapper
    participant DB as MySQL

    C->>S: save(form)
    S->>S: 校验名称唯一
    S->>M: insert(customer)
    M->>DB: 写入客户主表
    DB-->>M: 返回 customer.id
    loop 每个联系人
        S->>CM: insert(contact with customerId)
        CM->>DB: 写入联系人表
    end
    S-->>C: 成功

3. @Transactional 的正确位置

事务应该加在 Service 的公开方法上:

java
@Transactional(rollbackFor = Exception.class)
public boolean saveWithContacts(CustomerSaveForm form) {
    DemoCustomer customer = CustomerConverter.toEntity(form);
    customerMapper.insert(customer);

    for (CustomerContactSaveForm contactForm : form.getContacts()) {
        DemoCustomerContact contact = CustomerConverter.toContactEntity(contactForm);
        contact.setCustomerId(customer.getId());
        contactMapper.insert(contact);
    }
    return true;
}

关键点:

  • 主表 insert 后,customer.getId() 才能作为子表外键;
  • 任何一步抛异常,前面写入的主表和联系人都应撤销;
  • rollbackFor = Exception.class 能让受检异常也回滚;
  • 不要在 Controller 中管理事务。

4. 用故意失败验证事务

测试方法:

  1. 正常提交一个客户和两个联系人,确认两张表都有数据。
  2. 在保存第二个联系人前,临时写:
java
throw new IllegalStateException("模拟保存联系人失败");
  1. 再提交请求。
  2. 查询数据库:这次新增的客户和第一个联系人都不应存在。

如果客户还存在,检查:

  • Service 方法是否标注 @Transactional
  • 异常是否被捕获后吞掉;
  • 方法是否是 Spring 管理的 Bean 的 public 方法;
  • 是否在同一个类中绕过代理调用事务方法。

5. 业务规则应写在哪里

内容应放位置
字段必填、格式Form + Bean Validation
名称重复、状态是否允许修改Service
保存主子表、事务ServiceImpl
根据参数拼查询 SQLMapper / Provider
用户是否有权限权限组件或 Service
请求和返回 JSONController

典型错误:把“客户名称是否重复”的判断放在 Controller。这样其他调用方复用 Service 时就会绕过校验。

6. 操作日志:先理解,再接入

正式 Controller 使用:

java
@Log(systemModule = "客户资料表", operatorType = OperatorType.INSERT)

正式 Service 还会记录客户和子表的新增、修改、删除差异。它解决的是“谁在什么时候改了哪些字段”。

Demo 的学习版可以先实现最简单的日志表:

text
demo_operation_log
  id
  business_type
  business_id
  operation_type
  content
  created_at

在保存成功后写一条“新增客户”的日志。注意:日志和主业务是否必须同一事务,要由业务决定;审计日志通常希望与主业务一起成功或一起回滚。

7. 权限与数据权限:不要在 Demo 伪造完整平台

正式 CRM 有两类概念:

类型示例
功能权限是否有 customer:editcustomer:del 按钮权限
数据权限是否只能看自己创建的客户、所在部门客户或全部客户

客户分页会调用 DataPermissionServer.applyDataPermissionFilter(...),它会往 QueryModel 加过滤条件。

Demo 不接入认证中心时,最多用请求头模拟:

text
X-Demo-User-Id: 1001
X-Demo-Department-Id: 2001

先实现“只能编辑自己创建的客户”即可。等你理解请求上下文、用户身份和数据范围后,再看正式的 UserContextTenantContextHolder、数据权限组件。

8. 远程调用与 Feign:后学

正式 CustomerInfoServiceImpl 会调用成员、部门、字典、企业工商等其他内部服务。远程调用需要处理:

  • 超时;
  • 服务不可用;
  • 返回为空;
  • 网络重试导致重复写入;
  • 远程和本地数据库无法放在一个普通事务中。

因此 Demo 第一阶段不要接 Feign。你先用本地联系人表理解事务,再学习远程调用的容错和最终一致性。

9. 开发业务前的五个问题

开始任何正式需求前,先写清楚:

  1. 修改哪些表?主表和子表关系是什么?
  2. 哪些状态/字段必须校验?
  3. 哪些步骤必须在同一个事务中?
  4. 谁能操作,谁能看到数据?
  5. 操作成功后是否要记录日志、发消息、更新缓存或发起流程?

如果这五个问题没有答案,不要直接开始写 Controller。

10. 本章验收

  • 提交客户和两个联系人时,两张表都正确写入;
  • 故意让第二个联系人保存失败,主表和所有联系人都回滚;
  • 重复客户名称不会写入任何数据;
  • 删除客户时对联系人采用了明确策略;
  • 你能解释为什么事务放在 Service,而不是 Mapper 或 Controller。

Lucking