From 75b5d305a2ded580fba833ec35d29753814ca9a7 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 25 Oct 2026 01:43:02 +0800 Subject: [PATCH] feat(infra): 增加可选的 Seata AT 分布式事务集成材料 --- script/seata/README.md | 37 +++++++++++++++++++++++++++++++++++++ script/seata/dependencies.xml | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ script/seata/seata.yaml | 45 +++++++++++++++++++++++++++++++++++++++++++++ script/seata/sql/mysql/demo_seata.sql | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java | 40 ++++++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java | 43 +++++++++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java | 40 ++++++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java | 41 +++++++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java | 23 +++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java | 36 ++++++++++++++++++++++++++++++++++++ script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java | 40 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 489 insertions(+), 0 deletions(-) create mode 100644 script/seata/README.md create mode 100644 script/seata/dependencies.xml create mode 100644 script/seata/seata.yaml create mode 100644 script/seata/sql/mysql/demo_seata.sql create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java create mode 100644 script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java diff --git a/script/seata/README.md b/script/seata/README.md new file mode 100644 index 0000000..71ba19f --- /dev/null +++ b/script/seata/README.md @@ -0,0 +1,37 @@ +# 分布式事务(可选接入) + +基于 [Apache Seata](https://seata.apache.org/) 2.6.0 AT 模式的可选接入材料。项目默认**不启用**:本目录不是 Maven 模块,不参与默认构建,主工程的依赖、配置、业务表都不受影响。 + +接入后,`master`、`slave` 数据源保持不变、不参与全局事务,只有示例的 `seata_order`、`seata_account` 两个数据源由 Dynamic Datasource 包装 Seata 代理。示例 `DemoSeataTradeService#placeOrder` 在同一个进程中,先在订单库创建订单、再在账户库扣减余额,两个库的操作要么都成功,要么都回滚。 + +Seata Server 的启动、配置、验证及使用限制,请阅读项目文档《分布式事务》,本文只列出材料及分支适用范围: + +- 单体项目 ruoyi-vue-pro: +- 微服务项目 yudao-cloud: + +## 1. 材料清单 + +| 文件 | 说明 | 目标位置 | +| --- | --- | --- | +| `sql/mysql/demo_seata.sql` | 创建 2 个演示库,各一张示例表 + `undo_log` 表 | 在 MySQL 中执行 | +| `dependencies.xml` | Maven 依赖片段 | `yudao-module-system/pom.xml`(全局事务入口所在的模块,`yudao-server` 通过已有的模块依赖传递获得) | +| `seata.yaml` | `seata_order`、`seata_account` 数据源,以及 Seata 客户端的配置片段 | 合并到 `yudao-server` 的 `application-{profile}.yaml`;`seata: false` 只追加到已经存在的数据源,没有 `slave` 时不要复制 `slave` 段 | +| `src/main/java/.../system/seatademo/` | 订单 DO、Mapper、Service,以及全局事务入口 `DemoSeataTradeService` | `yudao-module-system/src/main/java` 的同名包 | +| `src/main/java/.../infra/seatademo/` | 账户 DO、Mapper、Service | `yudao-module-infra/src/main/java` 的同名包 | + +订单放在 `yudao-module-system`、账户放在 `yudao-module-infra`,是因为 `yudao-module-system` 已经依赖 `yudao-module-infra`,可以直接调用账户 Service;与微服务项目 yudao-cloud 中「`system-server` 发起、`infra-server` 参与」的示例保持一致。 + +## 2. 分支适用范围 + +目前只有 `master-jdk17` 分支提供本目录的材料;`master`、`master-jdk25` 分支请先从 `master-jdk17` 分支的 `script/seata/` 获取,同步到对应分支后,也可以直接从当前分支获取。 + +本目录的材料适用于 `master`(JDK 8 + Spring Boot 2.7.18)、`master-jdk17`(Spring Boot 3.5.15)、`master-jdk25`(Spring Boot 4.1.2)三个分支,接入步骤相同,兼容要点如下: + +| 要点 | 说明 | +| --- | --- | +| Seata 坐标 | 只使用 `org.apache.seata:seata-spring-boot-starter:2.6.0`,它自带 Dynamic Datasource 4.6.0 需要的 `io.seata` 兼容类,不要再引入 `io.seata:*` 坐标 | +| Druid | 排除 Seata 传递的 druid 1.2.25,继续使用项目 Druid starter 提供的版本 | +| Jackson 2 | Seata 的 undo_log 序列化需要 `jackson-databind`、`jackson-datatype-jsr310`;`master-jdk25` 的 Spring Boot 4 默认使用 Jackson 3,必须添加,其它分支添加后版本不变 | +| 示例代码 | 只使用 Java 8 语法,使用构造方法注入,不引用 `javax`、`jakarta` 包,三个分支无需修改 | + +`@GlobalTransactional` 注解的包名是 `org.apache.seata.spring.annotation`。全局事务入口必须是组件扫描的 `@Service` 等 Bean,Seata 2.6 不会增强 `@Bean` 方法定义的 Bean。 diff --git a/script/seata/dependencies.xml b/script/seata/dependencies.xml new file mode 100644 index 0000000..359b858 --- /dev/null +++ b/script/seata/dependencies.xml @@ -0,0 +1,59 @@ + + + + + + + + org.apache.seata + seata-spring-boot-starter + 2.6.0 + + + + com.alibaba + druid + + + + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + diff --git a/script/seata/seata.yaml b/script/seata/seata.yaml new file mode 100644 index 0000000..c40b781 --- /dev/null +++ b/script/seata/seata.yaml @@ -0,0 +1,45 @@ +# 分布式事务(Seata AT 模式)的配置片段,不能直接作为配置文件使用。 +# 合并到 yudao-server 的 application-{profile}.yaml 中,例如说 application-local.yaml: +# - spring.datasource.dynamic 下的配置:逐项合并到已有的配置中,master、slave 原有的 url、username、password 等保持不变,只追加 seata: false +# - seata: false 只追加到当前 profile【已经存在】的数据源上。没有配置 slave 时,不要复制 slave 段, +# 否则会新建一个没有 url 的 slave 数据源,导致启动失败 +# - seata 下的配置:新增到配置文件的顶层 +# 数据库账号密码通过环境变量传入,不要把真实密码写入配置文件、提交到 Git。 +spring: + datasource: + dynamic: + seata: true # 开启 Dynamic Datasource 的 Seata 支持,由它为每个数据源包装 Seata 的 DataSourceProxy + seata-mode: AT # 使用 AT 模式 + datasource: + master: + seata: false # 只追加这一行:master 主库不参与 AT,不被 Seata 代理 + slave: + seata: false # 只追加这一行:slave 从库不参与 AT,不被 Seata 代理;没有配置 slave 时,删除整个 slave 段 + seata_order: # 分布式事务示例的订单库,只有 @DS("seata_order") 的 Mapper、Service 使用 + url: ${YUDAO_SEATA_ORDER_URL:jdbc:mysql://127.0.0.1:3306/yudao_seata_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true} + username: ${YUDAO_SEATA_DB_USERNAME:root} + password: ${YUDAO_SEATA_DB_PASSWORD:} + seata: true # 参与 AT:被 Seata 代理,本地事务提交时注册分支、写入 undo_log + seata_account: # 分布式事务示例的账户库,只有 @DS("seata_account") 的 Mapper、Service 使用 + url: ${YUDAO_SEATA_ACCOUNT_URL:jdbc:mysql://127.0.0.1:3306/yudao_seata_account?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true} + username: ${YUDAO_SEATA_DB_USERNAME:root} + password: ${YUDAO_SEATA_DB_PASSWORD:} + seata: true # 参与 AT:被 Seata 代理,本地事务提交时注册分支、写入 undo_log + +seata: + enabled: true + application-id: ${spring.application.name} + tx-service-group: yudao_seata_demo_group # 事务分组,与下面的 vgroup-mapping 对应 + enable-auto-data-source-proxy: false # 必须关闭:数据源已经由 Dynamic Datasource 代理,避免双重代理 + client: + http: + interceptor-enabled: false # 单体项目不需要从 HTTP 请求头 TX_XID 接收 XID;关闭后,外部请求不能通过该请求头加入全局事务 + service: + vgroup-mapping: + yudao_seata_demo_group: default # 事务分组 -> Seata Server 集群名 + grouplist: + default: ${YUDAO_SEATA_SERVER_ADDR:127.0.0.1:18091} # Seata Server 地址,端口与启动 Seata Server 时的 SEATA_PORT 一致 + registry: + type: file # 直连上面的 grouplist,仅用于本机演示 + config: + type: file diff --git a/script/seata/sql/mysql/demo_seata.sql b/script/seata/sql/mysql/demo_seata.sql new file mode 100644 index 0000000..0b698d5 --- /dev/null +++ b/script/seata/sql/mysql/demo_seata.sql @@ -0,0 +1,85 @@ +-- ---------------------------- +-- 分布式事务示例:2 个独立的演示库,各自包含一张业务表 + Seata AT 模式的 undo_log 表 +-- +-- 1. yudao_seata_order:订单库,对应 seata_order 数据源(单体:yudao-server;微服务:system-server 发起方) +-- yudao_seata_account:账户库,对应 seata_account 数据源(单体:yudao-server;微服务:infra-server 参与方) +-- 2. 本脚本使用「库名.表名」,可以在同一个 MySQL 实例上直接执行,可重复执行; +-- 如果两个库在不同的 MySQL 实例上,则分别执行对应库的语句 +-- 3. undo_log 必须和业务表在同一个库中,Seata Server 不需要这张表 +-- 4. 不会修改 master 主库,也不会删除已有的库、表、数据 +-- ---------------------------- + +CREATE DATABASE IF NOT EXISTS `yudao_seata_order` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS `yudao_seata_account` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ---------------------------- +-- 订单库:yudao_seata_order +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `yudao_seata_order`.`demo_seata_order` ( + `id` bigint NOT NULL COMMENT '订单编号(由调用方传入,作为幂等主键)', + `user_id` bigint NOT NULL COMMENT '用户编号', + `amount` int NOT NULL COMMENT '订单金额,单位:分', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_user_id` (`user_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '分布式事务示例订单表'; + +-- ---------------------------- +-- 账户库:yudao_seata_account +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `yudao_seata_account`.`demo_seata_account` ( + `id` bigint NOT NULL COMMENT '账户编号', + `user_id` bigint NOT NULL COMMENT '用户编号', + `balance` int NOT NULL DEFAULT 0 COMMENT '余额,单位:分', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_tenant_user` (`tenant_id`, `user_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '分布式事务示例账户表'; + +-- 演示账户:租户 1、用户 1,初始余额 100 分;已存在时不覆盖(INSERT IGNORE),只影响本演示库 +INSERT IGNORE INTO `yudao_seata_account`.`demo_seata_account` (`id`, `user_id`, `balance`, `tenant_id`) VALUES (1, 1, 100, 1); + +-- ---------------------------- +-- undo_log:AT 模式的回滚日志,两个演示库各一张 +-- +-- 表结构来自 Apache Seata 2.6.0 的 script/client/at/db/mysql.sql: +-- https://github.com/apache/incubator-seata/blob/v2.6.0/script/client/at/db/mysql.sql +-- Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0 +-- (http://www.apache.org/licenses/LICENSE-2.0)。 +-- 与官方脚本的差异:官方使用单独的 ALTER TABLE 添加 ix_log_created 索引,这里写入 CREATE TABLE,便于重复执行;字段、索引保持一致。 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `yudao_seata_order`.`undo_log` +( + `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id', + `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id', + `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization', + `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', + `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status', + `log_created` DATETIME(6) NOT NULL COMMENT 'create datetime', + `log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime', + UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`), + KEY `ix_log_created` (`log_created`) +) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table'; + +CREATE TABLE IF NOT EXISTS `yudao_seata_account`.`undo_log` +( + `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id', + `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id', + `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization', + `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', + `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status', + `log_created` DATETIME(6) NOT NULL COMMENT 'create datetime', + `log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime', + UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`), + KEY `ix_log_created` (`log_created`) +) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table'; diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java new file mode 100644 index 0000000..571a2e3 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountDO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.infra.seatademo; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 分布式事务示例账户 DO + * + * 位于 yudao_seata_account 演示库,对应 seata_account 数据源 + * + * @author 芋道源码 + */ +@TableName("demo_seata_account") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DemoSeataAccountDO extends TenantBaseDO { + + /** + * 账户编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 同一个租户下唯一 + */ + private Long userId; + /** + * 余额,单位:分 + */ + private Integer balance; + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java new file mode 100644 index 0000000..4801147 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountMapper.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.infra.seatademo; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import com.baomidou.dynamic.datasource.annotation.DS; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分布式事务示例账户 Mapper + * + * 在 Mapper 上添加 @DS 注解,保证 demo_seata_account 表的 SQL 都使用 seata_account 数据源 + * + * @author 芋道源码 + */ +@Mapper +@DS(DemoSeataAccountMapper.DATA_SOURCE) +public interface DemoSeataAccountMapper extends BaseMapperX { + + /** + * 账户库的数据源名,对应 spring.datasource.dynamic.datasource.seata_account 配置项 + */ + String DATA_SOURCE = "seata_account"; + + default DemoSeataAccountDO selectByUserId(Long userId) { + return selectOne(DemoSeataAccountDO::getUserId, userId); + } + + /** + * 扣减余额 + * + * @param userId 用户编号 + * @param amount 扣减金额,必须大于 0 + * @return 更新条数;余额不足或账户不存在时为 0 + */ + default int updateBalanceDecr(Long userId, Integer amount) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .setSql(" balance = balance - " + amount) + .eq(DemoSeataAccountDO::getUserId, userId) + .ge(DemoSeataAccountDO::getBalance, amount); // 条件扣减:余额足够才更新,避免扣成负数 + return update(null, updateWrapper); + } + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java new file mode 100644 index 0000000..0cfd8c9 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/infra/seatademo/DemoSeataAccountService.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.infra.seatademo; + +import com.baomidou.dynamic.datasource.annotation.DS; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 分布式事务示例账户 Service + * + * 作为全局事务中的一个分支:在 seata_account 数据源上执行本地事务,提交时由 Seata 注册分支、写入 undo_log + * + * @author 芋道源码 + */ +@Service +@DS(DemoSeataAccountMapper.DATA_SOURCE) // 先切换到 seata_account 数据源,再开启 @Transactional 事务 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同 +public class DemoSeataAccountService { + + private final DemoSeataAccountMapper demoSeataAccountMapper; + + @Transactional(rollbackFor = Exception.class) + public void deductBalance(Long userId, Integer amount) { + Assert.notNull(userId, "用户编号不能为空"); + Assert.isTrue(amount != null && amount > 0, "扣减金额必须大于 0"); + int updateCount = demoSeataAccountMapper.updateBalanceDecr(userId, amount); + if (updateCount == 0) { + // 抛出异常,不要吞掉:本地事务回滚,并由全局事务的发起方回滚其它分支 + throw invalidParamException("用户({}) 的账户不存在或余额不足", userId); + } + } + + public DemoSeataAccountDO getAccount(Long userId) { + return demoSeataAccountMapper.selectByUserId(userId); + } + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java new file mode 100644 index 0000000..6ba0b76 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderDO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.system.seatademo; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 分布式事务示例订单 DO + * + * 位于 yudao_seata_order 演示库,对应 seata_order 数据源 + * + * @author 芋道源码 + */ +@TableName("demo_seata_order") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DemoSeataOrderDO extends TenantBaseDO { + + /** + * 订单编号 + * + * 由调用方传入,作为幂等主键:重复的下单请求会因主键冲突失败,不会重复扣减余额 + */ + @TableId(type = IdType.INPUT) + private Long id; + /** + * 用户编号 + */ + private Long userId; + /** + * 订单金额,单位:分 + */ + private Integer amount; + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java new file mode 100644 index 0000000..ad695c6 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderMapper.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.system.seatademo; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import com.baomidou.dynamic.datasource.annotation.DS; +import org.apache.ibatis.annotations.Mapper; + +/** + * 分布式事务示例订单 Mapper + * + * 在 Mapper 上添加 @DS 注解,保证 demo_seata_order 表的 SQL 都使用 seata_order 数据源 + * + * @author 芋道源码 + */ +@Mapper +@DS(DemoSeataOrderMapper.DATA_SOURCE) +public interface DemoSeataOrderMapper extends BaseMapperX { + + /** + * 订单库的数据源名,对应 spring.datasource.dynamic.datasource.seata_order 配置项 + */ + String DATA_SOURCE = "seata_order"; + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java new file mode 100644 index 0000000..9867c16 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataOrderService.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.system.seatademo; + +import com.baomidou.dynamic.datasource.annotation.DS; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +/** + * 分布式事务示例订单 Service + * + * 作为全局事务中的一个分支:在 seata_order 数据源上执行本地事务,提交时由 Seata 注册分支、写入 undo_log + * + * @author 芋道源码 + */ +@Service +@DS(DemoSeataOrderMapper.DATA_SOURCE) // 先切换到 seata_order 数据源,再开启 @Transactional 事务 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同 +public class DemoSeataOrderService { + + private final DemoSeataOrderMapper demoSeataOrderMapper; + + @Transactional(rollbackFor = Exception.class) + public void createOrder(Long id, Long userId, Integer amount) { + Assert.notNull(id, "订单编号不能为空"); + Assert.notNull(userId, "用户编号不能为空"); + Assert.isTrue(amount != null && amount > 0, "订单金额必须大于 0"); + DemoSeataOrderDO order = DemoSeataOrderDO.builder().id(id).userId(userId).amount(amount).build(); + demoSeataOrderMapper.insert(order); // 主键冲突时抛出 DuplicateKeyException,所在的全局事务回滚 + } + + public DemoSeataOrderDO getOrder(Long id) { + return demoSeataOrderMapper.selectById(id); + } + +} diff --git a/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java new file mode 100644 index 0000000..b7b5de0 --- /dev/null +++ b/script/seata/src/main/java/cn/iocoder/yudao/module/system/seatademo/DemoSeataTradeService.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.system.seatademo; + +import cn.iocoder.yudao.module.infra.seatademo.DemoSeataAccountService; +import lombok.RequiredArgsConstructor; +import org.apache.seata.spring.annotation.GlobalTransactional; +import org.springframework.stereotype.Service; + +/** + * 分布式事务示例下单 Service,即全局事务的入口(发起方) + * + * 单体项目中,订单、账户是同一个进程的两个数据源(seata_order、seata_account),直接调用账户 Service + * + * @author 芋道源码 + */ +@Service // 使用 @Service 组件扫描注册:Seata 2.6 不会增强 @Bean 方法定义的 Bean,@GlobalTransactional 会失效 +@RequiredArgsConstructor // 使用构造方法注入,避免 JDK 8 与 JDK 17+ 分支的 @Resource 包名不同 +public class DemoSeataTradeService { + + private final DemoSeataOrderService demoSeataOrderService; + private final DemoSeataAccountService demoSeataAccountService; + + /** + * 下单:创建订单 + 扣减余额,两个数据源的操作要么都成功,要么都回滚 + * + * 注意,这里只添加 @GlobalTransactional,【不要】添加 @Transactional: + * 本地事务会在开始时固定一个数据源的连接,导致后续的 @DS 切换失效,两个分支的事务由各自的 Service 开启 + * + * @param orderId 订单编号,由调用方生成,作为幂等主键 + * @param userId 用户编号 + * @param amount 订单金额,单位:分 + */ + @GlobalTransactional(name = "demo-seata-place-order", rollbackFor = Exception.class) + public void placeOrder(Long orderId, Long userId, Integer amount) { + // 1. 创建订单:分支一,本地事务提交后,订单已经写入 seata_order 库,同时记录 undo_log + demoSeataOrderService.createOrder(orderId, userId, amount); + // 2. 扣减余额:分支二;余额不足时抛出异常,Seata 根据 undo_log 删除分支一已经提交的订单 + demoSeataAccountService.deductBalance(userId, amount); + } + +} -- libgit2 0.21.4