From 3b937f2e58d26cf3b3dc5a22ecbcbfa4dc2d1c36 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 2 Nov 2026 13:16:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E9=A4=90=E6=AE=B5=E8=B4=AD=E4=B9=B0?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HardCanteenReservationDateBasisEnum.java | 14 ++ .../constant/order/HardSaleSourceEnum.java | 2 +- .../hard/api/dto/order/HardCarDto.java | 6 +- .../hard/api/dto/order/HardOrderDto.java | 6 +- .../order/HardCanteenTotalServiceImpl.java | 14 +- .../resources/mapper/order/HardCarMapper.xml | 26 ++- .../mapper/order/HardOrderMapper.xml | 14 +- ...ardCanteenReservationDateBasisSqlTest.java | 207 ++++++++++++++++++ .../HardCanteenTotalServiceImplTest.java | 103 ++++++++- 9 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenReservationDateBasisEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/mapper/order/HardCanteenReservationDateBasisSqlTest.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenReservationDateBasisEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenReservationDateBasisEnum.java new file mode 100644 index 00000000..8faa27ff --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenReservationDateBasisEnum.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +/** + * 食堂销售统计中已核销预约的日期归属口径,仅用于查询协议,不持久化到订单表。 + * 线下购买始终按支付时间统计,不受此枚举影响。 + */ +public enum HardCanteenReservationDateBasisEnum { + + /** 按实际核销时间统计;未传口径或显式传空时沿用此历史行为。 */ + VERIFICATION_TIME, + + /** 仅保留已核销订单,按预约用餐日期统计;预约日期为空时不计入。 */ + APPT_USE_DATE +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java index 293faed0..b378b3bb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java @@ -16,7 +16,7 @@ public enum HardSaleSourceEnum { /** 订单类型 1 的线下购买,统计时间取订单支付时间。 */ OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"), - /** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */ + /** 订单类型 2 且已完成核销的预约;默认按核销时间统计,也可由查询口径指定预约用餐日期。 */ RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销"); /** 统计接口和前端标签页使用的来源编码。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java index 37a48164..046949a7 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.api.dto.order; import com.bsd.pjcloud.admin.api.vo.base.BaseIsDeepDeptTreeInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import lombok.Data; @@ -37,9 +38,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf /** 预计使用日期范围。 */ private LocalDateTime[] apptUseDateTimeList; - /** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */ + /** 菜品销售统计日期闭区间,线下按支付时间、已核销预约按 reservationDateBasis 选择的日期过滤,不落库。 */ private LocalDateTime[] statisticsDateTimeList; + /** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */ + private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME; + /** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */ private List saleSourceCodes; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index f978819f..743d5f18 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import lombok.Data; @@ -54,10 +55,13 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] payTimeList; /** - * 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。 + * 食堂销售统计日期闭区间;线下按支付时间,已核销预约按 reservationDateBasis 选择的日期过滤,不落库。 */ private LocalDateTime[] statisticsDateTimeList; + /** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */ + private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME; + /** * 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index aa750f30..148377ab 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -32,6 +32,7 @@ import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; import com.bsd.pjcloud.hard.api.constant.total.DateRangeTypeEnum; @@ -124,6 +125,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { */ private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出"; + /** 预约用餐日口径的导出文件前缀及任务展示名称,避免与核销日口径的文件混淆。 */ + private static final String DINING_DATE_EXPORT_NAME = "食堂用餐日期统计"; + /** * 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 */ @@ -773,15 +777,17 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 提交部门统计异步分批导出任务。 + * 提交部门统计异步分批导出任务;预约用餐日口径使用独立的展示名称和文件前缀。 * - * @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件 + * @param dto 页面当前的统计日期、预约日期口径、来源、餐段、食堂和部门筛选条件 * @return 可供页面轮询的初始任务进度 */ @Override public AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto) { - return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, DEPARTMENT_SALES_EXPORT_TASK_NAME, - "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE); + boolean diningDateBasis = dto != null && dto.getReservationDateBasis() == HardCanteenReservationDateBasisEnum.APPT_USE_DATE; + return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, + diningDateBasis ? DINING_DATE_EXPORT_NAME + "导出" : DEPARTMENT_SALES_EXPORT_TASK_NAME, + diningDateBasis ? DINING_DATE_EXPORT_NAME : "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 754e6067..c2d7cf8e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -119,7 +119,7 @@ @@ -176,8 +176,16 @@ + + + + o.appt_use_date + o.verification_time + + + @@ -196,7 +204,7 @@ and o.hour_type is not null and o.hour_type not in ('-1') where refund_item.del_flag = '0' - and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + and between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} group by refund_item.parent_car_id, refund_item.product_id @@ -204,7 +212,7 @@ @@ -242,8 +250,8 @@ union all - select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date, - o.verification_time as stat_time, + select DATE_FORMAT(, '%Y-%m-%d') as stat_date, + as stat_time, o.id as order_id, o.user_id, o.merchant_id as mch_id, @@ -271,18 +279,18 @@ and refund_car.product_id = a.product_id where a.del_flag = '0' - and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + and between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id, - o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price + o.hour_type, , a.product_id, refund_car.product_count, refund_car.total_price having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0) - + and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index b2dd6283..df3ce235 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -287,7 +287,7 @@ or a.refund_status is null) - + and @@ -302,7 +302,7 @@ and (a.appt_id is not null or a.appt_rule_id is not null) and a.order_status = '20' and a.verification_time is not null - and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + and between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} ) @@ -314,13 +314,21 @@ and (a.appt_id is not null or a.appt_rule_id is not null) and a.order_status = '20' and a.verification_time is not null - and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}) + and between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}) ) + + + + a.appt_use_date + a.verification_time + + + + + (1 = 0 + + OR m.dept_id IN + #{deptId} + + OR m.create_by = #{scope.username} + ) + + + + r.del_flag = '0' + + AND NOT EXISTS ( + SELECT 1 FROM mch_checkout_relation_merchant b + LEFT JOIN mch_manage m ON m.id = b.merchant_id + WHERE b.relation_id = r.id + AND (m.id IS NULL OR NOT COALESCE(, 0)) + ) + + + + + r.id, r.intermediary_name, r.remark, r.version, r.create_time, r.update_time, + (SELECT COUNT(*) FROM mch_checkout_relation_merchant b + WHERE b.relation_id = r.id) AS merchant_count, + (SELECT COUNT(*) FROM mch_checkout_relation_merchant b + LEFT JOIN mch_manage m ON m.id = b.merchant_id + WHERE b.relation_id = r.id + AND (m.id IS NULL OR COALESCE(m.del_flag, '1') != '0')) AS invalid_count + + + + + + + + + + + + UPDATE mch_checkout_relation + SET intermediary_name = #{entity.intermediaryName}, remark = #{entity.remark}, + version = version + 1, update_by = #{entity.updateBy}, update_time = #{entity.updateTime} + WHERE id = #{entity.id} + AND version = #{entity.version} AND del_flag = '0' + + + + UPDATE mch_checkout_relation + SET del_flag = '1', version = version + 1, update_by = #{operator}, update_time = #{now} + WHERE id = #{id} AND version = #{version} AND del_flag = '0' + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml new file mode 100644 index 00000000..71a4c000 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml @@ -0,0 +1,97 @@ + + + + + + + + AND (1 = 0 + + OR m.dept_id IN + #{deptId} + + OR m.create_by = #{scope.username} + ) + + + + + m.id AS record_id, m.name AS merchant_name, m.dept_id, m.types AS merchant_types, + m.parent_id, m.del_flag AS merchant_del_flag, m.create_by AS merchant_create_by, + b.relation_id AS bound_relation_id + + + + FROM mch_manage m + LEFT JOIN mch_checkout_relation_merchant b ON b.merchant_id = m.id + WHERE m.del_flag = '0' + + + + + AND m.dept_id IN + #{deptId} + + AND 1 = 0 + + + + AND m.name LIKE CONCAT('%', #{query.merchantName}, '%') + + + AND FIND_IN_SET(#{query.merchantType}, m.types) > 0 + + + + + + + + + + + + + + + + INSERT INTO mch_checkout_relation_merchant (id, relation_id, merchant_id, create_by, create_time) + VALUES + + (#{member.id}, #{member.relationId}, #{member.merchantId}, #{member.createBy}, #{member.createTime}) + + + + + DELETE FROM mch_checkout_relation_merchant + WHERE relation_id = #{relationId} AND merchant_id IN + #{id} + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java new file mode 100644 index 00000000..4c90f72a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java @@ -0,0 +1,171 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.UUID; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 实际MySQL约束和事务验证,仅显式设置CHECKOUT_RELATION_TEST_JDBC_URL时执行。 + * 用户和密码分别取同名前缀的JDBC_USER、JDBC_PASSWORD;必须使用隔离测试库。 + * 读取实际迁移并替换为本次UUID表名,清理仅删除本测试创建的表,不触碰正式业务表。 + */ +@EnabledIfEnvironmentVariable(named = "CHECKOUT_RELATION_TEST_JDBC_URL", matches = "jdbc:mysql:.*") +class MchCheckoutRelationDatabaseTest { + + /** 实际迁移的UUID主体表,避免和其他运行或业务表冲突。 */ + private static String relationTable; + + /** 实际迁移的UUID绑定表,本测试结束时清理。 */ + private static String merchantTable; + + /** 仅通过测试库名称校验后才允许创建及清理随机表。 */ + private static boolean fixtureAllowed; + + /** 读取真实迁移并创建隔离表,重复建表验证迁移可重跑。 */ + @BeforeAll + static void createFixture() throws Exception { + String suffix = UUID.randomUUID().toString().replace("-", ""); + relationTable = "checkout_relation_test_" + suffix; + merchantTable = "checkout_member_test_" + suffix; + Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (cursor != null && !Files.exists(cursor.resolve("db/20261102_add_mch_checkout_relation.sql"))) { + cursor = cursor.getParent(); + } + assertNotNull(cursor, "需要从源码工程运行,以读取实际迁移文件"); + String migration = Files.readString(cursor.resolve("db/20261102_add_mch_checkout_relation.sql")) + .replaceAll("(?m)^--.*$", "") + .replace("`mch_checkout_relation_merchant`", "`" + merchantTable + "`") + .replace("`mch_checkout_relation`", "`" + relationTable + "`"); + try (Connection connection = connection()) { + String catalog = connection.getCatalog(); + assertNotNull(catalog, "URL必须明确指定隔离测试库"); + assertTrue(catalog.endsWith("_checkout_relation_test"), "测试库名必须以_checkout_relation_test结尾"); + fixtureAllowed = true; + for (int repeat = 0; repeat < 2; repeat++) { + for (String statement : migration.split(";")) { + if (!statement.isBlank()) execute(connection, statement); + } + } + } + } + + /** 仅清理本次随机表名,即使测试失败也不删除任何既存业务表。 */ + @AfterAll + static void dropFixture() throws Exception { + if (!fixtureAllowed || relationTable == null || merchantTable == null) return; + try (Connection connection = connection()) { + execute(connection, "DROP TABLE IF EXISTS `" + merchantTable + "`"); + execute(connection, "DROP TABLE IF EXISTS `" + relationTable + "`"); + } + } + + /** 两个真实连接并发占用同商户,第二个等待首事务提交后必须被唯一键拒绝。 */ + @Test + void concurrentMerchantClaimsShouldAllowOnlyOneCommit() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (Connection first = connection()) { + first.setAutoCommit(false); + execute(first, insertMember(101, 1, 901, 1)); + CountDownLatch attempted = new CountDownLatch(1); + Future second = executor.submit(() -> { + try (Connection next = connection()) { + attempted.countDown(); + try { + execute(next, insertMember(102, 2, 901, 1)); + return "unexpected-success"; + } catch (SQLException exception) { + return exception.getSQLState(); + } + } + }); + assertTrue(attempted.await(5, TimeUnit.SECONDS)); + first.commit(); + assertEquals("23000", second.get(10, TimeUnit.SECONDS)); + assertEquals(1, scalar(first, "SELECT COUNT(*) FROM `" + merchantTable + "` WHERE tenant_id=1 AND merchant_id=901")); + } finally { + executor.shutdownNow(); + } + } + + /** 有效名称使用真实数据库字符比较;软删除释放名称,不同租户可使用同名。 */ + @Test + void softDeleteShouldReleaseNameWithoutLosingTenantIsolation() throws Exception { + try (Connection connection = connection()) { + execute(connection, insertRelation(201, "Name", 1)); + assertThrows(SQLException.class, () -> execute(connection, insertRelation(202, "name", 1))); + execute(connection, insertRelation(203, "Name", 2)); + execute(connection, "UPDATE `" + relationTable + "` SET del_flag='1' WHERE id=201"); + execute(connection, insertRelation(202, "name", 1)); + assertEquals(3, scalar(connection, "SELECT COUNT(*) FROM `" + relationTable + "` WHERE id IN (201,202,203)")); + } + } + + /** 主体插入后绑定冲突,显式回滚必须同时撤销主体且保留原绑定。 */ + @Test + void bindingFailureShouldRollBackParentInsert() throws Exception { + try (Connection connection = connection()) { + execute(connection, insertMember(301, 30, 902, 1)); + connection.setAutoCommit(false); + execute(connection, insertRelation(302, "回滚名称", 1)); + assertThrows(SQLException.class, () -> execute(connection, insertMember(303, 302, 902, 1))); + connection.rollback(); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM `" + relationTable + "` WHERE id=302")); + assertEquals(1, scalar(connection, "SELECT COUNT(*) FROM `" + merchantTable + "` WHERE id=301")); + } + } + + /** 实际数据库版本条件阻止第二次使用旧版本覆盖。 */ + @Test + void staleVersionShouldAffectNoRows() throws Exception { + try (Connection connection = connection()) { + execute(connection, insertRelation(401, "版本测试", 1)); + String update = "UPDATE `" + relationTable + "` SET version=version+1 WHERE id=401 AND tenant_id=1 AND version=0 AND del_flag='0'"; + assertEquals(1, execute(connection, update)); + assertEquals(0, execute(connection, update)); + assertEquals(1, scalar(connection, "SELECT version FROM `" + relationTable + "` WHERE id=401")); + } + } + + /** 打开显式配置的测试连接,不从应用配置读取任何实际环境凭证。 */ + private static Connection connection() throws SQLException { + String username = System.getenv("CHECKOUT_RELATION_TEST_JDBC_USER"); + String password = System.getenv("CHECKOUT_RELATION_TEST_JDBC_PASSWORD"); + return DriverManager.getConnection(System.getenv("CHECKOUT_RELATION_TEST_JDBC_URL"), username == null ? "" : username, password == null ? "" : password); + } + + /** 执行隔离表SQL并返回实际影响行数,SQL仅包含固定文本及本测试生成的值。 */ + private static int execute(Connection connection, String sql) throws SQLException { + try (var statement = connection.createStatement()) { + return statement.executeUpdate(sql); + } + } + + /** 读取一个整型验证结果。 */ + private static long scalar(Connection connection, String sql) throws SQLException { + try (var statement = connection.createStatement(); var result = statement.executeQuery(sql)) { + assertTrue(result.next()); + return result.getLong(1); + } + } + + /** 构造隔离主体数据,名称仅来自本测试固定字符串。 */ + private static String insertRelation(long id, String name, long tenantId) { + return "INSERT INTO `" + relationTable + "` (id, intermediary_name, tenant_id) VALUES (" + id + ",'" + name + "'," + tenantId + ")"; + } + + /** 构造隔离绑定,不依赖任何正式商户或关系数据。 */ + private static String insertMember(long id, long relationId, long merchantId, long tenantId) { + return "INSERT INTO `" + merchantTable + "` (id, relation_id, merchant_id, tenant_id) VALUES (" + id + "," + relationId + "," + merchantId + "," + tenantId + ")"; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java new file mode 100644 index 00000000..0dc61403 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java @@ -0,0 +1,326 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationSaveDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity; +import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationMerchantEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.*; +import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMapper; +import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMerchantMapper; +import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationAccess; +import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 关系业务边界测试;Mapper为Mock,不将这些结果当作真实数据库事务或并发验证。 */ +@ExtendWith(MockitoExtension.class) +class MchCheckoutRelationServiceImplTest { + + /** 主体访问Mock,用于控制可见性、版本及名称冲突。 */ + @Mock + private MchCheckoutRelationMapper relationMapper; + + /** 商户及绑定访问Mock,捕获真实差异写入集合。 */ + @Mock + private MchCheckoutRelationMerchantMapper merchantMapper; + + /** 当前账号范围Mock,默认仅部门20。 */ + @Mock + private MchCheckoutRelationAccess access; + + /** 部门远程契约Mock,检查业务错误不能降级为空结果。 */ + @Mock + private RemoteDeptService remoteDeptService; + + /** 批量新增绑定捕获器,断言不会重建保留项。 */ + @Captor + private ArgumentCaptor> insertedMembers; + + /** 本轮测试不可由客户端指定的授权快照。 */ + private MchCheckoutRelationScope scope; + + /** 被测服务,通过构造器装配现有依赖。 */ + private MchCheckoutRelationServiceImpl service; + + /** 初始化局部授权和线程登录态;同步回调只用于检查提交后日志注册。 */ + @BeforeEach + void setUp() { + scope = new MchCheckoutRelationScope(false, List.of(20L), null); + lenient().when(access.requireScope()).thenReturn(scope); + service = new MchCheckoutRelationServiceImpl(relationMapper, merchantMapper, access, remoteDeptService); + PjcloudUser user = mock(PjcloudUser.class); + lenient().when(user.getUsername()).thenReturn("finance"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of())); + TransactionSynchronizationManager.initSynchronization(); + } + + /** 清理线程安全上下文和同步回调,不影响其他测试。 */ + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TransactionSynchronizationManager.clearSynchronization(); + } + + /** 越权新增ID只能在预览得到脱敏占位,不能暴露名称、部门或内部归属。 */ + @Test + void previewShouldMaskUnauthorizedMerchant() { + when(merchantMapper.listExact(eq(List.of(5L)), eq(false))).thenReturn(List.of(merchant(5L, 99L, null))); + + MchCheckoutRelationPreviewVo result = service.preview(draft(5L)); + + assertFalse(result.isValid()); + assertEquals("NO_PERMISSION", result.getMerchants().get(0).getState()); + assertNull(result.getMerchants().get(0).getMerchantName()); + assertNull(result.getMerchants().get(0).getDeptId()); + assertNull(result.getMerchants().get(0).getBoundRelationId()); + verifyNoInteractions(remoteDeptService); + } + + /** 即使主体查询被错误放行,服务仍检查完整旧名单,不能通过移除越权项绕过授权。 */ + @Test + void updateShouldRejectRemovalOfUnauthorizedOriginalMember() { + when(relationMapper.lockRelation(10L)).thenReturn(entity(0)); + when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(5L, 99L, 10L))); + when(merchantMapper.listExact(eq(List.of(2L, 5L)), eq(true))) + .thenReturn(List.of(merchant(2L, 20L, null), merchant(5L, 99L, 10L))); + + assertThrows(IllegalStateException.class, () -> service.update(editing(2L))); + + verify(relationMapper, never()).updateVersioned(any()); + verify(merchantMapper, never()).deleteMembers(any(), anyList()); + verify(merchantMapper, never()).insertMembers(anyList()); + } + + /** 部分权限不能清理物理缺失的旧商户,即使它没有可读取的部门。 */ + @Test + void deleteShouldRejectPhysicallyMissingMemberForLimitedScope() { + MchCheckoutRelationMerchantVo missing = new MchCheckoutRelationMerchantVo(); + missing.setMerchantId(5L); + when(relationMapper.lockRelation(10L)).thenReturn(entity(0)); + when(merchantMapper.listMembers(10L)).thenReturn(List.of(missing)); + when(merchantMapper.listExact(eq(List.of(5L)), eq(true))).thenReturn(List.of()); + + assertThrows(IllegalStateException.class, () -> service.delete(10L, 0)); + + verify(relationMapper, never()).deleteVersioned(any(), any(), any(), any()); + } + + /** 原软删除项被明确移除时允许预览通过;不能因失效项属于REMOVED而要求保留。 */ + @Test + void previewShouldAllowExplicitRemovalOfDeletedMember() { + MchCheckoutRelationMerchantVo deleted = merchant(1L, 20L, 10L); + deleted.setMerchantDelFlag("1"); + when(relationMapper.findVisible(10L, scope)).thenReturn(visible(0)); + when(merchantMapper.listMembers(10L)).thenReturn(List.of(deleted)); + when(merchantMapper.listExact(eq(List.of(2L)), eq(false))).thenReturn(List.of(merchant(2L, 20L, null))); + when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(Map.of(20L, department(20L, 0L)))); + + MchCheckoutRelationPreviewVo result = service.preview(editing(2L)); + + assertTrue(result.isValid()); + assertEquals(1, result.getAddedCount()); + assertEquals(1, result.getRemovedCount()); + assertEquals("DELETED", result.getMerchants().get(1).getState()); + assertEquals("REMOVED", result.getMerchants().get(1).getChange()); + } + + /** 商户已被其他关系占用时预览不可保存,且不暴露其他中间方名称。 */ + @Test + void previewShouldRejectAnotherRelationsMerchant() { + when(merchantMapper.listExact(eq(List.of(2L)), eq(false))).thenReturn(List.of(merchant(2L, 20L, 88L))); + when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(Map.of(20L, department(20L, 0L)))); + + MchCheckoutRelationPreviewVo result = service.preview(draft(2L)); + + assertFalse(result.isValid()); + assertEquals("BOUND", result.getMerchants().get(0).getState()); + assertFalse(result.getMerchants().get(0).isSelectable()); + } + + /** 旧版本编辑在任何写入前失败,不能覆盖当前主体或名单。 */ + @Test + void updateShouldRejectStaleVersionBeforeWriting() { + when(relationMapper.lockRelation(10L)).thenReturn(entity(1)); + when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(1L, 20L, 10L))); + when(merchantMapper.listExact(eq(List.of(1L)), eq(true))).thenReturn(List.of(merchant(1L, 20L, 10L))); + + assertThrows(IllegalStateException.class, () -> service.update(editing(1L))); + + verify(relationMapper, never()).updateVersioned(any()); + verify(merchantMapper, never()).insertMembers(anyList()); + } + + /** 编辑只移除3、增加2,保留1的原绑定创建信息,并在提交后才记录日志。 */ + @Test + void updateShouldWriteOnlyExplicitDifference() { + when(relationMapper.lockRelation(10L)).thenReturn(entity(0)); + when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(1L, 20L, 10L), merchant(3L, 20L, 10L))); + when(merchantMapper.listExact(eq(List.of(1L, 2L, 3L)), eq(true))) + .thenReturn(List.of(merchant(1L, 20L, 10L), merchant(2L, 20L, null), merchant(3L, 20L, 10L))); + when(relationMapper.updateVersioned(any())).thenReturn(1); + when(merchantMapper.deleteMembers(10L, List.of(3L))).thenReturn(1); + when(merchantMapper.insertMembers(anyList())).thenReturn(1); + when(relationMapper.findVisible(10L, scope)).thenReturn(visible(1)); + + MchCheckoutRelationVo result = service.update(editing(2L, 1L, 1L)); + + assertEquals(1, result.getVersion()); + verify(merchantMapper).insertMembers(insertedMembers.capture()); + assertEquals(List.of(2L), insertedMembers.getValue().stream().map(MchCheckoutRelationMerchantEntity::getMerchantId).toList()); + assertEquals(10L, insertedMembers.getValue().get(0).getRelationId()); + verify(merchantMapper).deleteMembers(10L, List.of(3L)); + assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size()); + TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit(); + } + + /** 名称冲突返回中文业务提示,异常路径不继续新增任何商户绑定。 */ + @Test + void duplicateNameShouldStopBeforeBinding() { + when(merchantMapper.listExact(eq(List.of(2L)), eq(true))).thenReturn(List.of(merchant(2L, 20L, null))); + when(relationMapper.insert(any(MchCheckoutRelationEntity.class))).thenThrow(new DuplicateKeyException("uk_mch_checkout_relation_name")); + + IllegalStateException error = assertThrows(IllegalStateException.class, () -> service.create(draft(2L))); + + assertTrue(error.getMessage().contains("同名")); + verify(merchantMapper, never()).insertMembers(anyList()); + assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty()); + } + + /** 部门完整解析探测到1001家时拒绝,不截取前1000条返回。 */ + @Test + void resolveShouldRejectOverflowWithoutTruncation() { + when(merchantMapper.resolveCandidates(any(), eq(scope), isNull())) + .thenReturn(Collections.nCopies(1001, merchant(2L, 20L, null))); + + assertThrows(IllegalArgumentException.class, () -> service.resolve(new MchCheckoutRelationMerchantQueryDto())); + + verifyNoInteractions(remoteDeptService); + } + + /** 无效部门不得清空过滤条件后查询全部候选。 */ + @Test + void removedDepartmentShouldNotBecomeUnfilteredQuery() { + MchCheckoutRelationMerchantQueryDto query = new MchCheckoutRelationMerchantQueryDto(); + query.setDeptIds(List.of(999L)); + when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.ok(List.of(department(20L, 0L)))); + + assertThrows(IllegalArgumentException.class, () -> service.merchantPage(new Page<>(1, 20), query)); + + verify(merchantMapper, never()).pageCandidates(any(), any(), any(), any()); + } + + /** 部门接口业务失败不能被误认为空目录继续查询。 */ + @Test + void departmentFailureShouldFailClosed() { + when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.failed("不可用")); + + assertThrows(IllegalStateException.class, () -> service.departmentTree()); + + verifyNoInteractions(merchantMapper); + } + + /** 树只保留授权商户部门及祖先,不受当前用户直属部门树裁剪影响。 */ + @Test + void departmentTreeShouldKeepOnlyRelevantAncestors() { + when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.ok(List.of(department(1L, 0L), department(20L, 1L), department(99L, 1L)))); + when(merchantMapper.listAccessibleDeptIds(scope)).thenReturn(List.of(20L)); + + List result = service.departmentTree(); + + assertEquals(1, result.size()); + assertEquals(1L, result.get(0).getId()); + assertEquals(List.of(20L), result.get(0).getChildren().stream().map(MchCheckoutRelationDeptVo::getId).toList()); + } + + /** 超出首期名单上限在任何数据库访问前拒绝。 */ + @Test + void saveShouldRejectMoreThanOneThousandIds() { + MchCheckoutRelationSaveDto dto = draft(1L); + dto.setMerchantIds(LongStream.rangeClosed(1, 1001).boxed().toList()); + + assertThrows(IllegalArgumentException.class, () -> service.create(dto)); + + verifyNoInteractions(relationMapper, merchantMapper); + } + + /** 构造新增草稿,带空格名称用于覆盖服务端规范化。 */ + private MchCheckoutRelationSaveDto draft(Long... ids) { + MchCheckoutRelationSaveDto dto = new MchCheckoutRelationSaveDto(); + dto.setIntermediaryName(" 甲中间方 "); + dto.setRemark("备注"); + dto.setMerchantIds(List.of(ids)); + return dto; + } + + /** 构造关系10的初始版本编辑草稿。 */ + private MchCheckoutRelationSaveDto editing(Long... ids) { + MchCheckoutRelationSaveDto dto = draft(ids); + dto.setId(10L); + dto.setVersion(0); + return dto; + } + + /** 构造原始商户查询行,含内部权限字段,不直接视为已授权响应。 */ + private MchCheckoutRelationMerchantVo merchant(Long id, Long deptId, Long relationId) { + MchCheckoutRelationMerchantVo row = new MchCheckoutRelationMerchantVo(); + row.setMerchantId(id); + row.setRecordId(id); + row.setMerchantName("商户" + id); + row.setDeptId(deptId); + row.setMerchantCreateBy("other"); + row.setMerchantDelFlag("0"); + row.setBoundRelationId(relationId); + return row; + } + + /** 构造租户1关系10的锁定主体。 */ + private MchCheckoutRelationEntity entity(int version) { + MchCheckoutRelationEntity relation = new MchCheckoutRelationEntity(); + relation.setId(10L); + relation.setTenantId(1L); + relation.setVersion(version); + return relation; + } + + /** 构造同一主体的可见响应。 */ + private MchCheckoutRelationVo visible(int version) { + MchCheckoutRelationVo relation = new MchCheckoutRelationVo(); + relation.setId(10L); + relation.setVersion(version); + return relation; + } + + /** 构造同租户部门及其导航父级。 */ + private SysDept department(Long id, Long parentId) { + SysDept dept = new SysDept(); + dept.setDeptId(id); + dept.setParentId(parentId); + dept.setTenantId(1L); + dept.setName("部门" + id); + return dept; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java new file mode 100644 index 00000000..499865df --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; +import com.bsd.pjcloud.common.data.datascope.DataScopeHandle; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** 权限组件装配回归,复现只有现有Feign依赖、没有独立DataScopeHandle Bean的实际配置。 */ +class MchCheckoutRelationAccessContextTest { + + /** 仅提供项目已有远程权限Bean即可启动权限组件,无需为接口额外注册全局Bean。 */ + @Test + void accessComponentShouldStartWithExistingFeignDependency() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(RemoteDataScopeService.class, () -> mock(RemoteDataScopeService.class)); + context.register(MchCheckoutRelationAccess.class); + + assertDoesNotThrow(context::refresh); + + assertNotNull(context.getBean(MchCheckoutRelationAccess.class)); + assertTrue(context.getBeansOfType(DataScopeHandle.class).isEmpty()); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java new file mode 100644 index 00000000..edf8546f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.wallet.support.mch; + +import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** 商户部门与本人范围的组合边界,验证失效绑定清理不会绕过权限。 */ +class MchCheckoutRelationScopeTest { + + /** 软删除商户仍按保留部门确定能否清理。 */ + @Test + void deletedMerchantShouldKeepItsDepartmentScope() { + MchCheckoutRelationMerchantVo row = merchant(20L, "other"); + row.setMerchantDelFlag("1"); + assertTrue(new MchCheckoutRelationScope(false, List.of(20L), null).allows(row)); + assertFalse(new MchCheckoutRelationScope(false, List.of(30L), null).allows(row)); + } + + /** 本人角色和部门角色按或关系组合,使用商户创建人而非关系创建人。 */ + @Test + void ownerScopeShouldAllowOwnMerchantOutsideDepartment() { + MchCheckoutRelationScope scope = new MchCheckoutRelationScope(false, List.of(30L), "self"); + assertTrue(scope.allows(merchant(20L, "self"))); + assertFalse(scope.allows(merchant(20L, "other"))); + } + + /** 物理缺失行没有部门可验证,只允许全部数据范围处理。 */ + @Test + void missingMerchantShouldRequireFullScope() { + assertFalse(new MchCheckoutRelationScope(false, List.of(20L), "self").allows(new MchCheckoutRelationMerchantVo())); + assertTrue(new MchCheckoutRelationScope(true, List.of(), null).allows(new MchCheckoutRelationMerchantVo())); + } + + /** 部门及创建人都为空时不能因空集合或SQL空值语义而被放行。 */ + @Test + void absentPermissionFieldsShouldDenyWithoutThrowing() { + assertFalse(new MchCheckoutRelationScope(false, List.of(), null).allows(merchant(null, null))); + } + + /** 构造保留原始权限字段的商户行。 */ + private MchCheckoutRelationMerchantVo merchant(Long deptId, String creator) { + MchCheckoutRelationMerchantVo row = new MchCheckoutRelationMerchantVo(); + row.setRecordId(1L); + row.setDeptId(deptId); + row.setMerchantCreateBy(creator); + return row; + } +}