Appearance
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. 用故意失败验证事务
测试方法:
- 正常提交一个客户和两个联系人,确认两张表都有数据。
- 在保存第二个联系人前,临时写:
java
throw new IllegalStateException("模拟保存联系人失败");- 再提交请求。
- 查询数据库:这次新增的客户和第一个联系人都不应存在。
如果客户还存在,检查:
- Service 方法是否标注
@Transactional; - 异常是否被捕获后吞掉;
- 方法是否是 Spring 管理的 Bean 的 public 方法;
- 是否在同一个类中绕过代理调用事务方法。
5. 业务规则应写在哪里
| 内容 | 应放位置 |
|---|---|
| 字段必填、格式 | Form + Bean Validation |
| 名称重复、状态是否允许修改 | Service |
| 保存主子表、事务 | ServiceImpl |
| 根据参数拼查询 SQL | Mapper / Provider |
| 用户是否有权限 | 权限组件或 Service |
| 请求和返回 JSON | Controller |
典型错误:把“客户名称是否重复”的判断放在 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:edit、customer:del 按钮权限 |
| 数据权限 | 是否只能看自己创建的客户、所在部门客户或全部客户 |
客户分页会调用 DataPermissionServer.applyDataPermissionFilter(...),它会往 QueryModel 加过滤条件。
Demo 不接入认证中心时,最多用请求头模拟:
text
X-Demo-User-Id: 1001
X-Demo-Department-Id: 2001先实现“只能编辑自己创建的客户”即可。等你理解请求上下文、用户身份和数据范围后,再看正式的 UserContext、TenantContextHolder、数据权限组件。
8. 远程调用与 Feign:后学
正式 CustomerInfoServiceImpl 会调用成员、部门、字典、企业工商等其他内部服务。远程调用需要处理:
- 超时;
- 服务不可用;
- 返回为空;
- 网络重试导致重复写入;
- 远程和本地数据库无法放在一个普通事务中。
因此 Demo 第一阶段不要接 Feign。你先用本地联系人表理解事务,再学习远程调用的容错和最终一致性。
9. 开发业务前的五个问题
开始任何正式需求前,先写清楚:
- 修改哪些表?主表和子表关系是什么?
- 哪些状态/字段必须校验?
- 哪些步骤必须在同一个事务中?
- 谁能操作,谁能看到数据?
- 操作成功后是否要记录日志、发消息、更新缓存或发起流程?
如果这五个问题没有答案,不要直接开始写 Controller。
10. 本章验收
- 提交客户和两个联系人时,两张表都正确写入;
- 故意让第二个联系人保存失败,主表和所有联系人都回滚;
- 重复客户名称不会写入任何数据;
- 删除客户时对联系人采用了明确策略;
- 你能解释为什么事务放在 Service,而不是 Mapper 或 Controller。
