diff --git a/sql/tools/convertor.py b/sql/tools/convertor.py index d9ed76b..b2e804a 100644 --- a/sql/tools/convertor.py +++ b/sql/tools/convertor.py @@ -23,7 +23,7 @@ import re import sys import time from abc import ABC, abstractmethod -from typing import Dict, Generator, Optional, Tuple, Union +from typing import Dict, Generator, List, Optional, Tuple, Union from simple_ddl_parser import DDLParser @@ -43,6 +43,52 @@ PREAMBLE = """/* """ +# 依次匹配:引号字符串(支持反斜杠转义、双写引号)、注释、语句分隔符;未闭合的引号吞掉剩余内容 +SQL_TOKEN_PATTERN = re.compile( + r"'(?:\\[\s\S]|''|[^'\\])*'" + r'|"(?:\\[\s\S]|""|[^"\\])*"' + r"|`(?:``|[^`])*`" + r"|--(?=\s|\Z)[^\n]*|#[^\n]*|/\*[\s\S]*?(?:\*/|\Z)" + r"|['\"`][\s\S]*" + r"|;" +) +# 语句开头的空白、注释 +SQL_LEADING_PATTERN = re.compile(r"(?:\s|--(?=\s|\Z)[^\n]*|#[^\n]*|/\*[\s\S]*?(?:\*/|\Z))*") +CREATE_TABLE_PATTERN = re.compile(r"CREATE\s+TABLE\s", flags=re.IGNORECASE) + + +def split_statements(content: str) -> List[str]: + """按分号拆分 SQL 语句,忽略引号、注释内的分号 + + 仅用于定位顶层语句,不是完整的 SQL 解析器。返回的语句保留原文,去掉开头的空白、注释,以分号结尾 + + Args: + content (str): sql 内容 + + Returns: + List[str]: 语句列表 + """ + statements = [] + start = 0 + for match in SQL_TOKEN_PATTERN.finditer(content): + if match.group() != ";": + continue + statement = content[start:match.end()] + start = match.end() + statement = statement[SQL_LEADING_PATTERN.match(statement).end():] + if statement != ";": + statements.append(statement) + return statements + + +def table_scripts(content: str) -> List[str]: + """获得真实的 CREATE TABLE 语句,不包括 INSERT 数据、注释中的建表文本 + + 相关 issue:https://github.com/YunaiV/ruoyi-vue-pro/issues/1273 + """ + return [statement for statement in split_statements(content) if CREATE_TABLE_PATTERN.match(statement)] + + def load_and_clean(sql_file: str) -> str: """加载源 SQL 文件,并清理内容方便下一步 ddl 解析 @@ -52,6 +98,19 @@ def load_and_clean(sql_file: str) -> str: Returns: str: 清理后的sql文件内容 """ + # utf-8-sig:只去掉文件开头的 BOM,避免首条语句无法识别 + return clean(open(sql_file, encoding="utf-8-sig").read()) + + +def clean(content: str) -> str: + """清理 SQL 内容方便下一步 ddl 解析 + + Args: + content (str): sql 内容 + + Returns: + str: 清理后的sql内容 + """ REPLACE_PAIR_LIST = ( (")\nVALUES ", ") VALUES "), (" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ", " "), @@ -62,7 +121,6 @@ def load_and_clean(sql_file: str) -> str: ("b'1'", "'1'"), ) - content = open(sql_file, encoding="utf-8").read() for replace_pair in REPLACE_PAIR_LIST: content = content.replace(*replace_pair) # 移除所有 CHARACTER SET / COLLATE 变体 (utf8mb3、utf8 等) @@ -88,10 +146,14 @@ class Convertor(ABC): self.db_type = db_type self.content = load_and_clean(self.src) # original_content 保留原始 COMMENT 信息,用于注释提取 - self.original_content = open(src, encoding="utf-8").read() - # 剥离列级 COMMENT 以避免 COMMENT 值内的分号截断 CREATE TABLE 正则 - content_no_comment = re.sub(r" COMMENT '(?:[^'\\]|\\.)*'", "", self.content) - self.table_script_list = re.findall(r"CREATE TABLE [^;]*;", content_no_comment) + self.original_content = open(src, encoding="utf-8-sig").read() + # 在原始内容上扫描建表语句,避免全局清理破坏引号边界;每个建表语句再单独清理 + self.original_table_script_list = table_scripts(self.original_content) + # 剥离列级 COMMENT,交给 DDLParser 解析 + self.table_script_list = [ + re.sub(r" COMMENT '(?:[^'\\]|\\.)*'", "", clean(table_sql)) + for table_sql in self.original_table_script_list + ] @abstractmethod def translate_type(self, type: str, size: Optional[Union[int, Tuple[int]]]) -> str: @@ -330,7 +392,7 @@ class Convertor(ABC): ) error_scripts = [] - for table_sql in self.table_script_list: + for table_sql, orig_table_sql in zip(self.table_script_list, self.original_table_script_list): # 剥离 COMMENT 子句避免 DDLParser 无法处理中文等特殊字符 table_sql_for_parse = re.sub(r"\s+COMMENT\s+'[^']*'", "", table_sql) ddl = DDLParser(table_sql_for_parse.replace("`", "")).run() @@ -354,14 +416,7 @@ class Convertor(ABC): continue # 解析注释 - # 从原始 SQL 提取注释(支持中文等 DDLParser 不能处理的内容) - # 按表名定位完整建表段(以「换行 + )」起始的 ENGINE 行作为终止),避免被列 COMMENT 内的分号截断 - orig_match = re.search( - rf"CREATE TABLE\s+`{re.escape(table_name)}`[\s\S]*?\n\)[^;]*;", - self.original_content, - flags=re.IGNORECASE, - ) - orig_table_sql = orig_match.group() if orig_match else table_sql + # 从原始建表语句提取注释(支持中文等 DDLParser 不能处理的内容),避免 INSERT 数据中的同名建表文本干扰 comments_dict = dict(Convertor.filed_comments(orig_table_sql)) for column in table_ddl["columns"]: column["comment"] = comments_dict.get(column["name"], "") diff --git a/sql/tools/test_convertor.py b/sql/tools/test_convertor.py new file mode 100644 index 0000000..8090c7a --- /dev/null +++ b/sql/tools/test_convertor.py @@ -0,0 +1,314 @@ +# encoding=utf8 +"""convertor.py 的单元测试 + +cd sql/tools +uv run --with simple-ddl-parser python -m unittest test_convertor -v +""" + +import contextlib +import io +import os +import re +import subprocess +import sys +import tempfile +import unittest + +from convertor import ( + DM8Convertor, + HighGoConvertor, + KingbaseConvertor, + OpengaussConvertor, + OracleConvertor, + PostgreSQLConvertor, + SQLServerConvertor, + split_statements, +) + +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +MYSQL_DIR = os.path.join(TOOLS_DIR, "..", "mysql") + +HEADER = """/* + Navicat Premium Data Transfer + + Source Server Type : MySQL +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; +""" + +DEMO_TABLE = """ +-- ---------------------------- +-- Table structure for demo +-- ---------------------------- +DROP TABLE IF EXISTS `demo`; +CREATE TABLE `demo` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称;别名', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '演示表'; + +-- ---------------------------- +-- Records of demo +-- ---------------------------- +BEGIN; +INSERT INTO `demo` (`id`, `name`) VALUES (1, 'a;b'); +COMMIT; +""" + +DEMO_DOC_TABLE = """ +DROP TABLE IF EXISTS `demo_doc`; +CREATE TABLE `demo_doc` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '内容', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文档表'; +""" + +# INSERT 数据中的建表文本:转义换行(issue #1273 的实际场景) +ESCAPED_NEWLINE_INSERT = r""" +INSERT INTO `demo_doc` (`id`, `content`) VALUES (1, 'Seata:\nCREATE TABLE `undo_log` (\n `id` bigint NOT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB;\n'); +""" + +# INSERT 数据中的建表文本:真实换行 +LITERAL_NEWLINE_INSERT = """ +INSERT INTO `demo_doc` (`id`, `content`) VALUES (2, 'line1 +CREATE TABLE `ghost` ( + `id` bigint NOT NULL +); +line3'); +""" + +# 注释中的建表文本 +COMMENTED_DDL = """ +/* CREATE TABLE `ghost_block` (`id` bigint NOT NULL); */ +-- CREATE TABLE `ghost_line` (`id` bigint NOT NULL); +# CREATE TABLE `ghost_hash` (`id` bigint NOT NULL); +""" + + +def table_names(table_script_list): + return [re.match(r"CREATE\s+TABLE\s+`([^`]+)`", script).group(1) for script in table_script_list] + + +def convert(convertor): + """执行转换,返回 stdout、stderr 的内容""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + convertor.print() + return out.getvalue(), err.getvalue() + + +class SplitStatementsTest(unittest.TestCase): + + def test_semicolon_in_quotes(self): + sql = r"""INSERT INTO `t` VALUES ('a;b', "c;d", 'it\'s;', 'x''y;', "q\";", 'C:\\'); +INSERT INTO `we;ird` VALUES (1);""" + self.assertEqual(sql.split("\n"), split_statements(sql)) + + def test_semicolon_in_comments(self): + sql = """-- comment; here +# hash; it's +/* block; here */ SELECT 1; +CREATE TABLE `c` ( + `id` bigint -- 编号; 注释 +);""" + self.assertEqual(["SELECT 1;", "CREATE TABLE `c` (\n `id` bigint -- 编号; 注释\n);"], split_statements(sql)) + + def test_double_dash_without_space(self): + # MySQL 中 -- 后面必须跟空白,才是注释 + self.assertEqual(["SELECT 5--2;"], split_statements("SELECT 5--2;")) + + def test_multiple_statements_one_line(self): + self.assertEqual(["SET NAMES utf8mb4;", "SET FOREIGN_KEY_CHECKS = 0;"], + split_statements("SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0;")) + + def test_unterminated(self): + # 未闭合的引号、缺少分号的结尾,都不作为语句 + self.assertEqual(["SELECT 1;"], split_statements("SELECT 1; INSERT INTO `t` VALUES ('a;")) + self.assertEqual(["SELECT 1;"], split_statements("SELECT 1;\nSELECT 2")) + + +class ConvertorTableScanTest(unittest.TestCase): + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = directory.name + + def write_sql(self, content): + path = os.path.join(self.directory, "dump.sql") + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return path + + def test_create_table_in_insert_ignored(self): + path = self.write_sql(HEADER + DEMO_DOC_TABLE + ESCAPED_NEWLINE_INSERT + LITERAL_NEWLINE_INSERT + + COMMENTED_DDL + DEMO_TABLE) + for convertor_class in (PostgreSQLConvertor, KingbaseConvertor, OpengaussConvertor, HighGoConvertor, + OracleConvertor, SQLServerConvertor, DM8Convertor): + with self.subTest(convertor_class.__name__): + convertor = convertor_class(path) + self.assertEqual(["demo_doc", "demo"], table_names(convertor.table_script_list)) + + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + self.assertIn("Table structure for demo_doc", out) + self.assertIn("Table structure for demo\n", out) + for ghost in ("undo_log", "ghost", "ghost_block", "ghost_line", "ghost_hash"): + self.assertNotIn(f"Table structure for {ghost}\n", out) + + def test_comment_from_real_table(self): + # 前面 INSERT 数据中存在同名的建表文本,不能影响真实建表语句的注释 + fake_insert = """ +INSERT INTO `demo_doc` (`id`, `content`) VALUES (1, 'CREATE TABLE `demo` ( + `name` varchar(64) NOT NULL COMMENT \\'假名称\\', + PRIMARY KEY (`id`) +) ENGINE = InnoDB COMMENT = \\'假表\\'; +'); +""" + path = self.write_sql(HEADER + DEMO_DOC_TABLE + fake_insert + DEMO_TABLE) + convertor = PostgreSQLConvertor(path) + self.assertEqual(["demo_doc", "demo"], table_names(convertor.table_script_list)) + # 保留原始建表语句 + self.assertIn("COMMENT '名称;别名'", convertor.original_table_script_list[1]) + + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + self.assertIn("COMMENT ON COLUMN demo.id IS '编号';", out) + self.assertIn("COMMENT ON COLUMN demo.name IS '名称;别名';", out) + self.assertIn("COMMENT ON TABLE demo IS '演示表';", out) + self.assertIn("COMMENT ON TABLE demo_doc IS '文档表';", out) + comment_lines = [line for line in out.split("\n") if line.startswith("COMMENT ON")] + self.assertFalse([line for line in comment_lines if "假" in line]) + + def test_indented_and_same_line_create_table(self): + sql = HEADER + """ + CREATE TABLE `indented` ( + `id` bigint NOT NULL COMMENT '编号', + PRIMARY KEY (`id`) USING BTREE + ) ENGINE = InnoDB COMMENT = '缩进表'; +CREATE TABLE `one` (`id` bigint NOT NULL, PRIMARY KEY (`id`)) ENGINE = InnoDB; CREATE TABLE `two` (`id` bigint NOT NULL, PRIMARY KEY (`id`)) ENGINE = InnoDB; +""" + convertor = PostgreSQLConvertor(self.write_sql(sql)) + self.assertEqual(["indented", "one", "two"], table_names(convertor.table_script_list)) + + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + self.assertIn("COMMENT ON COLUMN indented.id IS '编号';", out) + self.assertIn("COMMENT ON TABLE indented IS '缩进表';", out) + self.assertIn("CREATE TABLE one (", out) + self.assertIn("CREATE TABLE two (", out) + + def test_multiple_tables_with_inserts(self): + demo_item = """ +DROP TABLE IF EXISTS `demo_item`; +CREATE TABLE `demo_item` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `demo_id` bigint NOT NULL COMMENT '演示编号', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB COMMENT = '演示项'; + +BEGIN; +INSERT INTO `demo_item` (`id`, `demo_id`) VALUES (7, 1); +INSERT INTO `demo_item` (`id`, `demo_id`) VALUES (8, 1); +COMMIT; +""" + convertor = PostgreSQLConvertor(self.write_sql(HEADER + DEMO_TABLE + demo_item)) + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + self.assertIn("INSERT INTO demo (id, name) VALUES (1, 'a;b');", out) + self.assertIn("INSERT INTO demo_item (id, demo_id) VALUES (7, 1);\n" + "INSERT INTO demo_item (id, demo_id) VALUES (8, 1);", out) + self.assertIn("CREATE SEQUENCE demo_seq\n START 2;", out) + self.assertIn("CREATE SEQUENCE demo_item_seq\n START 9;", out) + + def test_postgres_char(self): + sql = HEADER + """ +CREATE TABLE `demo_char` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `flag` char NOT NULL DEFAULT 'N' COMMENT '标记', + `code` char(2) NULL DEFAULT NULL COMMENT '编码', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB COMMENT = '字符表'; +""" + out, err = convert(PostgreSQLConvertor(self.write_sql(sql))) + self.assertNotIn("无法正常解析", err) + self.assertIn("flag varchar(1) NOT NULL", out) + self.assertIn("code varchar(2) NULL", out) + + def test_utf8_bom(self): + # 文件开头的 BOM 紧挨着首条 CREATE TABLE;字符串中的 BOM 需原样保留 + sql = "\ufeff" + """CREATE TABLE `demo_bom` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `name` varchar(64) NOT NULL DEFAULT '' COMMENT '名称', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB COMMENT = 'BOM 表'; +INSERT INTO `demo_bom` (`id`, `name`) VALUES (1, 'a\ufeffb'); +""" + path = self.write_sql(sql) + with open(path, "rb") as f: + self.assertTrue(f.read().startswith(b"\xef\xbb\xbf")) + + convertor = PostgreSQLConvertor(path) + self.assertEqual(["demo_bom"], table_names(convertor.table_script_list)) + self.assertTrue(convertor.original_table_script_list[0].startswith("CREATE TABLE `demo_bom`")) + + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + self.assertIn("CREATE TABLE demo_bom (", out) + self.assertIn("name varchar(64) NOT NULL", out) + self.assertIn("COMMENT ON COLUMN demo_bom.name IS '名称';", out) + self.assertIn("COMMENT ON TABLE demo_bom IS 'BOM 表';", out) + self.assertIn("INSERT INTO demo_bom (id, name) VALUES (1, 'a\ufeffb');", out) + + def test_cli(self): + # 等价于:python convertor.py dump.sql + sql = HEADER + DEMO_DOC_TABLE + ESCAPED_NEWLINE_INSERT + LITERAL_NEWLINE_INSERT + DEMO_TABLE + path = self.write_sql(sql) + + # OceanBase:原样输出 + result = subprocess.run([sys.executable, "convertor.py", "oceanbase", path], + cwd=TOOLS_DIR, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr.decode("utf-8")) + self.assertEqual(sql, result.stdout.decode("utf-8")) + + # PostgreSQL:不误识别 INSERT 数据中的建表文本 + result = subprocess.run([sys.executable, "convertor.py", "postgres", path], + cwd=TOOLS_DIR, capture_output=True) + out, err = result.stdout.decode("utf-8"), result.stderr.decode("utf-8") + self.assertEqual(0, result.returncode, err) + self.assertNotIn("无法正常解析", err) + self.assertIn("Table structure for demo_doc", out) + self.assertIn("Table structure for demo\n", out) + self.assertNotIn("Table structure for undo_log", out) + self.assertNotIn("Table structure for ghost", out) + + +class ConvertorOfficialDumpTest(unittest.TestCase): + + def test_ruoyi_vue_pro(self): + convertor = PostgreSQLConvertor(os.path.join(MYSQL_DIR, "ruoyi-vue-pro.sql")) + names = table_names(convertor.table_script_list) + self.assertEqual(48, len(names)) + self.assertEqual(len(names), len(set(names))) + + out, err = convert(convertor) + self.assertNotIn("无法正常解析", err) + # 48 张表,加上 dual 表 + self.assertEqual(49, out.count("-- Table structure for ")) + + def test_quartz(self): + convertor = PostgreSQLConvertor(os.path.join(MYSQL_DIR, "quartz.sql")) + names = table_names(convertor.table_script_list) + self.assertEqual(11, len(names)) + self.assertTrue(all(name.startswith("QRTZ_") for name in names)) + + # quartz 的表不转换 + out, _ = convert(convertor) + self.assertNotIn("qrtz", out.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index f13aabc..e0bea18 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -27,13 +27,13 @@ 3.5.19 3.5.17 1.5.9 - 4.5.0 + 4.6.0 3.1.8 - 4.7.0 + 4.8.0 8.1.3.140 9.0.1.jre7 - 7.0.0-RC3-og - 3.9.2 + 7.0.0-og + 3.10.0 2.0.8 2.3.6 @@ -42,50 +42,49 @@ 9.7.0 3.5.9 - 1.66.0 + 1.67.0 - 2.3.232 8.0.2.RELEASE - 1.1.19 + 1.1.20 5.2.0 8.0.0 - 1.4.0 + 2.0.0 1.23.2 0.29.5 2.5.1 1.18.48 1.7.0.Beta2 - 5.8.47 + 5.8.48 6.0.0-M22 2.1.0-incubating 5.4.4 2.4.1 - 2.0.65 - 33.7.1-jre + 2.0.66 + 33.7.2-jre 2.14.5 3.13.0 - 3.20.0 - 2.28.7 + 3.21.0 + 2.28.8 3.3.1 2.7.0 3.0.6 - 4.2.18.Final + 4.2.19.Final 1.2.5 4.5.26 4.12.0 3.14.0 3.4.0 - 2.55.5 + 2.57.3 1.16.7 1.4.0 2.5.4 2.5.4 - 4.8.6-20260919.232150 - 1.80 - 4.40.1014.ALL + 4.8.6-20261010.094603 + 1.85 + 4.40.1044.ALL @@ -99,6 +98,13 @@ import + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + org.springframework.boot spring-boot-dependencies ${spring.boot.version} @@ -278,7 +284,7 @@ org.redisson - + redisson-spring-data-41 @@ -439,12 +445,6 @@ - com.h2database - h2 - ${h2.version} - - - com.github.fppt jedis-mock ${jedis-mock.version} @@ -707,7 +707,7 @@ - org.bouncycastle diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java index 21ef03f..5c85d3e 100644 --- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java +++ b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java @@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo.CrmOperateLogP import cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo.CrmOperateLogRespVO; import cn.iocoder.yudao.module.crm.enums.LogRecordConstants; import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; import cn.iocoder.yudao.module.system.api.logger.OperateLogApi; import cn.iocoder.yudao.module.system.api.logger.dto.OperateLogPageReqDTO; import io.swagger.v3.oas.annotations.Operation; @@ -52,6 +54,8 @@ public class CrmOperateLogController { @GetMapping("/page") @Operation(summary = "获得操作日志") + // 校验业务对象的数据权限:https://github.com/YunaiV/ruoyi-vue-pro/issues/1305 + @CrmPermission(bizTypeValue = "#pageReqVO.bizType", bizId = "#pageReqVO.bizId", level = CrmPermissionLevelEnum.READ) public CommonResult> getCustomerOperateLog(@Valid CrmOperateLogPageReqVO pageReqVO) { OperateLogPageReqDTO reqDTO = new OperateLogPageReqDTO(); reqDTO.setPageSize(PAGE_SIZE_NONE); // 默认不分页,需要分页需注释 diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java index 86f1f10..de9dcf3 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java @@ -211,7 +211,8 @@ public class CodegenServiceImplTest extends BaseDbUnitTest { CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class, o -> o.getTable().setId(table.getId()) .setTemplateType(CodegenTemplateTypeEnum.ONE.getType()) - .setScene(CodegenSceneEnum.ADMIN.getScene())); + .setScene(CodegenSceneEnum.ADMIN.getScene()) + .setModuleName("infra").setBusinessName("codegen").setClassName("CodegenTable")); CodegenColumnSaveReqVO columnVO01 = randomPojo(CodegenColumnSaveReqVO.class, o -> o.setId(column01.getId()).setTableId(table.getId())); CodegenColumnSaveReqVO columnVO02 = randomPojo(CodegenColumnSaveReqVO.class, diff --git a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/infra/ApiAccessLogStatisticsMapper.xml b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/infra/ApiAccessLogStatisticsMapper.xml index c307c02..96ebd47 100644 --- a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/infra/ApiAccessLogStatisticsMapper.xml +++ b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/infra/ApiAccessLogStatisticsMapper.xml @@ -7,7 +7,7 @@ FROM infra_api_access_log WHERE user_type = #{userType} AND create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -19,7 +19,7 @@ WHERE user_id > 0 AND user_type = #{userType} AND create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} diff --git a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml index 0c1be40..c156419 100644 --- a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml +++ b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml @@ -6,7 +6,7 @@ resultType="cn.iocoder.yudao.module.statistics.service.member.bo.MemberAreaStatisticsRespBO"> SELECT area_id, COUNT(1) AS userCount FROM member_user - WHERE deleted = FALSE + WHERE deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -17,7 +17,7 @@ resultType="cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberSexStatisticsRespVO"> SELECT sex, COUNT(1) AS userCount FROM member_user - WHERE deleted = FALSE + WHERE deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -28,7 +28,7 @@ resultType="cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberTerminalStatisticsRespVO"> SELECT register_terminal as terminal, COUNT(1) AS userCount FROM member_user - WHERE deleted = FALSE + WHERE deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -38,7 +38,7 @@ - SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS date, + SELECT CAST(create_time AS DATE) AS date, count(1) AS count FROM member_user WHERE create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} GROUP BY date + ORDER BY date diff --git a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/pay/PayWalletStatisticsMapper.xml b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/pay/PayWalletStatisticsMapper.xml index 78403c8..e4b39a9 100644 --- a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/pay/PayWalletStatisticsMapper.xml +++ b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/pay/PayWalletStatisticsMapper.xml @@ -9,7 +9,7 @@ FROM pay_wallet_recharge WHERE pay_status = #{payStatus} AND pay_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -22,7 +22,7 @@ FROM pay_wallet_recharge WHERE refund_status = #{refundStatus} AND refund_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -33,7 +33,7 @@ FROM pay_wallet_transaction WHERE biz_type = #{bizType} AND create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -51,17 +51,17 @@ AND pay_time <= #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} SELECT COUNT(DISTINCT (user_id)) FROM trade_order - WHERE deleted = FALSE + WHERE deleted = 0 AND create_time BETWEEN #{beginTime} AND #{endTime} AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -53,7 +53,7 @@ FROM trade_order WHERE pay_time BETWEEN #{beginTime} AND #{endTime} AND pay_status = TRUE - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -63,7 +63,7 @@ SELECT COUNT(1) FROM trade_order WHERE create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -74,7 +74,7 @@ FROM trade_order WHERE pay_status = TRUE AND create_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -84,7 +84,7 @@ SELECT SUM(pay_price) FROM trade_order AS s WHERE s.pay_status = TRUE - AND deleted = FALSE + AND deleted = 0 AND create_time BETWEEN #{beginTime} AND #{endTime} AND s.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} @@ -93,32 +93,35 @@ - SELECT IFNULL(SUM(pay_price), 0) AS order_pay_price, + SELECT COALESCE(SUM(pay_price), 0) AS order_pay_price, COUNT(1) AS order_pay_count FROM trade_order WHERE pay_status = #{payStatus} AND pay_time BETWEEN #{beginTime} AND #{endTime} - AND deleted = FALSE + AND deleted = 0 AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} diff --git a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeStatisticsMapper.xml b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeStatisticsMapper.xml index 2415f6d..0a49132 100644 --- a/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeStatisticsMapper.xml +++ b/yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeStatisticsMapper.xml @@ -4,11 +4,11 @@ SELECT -- 支出金额 = 余额支付金额 + 支付佣金金额 + 商品退款金额 SUM(wallet_pay_price + brokerage_settlement_price + after_sale_refund_price) AS expensePrice FROM trade_statistics - WHERE deleted = FALSE + WHERE deleted = 0 AND time >= #{beginTime} diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImpl.java index 9b57b32..37e309e 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImpl.java @@ -46,14 +46,14 @@ public class CartServiceImpl implements CartService { public Long addCart(Long userId, AppCartAddReqVO addReqVO) { // 查询 TradeCartDO CartDO cart = cartMapper.selectByUserIdAndSkuId(userId, addReqVO.getSkuId()); - // 校验 SKU - Integer count = addReqVO.getCount(); + // 校验 SKU:已存在时,按合并后的数量校验库存 https://github.com/YunaiV/ruoyi-vue-pro/issues/1267 + Integer count = cart != null ? cart.getCount() + addReqVO.getCount() : addReqVO.getCount(); ProductSkuRespDTO sku = checkProductSku(addReqVO.getSkuId(), count); // 情况一:存在,则进行数量更新 if (cart != null) { cartMapper.updateById(new CartDO().setId(cart.getId()).setSelected(true) - .setCount(cart.getCount() + count)); + .setCount(count)); return cart.getId(); // 情况二:不存在,则进行插入 } else { diff --git a/yudao-module-mall/yudao-module-trade/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml b/yudao-module-mall/yudao-module-trade/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml index 69ee4ed..315cc70 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml +++ b/yudao-module-mall/yudao-module-trade/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml @@ -17,14 +17,14 @@ resultType="cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO"> SELECT bu.id, bu.bind_user_time AS brokerageTime, (SELECT SUM(price) FROM trade_brokerage_record r - WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = FALSE) AS brokeragePrice, + WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = 0) AS brokeragePrice, (SELECT COUNT(1) FROM trade_brokerage_record r - WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = FALSE) AS brokerageOrderCount, + WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = 0) AS brokerageOrderCount, (SELECT COUNT(1) FROM trade_brokerage_user c - WHERE c.bind_user_id = bu.id AND c.deleted = FALSE) AS brokerageUserCount + WHERE c.bind_user_id = bu.id AND c.deleted = 0) AS brokerageUserCount FROM trade_brokerage_user AS bu - bu.deleted = false + bu.deleted = 0 and bu.id in diff --git a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImplTest.java b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImplTest.java new file mode 100644 index 0000000..b512151 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImplTest.java @@ -0,0 +1,190 @@ +package cn.iocoder.yudao.module.trade.service.cart; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.trade.controller.app.cart.vo.AppCartAddReqVO; +import cn.iocoder.yudao.module.trade.controller.app.cart.vo.AppCartUpdateCountReqVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.cart.CartDO; +import cn.iocoder.yudao.module.trade.dal.mysql.cart.CartMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.SKU_NOT_EXISTS; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.SKU_STOCK_NOT_ENOUGH; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link CartServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +public class CartServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private CartServiceImpl cartService; + + @Mock + private CartMapper cartMapper; + @Mock + private ProductSpuApi productSpuApi; + @Mock + private ProductSkuApi productSkuApi; + + @Test + public void testAddCart_insert() { + // 准备参数 + Long userId = 1L; + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(3); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + when(cartMapper.insert(any(CartDO.class))).thenAnswer(invocation -> { + invocation.getArgument(0).setId(100L); + return 1; + }); + + // 调用 + Long cartId = cartService.addCart(userId, reqVO); + // 断言 + assertEquals(100L, cartId); + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(CartDO.class); + verify(cartMapper).insert(cartCaptor.capture()); + CartDO cart = cartCaptor.getValue(); + assertEquals(userId, cart.getUserId()); + assertEquals(20L, cart.getSpuId()); + assertEquals(10L, cart.getSkuId()); + assertEquals(3, cart.getCount()); + assertTrue(cart.getSelected()); + verify(cartMapper, never()).updateById(any(CartDO.class)); + } + + @Test + public void testAddCart_mergeBelowStock() { + // 准备参数 + Long userId = 1L; + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(2); + // mock 数据 + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) + .setCount(3).setSelected(false); + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + + // 调用 + Long cartId = cartService.addCart(userId, reqVO); + // 断言 + assertEquals(100L, cartId); + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(CartDO.class); + verify(cartMapper).updateById(cartCaptor.capture()); + CartDO cart = cartCaptor.getValue(); + assertEquals(100L, cart.getId()); + assertEquals(5, cart.getCount()); + assertTrue(cart.getSelected()); + verify(cartMapper, never()).insert(any(CartDO.class)); + } + + @Test + public void testAddCart_mergeEqualsStock() { + // 准备参数 + Long userId = 1L; + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(4); + // mock 数据 + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) + .setCount(6).setSelected(true); + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + + // 调用 + Long cartId = cartService.addCart(userId, reqVO); + // 断言 + assertEquals(100L, cartId); + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(CartDO.class); + verify(cartMapper).updateById(cartCaptor.capture()); + assertEquals(10, cartCaptor.getValue().getCount()); + } + + @Test + public void testAddCart_mergeOverStock() { + // 准备参数:本次数量、已有数量都未超过库存,但合并后超过 + Long userId = 1L; + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(3); + // mock 数据 + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) + .setCount(8).setSelected(false); + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + + // 调用,并断言异常 + assertServiceException(() -> cartService.addCart(userId, reqVO), SKU_STOCK_NOT_ENOUGH); + // 断言:未写入,且原购物项未被修改 + verify(cartMapper, never()).updateById(any(CartDO.class)); + verify(cartMapper, never()).insert(any(CartDO.class)); + assertEquals(8, dbCart.getCount()); + assertFalse(dbCart.getSelected()); + } + + @Test + public void testAddCart_skuNotExists() { + // 准备参数 + Long userId = 1L; + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(1); + // mock 数据 + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) + .setCount(1).setSelected(true); + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); + // mock 方法:SKU 已被删除 + when(productSkuApi.getSku(eq(10L))).thenReturn(null); + + // 调用,并断言异常 + assertServiceException(() -> cartService.addCart(userId, reqVO), SKU_NOT_EXISTS); + // 断言:未写入 + verify(cartMapper, never()).updateById(any(CartDO.class)); + verify(cartMapper, never()).insert(any(CartDO.class)); + } + + @Test + public void testUpdateCartCount_equalsStock() { + // 准备参数 + Long userId = 1L; + AppCartUpdateCountReqVO reqVO = new AppCartUpdateCountReqVO().setId(100L).setCount(10); + // mock 数据 + when(cartMapper.selectById(eq(100L), eq(userId))).thenReturn(new CartDO().setId(100L).setUserId(userId) + .setSpuId(20L).setSkuId(10L).setCount(3).setSelected(true)); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + + // 调用 + cartService.updateCartCount(userId, reqVO); + // 断言 + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(CartDO.class); + verify(cartMapper).updateById(cartCaptor.capture()); + assertEquals(100L, cartCaptor.getValue().getId()); + assertEquals(10, cartCaptor.getValue().getCount()); + } + + @Test + public void testUpdateCartCount_overStock() { + // 准备参数 + Long userId = 1L; + AppCartUpdateCountReqVO reqVO = new AppCartUpdateCountReqVO().setId(100L).setCount(11); + // mock 数据 + when(cartMapper.selectById(eq(100L), eq(userId))).thenReturn(new CartDO().setId(100L).setUserId(userId) + .setSpuId(20L).setSkuId(10L).setCount(3).setSelected(true)); + // mock 方法 + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); + + // 调用,并断言异常 + assertServiceException(() -> cartService.updateCartCount(userId, reqVO), SKU_STOCK_NOT_ENOUGH); + // 断言:未写入 + verify(cartMapper, never()).updateById(any(CartDO.class)); + } + +} diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java index 9da83b1..e7903b3 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java @@ -97,7 +97,8 @@ public interface MemberUserMapper extends BaseMapperX { Assert.isTrue(incrCount < 0); LambdaUpdateWrapper lambdaUpdateWrapper = new LambdaUpdateWrapper() .setSql(" point = point + " + incrCount) // 负数,所以使用 + 号 - .eq(MemberUserDO::getId, id); + .eq(MemberUserDO::getId, id) + .ge(MemberUserDO::getPoint, -(long) incrCount); // 积分足够才扣减,避免并发扣减为负数;使用 long 避免取反溢出 return update(null, lambdaUpdateWrapper); } diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java index 8ab559f..62de421 100644 --- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java +++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java @@ -84,6 +84,7 @@ public class MemberPointRecordServiceImpl implements MemberPointRecordService { if (!success) { throw exception(USER_POINT_NOT_ENOUGH); } + totalPoint = memberUserService.getUser(userId).getPoint(); // 重新读取变动后的积分,避免并发时使用旧积分 // 3. 增加积分记录 MemberPointRecordDO record = new MemberPointRecordDO() diff --git a/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImplTest.java b/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImplTest.java new file mode 100644 index 0000000..735dbac --- /dev/null +++ b/yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImplTest.java @@ -0,0 +1,271 @@ +package cn.iocoder.yudao.module.member.service.point; + +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.member.dal.dataobject.point.MemberPointRecordDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.point.MemberPointRecordMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import cn.iocoder.yudao.module.member.service.user.MemberUserServiceImpl; +import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; +import cn.iocoder.yudao.module.system.api.social.SocialClientApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static cn.iocoder.yudao.module.member.enums.ErrorCodeConstants.USER_POINT_NOT_ENOUGH; +import static cn.iocoder.yudao.module.member.enums.point.MemberPointBizTypeEnum.SIGN; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link MemberPointRecordServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({MemberPointRecordServiceImpl.class, MemberUserServiceImpl.class}) +public class MemberPointRecordServiceImplTest extends BaseDbUnitTest { + + @Resource + private MemberPointRecordServiceImpl memberPointRecordService; + + @Resource + private MemberUserMapper memberUserMapper; + @Resource + private MemberPointRecordMapper memberPointRecordMapper; + @Resource + private PlatformTransactionManager transactionManager; + + @MockitoSpyBean + private MemberUserService memberUserService; + @MockitoBean + private SmsCodeApi smsCodeApi; + @MockitoBean + private SocialClientApi socialClientApi; + @MockitoBean + private OAuth2TokenCommonApi oauth2TokenApi; + @MockitoBean + private PasswordEncoder passwordEncoder; + @MockitoBean + private MemberUserProducer memberUserProducer; + + // ========== createPointRecord ========== + + @Test + public void testCreatePointRecord_concurrentDecrNotEnough() throws Exception { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用:两笔并发各扣 80 积分 + List errors = concurrentCreatePointRecord(user.getId(), -80); + // 断言:两笔都通过了预检查,但只有一笔扣减成功 + verify(memberUserService, times(2)).updateUserPoint(eq(user.getId()), eq(-80)); + assertEquals(1, errors.size()); + assertServiceException(() -> { + throw errors.get(0); + }, USER_POINT_NOT_ENOUGH); + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); + // 断言:失败的一笔整体回滚,只有成功的一笔流水 + List records = getPointRecordList(user.getId()); + assertEquals(1, records.size()); + assertEquals(-80, records.get(0).getPoint()); + assertEquals(20, records.get(0).getTotalPoint()); + } + + @Test + public void testCreatePointRecord_concurrentDecr() throws Exception { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用:两笔并发各扣 40 积分 + List errors = concurrentCreatePointRecord(user.getId(), -40); + // 断言:流水按实际扣减先后记录变动后的积分 + assertEquals(0, errors.size()); + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); + List records = getPointRecordList(user.getId()); + assertEquals(List.of(-40, -40), convertList(records, MemberPointRecordDO::getPoint)); + assertEquals(List.of(60, 20), convertList(records, MemberPointRecordDO::getTotalPoint)); + } + + @Test + public void testCreatePointRecord_concurrentIncr() throws Exception { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用:两笔并发各加 40 积分 + List errors = concurrentCreatePointRecord(user.getId(), 40); + // 断言:流水按实际增加先后记录变动后的积分 + assertEquals(0, errors.size()); + assertEquals(180, memberUserMapper.selectById(user.getId()).getPoint()); + List records = getPointRecordList(user.getId()); + assertEquals(List.of(40, 40), convertList(records, MemberPointRecordDO::getPoint)); + assertEquals(List.of(140, 180), convertList(records, MemberPointRecordDO::getTotalPoint)); + } + + @Test + public void testCreatePointRecord_notEnough() { + // mock 数据 + MemberUserDO user = createUser(20); + + // 调用:预检查积分不足,打印日志后直接返回 + memberPointRecordService.createPointRecord(user.getId(), -80, SIGN, randomString()); + // 断言 + verify(memberUserService, never()).updateUserPoint(anyLong(), anyInt()); + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); + assertEquals(0, getPointRecordList(user.getId()).size()); + } + + @Test + public void testCreatePointRecord_sameSqlSession() { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用:同一事务(SqlSession)内,先查询,再变动积分,再查询 + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + MemberUserDO cachedUser = memberUserMapper.selectById(user.getId()); + assertSame(cachedUser, memberUserMapper.selectById(user.getId())); // 命中一级缓存 + assertEquals(100, cachedUser.getPoint()); + + memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, randomString()); + + // 断言:UPDATE 清空了一级缓存,查询到自身写入的积分 + MemberUserDO latestUser = memberUserMapper.selectById(user.getId()); + assertNotSame(cachedUser, latestUser); + assertEquals(70, latestUser.getPoint()); + }); + // 断言:流水使用 UPDATE 后重新读取的积分 + verify(memberUserService, times(2)).getUser(eq(user.getId())); + List records = getPointRecordList(user.getId()); + assertEquals(1, records.size()); + assertEquals(70, records.get(0).getTotalPoint()); + assertEquals(70, memberUserMapper.selectById(user.getId()).getPoint()); + } + + @Test + public void testCreatePointRecord_insertFailRollback() { + // mock 数据 + MemberUserDO user = createUser(100); + // mock 方法:记录每次读取到的积分 + List readPoints = new CopyOnWriteArrayList<>(); + doAnswer(invocation -> { + MemberUserDO result = (MemberUserDO) invocation.callRealMethod(); + readPoints.add(result.getPoint()); + return result; + }).when(memberUserService).getUser(eq(user.getId())); + + // 调用:bizId 为空,违反 biz_id 非空约束,插入流水失败 + assertThrows(DataIntegrityViolationException.class, + () -> memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, null)); + // 断言:事务内积分已扣减为 70,随插入流水失败一起回滚 + assertEquals(List.of(100, 70), readPoints); + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); + assertEquals(0, getPointRecordList(user.getId()).size()); + } + + // ========== MemberUserMapper.updatePointDecr ========== + + @Test + public void testUpdatePointDecr_exact() { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用,并断言 + assertEquals(1, memberUserMapper.updatePointDecr(user.getId(), -100)); + assertEquals(0, memberUserMapper.selectById(user.getId()).getPoint()); + } + + @Test + public void testUpdatePointDecr_notEnough() { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用,并断言 + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), -101)); + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); + } + + @Test + public void testUpdatePointDecr_minValue() { + // mock 数据 + MemberUserDO user = createUser(100); + + // 调用,并断言:Integer.MIN_VALUE 取反不溢出,按积分不足处理 + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), Integer.MIN_VALUE)); + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); + } + + // ========== 私有方法 ========== + + /** + * 两个线程各自开启事务,都读取到变动前的积分后,再并发变动积分 + * + * @return 失败线程的异常 + */ + private List concurrentCreatePointRecord(Long userId, Integer point) throws Exception { + // 首次读取积分后,等待另一个线程也读取完成;UPDATE 后的重新读取不再等待 + CountDownLatch latch = new CountDownLatch(2); + doAnswer(invocation -> { + Object result = invocation.callRealMethod(); + latch.countDown(); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + return result; + }).when(memberUserService).getUser(eq(userId)); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + futures.add(executor.submit(() -> memberPointRecordService.createPointRecord( + userId, point, SIGN, randomString()))); + } + List errors = new ArrayList<>(); + for (Future future : futures) { + try { + future.get(30, TimeUnit.SECONDS); + } catch (ExecutionException ex) { + errors.add(ex.getCause()); + } + } + return errors; + } finally { + executor.shutdownNow(); + } + } + + private MemberUserDO createUser(Integer point) { + MemberUserDO user = MemberUserDO.builder().mobile("15601691300").status(CommonStatusEnum.ENABLE.getStatus()) + .registerIp("127.0.0.1").point(point).build(); + memberUserMapper.insert(user); + return user; + } + + private List getPointRecordList(Long userId) { + return memberPointRecordMapper.selectList(new LambdaQueryWrapperX() + .eq(MemberPointRecordDO::getUserId, userId) + .orderByAsc(MemberPointRecordDO::getId)); + } + +} diff --git a/yudao-module-member/src/test/resources/sql/clean.sql b/yudao-module-member/src/test/resources/sql/clean.sql index f972e04..ecf6432 100644 --- a/yudao-module-member/src/test/resources/sql/clean.sql +++ b/yudao-module-member/src/test/resources/sql/clean.sql @@ -2,4 +2,5 @@ DELETE FROM "member_user"; DELETE FROM "member_address"; DELETE FROM "member_tag"; DELETE FROM "member_level"; -DELETE FROM "member_group"; \ No newline at end of file +DELETE FROM "member_group"; +DELETE FROM "member_point_record"; \ No newline at end of file diff --git a/yudao-module-member/src/test/resources/sql/create_tables.sql b/yudao-module-member/src/test/resources/sql/create_tables.sql index 9775a30..54ab485 100644 --- a/yudao-module-member/src/test/resources/sql/create_tables.sql +++ b/yudao-module-member/src/test/resources/sql/create_tables.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS "member_user" "email" varchar(50) NULL COMMENT '邮箱', "password" varchar(100) NOT NULL DEFAULT '' COMMENT '密码', "register_ip" varchar(32) NOT NULL COMMENT '注册 IP', + "register_terminal" tinyint NULL DEFAULT NULL COMMENT '注册终端', "login_ip" varchar(50) NULL DEFAULT '' COMMENT '最后登录IP', "login_date" datetime NULL DEFAULT NULL COMMENT '最后登录时间', "tag_ids" varchar(255) NULL DEFAULT NULL COMMENT '用户标签编号列表,以逗号分隔', @@ -112,3 +113,22 @@ CREATE TABLE IF NOT EXISTS "member_brokerage_record" "tenant_id" bigint not null default '0', PRIMARY KEY ("id") ) COMMENT '佣金记录'; + +CREATE TABLE IF NOT EXISTS "member_point_record" +( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "biz_id" varchar(255) NOT NULL, + "biz_type" tinyint NOT NULL, + "title" varchar(255) NOT NULL, + "description" varchar(5000) NULL, + "point" int NOT NULL, + "total_point" int NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL default '0', + PRIMARY KEY ("id") +) COMMENT '用户积分记录'; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java index d1e4f1c..31c50fd 100644 --- a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java @@ -46,6 +46,7 @@ public class OaVehicleDO extends BaseDO { * * 关联 {@link DeptRespDTO#getId()} */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long deptId; /** * 车型 diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java index b028fcd..b5cf8a2 100644 --- a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java @@ -190,13 +190,13 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { // mock 数据:已有点评与本次追加内容按字符数计算 Long ownerId = randomLongId(); Long managerId = randomLongId(); - OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 998 - System.lineSeparator().length())); planMapper.insert(plan); // mock 方法 when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( Collections.singletonList(new AdminUserRespDTO().setId(ownerId))); - // 调用:包含换行后恰好达到 65535 个字符 + // 调用:包含换行后恰好达到 1000 个字符 planService.addPlanComment(plan.getId(), "ab", managerId); // 断言 @@ -209,7 +209,7 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { // mock 数据:已有点评与本次追加内容按字符数计算 Long ownerId = randomLongId(); Long managerId = randomLongId(); - OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 998 - System.lineSeparator().length())); planMapper.insert(plan); // mock 方法 when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( @@ -220,7 +220,7 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { () -> planService.addPlanComment(plan.getId(), "abc", managerId)); assertTrue(exception.getConstraintViolations().stream().anyMatch(violation -> "comment".equals(violation.getPropertyPath().toString()) - && "点评内容长度不能超过 65535 个字符".equals(violation.getMessage()))); + && "点评内容长度不能超过 1000 个字符".equals(violation.getMessage()))); assertEquals(plan.getComment(), planMapper.selectById(plan.getId()).getComment()); } diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java index 25314cc..0d58c77 100644 --- a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java @@ -64,7 +64,8 @@ public class OaVehicleReturnServiceImplTest extends BaseDbUnitTest { // mock 数据:实际提前出车,不能用计划时间代替事实 OaVehicleApplyDO apply = randomVehicleApplyDO(); OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class) - .setActualStartTime(apply.getStartTime().minusHours(2)).setActualReturnTime(apply.getStartTime().minusHours(1)) + .setActualStartTime(apply.getStartTime().minusHours(2).withNano(0)) + .setActualReturnTime(apply.getStartTime().minusHours(1).withNano(0)) .setStartLocation("客户现场").setReason("现场支持").setPassenger("李四"); // mock 方法 when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(apply); diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java index 5ac5fad..0728c68 100644 --- a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java @@ -16,9 +16,11 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Collections; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.DEPT_NOT_FOUND; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -187,6 +189,25 @@ public class OaVehicleServiceImplTest extends BaseDbUnitTest { } @Test + public void testUpdateVehicle_deptInvalid() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO().setDeptId(103L).setPicUrl("https://example.com/old.png"); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class) + .setDeptId(104L).setPicUrl("https://example.com/new.png"); + // mock 方法 + doThrow(exception(DEPT_NOT_FOUND)).when(deptApi).validateDeptList(Collections.singleton(104L)); + + // 调用,并断言异常 + assertServiceException(() -> vehicleService.updateVehicle(reqVO), DEPT_NOT_FOUND); + // 断言:部门校验失败时不更新车辆 + OaVehicleDO dbVehicle = vehicleMapper.selectById(vehicle.getId()); + assertEquals(103L, dbVehicle.getDeptId()); + assertEquals("https://example.com/old.png", dbVehicle.getPicUrl()); + } + + @Test public void testCreateVehicle_deletedPlateCanBeReused() { // mock 数据 OaVehicleDO vehicle = randomVehicleDO(); diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/app/order/AppPayOrderController.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/app/order/AppPayOrderController.java index 1de6ca9..3f7c2c9 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/app/order/AppPayOrderController.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/app/order/AppPayOrderController.java @@ -27,10 +27,12 @@ import org.springframework.web.bind.annotation.*; import java.util.Objects; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserType; +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.PAY_ORDER_NOT_FOUND; @Tag(name = "用户 APP - 支付订单") @RestController @@ -65,8 +67,7 @@ public class AppPayOrderController { return success(null); } // 重要:校验订单是否是当前用户,避免越权 - if (order.getUserId() != null // 特殊:早期订单未存储 userId,所以忽略 - && ObjUtil.notEqual(order.getUserId(), getLoginUserId())) { + if (!isLoginUserOrder(order)) { return success(null); } @@ -82,7 +83,13 @@ public class AppPayOrderController { @PostMapping("/submit") @Operation(summary = "提交支付订单") public CommonResult submitPayOrder(@RequestBody AppPayOrderSubmitReqVO reqVO) { - // 1. 钱包支付事,需要额外传 user_id 和 user_type + // 1. 重要:校验订单是否是当前用户,避免越权 + PayOrderDO order = payOrderService.getOrder(reqVO.getId()); + if (order == null || !isLoginUserOrder(order)) { + throw exception(PAY_ORDER_NOT_FOUND); + } + + // 2. 钱包支付事,需要额外传 user_id 和 user_type if (Objects.equals(reqVO.getChannelCode(), PayChannelEnum.WALLET.getCode())) { if (reqVO.getChannelExtras() == null) { reqVO.setChannelExtras(Maps.newHashMapWithExpectedSize(1)); @@ -91,9 +98,23 @@ public class AppPayOrderController { reqVO.getChannelExtras().put(WalletPayClient.WALLET_ID_KEY, String.valueOf(wallet.getId())); } - // 2. 提交支付 + // 3. 提交支付 PayOrderSubmitRespVO respVO = payOrderService.submitOrder(reqVO, getClientIP()); return success(BeanUtils.toBean(respVO, AppPayOrderSubmitRespVO.class)); } + /** + * 判断订单是否属于当前登录用户,需同时匹配用户编号和用户类型 + * + * 相关 issue:https://github.com/YunaiV/ruoyi-vue-pro/issues/1303 + * + * @param order 支付订单 + * @return 是否属于 + */ + private boolean isLoginUserOrder(PayOrderDO order) { + // 特殊:早期订单未存储 userId、userType,所以忽略 + return (order.getUserId() == null || ObjUtil.equal(order.getUserId(), getLoginUserId())) + && (order.getUserType() == null || ObjUtil.equal(order.getUserType(), getLoginUserType())); + } + } diff --git a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java index 50673b5..b25e7d0 100644 --- a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java +++ b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java @@ -167,20 +167,65 @@ public class JmReportTokenServiceImpl implements JmReportTokenServiceI { TenantContextHolder.setTenantId(loginUser.getTenantId()); // 参见文档 https://help.jimureport.com/prodSafe/ 文档 - // 适配:如果是本系统的管理员,则返回积木报表(仪表盘/大屏设计器)的所有权限指令 + // 适配:如果是本系统的管理员,则返回积木报表(仪表盘/大屏设计器、报表设计器)的所有权限指令 // 如果不处理,会碰到 https://t.zsxq.com/yzlkA 反馈的问题 + // 权限清单参考 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1600 Long userId = SecurityFrameworkUtils.getLoginUserId(); if (permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())) { return new String[]{ - "drag:datasource:testConnection", // 数据库连接测试 - "drag:datasource:saveOrUpate", // 数据源保存 - "drag:datasource:delete", // 数据源删除 - "drag:analysis:sql", // SQL解析 - "drag:design:getTotalData", // 展示Online表单数据 - "drag:dataset:save", // 数据集保存 - "drag:dataset:delete", // 数据集删除 - "onl:drag:clear:recovery", // 清空回收站 - "onl:drag:page:delete" // 数据删除 + // ========== 第一块:大屏(仪表盘 BI)按钮权限 ========== + "drag:datasource:testConnection", // 数据源连接测试 + "drag:datasource:saveOrUpate", // 数据源新增/编辑 + "drag:datasource:delete", // 数据源删除 + "drag:datasource:deleteBatch", // 数据源批量删除 + "drag:datasource:queryById", // 数据源详情查询 + "drag:dataset:save", // 数据集保存 + "drag:dataset:delete", // 数据集删除 + "drag:analysis:sql", // SQL解析 + "drag:design:getTotalData", // 仪表盘对Online表单展示数据 + "drag:iconlib:config", // 图标库配置 + "onl:drag:clear:recovery", // 清空回收站 + "onl:drag:page:delete", // 仪表盘页面删除 + "onl:drag:category:delete", // 仪表盘分类删除 + "onl:drag:comp:add", // 组件新增 + "onl:drag:comp:edit", // 组件编辑 + "onl:drag:comp:delete", // 组件删除 + "onl:drag:comp:deleteBatch", // 组件批量删除 + "onl:drag:getRawTableData", // 获取原始表数据 + // ========== 第二块:积木报表按钮权限 ========== + "jmreport:exportJob:plugin", // 导出任务-插件 + "jmreport:exportJob:list", // 导出任务-列表 + "jmreport:exportJob:save", // 导出任务-保存 + "jmreport:exportJob:detail", // 导出任务-详情 + "jmreport:exportJob:delete", // 导出任务-删除 + "jmreport:exportJob:status", // 导出任务-状态变更 + "jmreport:exportJob:run", // 导出任务-执行 + "jmreport:exportJob:export", // 导出任务-导出 + "jmreport:map:add", // 地图-新增 + "jmreport:map:delete", // 地图-删除 + "jmreport:reportGroup:list", // 报表分组-列表 + "jmreport:reportGroup:detail", // 报表分组-详情 + "jmreport:reportGroup:save", // 报表分组-保存 + "jmreport:reportGroup:delete", // 报表分组-删除 + "jmreport:category:add", // 报表分类-新增 + "jmreport:category:edit", // 报表分类-编辑 + "jmreport:category:delete", // 报表分类-删除 + "jmreport:category:reduction", // 报表分类-还原 + "jmreport:category:completelyDelete", // 报表分类-彻底删除 + "jmreport:category:batchMove", // 报表分类-批量移动 + "jmreport:category:folderSort", // 报表分类-文件夹排序 + "jmreport:datasource:testConnection", // 数据源-连接测试 + "jmreport:datasource:saveOrUpate", // 数据源-新增/编辑 + "jmreport:datasource:delete", // 数据源-删除 + "jmreport:datasource:query", // 数据源-查询(列表/详情) + "jmreport:design:loadTableData", // 设计器-加载表数据 + "jmreport:design:parseField", // 设计器-解析字段(SQL/API/JavaBean/填报处理器) + "jmreport:design:loadTable", // 设计器-库表/字段元数据查询 + "jmreport:design:saveDb", // 设计器-数据集保存 + "jmreport:design:filesDataset", // 设计器-文件数据集 + "jmreport:design:editEnhance", // 设计器-增强代码(JS/CSS/Python) + "jmreport:ai:tableMeta", // AI-表元数据管理 + "jimu:chat2bi:permEdit" // Chat2BI-权限编辑 }; } return null; diff --git a/yudao-module-report/src/test/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImplTest.java b/yudao-module-report/src/test/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImplTest.java new file mode 100644 index 0000000..6e8910c --- /dev/null +++ b/yudao-module-report/src/test/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImplTest.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.report.framework.jmreport.core.service; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; +import cn.iocoder.yudao.framework.security.config.SecurityProperties; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link JmReportTokenServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +public class JmReportTokenServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private JmReportTokenServiceImpl jmReportTokenService; + + @Mock + private OAuth2TokenCommonApi oauth2TokenApi; + @Mock + private PermissionCommonApi permissionApi; + @Mock + private SecurityProperties securityProperties; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @Test + public void testGetPermissions_superAdmin() { + // 准备参数 + Long userId = randomLongId(); + Long tenantId = randomLongId(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), + new MockHttpServletRequest()); + // mock 方法 + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(true); + + // 调用 + String[] permissions = jmReportTokenService.getPermissions("token"); + // 断言:原有的大屏权限仍保留 + assertTrue(ArrayUtil.containsAll(permissions, "drag:datasource:testConnection", "drag:analysis:sql", + "onl:drag:page:delete")); + // 断言:大屏只校验权限、未配置时超级管理员会被拒绝的三处 + assertTrue(ArrayUtil.containsAll(permissions, "drag:datasource:queryById", "drag:datasource:deleteBatch", + "onl:drag:getRawTableData")); + // 断言:报表设计器的扩展权限 + assertTrue(ArrayUtil.containsAll(permissions, "jmreport:datasource:query", "jmreport:exportJob:export", + "jmreport:ai:tableMeta")); + assertEquals(permissions.length, ArrayUtil.distinct(permissions).length); + assertEquals(tenantId, TenantContextHolder.getTenantId()); + } + + @Test + public void testGetPermissions_notSuperAdmin() { + // 准备参数 + Long userId = randomLongId(); + Long tenantId = randomLongId(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), + new MockHttpServletRequest()); + // mock 方法 + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(false); + + // 调用 + String[] permissions = jmReportTokenService.getPermissions("token"); + // 断言 + assertNull(permissions); + assertEquals(tenantId, TenantContextHolder.getTenantId()); + } + + @Test + public void testGetPermissions_notLogin() { + // 调用 + String[] permissions = jmReportTokenService.getPermissions("token"); + // 断言 + assertNull(permissions); + assertNull(TenantContextHolder.getTenantId()); + verifyNoInteractions(permissionApi); + } + + @Test + public void testGetRoles_superAdmin() { + // 准备参数 + Long userId = randomLongId(); + Long tenantId = randomLongId(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), + new MockHttpServletRequest()); + // mock 方法 + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(true); + + // 调用 + String[] roles = jmReportTokenService.getRoles("token"); + // 断言 + assertArrayEquals(new String[]{"admin"}, roles); + assertEquals(tenantId, TenantContextHolder.getTenantId()); + } + + @Test + public void testGetRoles_notSuperAdmin() { + // 准备参数 + Long userId = randomLongId(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(randomLongId()), + new MockHttpServletRequest()); + // mock 方法 + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(false); + + // 调用 + String[] roles = jmReportTokenService.getRoles("token"); + // 断言 + assertNull(roles); + } + + @Test + public void testGetRoles_notLogin() { + // 调用 + String[] roles = jmReportTokenService.getRoles("token"); + // 断言 + assertNull(roles); + verifyNoInteractions(permissionApi); + } + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java index 7a243b7..ea2b4ff 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java @@ -21,6 +21,7 @@ import jakarta.validation.Valid; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - 部门") @RestController @@ -61,7 +62,7 @@ public class DeptController { @Parameter(name = "ids", description = "编号列表", required = true) @PreAuthorize("@ss.hasPermission('system:dept:delete')") public CommonResult deleteDeptList(@RequestParam("ids") List ids) { - deptService.deleteDeptList(ids); + deptService.deleteDeptList(ids, getLoginUserId()); return success(true); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index 459f395..7737053 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -57,6 +57,7 @@ public interface ErrorCodeConstants { ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择"); ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门"); + ErrorCode DEPT_NOT_IN_SCOPE = new ErrorCode(1_002_004_008, "部门({})不在数据权限范围内,禁止操作"); // ========== 岗位模块 1-002-005-000 ========== ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在"); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java index 57cec8d..0854146 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java @@ -40,8 +40,9 @@ public interface DeptService { * 批量删除部门 * * @param ids 部门编号数组 + * @param userId 操作的用户编号,用于校验数据权限 */ - void deleteDeptList(List ids); + void deleteDeptList(List ids, Long userId); /** * 获得部门信息 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java index 49db60e..fb6ee6d 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java @@ -2,18 +2,22 @@ package cn.iocoder.yudao.module.system.service.dept; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission; +import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants; +import cn.iocoder.yudao.module.system.service.permission.PermissionService; import com.google.common.annotations.VisibleForTesting; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -22,6 +26,7 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck; import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; /** @@ -37,6 +42,10 @@ public class DeptServiceImpl implements DeptService { @Resource private DeptMapper deptMapper; + @Resource + @Lazy // 延迟,避免循环依赖报错 + private PermissionService permissionService; + @Override @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 @@ -91,7 +100,9 @@ public class DeptServiceImpl implements DeptService { @Override @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 - public void deleteDeptList(List ids) { + public void deleteDeptList(List ids, Long userId) { + // 校验数据权限 + validateDeptListDataScope(ids, userId); // 校验是否有子部门 for (Long id : ids) { if (deptMapper.selectCountByParentId(id) > 0) { @@ -103,6 +114,41 @@ public class DeptServiceImpl implements DeptService { deptMapper.deleteByIds(ids); } + /** + * 校验部门们是否都在用户的数据权限范围内,任一不在则整体拒绝 + * + * 原因:数据权限插件只会删除范围内的部门,导致批量删除时“部分成功” + * 相关 PR:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1582 + * + * @param ids 部门编号数组 + * @param userId 用户编号 + */ + @VisibleForTesting + void validateDeptListDataScope(Collection ids, Long userId) { + if (CollUtil.isEmpty(ids)) { + return; + } + // 特殊:跨租户访问、忽略数据权限时,与数据权限插件保持一致,无需校验 + if (skipPermissionCheck()) { + return; + } + DataPermission dataPermission = DataPermissionContextHolder.get(); + if (dataPermission != null && !dataPermission.enable()) { + return; + } + + // 校验每个部门:与 DeptDataPermissionRule 对部门表的过滤保持一致 + DeptDataPermissionRespDTO deptDataPermission = permissionService.getDeptDataPermission(userId); + if (Boolean.TRUE.equals(deptDataPermission.getAll())) { + return; + } + for (Long id : ids) { + if (!CollUtil.contains(deptDataPermission.getDeptIds(), id)) { + throw exception(DEPT_NOT_IN_SCOPE, id); + } + } + } + @VisibleForTesting void validateDeptExists(Long id) { if (id == null) { diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java index df16d4f..e974820 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java @@ -1,26 +1,39 @@ package cn.iocoder.yudao.module.system.service.dept; +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; +import cn.iocoder.yudao.module.system.service.permission.PermissionService; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import jakarta.annotation.Resource; import java.util.Arrays; import java.util.List; import java.util.Set; +import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; /** * {@link DeptServiceImpl} 的单元测试类 @@ -35,6 +48,9 @@ public class DeptServiceImplTest extends BaseDbUnitTest { @Resource private DeptMapper deptMapper; + @MockitoBean + private PermissionService permissionService; + @Test public void testCreateDept() { // 准备参数 @@ -113,12 +129,19 @@ public class DeptServiceImplTest extends BaseDbUnitTest { deptMapper.insert(deptDO2); // 准备参数 List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限包含全部部门) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); // 调用 - deptService.deleteDeptList(ids); + deptService.deleteDeptList(ids, userId); // 校验数据不存在了 assertNull(deptMapper.selectById(deptDO1.getId())); assertNull(deptMapper.selectById(deptDO2.getId())); + // 校验使用传入的用户编号,获取数据权限 + verify(permissionService).getDeptDataPermission(eq(userId)); } @Test @@ -136,9 +159,138 @@ public class DeptServiceImplTest extends BaseDbUnitTest { // 准备参数(包含有子部门的 parentDept) List ids = Arrays.asList(parentDept.getId(), anotherDept.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为全部) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setAll(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用, 并断言异常 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_EXITS_CHILDREN); + } + + @Test + public void testDeleteDeptList_notInScope() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数 + List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限只包含 deptDO1) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); // 调用, 并断言异常 - assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN); + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO2.getId()); + // 校验整体拒绝,范围内的部门也未被删除 + assertNotNull(deptMapper.selectById(deptDO1.getId())); + assertNotNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_selfScope() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为仅本人,部门表无对应的用户字段,无法操作任何部门) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setSelf(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用, 并断言异常 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO.getId()); + assertNotNull(deptMapper.selectById(deptDO.getId())); + } + + @Test + public void testDeleteDeptList_allScope() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数 + List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为全部,deptIds 为空) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setAll(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用 + deptService.deleteDeptList(ids, userId); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO1.getId())); + assertNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_duplicateIds() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数(deptDO1 重复) + List ids = Arrays.asList(deptDO1.getId(), deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用 + deptService.deleteDeptList(ids, userId); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO1.getId())); + assertNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_visitTenant() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + // mock 登录用户(跨租户访问) + LoginUser loginUser = new LoginUser().setId(userId).setUserType(UserTypeEnum.ADMIN.getValue()) + .setTenantId(1L).setVisitTenantId(2L); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + + try { + // 调用 + deptService.deleteDeptList(ids, userId); + } finally { + SecurityContextHolder.clearContext(); + } + // 校验数据不存在了,且未获取目标租户的数据权限 + assertNull(deptMapper.selectById(deptDO.getId())); + verify(permissionService, never()).getDeptDataPermission(anyLong()); + } + + @Test + public void testDeleteDeptList_ignoreDataPermission() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + + // 调用(忽略数据权限) + DataPermissionUtils.executeIgnore(() -> deptService.deleteDeptList(ids, userId)); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO.getId())); + verify(permissionService, never()).getDeptDataPermission(anyLong()); } @Test diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImplTest.java index 9773917..8b8de87 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImplTest.java @@ -223,7 +223,7 @@ public class OAuth2ApproveServiceImplTest extends BaseDbUnitTest { String clientId = randomString(); String scope = randomString(); Boolean approved = randomBoolean(); - LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()); + LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()).withNano(0); // mock 方法 // 调用 @@ -251,7 +251,7 @@ public class OAuth2ApproveServiceImplTest extends BaseDbUnitTest { String clientId = approve.getClientId(); String scope = approve.getScope(); Boolean approved = randomBoolean(); - LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()); + LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()).withNano(0); // mock 方法 // 调用 diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImplTest.java index 36b69c7..487667f 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImplTest.java @@ -311,7 +311,7 @@ public class OAuth2TokenServiceImplTest extends BaseDbAndRedisUnitTest { // 测试 userType 不匹配 oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setClientId("it_client"))); // 测试 expireTime 不匹配 - oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setExpiresTime(LocalDateTimeUtil.now()))); + oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setExpiresTime(LocalDateTimeUtil.now().minusDays(1)))); // 准备参数 OAuth2AccessTokenPageReqVO reqVO = new OAuth2AccessTokenPageReqVO(); reqVO.setUserId(10L);