Commit 21201e122486b09edc69265834ba1e116445de14
Merge remote-tracking branch 'origin/master-jdk17' into master-jdk17
Showing
35 changed files
with
1414 additions
and
122 deletions
sql/tools/convertor.py
| ... | ... | @@ -23,7 +23,7 @@ import re |
| 23 | 23 | import sys |
| 24 | 24 | import time |
| 25 | 25 | from abc import ABC, abstractmethod |
| 26 | -from typing import Dict, Generator, Optional, Tuple, Union | |
| 26 | +from typing import Dict, Generator, List, Optional, Tuple, Union | |
| 27 | 27 | |
| 28 | 28 | from simple_ddl_parser import DDLParser |
| 29 | 29 | |
| ... | ... | @@ -43,6 +43,52 @@ PREAMBLE = """/* |
| 43 | 43 | """ |
| 44 | 44 | |
| 45 | 45 | |
| 46 | +# 依次匹配:引号字符串(支持反斜杠转义、双写引号)、注释、语句分隔符;未闭合的引号吞掉剩余内容 | |
| 47 | +SQL_TOKEN_PATTERN = re.compile( | |
| 48 | + r"'(?:\\[\s\S]|''|[^'\\])*'" | |
| 49 | + r'|"(?:\\[\s\S]|""|[^"\\])*"' | |
| 50 | + r"|`(?:``|[^`])*`" | |
| 51 | + r"|--(?=\s|\Z)[^\n]*|#[^\n]*|/\*[\s\S]*?(?:\*/|\Z)" | |
| 52 | + r"|['\"`][\s\S]*" | |
| 53 | + r"|;" | |
| 54 | +) | |
| 55 | +# 语句开头的空白、注释 | |
| 56 | +SQL_LEADING_PATTERN = re.compile(r"(?:\s|--(?=\s|\Z)[^\n]*|#[^\n]*|/\*[\s\S]*?(?:\*/|\Z))*") | |
| 57 | +CREATE_TABLE_PATTERN = re.compile(r"CREATE\s+TABLE\s", flags=re.IGNORECASE) | |
| 58 | + | |
| 59 | + | |
| 60 | +def split_statements(content: str) -> List[str]: | |
| 61 | + """按分号拆分 SQL 语句,忽略引号、注释内的分号 | |
| 62 | + | |
| 63 | + 仅用于定位顶层语句,不是完整的 SQL 解析器。返回的语句保留原文,去掉开头的空白、注释,以分号结尾 | |
| 64 | + | |
| 65 | + Args: | |
| 66 | + content (str): sql 内容 | |
| 67 | + | |
| 68 | + Returns: | |
| 69 | + List[str]: 语句列表 | |
| 70 | + """ | |
| 71 | + statements = [] | |
| 72 | + start = 0 | |
| 73 | + for match in SQL_TOKEN_PATTERN.finditer(content): | |
| 74 | + if match.group() != ";": | |
| 75 | + continue | |
| 76 | + statement = content[start:match.end()] | |
| 77 | + start = match.end() | |
| 78 | + statement = statement[SQL_LEADING_PATTERN.match(statement).end():] | |
| 79 | + if statement != ";": | |
| 80 | + statements.append(statement) | |
| 81 | + return statements | |
| 82 | + | |
| 83 | + | |
| 84 | +def table_scripts(content: str) -> List[str]: | |
| 85 | + """获得真实的 CREATE TABLE 语句,不包括 INSERT 数据、注释中的建表文本 | |
| 86 | + | |
| 87 | + 相关 issue:https://github.com/YunaiV/ruoyi-vue-pro/issues/1273 | |
| 88 | + """ | |
| 89 | + return [statement for statement in split_statements(content) if CREATE_TABLE_PATTERN.match(statement)] | |
| 90 | + | |
| 91 | + | |
| 46 | 92 | def load_and_clean(sql_file: str) -> str: |
| 47 | 93 | """加载源 SQL 文件,并清理内容方便下一步 ddl 解析 |
| 48 | 94 | |
| ... | ... | @@ -52,6 +98,19 @@ def load_and_clean(sql_file: str) -> str: |
| 52 | 98 | Returns: |
| 53 | 99 | str: 清理后的sql文件内容 |
| 54 | 100 | """ |
| 101 | + # utf-8-sig:只去掉文件开头的 BOM,避免首条语句无法识别 | |
| 102 | + return clean(open(sql_file, encoding="utf-8-sig").read()) | |
| 103 | + | |
| 104 | + | |
| 105 | +def clean(content: str) -> str: | |
| 106 | + """清理 SQL 内容方便下一步 ddl 解析 | |
| 107 | + | |
| 108 | + Args: | |
| 109 | + content (str): sql 内容 | |
| 110 | + | |
| 111 | + Returns: | |
| 112 | + str: 清理后的sql内容 | |
| 113 | + """ | |
| 55 | 114 | REPLACE_PAIR_LIST = ( |
| 56 | 115 | (")\nVALUES ", ") VALUES "), |
| 57 | 116 | (" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ", " "), |
| ... | ... | @@ -62,7 +121,6 @@ def load_and_clean(sql_file: str) -> str: |
| 62 | 121 | ("b'1'", "'1'"), |
| 63 | 122 | ) |
| 64 | 123 | |
| 65 | - content = open(sql_file, encoding="utf-8").read() | |
| 66 | 124 | for replace_pair in REPLACE_PAIR_LIST: |
| 67 | 125 | content = content.replace(*replace_pair) |
| 68 | 126 | # 移除所有 CHARACTER SET / COLLATE 变体 (utf8mb3、utf8 等) |
| ... | ... | @@ -88,10 +146,14 @@ class Convertor(ABC): |
| 88 | 146 | self.db_type = db_type |
| 89 | 147 | self.content = load_and_clean(self.src) |
| 90 | 148 | # original_content 保留原始 COMMENT 信息,用于注释提取 |
| 91 | - self.original_content = open(src, encoding="utf-8").read() | |
| 92 | - # 剥离列级 COMMENT 以避免 COMMENT 值内的分号截断 CREATE TABLE 正则 | |
| 93 | - content_no_comment = re.sub(r" COMMENT '(?:[^'\\]|\\.)*'", "", self.content) | |
| 94 | - self.table_script_list = re.findall(r"CREATE TABLE [^;]*;", content_no_comment) | |
| 149 | + self.original_content = open(src, encoding="utf-8-sig").read() | |
| 150 | + # 在原始内容上扫描建表语句,避免全局清理破坏引号边界;每个建表语句再单独清理 | |
| 151 | + self.original_table_script_list = table_scripts(self.original_content) | |
| 152 | + # 剥离列级 COMMENT,交给 DDLParser 解析 | |
| 153 | + self.table_script_list = [ | |
| 154 | + re.sub(r" COMMENT '(?:[^'\\]|\\.)*'", "", clean(table_sql)) | |
| 155 | + for table_sql in self.original_table_script_list | |
| 156 | + ] | |
| 95 | 157 | |
| 96 | 158 | @abstractmethod |
| 97 | 159 | def translate_type(self, type: str, size: Optional[Union[int, Tuple[int]]]) -> str: |
| ... | ... | @@ -330,7 +392,7 @@ class Convertor(ABC): |
| 330 | 392 | ) |
| 331 | 393 | |
| 332 | 394 | error_scripts = [] |
| 333 | - for table_sql in self.table_script_list: | |
| 395 | + for table_sql, orig_table_sql in zip(self.table_script_list, self.original_table_script_list): | |
| 334 | 396 | # 剥离 COMMENT 子句避免 DDLParser 无法处理中文等特殊字符 |
| 335 | 397 | table_sql_for_parse = re.sub(r"\s+COMMENT\s+'[^']*'", "", table_sql) |
| 336 | 398 | ddl = DDLParser(table_sql_for_parse.replace("`", "")).run() |
| ... | ... | @@ -354,14 +416,7 @@ class Convertor(ABC): |
| 354 | 416 | continue |
| 355 | 417 | |
| 356 | 418 | # 解析注释 |
| 357 | - # 从原始 SQL 提取注释(支持中文等 DDLParser 不能处理的内容) | |
| 358 | - # 按表名定位完整建表段(以「换行 + )」起始的 ENGINE 行作为终止),避免被列 COMMENT 内的分号截断 | |
| 359 | - orig_match = re.search( | |
| 360 | - rf"CREATE TABLE\s+`{re.escape(table_name)}`[\s\S]*?\n\)[^;]*;", | |
| 361 | - self.original_content, | |
| 362 | - flags=re.IGNORECASE, | |
| 363 | - ) | |
| 364 | - orig_table_sql = orig_match.group() if orig_match else table_sql | |
| 419 | + # 从原始建表语句提取注释(支持中文等 DDLParser 不能处理的内容),避免 INSERT 数据中的同名建表文本干扰 | |
| 365 | 420 | comments_dict = dict(Convertor.filed_comments(orig_table_sql)) |
| 366 | 421 | for column in table_ddl["columns"]: |
| 367 | 422 | column["comment"] = comments_dict.get(column["name"], "") | ... | ... |
sql/tools/test_convertor.py
0 → 100644
| 1 | +# encoding=utf8 | |
| 2 | +"""convertor.py 的单元测试 | |
| 3 | + | |
| 4 | +cd sql/tools | |
| 5 | +uv run --with simple-ddl-parser python -m unittest test_convertor -v | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import contextlib | |
| 9 | +import io | |
| 10 | +import os | |
| 11 | +import re | |
| 12 | +import subprocess | |
| 13 | +import sys | |
| 14 | +import tempfile | |
| 15 | +import unittest | |
| 16 | + | |
| 17 | +from convertor import ( | |
| 18 | + DM8Convertor, | |
| 19 | + HighGoConvertor, | |
| 20 | + KingbaseConvertor, | |
| 21 | + OpengaussConvertor, | |
| 22 | + OracleConvertor, | |
| 23 | + PostgreSQLConvertor, | |
| 24 | + SQLServerConvertor, | |
| 25 | + split_statements, | |
| 26 | +) | |
| 27 | + | |
| 28 | +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| 29 | +MYSQL_DIR = os.path.join(TOOLS_DIR, "..", "mysql") | |
| 30 | + | |
| 31 | +HEADER = """/* | |
| 32 | + Navicat Premium Data Transfer | |
| 33 | + | |
| 34 | + Source Server Type : MySQL | |
| 35 | +*/ | |
| 36 | + | |
| 37 | +SET NAMES utf8mb4; | |
| 38 | +SET FOREIGN_KEY_CHECKS = 0; | |
| 39 | +""" | |
| 40 | + | |
| 41 | +DEMO_TABLE = """ | |
| 42 | +-- ---------------------------- | |
| 43 | +-- Table structure for demo | |
| 44 | +-- ---------------------------- | |
| 45 | +DROP TABLE IF EXISTS `demo`; | |
| 46 | +CREATE TABLE `demo` ( | |
| 47 | + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 48 | + `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称;别名', | |
| 49 | + PRIMARY KEY (`id`) USING BTREE | |
| 50 | +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '演示表'; | |
| 51 | + | |
| 52 | +-- ---------------------------- | |
| 53 | +-- Records of demo | |
| 54 | +-- ---------------------------- | |
| 55 | +BEGIN; | |
| 56 | +INSERT INTO `demo` (`id`, `name`) VALUES (1, 'a;b'); | |
| 57 | +COMMIT; | |
| 58 | +""" | |
| 59 | + | |
| 60 | +DEMO_DOC_TABLE = """ | |
| 61 | +DROP TABLE IF EXISTS `demo_doc`; | |
| 62 | +CREATE TABLE `demo_doc` ( | |
| 63 | + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 64 | + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '内容', | |
| 65 | + PRIMARY KEY (`id`) USING BTREE | |
| 66 | +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文档表'; | |
| 67 | +""" | |
| 68 | + | |
| 69 | +# INSERT 数据中的建表文本:转义换行(issue #1273 的实际场景) | |
| 70 | +ESCAPED_NEWLINE_INSERT = r""" | |
| 71 | +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'); | |
| 72 | +""" | |
| 73 | + | |
| 74 | +# INSERT 数据中的建表文本:真实换行 | |
| 75 | +LITERAL_NEWLINE_INSERT = """ | |
| 76 | +INSERT INTO `demo_doc` (`id`, `content`) VALUES (2, 'line1 | |
| 77 | +CREATE TABLE `ghost` ( | |
| 78 | + `id` bigint NOT NULL | |
| 79 | +); | |
| 80 | +line3'); | |
| 81 | +""" | |
| 82 | + | |
| 83 | +# 注释中的建表文本 | |
| 84 | +COMMENTED_DDL = """ | |
| 85 | +/* CREATE TABLE `ghost_block` (`id` bigint NOT NULL); */ | |
| 86 | +-- CREATE TABLE `ghost_line` (`id` bigint NOT NULL); | |
| 87 | +# CREATE TABLE `ghost_hash` (`id` bigint NOT NULL); | |
| 88 | +""" | |
| 89 | + | |
| 90 | + | |
| 91 | +def table_names(table_script_list): | |
| 92 | + return [re.match(r"CREATE\s+TABLE\s+`([^`]+)`", script).group(1) for script in table_script_list] | |
| 93 | + | |
| 94 | + | |
| 95 | +def convert(convertor): | |
| 96 | + """执行转换,返回 stdout、stderr 的内容""" | |
| 97 | + out, err = io.StringIO(), io.StringIO() | |
| 98 | + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): | |
| 99 | + convertor.print() | |
| 100 | + return out.getvalue(), err.getvalue() | |
| 101 | + | |
| 102 | + | |
| 103 | +class SplitStatementsTest(unittest.TestCase): | |
| 104 | + | |
| 105 | + def test_semicolon_in_quotes(self): | |
| 106 | + sql = r"""INSERT INTO `t` VALUES ('a;b', "c;d", 'it\'s;', 'x''y;', "q\";", 'C:\\'); | |
| 107 | +INSERT INTO `we;ird` VALUES (1);""" | |
| 108 | + self.assertEqual(sql.split("\n"), split_statements(sql)) | |
| 109 | + | |
| 110 | + def test_semicolon_in_comments(self): | |
| 111 | + sql = """-- comment; here | |
| 112 | +# hash; it's | |
| 113 | +/* block; here */ SELECT 1; | |
| 114 | +CREATE TABLE `c` ( | |
| 115 | + `id` bigint -- 编号; 注释 | |
| 116 | +);""" | |
| 117 | + self.assertEqual(["SELECT 1;", "CREATE TABLE `c` (\n `id` bigint -- 编号; 注释\n);"], split_statements(sql)) | |
| 118 | + | |
| 119 | + def test_double_dash_without_space(self): | |
| 120 | + # MySQL 中 -- 后面必须跟空白,才是注释 | |
| 121 | + self.assertEqual(["SELECT 5--2;"], split_statements("SELECT 5--2;")) | |
| 122 | + | |
| 123 | + def test_multiple_statements_one_line(self): | |
| 124 | + self.assertEqual(["SET NAMES utf8mb4;", "SET FOREIGN_KEY_CHECKS = 0;"], | |
| 125 | + split_statements("SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0;")) | |
| 126 | + | |
| 127 | + def test_unterminated(self): | |
| 128 | + # 未闭合的引号、缺少分号的结尾,都不作为语句 | |
| 129 | + self.assertEqual(["SELECT 1;"], split_statements("SELECT 1; INSERT INTO `t` VALUES ('a;")) | |
| 130 | + self.assertEqual(["SELECT 1;"], split_statements("SELECT 1;\nSELECT 2")) | |
| 131 | + | |
| 132 | + | |
| 133 | +class ConvertorTableScanTest(unittest.TestCase): | |
| 134 | + | |
| 135 | + def setUp(self): | |
| 136 | + directory = tempfile.TemporaryDirectory() | |
| 137 | + self.addCleanup(directory.cleanup) | |
| 138 | + self.directory = directory.name | |
| 139 | + | |
| 140 | + def write_sql(self, content): | |
| 141 | + path = os.path.join(self.directory, "dump.sql") | |
| 142 | + with open(path, "w", encoding="utf-8") as f: | |
| 143 | + f.write(content) | |
| 144 | + return path | |
| 145 | + | |
| 146 | + def test_create_table_in_insert_ignored(self): | |
| 147 | + path = self.write_sql(HEADER + DEMO_DOC_TABLE + ESCAPED_NEWLINE_INSERT + LITERAL_NEWLINE_INSERT | |
| 148 | + + COMMENTED_DDL + DEMO_TABLE) | |
| 149 | + for convertor_class in (PostgreSQLConvertor, KingbaseConvertor, OpengaussConvertor, HighGoConvertor, | |
| 150 | + OracleConvertor, SQLServerConvertor, DM8Convertor): | |
| 151 | + with self.subTest(convertor_class.__name__): | |
| 152 | + convertor = convertor_class(path) | |
| 153 | + self.assertEqual(["demo_doc", "demo"], table_names(convertor.table_script_list)) | |
| 154 | + | |
| 155 | + out, err = convert(convertor) | |
| 156 | + self.assertNotIn("无法正常解析", err) | |
| 157 | + self.assertIn("Table structure for demo_doc", out) | |
| 158 | + self.assertIn("Table structure for demo\n", out) | |
| 159 | + for ghost in ("undo_log", "ghost", "ghost_block", "ghost_line", "ghost_hash"): | |
| 160 | + self.assertNotIn(f"Table structure for {ghost}\n", out) | |
| 161 | + | |
| 162 | + def test_comment_from_real_table(self): | |
| 163 | + # 前面 INSERT 数据中存在同名的建表文本,不能影响真实建表语句的注释 | |
| 164 | + fake_insert = """ | |
| 165 | +INSERT INTO `demo_doc` (`id`, `content`) VALUES (1, 'CREATE TABLE `demo` ( | |
| 166 | + `name` varchar(64) NOT NULL COMMENT \\'假名称\\', | |
| 167 | + PRIMARY KEY (`id`) | |
| 168 | +) ENGINE = InnoDB COMMENT = \\'假表\\'; | |
| 169 | +'); | |
| 170 | +""" | |
| 171 | + path = self.write_sql(HEADER + DEMO_DOC_TABLE + fake_insert + DEMO_TABLE) | |
| 172 | + convertor = PostgreSQLConvertor(path) | |
| 173 | + self.assertEqual(["demo_doc", "demo"], table_names(convertor.table_script_list)) | |
| 174 | + # 保留原始建表语句 | |
| 175 | + self.assertIn("COMMENT '名称;别名'", convertor.original_table_script_list[1]) | |
| 176 | + | |
| 177 | + out, err = convert(convertor) | |
| 178 | + self.assertNotIn("无法正常解析", err) | |
| 179 | + self.assertIn("COMMENT ON COLUMN demo.id IS '编号';", out) | |
| 180 | + self.assertIn("COMMENT ON COLUMN demo.name IS '名称;别名';", out) | |
| 181 | + self.assertIn("COMMENT ON TABLE demo IS '演示表';", out) | |
| 182 | + self.assertIn("COMMENT ON TABLE demo_doc IS '文档表';", out) | |
| 183 | + comment_lines = [line for line in out.split("\n") if line.startswith("COMMENT ON")] | |
| 184 | + self.assertFalse([line for line in comment_lines if "假" in line]) | |
| 185 | + | |
| 186 | + def test_indented_and_same_line_create_table(self): | |
| 187 | + sql = HEADER + """ | |
| 188 | + CREATE TABLE `indented` ( | |
| 189 | + `id` bigint NOT NULL COMMENT '编号', | |
| 190 | + PRIMARY KEY (`id`) USING BTREE | |
| 191 | + ) ENGINE = InnoDB COMMENT = '缩进表'; | |
| 192 | +CREATE TABLE `one` (`id` bigint NOT NULL, PRIMARY KEY (`id`)) ENGINE = InnoDB; CREATE TABLE `two` (`id` bigint NOT NULL, PRIMARY KEY (`id`)) ENGINE = InnoDB; | |
| 193 | +""" | |
| 194 | + convertor = PostgreSQLConvertor(self.write_sql(sql)) | |
| 195 | + self.assertEqual(["indented", "one", "two"], table_names(convertor.table_script_list)) | |
| 196 | + | |
| 197 | + out, err = convert(convertor) | |
| 198 | + self.assertNotIn("无法正常解析", err) | |
| 199 | + self.assertIn("COMMENT ON COLUMN indented.id IS '编号';", out) | |
| 200 | + self.assertIn("COMMENT ON TABLE indented IS '缩进表';", out) | |
| 201 | + self.assertIn("CREATE TABLE one (", out) | |
| 202 | + self.assertIn("CREATE TABLE two (", out) | |
| 203 | + | |
| 204 | + def test_multiple_tables_with_inserts(self): | |
| 205 | + demo_item = """ | |
| 206 | +DROP TABLE IF EXISTS `demo_item`; | |
| 207 | +CREATE TABLE `demo_item` ( | |
| 208 | + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 209 | + `demo_id` bigint NOT NULL COMMENT '演示编号', | |
| 210 | + PRIMARY KEY (`id`) USING BTREE | |
| 211 | +) ENGINE = InnoDB COMMENT = '演示项'; | |
| 212 | + | |
| 213 | +BEGIN; | |
| 214 | +INSERT INTO `demo_item` (`id`, `demo_id`) VALUES (7, 1); | |
| 215 | +INSERT INTO `demo_item` (`id`, `demo_id`) VALUES (8, 1); | |
| 216 | +COMMIT; | |
| 217 | +""" | |
| 218 | + convertor = PostgreSQLConvertor(self.write_sql(HEADER + DEMO_TABLE + demo_item)) | |
| 219 | + out, err = convert(convertor) | |
| 220 | + self.assertNotIn("无法正常解析", err) | |
| 221 | + self.assertIn("INSERT INTO demo (id, name) VALUES (1, 'a;b');", out) | |
| 222 | + self.assertIn("INSERT INTO demo_item (id, demo_id) VALUES (7, 1);\n" | |
| 223 | + "INSERT INTO demo_item (id, demo_id) VALUES (8, 1);", out) | |
| 224 | + self.assertIn("CREATE SEQUENCE demo_seq\n START 2;", out) | |
| 225 | + self.assertIn("CREATE SEQUENCE demo_item_seq\n START 9;", out) | |
| 226 | + | |
| 227 | + def test_postgres_char(self): | |
| 228 | + sql = HEADER + """ | |
| 229 | +CREATE TABLE `demo_char` ( | |
| 230 | + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 231 | + `flag` char NOT NULL DEFAULT 'N' COMMENT '标记', | |
| 232 | + `code` char(2) NULL DEFAULT NULL COMMENT '编码', | |
| 233 | + PRIMARY KEY (`id`) USING BTREE | |
| 234 | +) ENGINE = InnoDB COMMENT = '字符表'; | |
| 235 | +""" | |
| 236 | + out, err = convert(PostgreSQLConvertor(self.write_sql(sql))) | |
| 237 | + self.assertNotIn("无法正常解析", err) | |
| 238 | + self.assertIn("flag varchar(1) NOT NULL", out) | |
| 239 | + self.assertIn("code varchar(2) NULL", out) | |
| 240 | + | |
| 241 | + def test_utf8_bom(self): | |
| 242 | + # 文件开头的 BOM 紧挨着首条 CREATE TABLE;字符串中的 BOM 需原样保留 | |
| 243 | + sql = "\ufeff" + """CREATE TABLE `demo_bom` ( | |
| 244 | + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', | |
| 245 | + `name` varchar(64) NOT NULL DEFAULT '' COMMENT '名称', | |
| 246 | + PRIMARY KEY (`id`) USING BTREE | |
| 247 | +) ENGINE = InnoDB COMMENT = 'BOM 表'; | |
| 248 | +INSERT INTO `demo_bom` (`id`, `name`) VALUES (1, 'a\ufeffb'); | |
| 249 | +""" | |
| 250 | + path = self.write_sql(sql) | |
| 251 | + with open(path, "rb") as f: | |
| 252 | + self.assertTrue(f.read().startswith(b"\xef\xbb\xbf")) | |
| 253 | + | |
| 254 | + convertor = PostgreSQLConvertor(path) | |
| 255 | + self.assertEqual(["demo_bom"], table_names(convertor.table_script_list)) | |
| 256 | + self.assertTrue(convertor.original_table_script_list[0].startswith("CREATE TABLE `demo_bom`")) | |
| 257 | + | |
| 258 | + out, err = convert(convertor) | |
| 259 | + self.assertNotIn("无法正常解析", err) | |
| 260 | + self.assertIn("CREATE TABLE demo_bom (", out) | |
| 261 | + self.assertIn("name varchar(64) NOT NULL", out) | |
| 262 | + self.assertIn("COMMENT ON COLUMN demo_bom.name IS '名称';", out) | |
| 263 | + self.assertIn("COMMENT ON TABLE demo_bom IS 'BOM 表';", out) | |
| 264 | + self.assertIn("INSERT INTO demo_bom (id, name) VALUES (1, 'a\ufeffb');", out) | |
| 265 | + | |
| 266 | + def test_cli(self): | |
| 267 | + # 等价于:python convertor.py <type> dump.sql | |
| 268 | + sql = HEADER + DEMO_DOC_TABLE + ESCAPED_NEWLINE_INSERT + LITERAL_NEWLINE_INSERT + DEMO_TABLE | |
| 269 | + path = self.write_sql(sql) | |
| 270 | + | |
| 271 | + # OceanBase:原样输出 | |
| 272 | + result = subprocess.run([sys.executable, "convertor.py", "oceanbase", path], | |
| 273 | + cwd=TOOLS_DIR, capture_output=True) | |
| 274 | + self.assertEqual(0, result.returncode, result.stderr.decode("utf-8")) | |
| 275 | + self.assertEqual(sql, result.stdout.decode("utf-8")) | |
| 276 | + | |
| 277 | + # PostgreSQL:不误识别 INSERT 数据中的建表文本 | |
| 278 | + result = subprocess.run([sys.executable, "convertor.py", "postgres", path], | |
| 279 | + cwd=TOOLS_DIR, capture_output=True) | |
| 280 | + out, err = result.stdout.decode("utf-8"), result.stderr.decode("utf-8") | |
| 281 | + self.assertEqual(0, result.returncode, err) | |
| 282 | + self.assertNotIn("无法正常解析", err) | |
| 283 | + self.assertIn("Table structure for demo_doc", out) | |
| 284 | + self.assertIn("Table structure for demo\n", out) | |
| 285 | + self.assertNotIn("Table structure for undo_log", out) | |
| 286 | + self.assertNotIn("Table structure for ghost", out) | |
| 287 | + | |
| 288 | + | |
| 289 | +class ConvertorOfficialDumpTest(unittest.TestCase): | |
| 290 | + | |
| 291 | + def test_ruoyi_vue_pro(self): | |
| 292 | + convertor = PostgreSQLConvertor(os.path.join(MYSQL_DIR, "ruoyi-vue-pro.sql")) | |
| 293 | + names = table_names(convertor.table_script_list) | |
| 294 | + self.assertEqual(48, len(names)) | |
| 295 | + self.assertEqual(len(names), len(set(names))) | |
| 296 | + | |
| 297 | + out, err = convert(convertor) | |
| 298 | + self.assertNotIn("无法正常解析", err) | |
| 299 | + # 48 张表,加上 dual 表 | |
| 300 | + self.assertEqual(49, out.count("-- Table structure for ")) | |
| 301 | + | |
| 302 | + def test_quartz(self): | |
| 303 | + convertor = PostgreSQLConvertor(os.path.join(MYSQL_DIR, "quartz.sql")) | |
| 304 | + names = table_names(convertor.table_script_list) | |
| 305 | + self.assertEqual(11, len(names)) | |
| 306 | + self.assertTrue(all(name.startswith("QRTZ_") for name in names)) | |
| 307 | + | |
| 308 | + # quartz 的表不转换 | |
| 309 | + out, _ = convert(convertor) | |
| 310 | + self.assertNotIn("qrtz", out.lower()) | |
| 311 | + | |
| 312 | + | |
| 313 | +if __name__ == "__main__": | |
| 314 | + unittest.main() | ... | ... |
yudao-dependencies/pom.xml
| ... | ... | @@ -27,13 +27,13 @@ |
| 27 | 27 | <mybatis.version>3.5.19</mybatis.version> |
| 28 | 28 | <mybatis-plus.version>3.5.17</mybatis-plus.version> |
| 29 | 29 | <mybatis-plus-join.version>1.5.9</mybatis-plus-join.version> |
| 30 | - <dynamic-datasource.version>4.5.0</dynamic-datasource.version> | |
| 30 | + <dynamic-datasource.version>4.6.0</dynamic-datasource.version> | |
| 31 | 31 | <easy-trans.version>3.1.8</easy-trans.version> |
| 32 | - <redisson.version>4.7.0</redisson.version> | |
| 32 | + <redisson.version>4.8.0</redisson.version> | |
| 33 | 33 | <dm8.jdbc.version>8.1.3.140</dm8.jdbc.version> |
| 34 | 34 | <kingbase.jdbc.version>9.0.1.jre7</kingbase.jdbc.version> |
| 35 | - <opengauss.jdbc.version>7.0.0-RC3-og</opengauss.jdbc.version> | |
| 36 | - <taos.version>3.9.2</taos.version> | |
| 35 | + <opengauss.jdbc.version>7.0.0-og</opengauss.jdbc.version> | |
| 36 | + <taos.version>3.10.0</taos.version> | |
| 37 | 37 | <iotdb.version>2.0.8</iotdb.version> <!-- TODO 特殊:暂不升级 2.0.10/2.0.11,其 TablePreparedStatement.execute 不设置 ResultSet,导致 Mapper 的 SELECT 为空、COUNT 为 null --> |
| 38 | 38 | <!-- 消息队列 --> |
| 39 | 39 | <rocketmq-spring.version>2.3.6</rocketmq-spring.version> |
| ... | ... | @@ -42,50 +42,49 @@ |
| 42 | 42 | <!-- 监控相关 --> |
| 43 | 43 | <skywalking.version>9.7.0</skywalking.version> |
| 44 | 44 | <spring-boot-admin.version>3.5.9</spring-boot-admin.version> |
| 45 | - <opentelemetry.version>1.66.0</opentelemetry.version> | |
| 45 | + <opentelemetry.version>1.67.0</opentelemetry.version> | |
| 46 | 46 | <!-- Test 测试相关 --> |
| 47 | - <h2.version>2.3.232</h2.version> | |
| 48 | 47 | <podam.version>8.0.2.RELEASE</podam.version> |
| 49 | - <jedis-mock.version>1.1.19</jedis-mock.version> | |
| 48 | + <jedis-mock.version>1.1.20</jedis-mock.version> | |
| 50 | 49 | <mockito-inline.version>5.2.0</mockito-inline.version> |
| 51 | 50 | <!-- Bpm 工作流相关 --> |
| 52 | 51 | <flowable.version>8.0.0</flowable.version> |
| 53 | 52 | <!-- 工具类相关 --> |
| 54 | - <anji-plus-captcha.version>1.4.0</anji-plus-captcha.version> | |
| 53 | + <anji-plus-captcha.version>2.0.0</anji-plus-captcha.version> | |
| 55 | 54 | <jsoup.version>1.23.2</jsoup.version> |
| 56 | 55 | <sensitive-word.version>0.29.5</sensitive-word.version> |
| 57 | 56 | <pinyin4j.version>2.5.1</pinyin4j.version> |
| 58 | 57 | <lombok.version>1.18.48</lombok.version> |
| 59 | 58 | <mapstruct.version>1.7.0.Beta2</mapstruct.version> |
| 60 | - <hutool-5.version>5.8.47</hutool-5.version> | |
| 59 | + <hutool-5.version>5.8.48</hutool-5.version> | |
| 61 | 60 | <hutool-6.version>6.0.0-M22</hutool-6.version> |
| 62 | 61 | <fesod.version>2.1.0-incubating</fesod.version> |
| 63 | 62 | <aviator.version>5.4.4</aviator.version> |
| 64 | 63 | <velocity.version>2.4.1</velocity.version> |
| 65 | - <fastjson2.version>2.0.65</fastjson2.version> | |
| 66 | - <guava.version>33.7.1-jre</guava.version> | |
| 64 | + <fastjson2.version>2.0.66</fastjson2.version> | |
| 65 | + <guava.version>33.7.2-jre</guava.version> | |
| 67 | 66 | <transmittable-thread-local.version>2.14.5</transmittable-thread-local.version> |
| 68 | 67 | <commons-net.version>3.13.0</commons-net.version> |
| 69 | - <commons-lang3.version>3.20.0</commons-lang3.version> | |
| 70 | - <jsch.version>2.28.7</jsch.version> | |
| 68 | + <commons-lang3.version>3.21.0</commons-lang3.version> | |
| 69 | + <jsch.version>2.28.8</jsch.version> | |
| 71 | 70 | <tika-core.version>3.3.1</tika-core.version> |
| 72 | 71 | <ip2region.version>2.7.0</ip2region.version> |
| 73 | 72 | <bizlog-sdk.version>3.0.6</bizlog-sdk.version> |
| 74 | - <netty.version>4.2.18.Final</netty.version> | |
| 73 | + <netty.version>4.2.19.Final</netty.version> | |
| 75 | 74 | <mqtt.version>1.2.5</mqtt.version> |
| 76 | 75 | <vertx.version>4.5.26</vertx.version> |
| 77 | 76 | <okhttp.version>4.12.0</okhttp.version> |
| 78 | 77 | <californium.version>3.14.0</californium.version> |
| 79 | 78 | <j2mod.version>3.4.0</j2mod.version> |
| 80 | 79 | <!-- 三方云服务相关 --> |
| 81 | - <awssdk.version>2.55.5</awssdk.version> | |
| 80 | + <awssdk.version>2.57.3</awssdk.version> | |
| 82 | 81 | <justauth.version>1.16.7</justauth.version> |
| 83 | 82 | <justauth-starter.version>1.4.0</justauth-starter.version> |
| 84 | 83 | <jimureport.version>2.5.4</jimureport.version> |
| 85 | 84 | <jimubi.version>2.5.4</jimubi.version> |
| 86 | - <weixin-java.version>4.8.6-20260919.232150</weixin-java.version> | |
| 87 | - <bouncycastle.version>1.80</bouncycastle.version> | |
| 88 | - <alipay-sdk-java.version>4.40.1014.ALL</alipay-sdk-java.version> | |
| 85 | + <weixin-java.version>4.8.6-20261010.094603</weixin-java.version> | |
| 86 | + <bouncycastle.version>1.85</bouncycastle.version> | |
| 87 | + <alipay-sdk-java.version>4.40.1044.ALL</alipay-sdk-java.version> | |
| 89 | 88 | </properties> |
| 90 | 89 | |
| 91 | 90 | <dependencyManagement> |
| ... | ... | @@ -99,6 +98,13 @@ |
| 99 | 98 | <scope>import</scope> |
| 100 | 99 | </dependency> |
| 101 | 100 | <dependency> |
| 101 | + <groupId>io.opentelemetry</groupId> | |
| 102 | + <artifactId>opentelemetry-bom</artifactId> | |
| 103 | + <version>${opentelemetry.version}</version> | |
| 104 | + <type>pom</type> | |
| 105 | + <scope>import</scope> | |
| 106 | + </dependency> | |
| 107 | + <dependency> | |
| 102 | 108 | <groupId>org.springframework.boot</groupId> |
| 103 | 109 | <artifactId>spring-boot-dependencies</artifactId> |
| 104 | 110 | <version>${spring.boot.version}</version> |
| ... | ... | @@ -278,7 +284,7 @@ |
| 278 | 284 | </exclusion> |
| 279 | 285 | <exclusion> |
| 280 | 286 | <groupId>org.redisson</groupId> |
| 281 | - <!-- Redisson 4.7.x 默认依赖 redisson-spring-data-41(适配 Spring Data Redis 4.x),排除后使用下方的 spring-data-35 适配 Spring Boot 3.5 --> | |
| 287 | + <!-- Redisson 4.x 默认依赖 redisson-spring-data-41(适配 Spring Data Redis 4.x),排除后使用下方的 spring-data-35 适配 Spring Boot 3.5 --> | |
| 282 | 288 | <artifactId>redisson-spring-data-41</artifactId> |
| 283 | 289 | </exclusion> |
| 284 | 290 | </exclusions> |
| ... | ... | @@ -439,12 +445,6 @@ |
| 439 | 445 | </dependency> |
| 440 | 446 | |
| 441 | 447 | <dependency> |
| 442 | - <groupId>com.h2database</groupId> | |
| 443 | - <artifactId>h2</artifactId> | |
| 444 | - <version>${h2.version}</version> | |
| 445 | - </dependency> | |
| 446 | - | |
| 447 | - <dependency> | |
| 448 | 448 | <groupId>com.github.fppt</groupId> <!-- 单元测试,我们采用内嵌的 Redis 数据库 --> |
| 449 | 449 | <artifactId>jedis-mock</artifactId> |
| 450 | 450 | <version>${jedis-mock.version}</version> |
| ... | ... | @@ -707,7 +707,7 @@ |
| 707 | 707 | </exclusions> |
| 708 | 708 | </dependency> |
| 709 | 709 | |
| 710 | - <!-- 锁定 weixin-java 传递依赖,避免 Maven 版本范围自动升级到 1.80.2 后 Fat Jar 启动失败。 | |
| 710 | + <!-- 对齐 Tika 与 WxJava 的 BouncyCastle 依赖,避免 bcprov 与 bcpkix/bcutil 版本不一致。 | |
| 711 | 711 | 反馈:https://t.zsxq.com/pCVBo --> |
| 712 | 712 | <dependency> |
| 713 | 713 | <groupId>org.bouncycastle</groupId> | ... | ... |
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 |
| 7 | 7 | import cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo.CrmOperateLogRespVO; |
| 8 | 8 | import cn.iocoder.yudao.module.crm.enums.LogRecordConstants; |
| 9 | 9 | import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; |
| 10 | +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; | |
| 11 | +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; | |
| 10 | 12 | import cn.iocoder.yudao.module.system.api.logger.OperateLogApi; |
| 11 | 13 | import cn.iocoder.yudao.module.system.api.logger.dto.OperateLogPageReqDTO; |
| 12 | 14 | import io.swagger.v3.oas.annotations.Operation; |
| ... | ... | @@ -52,6 +54,8 @@ public class CrmOperateLogController { |
| 52 | 54 | |
| 53 | 55 | @GetMapping("/page") |
| 54 | 56 | @Operation(summary = "获得操作日志") |
| 57 | + // 校验业务对象的数据权限:https://github.com/YunaiV/ruoyi-vue-pro/issues/1305 | |
| 58 | + @CrmPermission(bizTypeValue = "#pageReqVO.bizType", bizId = "#pageReqVO.bizId", level = CrmPermissionLevelEnum.READ) | |
| 55 | 59 | public CommonResult<PageResult<CrmOperateLogRespVO>> getCustomerOperateLog(@Valid CrmOperateLogPageReqVO pageReqVO) { |
| 56 | 60 | OperateLogPageReqDTO reqDTO = new OperateLogPageReqDTO(); |
| 57 | 61 | reqDTO.setPageSize(PAGE_SIZE_NONE); // 默认不分页,需要分页需注释 | ... | ... |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java
| ... | ... | @@ -211,7 +211,8 @@ public class CodegenServiceImplTest extends BaseDbUnitTest { |
| 211 | 211 | CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class, |
| 212 | 212 | o -> o.getTable().setId(table.getId()) |
| 213 | 213 | .setTemplateType(CodegenTemplateTypeEnum.ONE.getType()) |
| 214 | - .setScene(CodegenSceneEnum.ADMIN.getScene())); | |
| 214 | + .setScene(CodegenSceneEnum.ADMIN.getScene()) | |
| 215 | + .setModuleName("infra").setBusinessName("codegen").setClassName("CodegenTable")); | |
| 215 | 216 | CodegenColumnSaveReqVO columnVO01 = randomPojo(CodegenColumnSaveReqVO.class, |
| 216 | 217 | o -> o.setId(column01.getId()).setTableId(table.getId())); |
| 217 | 218 | CodegenColumnSaveReqVO columnVO02 = randomPojo(CodegenColumnSaveReqVO.class, | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/infra/ApiAccessLogStatisticsMapper.xml
| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | FROM infra_api_access_log |
| 8 | 8 | WHERE user_type = #{userType} |
| 9 | 9 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 10 | - AND deleted = FALSE | |
| 10 | + AND deleted = 0 | |
| 11 | 11 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 12 | 12 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 13 | 13 | </if> |
| ... | ... | @@ -19,7 +19,7 @@ |
| 19 | 19 | WHERE user_id > 0 |
| 20 | 20 | AND user_type = #{userType} |
| 21 | 21 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 22 | - AND deleted = FALSE | |
| 22 | + AND deleted = 0 | |
| 23 | 23 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 24 | 24 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 25 | 25 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/member/MemberStatisticsMapper.xml
| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | resultType="cn.iocoder.yudao.module.statistics.service.member.bo.MemberAreaStatisticsRespBO"> |
| 7 | 7 | SELECT area_id, COUNT(1) AS userCount |
| 8 | 8 | FROM member_user |
| 9 | - WHERE deleted = FALSE | |
| 9 | + WHERE deleted = 0 | |
| 10 | 10 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 11 | 11 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 12 | 12 | </if> |
| ... | ... | @@ -17,7 +17,7 @@ |
| 17 | 17 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberSexStatisticsRespVO"> |
| 18 | 18 | SELECT sex, COUNT(1) AS userCount |
| 19 | 19 | FROM member_user |
| 20 | - WHERE deleted = FALSE | |
| 20 | + WHERE deleted = 0 | |
| 21 | 21 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 22 | 22 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 23 | 23 | </if> |
| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberTerminalStatisticsRespVO"> |
| 29 | 29 | SELECT register_terminal as terminal, COUNT(1) AS userCount |
| 30 | 30 | FROM member_user |
| 31 | - WHERE deleted = FALSE | |
| 31 | + WHERE deleted = 0 | |
| 32 | 32 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 33 | 33 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 34 | 34 | </if> |
| ... | ... | @@ -38,7 +38,7 @@ |
| 38 | 38 | <select id="selectUserCount" resultType="java.lang.Integer"> |
| 39 | 39 | SELECT COUNT(1) |
| 40 | 40 | FROM member_user |
| 41 | - WHERE deleted = FALSE | |
| 41 | + WHERE deleted = 0 | |
| 42 | 42 | <if test="beginTime != null"> |
| 43 | 43 | AND create_time >= #{beginTime} |
| 44 | 44 | </if> |
| ... | ... | @@ -52,15 +52,16 @@ |
| 52 | 52 | |
| 53 | 53 | <select id="selectListByCreateTimeBetween" |
| 54 | 54 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberRegisterCountRespVO"> |
| 55 | - SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS date, | |
| 55 | + SELECT CAST(create_time AS DATE) AS date, | |
| 56 | 56 | count(1) AS count |
| 57 | 57 | FROM member_user |
| 58 | 58 | WHERE create_time BETWEEN #{beginTime} AND #{endTime} |
| 59 | - AND deleted = FALSE | |
| 59 | + AND deleted = 0 | |
| 60 | 60 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 61 | 61 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 62 | 62 | </if> |
| 63 | 63 | GROUP BY date |
| 64 | + ORDER BY date | |
| 64 | 65 | </select> |
| 65 | 66 | |
| 66 | 67 | </mapper> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/pay/PayWalletStatisticsMapper.xml
| ... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 | FROM pay_wallet_recharge |
| 10 | 10 | WHERE pay_status = #{payStatus} |
| 11 | 11 | AND pay_time BETWEEN #{beginTime} AND #{endTime} |
| 12 | - AND deleted = FALSE | |
| 12 | + AND deleted = 0 | |
| 13 | 13 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 14 | 14 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 15 | 15 | </if> |
| ... | ... | @@ -22,7 +22,7 @@ |
| 22 | 22 | FROM pay_wallet_recharge |
| 23 | 23 | WHERE refund_status = #{refundStatus} |
| 24 | 24 | AND refund_time BETWEEN #{beginTime} AND #{endTime} |
| 25 | - AND deleted = FALSE | |
| 25 | + AND deleted = 0 | |
| 26 | 26 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 27 | 27 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 28 | 28 | </if> |
| ... | ... | @@ -33,7 +33,7 @@ |
| 33 | 33 | FROM pay_wallet_transaction |
| 34 | 34 | WHERE biz_type = #{bizType} |
| 35 | 35 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 36 | - AND deleted = FALSE | |
| 36 | + AND deleted = 0 | |
| 37 | 37 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 38 | 38 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 39 | 39 | </if> |
| ... | ... | @@ -51,17 +51,17 @@ |
| 51 | 51 | <if test="endTime != null"> |
| 52 | 52 | AND pay_time <= #{endTime} |
| 53 | 53 | </if> |
| 54 | - AND deleted = FALSE | |
| 54 | + AND deleted = 0 | |
| 55 | 55 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 56 | 56 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 57 | 57 | </if> |
| 58 | 58 | </select> |
| 59 | 59 | |
| 60 | 60 | <select id="selectRechargePriceSummary" resultType="java.lang.Integer"> |
| 61 | - SELECT IFNULL(SUM(pay_price), 0) | |
| 61 | + SELECT COALESCE(SUM(pay_price), 0) | |
| 62 | 62 | FROM pay_wallet_recharge |
| 63 | 63 | WHERE pay_status = #{payStatus} |
| 64 | - AND deleted = FALSE | |
| 64 | + AND deleted = 0 | |
| 65 | 65 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 66 | 66 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 67 | 67 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/product/ProductStatisticsMapper.xml
| ... | ... | @@ -42,7 +42,7 @@ |
| 42 | 42 | </if> |
| 43 | 43 | ) AS cart_count |
| 44 | 44 | -- 下单件数 |
| 45 | - , (SELECT IFNULL(SUM(count), 0) | |
| 45 | + , (SELECT COALESCE(SUM(count), 0) | |
| 46 | 46 | FROM trade_order_item |
| 47 | 47 | WHERE spu_id = spu.id |
| 48 | 48 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| ... | ... | @@ -51,7 +51,7 @@ |
| 51 | 51 | </if> |
| 52 | 52 | ) AS order_count |
| 53 | 53 | -- 支付件数 |
| 54 | - , (SELECT IFNULL(SUM(item.count), 0) | |
| 54 | + , (SELECT COALESCE(SUM(item.count), 0) | |
| 55 | 55 | FROM trade_order_item item |
| 56 | 56 | JOIN trade_order o ON item.order_id = o.id |
| 57 | 57 | WHERE spu_id = spu.id |
| ... | ... | @@ -63,7 +63,7 @@ |
| 63 | 63 | </if> |
| 64 | 64 | ) AS order_pay_count |
| 65 | 65 | -- 支付金额 |
| 66 | - , (SELECT IFNULL(SUM(item.pay_price), 0) | |
| 66 | + , (SELECT COALESCE(SUM(item.pay_price), 0) | |
| 67 | 67 | FROM trade_order_item item |
| 68 | 68 | JOIN trade_order o ON item.order_id = o.id |
| 69 | 69 | WHERE spu_id = spu.id |
| ... | ... | @@ -75,7 +75,7 @@ |
| 75 | 75 | </if> |
| 76 | 76 | ) AS order_pay_price |
| 77 | 77 | -- 退款件数 |
| 78 | - , (SELECT IFNULL(SUM(count), 0) | |
| 78 | + , (SELECT COALESCE(SUM(count), 0) | |
| 79 | 79 | FROM trade_after_sale |
| 80 | 80 | WHERE spu_id = spu.id |
| 81 | 81 | AND refund_time IS NOT NULL |
| ... | ... | @@ -85,7 +85,7 @@ |
| 85 | 85 | </if> |
| 86 | 86 | ) AS after_sale_count |
| 87 | 87 | -- 退款金额 |
| 88 | - , (SELECT IFNULL(SUM(refund_price), 0) | |
| 88 | + , (SELECT COALESCE(SUM(refund_price), 0) | |
| 89 | 89 | FROM trade_after_sale |
| 90 | 90 | WHERE spu_id = spu.id |
| 91 | 91 | AND refund_time IS NOT NULL |
| ... | ... | @@ -95,7 +95,7 @@ |
| 95 | 95 | </if> |
| 96 | 96 | ) AS after_sale_refund_price |
| 97 | 97 | FROM product_spu spu |
| 98 | - WHERE spu.deleted = FALSE | |
| 98 | + WHERE spu.deleted = 0 | |
| 99 | 99 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 100 | 100 | AND spu.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 101 | 101 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/AfterSaleStatisticsMapper.xml
| ... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 | SUM(refund_price) AS afterSaleRefundPrice |
| 9 | 9 | FROM trade_after_sale |
| 10 | 10 | WHERE refund_time BETWEEN #{beginTime} AND #{endTime} |
| 11 | - AND deleted = FALSE | |
| 11 | + AND deleted = 0 | |
| 12 | 12 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 13 | 13 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 14 | 14 | </if> |
| ... | ... | @@ -18,7 +18,7 @@ |
| 18 | 18 | SELECT COUNT(1) |
| 19 | 19 | FROM trade_after_sale |
| 20 | 20 | WHERE status = #{status} |
| 21 | - AND deleted = FALSE | |
| 21 | + AND deleted = 0 | |
| 22 | 22 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 23 | 23 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 24 | 24 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/BrokerageStatisticsMapper.xml
| ... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 | WHERE biz_type = #{bizType} |
| 9 | 9 | AND status = #{status} |
| 10 | 10 | AND unfreeze_time BETWEEN #{beginTime} AND #{endTime} |
| 11 | - AND deleted = FALSE | |
| 11 | + AND deleted = 0 | |
| 12 | 12 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 13 | 13 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 14 | 14 | </if> |
| ... | ... | @@ -18,7 +18,7 @@ |
| 18 | 18 | SELECT COUNT(1) |
| 19 | 19 | FROM trade_brokerage_withdraw |
| 20 | 20 | WHERE status = #{status} |
| 21 | - AND deleted = FALSE | |
| 21 | + AND deleted = 0 | |
| 22 | 22 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 23 | 23 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 24 | 24 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeOrderStatisticsMapper.xml
| ... | ... | @@ -16,7 +16,7 @@ |
| 16 | 16 | FROM trade_order AS s |
| 17 | 17 | WHERE s.receiver_area_id = m.receiver_area_id |
| 18 | 18 | AND s.pay_status = TRUE |
| 19 | - AND s.deleted = FALSE | |
| 19 | + AND s.deleted = 0 | |
| 20 | 20 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 21 | 21 | AND s.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 22 | 22 | </if> |
| ... | ... | @@ -25,13 +25,13 @@ |
| 25 | 25 | FROM trade_order AS s |
| 26 | 26 | WHERE s.receiver_area_id = m.receiver_area_id |
| 27 | 27 | AND s.pay_status = TRUE |
| 28 | - AND s.deleted = FALSE | |
| 28 | + AND s.deleted = 0 | |
| 29 | 29 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 30 | 30 | AND s.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 31 | 31 | </if> |
| 32 | 32 | ) AS orderPayPrice |
| 33 | 33 | FROM trade_order m |
| 34 | - WHERE deleted = FALSE | |
| 34 | + WHERE deleted = 0 | |
| 35 | 35 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 36 | 36 | AND m.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 37 | 37 | </if> |
| ... | ... | @@ -41,7 +41,7 @@ |
| 41 | 41 | <select id="selectUserCountByCreateTimeBetween" resultType="java.lang.Integer"> |
| 42 | 42 | SELECT COUNT(DISTINCT (user_id)) |
| 43 | 43 | FROM trade_order |
| 44 | - WHERE deleted = FALSE | |
| 44 | + WHERE deleted = 0 | |
| 45 | 45 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 46 | 46 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 47 | 47 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| ... | ... | @@ -53,7 +53,7 @@ |
| 53 | 53 | FROM trade_order |
| 54 | 54 | WHERE pay_time BETWEEN #{beginTime} AND #{endTime} |
| 55 | 55 | AND pay_status = TRUE |
| 56 | - AND deleted = FALSE | |
| 56 | + AND deleted = 0 | |
| 57 | 57 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 58 | 58 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 59 | 59 | </if> |
| ... | ... | @@ -63,7 +63,7 @@ |
| 63 | 63 | SELECT COUNT(1) |
| 64 | 64 | FROM trade_order |
| 65 | 65 | WHERE create_time BETWEEN #{beginTime} AND #{endTime} |
| 66 | - AND deleted = FALSE | |
| 66 | + AND deleted = 0 | |
| 67 | 67 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 68 | 68 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 69 | 69 | </if> |
| ... | ... | @@ -74,7 +74,7 @@ |
| 74 | 74 | FROM trade_order |
| 75 | 75 | WHERE pay_status = TRUE |
| 76 | 76 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 77 | - AND deleted = FALSE | |
| 77 | + AND deleted = 0 | |
| 78 | 78 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 79 | 79 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 80 | 80 | </if> |
| ... | ... | @@ -84,7 +84,7 @@ |
| 84 | 84 | SELECT SUM(pay_price) |
| 85 | 85 | FROM trade_order AS s |
| 86 | 86 | WHERE s.pay_status = TRUE |
| 87 | - AND deleted = FALSE | |
| 87 | + AND deleted = 0 | |
| 88 | 88 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 89 | 89 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 90 | 90 | AND s.tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| ... | ... | @@ -93,32 +93,35 @@ |
| 93 | 93 | |
| 94 | 94 | <select id="selectListByPayTimeBetweenAndGroupByDay" |
| 95 | 95 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeOrderTrendRespVO"> |
| 96 | - SELECT DATE_FORMAT(pay_time, '%Y-%m-%d') AS date, | |
| 96 | + SELECT CAST(pay_time AS DATE) AS date, | |
| 97 | 97 | COUNT(1) AS orderPayCount, |
| 98 | 98 | SUM(pay_price) AS orderPayPrice |
| 99 | 99 | FROM trade_order |
| 100 | 100 | WHERE pay_status = TRUE |
| 101 | 101 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 102 | - AND deleted = FALSE | |
| 102 | + AND deleted = 0 | |
| 103 | 103 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 104 | 104 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 105 | 105 | </if> |
| 106 | 106 | GROUP BY date |
| 107 | + ORDER BY date | |
| 107 | 108 | </select> |
| 108 | 109 | |
| 109 | 110 | <select id="selectListByPayTimeBetweenAndGroupByMonth" |
| 110 | 111 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeOrderTrendRespVO"> |
| 111 | - SELECT DATE_FORMAT(pay_time, '%Y-%m') AS date, | |
| 112 | + SELECT CONCAT(EXTRACT(YEAR FROM pay_time), '-', | |
| 113 | + LPAD(CONCAT(EXTRACT(MONTH FROM pay_time)), 2, '0')) AS date, | |
| 112 | 114 | COUNT(1) AS order_pay_count, |
| 113 | 115 | SUM(pay_price) AS order_pay_price |
| 114 | 116 | FROM trade_order |
| 115 | 117 | WHERE pay_status = TRUE |
| 116 | 118 | AND create_time BETWEEN #{beginTime} AND #{endTime} |
| 117 | - AND deleted = FALSE | |
| 119 | + AND deleted = 0 | |
| 118 | 120 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 119 | 121 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 120 | 122 | </if> |
| 121 | 123 | GROUP BY date |
| 124 | + ORDER BY date | |
| 122 | 125 | </select> |
| 123 | 126 | |
| 124 | 127 | <select id="selectCountByStatusAndDeliveryType" resultType="java.lang.Long"> |
| ... | ... | @@ -126,7 +129,7 @@ |
| 126 | 129 | FROM trade_order |
| 127 | 130 | WHERE status = #{status} |
| 128 | 131 | ANd delivery_type = #{deliveryType} |
| 129 | - AND deleted = FALSE | |
| 132 | + AND deleted = 0 | |
| 130 | 133 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 131 | 134 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 132 | 135 | </if> |
| ... | ... | @@ -134,12 +137,12 @@ |
| 134 | 137 | |
| 135 | 138 | <select id="selectPaySummaryByPayStatusAndPayTimeBetween" |
| 136 | 139 | resultType="cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeOrderSummaryRespVO"> |
| 137 | - SELECT IFNULL(SUM(pay_price), 0) AS order_pay_price, | |
| 140 | + SELECT COALESCE(SUM(pay_price), 0) AS order_pay_price, | |
| 138 | 141 | COUNT(1) AS order_pay_count |
| 139 | 142 | FROM trade_order |
| 140 | 143 | WHERE pay_status = #{payStatus} |
| 141 | 144 | AND pay_time BETWEEN #{beginTime} AND #{endTime} |
| 142 | - AND deleted = FALSE | |
| 145 | + AND deleted = 0 | |
| 143 | 146 | <if test="@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId() != null"> |
| 144 | 147 | AND tenant_id = ${@cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder@getTenantId()} |
| 145 | 148 | </if> | ... | ... |
yudao-module-mall/yudao-module-statistics/src/main/resources/mapper/trade/TradeStatisticsMapper.xml
| ... | ... | @@ -4,11 +4,11 @@ |
| 4 | 4 | |
| 5 | 5 | <select id="selectOrderCreateCountSumAndOrderPayPriceSumByTimeBetween" |
| 6 | 6 | resultType="cn.iocoder.yudao.module.statistics.service.trade.bo.TradeSummaryRespBO"> |
| 7 | - SELECT IFNULL(SUM(order_create_count), 0) AS count, | |
| 8 | - IFNULL(SUM(order_pay_price), 0) AS summary | |
| 7 | + SELECT COALESCE(SUM(order_create_count), 0) AS count, | |
| 8 | + COALESCE(SUM(order_pay_price), 0) AS summary | |
| 9 | 9 | FROM trade_statistics |
| 10 | 10 | WHERE time BETWEEN #{beginTime} AND #{endTime} |
| 11 | - AND deleted = FALSE | |
| 11 | + AND deleted = 0 | |
| 12 | 12 | </select> |
| 13 | 13 | |
| 14 | 14 | <select id="selectVoByTimeBetween" |
| ... | ... | @@ -25,14 +25,14 @@ |
| 25 | 25 | SUM(after_sale_refund_price) AS afterSaleRefundPrice |
| 26 | 26 | FROM trade_statistics |
| 27 | 27 | WHERE time BETWEEN #{beginTime} AND #{endTime} |
| 28 | - AND deleted = FALSE | |
| 28 | + AND deleted = 0 | |
| 29 | 29 | </select> |
| 30 | 30 | |
| 31 | 31 | <select id="selectExpensePriceByTimeBetween" resultType="java.lang.Integer"> |
| 32 | 32 | SELECT -- 支出金额 = 余额支付金额 + 支付佣金金额 + 商品退款金额 |
| 33 | 33 | SUM(wallet_pay_price + brokerage_settlement_price + after_sale_refund_price) AS expensePrice |
| 34 | 34 | FROM trade_statistics |
| 35 | - WHERE deleted = FALSE | |
| 35 | + WHERE deleted = 0 | |
| 36 | 36 | <if test="beginTime != null"> |
| 37 | 37 | AND time >= #{beginTime} |
| 38 | 38 | </if> | ... | ... |
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 { |
| 46 | 46 | public Long addCart(Long userId, AppCartAddReqVO addReqVO) { |
| 47 | 47 | // 查询 TradeCartDO |
| 48 | 48 | CartDO cart = cartMapper.selectByUserIdAndSkuId(userId, addReqVO.getSkuId()); |
| 49 | - // 校验 SKU | |
| 50 | - Integer count = addReqVO.getCount(); | |
| 49 | + // 校验 SKU:已存在时,按合并后的数量校验库存 https://github.com/YunaiV/ruoyi-vue-pro/issues/1267 | |
| 50 | + Integer count = cart != null ? cart.getCount() + addReqVO.getCount() : addReqVO.getCount(); | |
| 51 | 51 | ProductSkuRespDTO sku = checkProductSku(addReqVO.getSkuId(), count); |
| 52 | 52 | |
| 53 | 53 | // 情况一:存在,则进行数量更新 |
| 54 | 54 | if (cart != null) { |
| 55 | 55 | cartMapper.updateById(new CartDO().setId(cart.getId()).setSelected(true) |
| 56 | - .setCount(cart.getCount() + count)); | |
| 56 | + .setCount(count)); | |
| 57 | 57 | return cart.getId(); |
| 58 | 58 | // 情况二:不存在,则进行插入 |
| 59 | 59 | } else { | ... | ... |
yudao-module-mall/yudao-module-trade/src/main/resources/mapper/brokerage/BrokerageUserMapper.xml
| ... | ... | @@ -17,14 +17,14 @@ |
| 17 | 17 | resultType="cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryRespVO"> |
| 18 | 18 | SELECT bu.id, bu.bind_user_time AS brokerageTime, |
| 19 | 19 | (SELECT SUM(price) FROM trade_brokerage_record r |
| 20 | - WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = FALSE) AS brokeragePrice, | |
| 20 | + WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = 0) AS brokeragePrice, | |
| 21 | 21 | (SELECT COUNT(1) FROM trade_brokerage_record r |
| 22 | - WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = FALSE) AS brokerageOrderCount, | |
| 22 | + WHERE r.user_id = bu.id AND biz_type = #{bizType} AND r.status = #{status} AND r.deleted = 0) AS brokerageOrderCount, | |
| 23 | 23 | (SELECT COUNT(1) FROM trade_brokerage_user c |
| 24 | - WHERE c.bind_user_id = bu.id AND c.deleted = FALSE) AS brokerageUserCount | |
| 24 | + WHERE c.bind_user_id = bu.id AND c.deleted = 0) AS brokerageUserCount | |
| 25 | 25 | FROM trade_brokerage_user AS bu |
| 26 | 26 | <where> |
| 27 | - bu.deleted = false | |
| 27 | + bu.deleted = 0 | |
| 28 | 28 | <if test="ids != null and ids.size() > 0"> |
| 29 | 29 | and bu.id in |
| 30 | 30 | <foreach collection="ids" open="(" item="id" separator="," close=")"> | ... | ... |
yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/cart/CartServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.trade.service.cart; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; | |
| 5 | +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; | |
| 6 | +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; | |
| 7 | +import cn.iocoder.yudao.module.trade.controller.app.cart.vo.AppCartAddReqVO; | |
| 8 | +import cn.iocoder.yudao.module.trade.controller.app.cart.vo.AppCartUpdateCountReqVO; | |
| 9 | +import cn.iocoder.yudao.module.trade.dal.dataobject.cart.CartDO; | |
| 10 | +import cn.iocoder.yudao.module.trade.dal.mysql.cart.CartMapper; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.mockito.ArgumentCaptor; | |
| 13 | +import org.mockito.InjectMocks; | |
| 14 | +import org.mockito.Mock; | |
| 15 | + | |
| 16 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 17 | +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.SKU_NOT_EXISTS; | |
| 18 | +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.SKU_STOCK_NOT_ENOUGH; | |
| 19 | +import static org.junit.jupiter.api.Assertions.*; | |
| 20 | +import static org.mockito.ArgumentMatchers.any; | |
| 21 | +import static org.mockito.Mockito.*; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * {@link CartServiceImpl} 的单元测试 | |
| 25 | + * | |
| 26 | + * @author 芋道源码 | |
| 27 | + */ | |
| 28 | +public class CartServiceImplTest extends BaseMockitoUnitTest { | |
| 29 | + | |
| 30 | + @InjectMocks | |
| 31 | + private CartServiceImpl cartService; | |
| 32 | + | |
| 33 | + @Mock | |
| 34 | + private CartMapper cartMapper; | |
| 35 | + @Mock | |
| 36 | + private ProductSpuApi productSpuApi; | |
| 37 | + @Mock | |
| 38 | + private ProductSkuApi productSkuApi; | |
| 39 | + | |
| 40 | + @Test | |
| 41 | + public void testAddCart_insert() { | |
| 42 | + // 准备参数 | |
| 43 | + Long userId = 1L; | |
| 44 | + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(3); | |
| 45 | + // mock 方法 | |
| 46 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 47 | + when(cartMapper.insert(any(CartDO.class))).thenAnswer(invocation -> { | |
| 48 | + invocation.<CartDO>getArgument(0).setId(100L); | |
| 49 | + return 1; | |
| 50 | + }); | |
| 51 | + | |
| 52 | + // 调用 | |
| 53 | + Long cartId = cartService.addCart(userId, reqVO); | |
| 54 | + // 断言 | |
| 55 | + assertEquals(100L, cartId); | |
| 56 | + ArgumentCaptor<CartDO> cartCaptor = ArgumentCaptor.forClass(CartDO.class); | |
| 57 | + verify(cartMapper).insert(cartCaptor.capture()); | |
| 58 | + CartDO cart = cartCaptor.getValue(); | |
| 59 | + assertEquals(userId, cart.getUserId()); | |
| 60 | + assertEquals(20L, cart.getSpuId()); | |
| 61 | + assertEquals(10L, cart.getSkuId()); | |
| 62 | + assertEquals(3, cart.getCount()); | |
| 63 | + assertTrue(cart.getSelected()); | |
| 64 | + verify(cartMapper, never()).updateById(any(CartDO.class)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + public void testAddCart_mergeBelowStock() { | |
| 69 | + // 准备参数 | |
| 70 | + Long userId = 1L; | |
| 71 | + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(2); | |
| 72 | + // mock 数据 | |
| 73 | + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) | |
| 74 | + .setCount(3).setSelected(false); | |
| 75 | + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); | |
| 76 | + // mock 方法 | |
| 77 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 78 | + | |
| 79 | + // 调用 | |
| 80 | + Long cartId = cartService.addCart(userId, reqVO); | |
| 81 | + // 断言 | |
| 82 | + assertEquals(100L, cartId); | |
| 83 | + ArgumentCaptor<CartDO> cartCaptor = ArgumentCaptor.forClass(CartDO.class); | |
| 84 | + verify(cartMapper).updateById(cartCaptor.capture()); | |
| 85 | + CartDO cart = cartCaptor.getValue(); | |
| 86 | + assertEquals(100L, cart.getId()); | |
| 87 | + assertEquals(5, cart.getCount()); | |
| 88 | + assertTrue(cart.getSelected()); | |
| 89 | + verify(cartMapper, never()).insert(any(CartDO.class)); | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Test | |
| 93 | + public void testAddCart_mergeEqualsStock() { | |
| 94 | + // 准备参数 | |
| 95 | + Long userId = 1L; | |
| 96 | + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(4); | |
| 97 | + // mock 数据 | |
| 98 | + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) | |
| 99 | + .setCount(6).setSelected(true); | |
| 100 | + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); | |
| 101 | + // mock 方法 | |
| 102 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 103 | + | |
| 104 | + // 调用 | |
| 105 | + Long cartId = cartService.addCart(userId, reqVO); | |
| 106 | + // 断言 | |
| 107 | + assertEquals(100L, cartId); | |
| 108 | + ArgumentCaptor<CartDO> cartCaptor = ArgumentCaptor.forClass(CartDO.class); | |
| 109 | + verify(cartMapper).updateById(cartCaptor.capture()); | |
| 110 | + assertEquals(10, cartCaptor.getValue().getCount()); | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test | |
| 114 | + public void testAddCart_mergeOverStock() { | |
| 115 | + // 准备参数:本次数量、已有数量都未超过库存,但合并后超过 | |
| 116 | + Long userId = 1L; | |
| 117 | + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(3); | |
| 118 | + // mock 数据 | |
| 119 | + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) | |
| 120 | + .setCount(8).setSelected(false); | |
| 121 | + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); | |
| 122 | + // mock 方法 | |
| 123 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 124 | + | |
| 125 | + // 调用,并断言异常 | |
| 126 | + assertServiceException(() -> cartService.addCart(userId, reqVO), SKU_STOCK_NOT_ENOUGH); | |
| 127 | + // 断言:未写入,且原购物项未被修改 | |
| 128 | + verify(cartMapper, never()).updateById(any(CartDO.class)); | |
| 129 | + verify(cartMapper, never()).insert(any(CartDO.class)); | |
| 130 | + assertEquals(8, dbCart.getCount()); | |
| 131 | + assertFalse(dbCart.getSelected()); | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + public void testAddCart_skuNotExists() { | |
| 136 | + // 准备参数 | |
| 137 | + Long userId = 1L; | |
| 138 | + AppCartAddReqVO reqVO = new AppCartAddReqVO().setSkuId(10L).setCount(1); | |
| 139 | + // mock 数据 | |
| 140 | + CartDO dbCart = new CartDO().setId(100L).setUserId(userId).setSpuId(20L).setSkuId(10L) | |
| 141 | + .setCount(1).setSelected(true); | |
| 142 | + when(cartMapper.selectByUserIdAndSkuId(eq(userId), eq(10L))).thenReturn(dbCart); | |
| 143 | + // mock 方法:SKU 已被删除 | |
| 144 | + when(productSkuApi.getSku(eq(10L))).thenReturn(null); | |
| 145 | + | |
| 146 | + // 调用,并断言异常 | |
| 147 | + assertServiceException(() -> cartService.addCart(userId, reqVO), SKU_NOT_EXISTS); | |
| 148 | + // 断言:未写入 | |
| 149 | + verify(cartMapper, never()).updateById(any(CartDO.class)); | |
| 150 | + verify(cartMapper, never()).insert(any(CartDO.class)); | |
| 151 | + } | |
| 152 | + | |
| 153 | + @Test | |
| 154 | + public void testUpdateCartCount_equalsStock() { | |
| 155 | + // 准备参数 | |
| 156 | + Long userId = 1L; | |
| 157 | + AppCartUpdateCountReqVO reqVO = new AppCartUpdateCountReqVO().setId(100L).setCount(10); | |
| 158 | + // mock 数据 | |
| 159 | + when(cartMapper.selectById(eq(100L), eq(userId))).thenReturn(new CartDO().setId(100L).setUserId(userId) | |
| 160 | + .setSpuId(20L).setSkuId(10L).setCount(3).setSelected(true)); | |
| 161 | + // mock 方法 | |
| 162 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 163 | + | |
| 164 | + // 调用 | |
| 165 | + cartService.updateCartCount(userId, reqVO); | |
| 166 | + // 断言 | |
| 167 | + ArgumentCaptor<CartDO> cartCaptor = ArgumentCaptor.forClass(CartDO.class); | |
| 168 | + verify(cartMapper).updateById(cartCaptor.capture()); | |
| 169 | + assertEquals(100L, cartCaptor.getValue().getId()); | |
| 170 | + assertEquals(10, cartCaptor.getValue().getCount()); | |
| 171 | + } | |
| 172 | + | |
| 173 | + @Test | |
| 174 | + public void testUpdateCartCount_overStock() { | |
| 175 | + // 准备参数 | |
| 176 | + Long userId = 1L; | |
| 177 | + AppCartUpdateCountReqVO reqVO = new AppCartUpdateCountReqVO().setId(100L).setCount(11); | |
| 178 | + // mock 数据 | |
| 179 | + when(cartMapper.selectById(eq(100L), eq(userId))).thenReturn(new CartDO().setId(100L).setUserId(userId) | |
| 180 | + .setSpuId(20L).setSkuId(10L).setCount(3).setSelected(true)); | |
| 181 | + // mock 方法 | |
| 182 | + when(productSkuApi.getSku(eq(10L))).thenReturn(new ProductSkuRespDTO().setId(10L).setSpuId(20L).setStock(10)); | |
| 183 | + | |
| 184 | + // 调用,并断言异常 | |
| 185 | + assertServiceException(() -> cartService.updateCartCount(userId, reqVO), SKU_STOCK_NOT_ENOUGH); | |
| 186 | + // 断言:未写入 | |
| 187 | + verify(cartMapper, never()).updateById(any(CartDO.class)); | |
| 188 | + } | |
| 189 | + | |
| 190 | +} | ... | ... |
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<MemberUserDO> { |
| 97 | 97 | Assert.isTrue(incrCount < 0); |
| 98 | 98 | LambdaUpdateWrapper<MemberUserDO> lambdaUpdateWrapper = new LambdaUpdateWrapper<MemberUserDO>() |
| 99 | 99 | .setSql(" point = point + " + incrCount) // 负数,所以使用 + 号 |
| 100 | - .eq(MemberUserDO::getId, id); | |
| 100 | + .eq(MemberUserDO::getId, id) | |
| 101 | + .ge(MemberUserDO::getPoint, -(long) incrCount); // 积分足够才扣减,避免并发扣减为负数;使用 long 避免取反溢出 | |
| 101 | 102 | return update(null, lambdaUpdateWrapper); |
| 102 | 103 | } |
| 103 | 104 | ... | ... |
yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImpl.java
| ... | ... | @@ -84,6 +84,7 @@ public class MemberPointRecordServiceImpl implements MemberPointRecordService { |
| 84 | 84 | if (!success) { |
| 85 | 85 | throw exception(USER_POINT_NOT_ENOUGH); |
| 86 | 86 | } |
| 87 | + totalPoint = memberUserService.getUser(userId).getPoint(); // 重新读取变动后的积分,避免并发时使用旧积分 | |
| 87 | 88 | |
| 88 | 89 | // 3. 增加积分记录 |
| 89 | 90 | MemberPointRecordDO record = new MemberPointRecordDO() | ... | ... |
yudao-module-member/src/test/java/cn/iocoder/yudao/module/member/service/point/MemberPointRecordServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.member.service.point; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; | |
| 4 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 5 | +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | |
| 6 | +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; | |
| 7 | +import cn.iocoder.yudao.module.member.dal.dataobject.point.MemberPointRecordDO; | |
| 8 | +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; | |
| 9 | +import cn.iocoder.yudao.module.member.dal.mysql.point.MemberPointRecordMapper; | |
| 10 | +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; | |
| 11 | +import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer; | |
| 12 | +import cn.iocoder.yudao.module.member.service.user.MemberUserService; | |
| 13 | +import cn.iocoder.yudao.module.member.service.user.MemberUserServiceImpl; | |
| 14 | +import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; | |
| 15 | +import cn.iocoder.yudao.module.system.api.social.SocialClientApi; | |
| 16 | +import jakarta.annotation.Resource; | |
| 17 | +import org.junit.jupiter.api.Test; | |
| 18 | +import org.springframework.context.annotation.Import; | |
| 19 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 20 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 21 | +import org.springframework.test.context.bean.override.mockito.MockitoBean; | |
| 22 | +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; | |
| 23 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 24 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 25 | + | |
| 26 | +import java.util.ArrayList; | |
| 27 | +import java.util.List; | |
| 28 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 29 | +import java.util.concurrent.CountDownLatch; | |
| 30 | +import java.util.concurrent.ExecutionException; | |
| 31 | +import java.util.concurrent.ExecutorService; | |
| 32 | +import java.util.concurrent.Executors; | |
| 33 | +import java.util.concurrent.Future; | |
| 34 | +import java.util.concurrent.TimeUnit; | |
| 35 | + | |
| 36 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 37 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 38 | +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; | |
| 39 | +import static cn.iocoder.yudao.module.member.enums.ErrorCodeConstants.USER_POINT_NOT_ENOUGH; | |
| 40 | +import static cn.iocoder.yudao.module.member.enums.point.MemberPointBizTypeEnum.SIGN; | |
| 41 | +import static org.junit.jupiter.api.Assertions.*; | |
| 42 | +import static org.mockito.Mockito.*; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * {@link MemberPointRecordServiceImpl} 的单元测试类 | |
| 46 | + * | |
| 47 | + * @author 芋道源码 | |
| 48 | + */ | |
| 49 | +@Import({MemberPointRecordServiceImpl.class, MemberUserServiceImpl.class}) | |
| 50 | +public class MemberPointRecordServiceImplTest extends BaseDbUnitTest { | |
| 51 | + | |
| 52 | + @Resource | |
| 53 | + private MemberPointRecordServiceImpl memberPointRecordService; | |
| 54 | + | |
| 55 | + @Resource | |
| 56 | + private MemberUserMapper memberUserMapper; | |
| 57 | + @Resource | |
| 58 | + private MemberPointRecordMapper memberPointRecordMapper; | |
| 59 | + @Resource | |
| 60 | + private PlatformTransactionManager transactionManager; | |
| 61 | + | |
| 62 | + @MockitoSpyBean | |
| 63 | + private MemberUserService memberUserService; | |
| 64 | + @MockitoBean | |
| 65 | + private SmsCodeApi smsCodeApi; | |
| 66 | + @MockitoBean | |
| 67 | + private SocialClientApi socialClientApi; | |
| 68 | + @MockitoBean | |
| 69 | + private OAuth2TokenCommonApi oauth2TokenApi; | |
| 70 | + @MockitoBean | |
| 71 | + private PasswordEncoder passwordEncoder; | |
| 72 | + @MockitoBean | |
| 73 | + private MemberUserProducer memberUserProducer; | |
| 74 | + | |
| 75 | + // ========== createPointRecord ========== | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + public void testCreatePointRecord_concurrentDecrNotEnough() throws Exception { | |
| 79 | + // mock 数据 | |
| 80 | + MemberUserDO user = createUser(100); | |
| 81 | + | |
| 82 | + // 调用:两笔并发各扣 80 积分 | |
| 83 | + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), -80); | |
| 84 | + // 断言:两笔都通过了预检查,但只有一笔扣减成功 | |
| 85 | + verify(memberUserService, times(2)).updateUserPoint(eq(user.getId()), eq(-80)); | |
| 86 | + assertEquals(1, errors.size()); | |
| 87 | + assertServiceException(() -> { | |
| 88 | + throw errors.get(0); | |
| 89 | + }, USER_POINT_NOT_ENOUGH); | |
| 90 | + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 91 | + // 断言:失败的一笔整体回滚,只有成功的一笔流水 | |
| 92 | + List<MemberPointRecordDO> records = getPointRecordList(user.getId()); | |
| 93 | + assertEquals(1, records.size()); | |
| 94 | + assertEquals(-80, records.get(0).getPoint()); | |
| 95 | + assertEquals(20, records.get(0).getTotalPoint()); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + public void testCreatePointRecord_concurrentDecr() throws Exception { | |
| 100 | + // mock 数据 | |
| 101 | + MemberUserDO user = createUser(100); | |
| 102 | + | |
| 103 | + // 调用:两笔并发各扣 40 积分 | |
| 104 | + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), -40); | |
| 105 | + // 断言:流水按实际扣减先后记录变动后的积分 | |
| 106 | + assertEquals(0, errors.size()); | |
| 107 | + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 108 | + List<MemberPointRecordDO> records = getPointRecordList(user.getId()); | |
| 109 | + assertEquals(List.of(-40, -40), convertList(records, MemberPointRecordDO::getPoint)); | |
| 110 | + assertEquals(List.of(60, 20), convertList(records, MemberPointRecordDO::getTotalPoint)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test | |
| 114 | + public void testCreatePointRecord_concurrentIncr() throws Exception { | |
| 115 | + // mock 数据 | |
| 116 | + MemberUserDO user = createUser(100); | |
| 117 | + | |
| 118 | + // 调用:两笔并发各加 40 积分 | |
| 119 | + List<Throwable> errors = concurrentCreatePointRecord(user.getId(), 40); | |
| 120 | + // 断言:流水按实际增加先后记录变动后的积分 | |
| 121 | + assertEquals(0, errors.size()); | |
| 122 | + assertEquals(180, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 123 | + List<MemberPointRecordDO> records = getPointRecordList(user.getId()); | |
| 124 | + assertEquals(List.of(40, 40), convertList(records, MemberPointRecordDO::getPoint)); | |
| 125 | + assertEquals(List.of(140, 180), convertList(records, MemberPointRecordDO::getTotalPoint)); | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + public void testCreatePointRecord_notEnough() { | |
| 130 | + // mock 数据 | |
| 131 | + MemberUserDO user = createUser(20); | |
| 132 | + | |
| 133 | + // 调用:预检查积分不足,打印日志后直接返回 | |
| 134 | + memberPointRecordService.createPointRecord(user.getId(), -80, SIGN, randomString()); | |
| 135 | + // 断言 | |
| 136 | + verify(memberUserService, never()).updateUserPoint(anyLong(), anyInt()); | |
| 137 | + assertEquals(20, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 138 | + assertEquals(0, getPointRecordList(user.getId()).size()); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + public void testCreatePointRecord_sameSqlSession() { | |
| 143 | + // mock 数据 | |
| 144 | + MemberUserDO user = createUser(100); | |
| 145 | + | |
| 146 | + // 调用:同一事务(SqlSession)内,先查询,再变动积分,再查询 | |
| 147 | + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { | |
| 148 | + MemberUserDO cachedUser = memberUserMapper.selectById(user.getId()); | |
| 149 | + assertSame(cachedUser, memberUserMapper.selectById(user.getId())); // 命中一级缓存 | |
| 150 | + assertEquals(100, cachedUser.getPoint()); | |
| 151 | + | |
| 152 | + memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, randomString()); | |
| 153 | + | |
| 154 | + // 断言:UPDATE 清空了一级缓存,查询到自身写入的积分 | |
| 155 | + MemberUserDO latestUser = memberUserMapper.selectById(user.getId()); | |
| 156 | + assertNotSame(cachedUser, latestUser); | |
| 157 | + assertEquals(70, latestUser.getPoint()); | |
| 158 | + }); | |
| 159 | + // 断言:流水使用 UPDATE 后重新读取的积分 | |
| 160 | + verify(memberUserService, times(2)).getUser(eq(user.getId())); | |
| 161 | + List<MemberPointRecordDO> records = getPointRecordList(user.getId()); | |
| 162 | + assertEquals(1, records.size()); | |
| 163 | + assertEquals(70, records.get(0).getTotalPoint()); | |
| 164 | + assertEquals(70, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 165 | + } | |
| 166 | + | |
| 167 | + @Test | |
| 168 | + public void testCreatePointRecord_insertFailRollback() { | |
| 169 | + // mock 数据 | |
| 170 | + MemberUserDO user = createUser(100); | |
| 171 | + // mock 方法:记录每次读取到的积分 | |
| 172 | + List<Integer> readPoints = new CopyOnWriteArrayList<>(); | |
| 173 | + doAnswer(invocation -> { | |
| 174 | + MemberUserDO result = (MemberUserDO) invocation.callRealMethod(); | |
| 175 | + readPoints.add(result.getPoint()); | |
| 176 | + return result; | |
| 177 | + }).when(memberUserService).getUser(eq(user.getId())); | |
| 178 | + | |
| 179 | + // 调用:bizId 为空,违反 biz_id 非空约束,插入流水失败 | |
| 180 | + assertThrows(DataIntegrityViolationException.class, | |
| 181 | + () -> memberPointRecordService.createPointRecord(user.getId(), -30, SIGN, null)); | |
| 182 | + // 断言:事务内积分已扣减为 70,随插入流水失败一起回滚 | |
| 183 | + assertEquals(List.of(100, 70), readPoints); | |
| 184 | + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 185 | + assertEquals(0, getPointRecordList(user.getId()).size()); | |
| 186 | + } | |
| 187 | + | |
| 188 | + // ========== MemberUserMapper.updatePointDecr ========== | |
| 189 | + | |
| 190 | + @Test | |
| 191 | + public void testUpdatePointDecr_exact() { | |
| 192 | + // mock 数据 | |
| 193 | + MemberUserDO user = createUser(100); | |
| 194 | + | |
| 195 | + // 调用,并断言 | |
| 196 | + assertEquals(1, memberUserMapper.updatePointDecr(user.getId(), -100)); | |
| 197 | + assertEquals(0, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 198 | + } | |
| 199 | + | |
| 200 | + @Test | |
| 201 | + public void testUpdatePointDecr_notEnough() { | |
| 202 | + // mock 数据 | |
| 203 | + MemberUserDO user = createUser(100); | |
| 204 | + | |
| 205 | + // 调用,并断言 | |
| 206 | + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), -101)); | |
| 207 | + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 208 | + } | |
| 209 | + | |
| 210 | + @Test | |
| 211 | + public void testUpdatePointDecr_minValue() { | |
| 212 | + // mock 数据 | |
| 213 | + MemberUserDO user = createUser(100); | |
| 214 | + | |
| 215 | + // 调用,并断言:Integer.MIN_VALUE 取反不溢出,按积分不足处理 | |
| 216 | + assertEquals(0, memberUserMapper.updatePointDecr(user.getId(), Integer.MIN_VALUE)); | |
| 217 | + assertEquals(100, memberUserMapper.selectById(user.getId()).getPoint()); | |
| 218 | + } | |
| 219 | + | |
| 220 | + // ========== 私有方法 ========== | |
| 221 | + | |
| 222 | + /** | |
| 223 | + * 两个线程各自开启事务,都读取到变动前的积分后,再并发变动积分 | |
| 224 | + * | |
| 225 | + * @return 失败线程的异常 | |
| 226 | + */ | |
| 227 | + private List<Throwable> concurrentCreatePointRecord(Long userId, Integer point) throws Exception { | |
| 228 | + // 首次读取积分后,等待另一个线程也读取完成;UPDATE 后的重新读取不再等待 | |
| 229 | + CountDownLatch latch = new CountDownLatch(2); | |
| 230 | + doAnswer(invocation -> { | |
| 231 | + Object result = invocation.callRealMethod(); | |
| 232 | + latch.countDown(); | |
| 233 | + assertTrue(latch.await(10, TimeUnit.SECONDS)); | |
| 234 | + return result; | |
| 235 | + }).when(memberUserService).getUser(eq(userId)); | |
| 236 | + | |
| 237 | + ExecutorService executor = Executors.newFixedThreadPool(2); | |
| 238 | + try { | |
| 239 | + List<Future<?>> futures = new ArrayList<>(); | |
| 240 | + for (int i = 0; i < 2; i++) { | |
| 241 | + futures.add(executor.submit(() -> memberPointRecordService.createPointRecord( | |
| 242 | + userId, point, SIGN, randomString()))); | |
| 243 | + } | |
| 244 | + List<Throwable> errors = new ArrayList<>(); | |
| 245 | + for (Future<?> future : futures) { | |
| 246 | + try { | |
| 247 | + future.get(30, TimeUnit.SECONDS); | |
| 248 | + } catch (ExecutionException ex) { | |
| 249 | + errors.add(ex.getCause()); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + return errors; | |
| 253 | + } finally { | |
| 254 | + executor.shutdownNow(); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + private MemberUserDO createUser(Integer point) { | |
| 259 | + MemberUserDO user = MemberUserDO.builder().mobile("15601691300").status(CommonStatusEnum.ENABLE.getStatus()) | |
| 260 | + .registerIp("127.0.0.1").point(point).build(); | |
| 261 | + memberUserMapper.insert(user); | |
| 262 | + return user; | |
| 263 | + } | |
| 264 | + | |
| 265 | + private List<MemberPointRecordDO> getPointRecordList(Long userId) { | |
| 266 | + return memberPointRecordMapper.selectList(new LambdaQueryWrapperX<MemberPointRecordDO>() | |
| 267 | + .eq(MemberPointRecordDO::getUserId, userId) | |
| 268 | + .orderByAsc(MemberPointRecordDO::getId)); | |
| 269 | + } | |
| 270 | + | |
| 271 | +} | ... | ... |
yudao-module-member/src/test/resources/sql/clean.sql
| ... | ... | @@ -2,4 +2,5 @@ DELETE FROM "member_user"; |
| 2 | 2 | DELETE FROM "member_address"; |
| 3 | 3 | DELETE FROM "member_tag"; |
| 4 | 4 | DELETE FROM "member_level"; |
| 5 | -DELETE FROM "member_group"; | |
| 6 | 5 | \ No newline at end of file |
| 6 | +DELETE FROM "member_group"; | |
| 7 | +DELETE FROM "member_point_record"; | |
| 7 | 8 | \ No newline at end of file | ... | ... |
yudao-module-member/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS "member_user" |
| 14 | 14 | "email" varchar(50) NULL COMMENT '邮箱', |
| 15 | 15 | "password" varchar(100) NOT NULL DEFAULT '' COMMENT '密码', |
| 16 | 16 | "register_ip" varchar(32) NOT NULL COMMENT '注册 IP', |
| 17 | + "register_terminal" tinyint NULL DEFAULT NULL COMMENT '注册终端', | |
| 17 | 18 | "login_ip" varchar(50) NULL DEFAULT '' COMMENT '最后登录IP', |
| 18 | 19 | "login_date" datetime NULL DEFAULT NULL COMMENT '最后登录时间', |
| 19 | 20 | "tag_ids" varchar(255) NULL DEFAULT NULL COMMENT '用户标签编号列表,以逗号分隔', |
| ... | ... | @@ -112,3 +113,22 @@ CREATE TABLE IF NOT EXISTS "member_brokerage_record" |
| 112 | 113 | "tenant_id" bigint not null default '0', |
| 113 | 114 | PRIMARY KEY ("id") |
| 114 | 115 | ) COMMENT '佣金记录'; |
| 116 | + | |
| 117 | +CREATE TABLE IF NOT EXISTS "member_point_record" | |
| 118 | +( | |
| 119 | + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, | |
| 120 | + "user_id" bigint NOT NULL, | |
| 121 | + "biz_id" varchar(255) NOT NULL, | |
| 122 | + "biz_type" tinyint NOT NULL, | |
| 123 | + "title" varchar(255) NOT NULL, | |
| 124 | + "description" varchar(5000) NULL, | |
| 125 | + "point" int NOT NULL, | |
| 126 | + "total_point" int NOT NULL, | |
| 127 | + "creator" varchar(64) DEFAULT '', | |
| 128 | + "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 129 | + "updater" varchar(64) DEFAULT '', | |
| 130 | + "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |
| 131 | + "deleted" bit NOT NULL DEFAULT FALSE, | |
| 132 | + "tenant_id" bigint NOT NULL default '0', | |
| 133 | + PRIMARY KEY ("id") | |
| 134 | +) COMMENT '用户积分记录'; | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java
| ... | ... | @@ -190,13 +190,13 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { |
| 190 | 190 | // mock 数据:已有点评与本次追加内容按字符数计算 |
| 191 | 191 | Long ownerId = randomLongId(); |
| 192 | 192 | Long managerId = randomLongId(); |
| 193 | - OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); | |
| 193 | + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 998 - System.lineSeparator().length())); | |
| 194 | 194 | planMapper.insert(plan); |
| 195 | 195 | // mock 方法 |
| 196 | 196 | when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( |
| 197 | 197 | Collections.singletonList(new AdminUserRespDTO().setId(ownerId))); |
| 198 | 198 | |
| 199 | - // 调用:包含换行后恰好达到 65535 个字符 | |
| 199 | + // 调用:包含换行后恰好达到 1000 个字符 | |
| 200 | 200 | planService.addPlanComment(plan.getId(), "ab", managerId); |
| 201 | 201 | |
| 202 | 202 | // 断言 |
| ... | ... | @@ -209,7 +209,7 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { |
| 209 | 209 | // mock 数据:已有点评与本次追加内容按字符数计算 |
| 210 | 210 | Long ownerId = randomLongId(); |
| 211 | 211 | Long managerId = randomLongId(); |
| 212 | - OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); | |
| 212 | + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 998 - System.lineSeparator().length())); | |
| 213 | 213 | planMapper.insert(plan); |
| 214 | 214 | // mock 方法 |
| 215 | 215 | when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( |
| ... | ... | @@ -220,7 +220,7 @@ public class OaPlanServiceImplTest extends BaseDbUnitTest { |
| 220 | 220 | () -> planService.addPlanComment(plan.getId(), "abc", managerId)); |
| 221 | 221 | assertTrue(exception.getConstraintViolations().stream().anyMatch(violation -> |
| 222 | 222 | "comment".equals(violation.getPropertyPath().toString()) |
| 223 | - && "点评内容长度不能超过 65535 个字符".equals(violation.getMessage()))); | |
| 223 | + && "点评内容长度不能超过 1000 个字符".equals(violation.getMessage()))); | |
| 224 | 224 | assertEquals(plan.getComment(), planMapper.selectById(plan.getId()).getComment()); |
| 225 | 225 | } |
| 226 | 226 | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java
| ... | ... | @@ -64,7 +64,8 @@ public class OaVehicleReturnServiceImplTest extends BaseDbUnitTest { |
| 64 | 64 | // mock 数据:实际提前出车,不能用计划时间代替事实 |
| 65 | 65 | OaVehicleApplyDO apply = randomVehicleApplyDO(); |
| 66 | 66 | OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class) |
| 67 | - .setActualStartTime(apply.getStartTime().minusHours(2)).setActualReturnTime(apply.getStartTime().minusHours(1)) | |
| 67 | + .setActualStartTime(apply.getStartTime().minusHours(2).withNano(0)) | |
| 68 | + .setActualReturnTime(apply.getStartTime().minusHours(1).withNano(0)) | |
| 68 | 69 | .setStartLocation("客户现场").setReason("现场支持").setPassenger("李四"); |
| 69 | 70 | // mock 方法 |
| 70 | 71 | when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(apply); | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java
| ... | ... | @@ -16,9 +16,11 @@ import java.math.BigDecimal; |
| 16 | 16 | import java.time.LocalDateTime; |
| 17 | 17 | import java.util.Collections; |
| 18 | 18 | |
| 19 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 19 | 20 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 20 | 21 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 21 | 22 | import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; |
| 23 | +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.DEPT_NOT_FOUND; | |
| 22 | 24 | import static org.junit.jupiter.api.Assertions.*; |
| 23 | 25 | import static org.mockito.Mockito.*; |
| 24 | 26 | |
| ... | ... | @@ -187,6 +189,25 @@ public class OaVehicleServiceImplTest extends BaseDbUnitTest { |
| 187 | 189 | } |
| 188 | 190 | |
| 189 | 191 | @Test |
| 192 | + public void testUpdateVehicle_deptInvalid() { | |
| 193 | + // mock 数据 | |
| 194 | + OaVehicleDO vehicle = randomVehicleDO().setDeptId(103L).setPicUrl("https://example.com/old.png"); | |
| 195 | + vehicleMapper.insert(vehicle); | |
| 196 | + // 准备参数 | |
| 197 | + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class) | |
| 198 | + .setDeptId(104L).setPicUrl("https://example.com/new.png"); | |
| 199 | + // mock 方法 | |
| 200 | + doThrow(exception(DEPT_NOT_FOUND)).when(deptApi).validateDeptList(Collections.singleton(104L)); | |
| 201 | + | |
| 202 | + // 调用,并断言异常 | |
| 203 | + assertServiceException(() -> vehicleService.updateVehicle(reqVO), DEPT_NOT_FOUND); | |
| 204 | + // 断言:部门校验失败时不更新车辆 | |
| 205 | + OaVehicleDO dbVehicle = vehicleMapper.selectById(vehicle.getId()); | |
| 206 | + assertEquals(103L, dbVehicle.getDeptId()); | |
| 207 | + assertEquals("https://example.com/old.png", dbVehicle.getPicUrl()); | |
| 208 | + } | |
| 209 | + | |
| 210 | + @Test | |
| 190 | 211 | public void testCreateVehicle_deletedPlateCanBeReused() { |
| 191 | 212 | // mock 数据 |
| 192 | 213 | OaVehicleDO vehicle = randomVehicleDO(); | ... | ... |
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.*; |
| 27 | 27 | |
| 28 | 28 | import java.util.Objects; |
| 29 | 29 | |
| 30 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 30 | 31 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 31 | 32 | import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP; |
| 32 | 33 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; |
| 33 | 34 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserType; |
| 35 | +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.PAY_ORDER_NOT_FOUND; | |
| 34 | 36 | |
| 35 | 37 | @Tag(name = "用户 APP - 支付订单") |
| 36 | 38 | @RestController |
| ... | ... | @@ -65,8 +67,7 @@ public class AppPayOrderController { |
| 65 | 67 | return success(null); |
| 66 | 68 | } |
| 67 | 69 | // 重要:校验订单是否是当前用户,避免越权 |
| 68 | - if (order.getUserId() != null // 特殊:早期订单未存储 userId,所以忽略 | |
| 69 | - && ObjUtil.notEqual(order.getUserId(), getLoginUserId())) { | |
| 70 | + if (!isLoginUserOrder(order)) { | |
| 70 | 71 | return success(null); |
| 71 | 72 | } |
| 72 | 73 | |
| ... | ... | @@ -82,7 +83,13 @@ public class AppPayOrderController { |
| 82 | 83 | @PostMapping("/submit") |
| 83 | 84 | @Operation(summary = "提交支付订单") |
| 84 | 85 | public CommonResult<AppPayOrderSubmitRespVO> submitPayOrder(@RequestBody AppPayOrderSubmitReqVO reqVO) { |
| 85 | - // 1. 钱包支付事,需要额外传 user_id 和 user_type | |
| 86 | + // 1. 重要:校验订单是否是当前用户,避免越权 | |
| 87 | + PayOrderDO order = payOrderService.getOrder(reqVO.getId()); | |
| 88 | + if (order == null || !isLoginUserOrder(order)) { | |
| 89 | + throw exception(PAY_ORDER_NOT_FOUND); | |
| 90 | + } | |
| 91 | + | |
| 92 | + // 2. 钱包支付事,需要额外传 user_id 和 user_type | |
| 86 | 93 | if (Objects.equals(reqVO.getChannelCode(), PayChannelEnum.WALLET.getCode())) { |
| 87 | 94 | if (reqVO.getChannelExtras() == null) { |
| 88 | 95 | reqVO.setChannelExtras(Maps.newHashMapWithExpectedSize(1)); |
| ... | ... | @@ -91,9 +98,23 @@ public class AppPayOrderController { |
| 91 | 98 | reqVO.getChannelExtras().put(WalletPayClient.WALLET_ID_KEY, String.valueOf(wallet.getId())); |
| 92 | 99 | } |
| 93 | 100 | |
| 94 | - // 2. 提交支付 | |
| 101 | + // 3. 提交支付 | |
| 95 | 102 | PayOrderSubmitRespVO respVO = payOrderService.submitOrder(reqVO, getClientIP()); |
| 96 | 103 | return success(BeanUtils.toBean(respVO, AppPayOrderSubmitRespVO.class)); |
| 97 | 104 | } |
| 98 | 105 | |
| 106 | + /** | |
| 107 | + * 判断订单是否属于当前登录用户,需同时匹配用户编号和用户类型 | |
| 108 | + * | |
| 109 | + * 相关 issue:https://github.com/YunaiV/ruoyi-vue-pro/issues/1303 | |
| 110 | + * | |
| 111 | + * @param order 支付订单 | |
| 112 | + * @return 是否属于 | |
| 113 | + */ | |
| 114 | + private boolean isLoginUserOrder(PayOrderDO order) { | |
| 115 | + // 特殊:早期订单未存储 userId、userType,所以忽略 | |
| 116 | + return (order.getUserId() == null || ObjUtil.equal(order.getUserId(), getLoginUserId())) | |
| 117 | + && (order.getUserType() == null || ObjUtil.equal(order.getUserType(), getLoginUserType())); | |
| 118 | + } | |
| 119 | + | |
| 99 | 120 | } | ... | ... |
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 { |
| 167 | 167 | TenantContextHolder.setTenantId(loginUser.getTenantId()); |
| 168 | 168 | |
| 169 | 169 | // 参见文档 https://help.jimureport.com/prodSafe/ 文档 |
| 170 | - // 适配:如果是本系统的管理员,则返回积木报表(仪表盘/大屏设计器)的所有权限指令 | |
| 170 | + // 适配:如果是本系统的管理员,则返回积木报表(仪表盘/大屏设计器、报表设计器)的所有权限指令 | |
| 171 | 171 | // 如果不处理,会碰到 https://t.zsxq.com/yzlkA 反馈的问题 |
| 172 | + // 权限清单参考 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1600 | |
| 172 | 173 | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| 173 | 174 | if (permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())) { |
| 174 | 175 | return new String[]{ |
| 175 | - "drag:datasource:testConnection", // 数据库连接测试 | |
| 176 | - "drag:datasource:saveOrUpate", // 数据源保存 | |
| 177 | - "drag:datasource:delete", // 数据源删除 | |
| 178 | - "drag:analysis:sql", // SQL解析 | |
| 179 | - "drag:design:getTotalData", // 展示Online表单数据 | |
| 180 | - "drag:dataset:save", // 数据集保存 | |
| 181 | - "drag:dataset:delete", // 数据集删除 | |
| 182 | - "onl:drag:clear:recovery", // 清空回收站 | |
| 183 | - "onl:drag:page:delete" // 数据删除 | |
| 176 | + // ========== 第一块:大屏(仪表盘 BI)按钮权限 ========== | |
| 177 | + "drag:datasource:testConnection", // 数据源连接测试 | |
| 178 | + "drag:datasource:saveOrUpate", // 数据源新增/编辑 | |
| 179 | + "drag:datasource:delete", // 数据源删除 | |
| 180 | + "drag:datasource:deleteBatch", // 数据源批量删除 | |
| 181 | + "drag:datasource:queryById", // 数据源详情查询 | |
| 182 | + "drag:dataset:save", // 数据集保存 | |
| 183 | + "drag:dataset:delete", // 数据集删除 | |
| 184 | + "drag:analysis:sql", // SQL解析 | |
| 185 | + "drag:design:getTotalData", // 仪表盘对Online表单展示数据 | |
| 186 | + "drag:iconlib:config", // 图标库配置 | |
| 187 | + "onl:drag:clear:recovery", // 清空回收站 | |
| 188 | + "onl:drag:page:delete", // 仪表盘页面删除 | |
| 189 | + "onl:drag:category:delete", // 仪表盘分类删除 | |
| 190 | + "onl:drag:comp:add", // 组件新增 | |
| 191 | + "onl:drag:comp:edit", // 组件编辑 | |
| 192 | + "onl:drag:comp:delete", // 组件删除 | |
| 193 | + "onl:drag:comp:deleteBatch", // 组件批量删除 | |
| 194 | + "onl:drag:getRawTableData", // 获取原始表数据 | |
| 195 | + // ========== 第二块:积木报表按钮权限 ========== | |
| 196 | + "jmreport:exportJob:plugin", // 导出任务-插件 | |
| 197 | + "jmreport:exportJob:list", // 导出任务-列表 | |
| 198 | + "jmreport:exportJob:save", // 导出任务-保存 | |
| 199 | + "jmreport:exportJob:detail", // 导出任务-详情 | |
| 200 | + "jmreport:exportJob:delete", // 导出任务-删除 | |
| 201 | + "jmreport:exportJob:status", // 导出任务-状态变更 | |
| 202 | + "jmreport:exportJob:run", // 导出任务-执行 | |
| 203 | + "jmreport:exportJob:export", // 导出任务-导出 | |
| 204 | + "jmreport:map:add", // 地图-新增 | |
| 205 | + "jmreport:map:delete", // 地图-删除 | |
| 206 | + "jmreport:reportGroup:list", // 报表分组-列表 | |
| 207 | + "jmreport:reportGroup:detail", // 报表分组-详情 | |
| 208 | + "jmreport:reportGroup:save", // 报表分组-保存 | |
| 209 | + "jmreport:reportGroup:delete", // 报表分组-删除 | |
| 210 | + "jmreport:category:add", // 报表分类-新增 | |
| 211 | + "jmreport:category:edit", // 报表分类-编辑 | |
| 212 | + "jmreport:category:delete", // 报表分类-删除 | |
| 213 | + "jmreport:category:reduction", // 报表分类-还原 | |
| 214 | + "jmreport:category:completelyDelete", // 报表分类-彻底删除 | |
| 215 | + "jmreport:category:batchMove", // 报表分类-批量移动 | |
| 216 | + "jmreport:category:folderSort", // 报表分类-文件夹排序 | |
| 217 | + "jmreport:datasource:testConnection", // 数据源-连接测试 | |
| 218 | + "jmreport:datasource:saveOrUpate", // 数据源-新增/编辑 | |
| 219 | + "jmreport:datasource:delete", // 数据源-删除 | |
| 220 | + "jmreport:datasource:query", // 数据源-查询(列表/详情) | |
| 221 | + "jmreport:design:loadTableData", // 设计器-加载表数据 | |
| 222 | + "jmreport:design:parseField", // 设计器-解析字段(SQL/API/JavaBean/填报处理器) | |
| 223 | + "jmreport:design:loadTable", // 设计器-库表/字段元数据查询 | |
| 224 | + "jmreport:design:saveDb", // 设计器-数据集保存 | |
| 225 | + "jmreport:design:filesDataset", // 设计器-文件数据集 | |
| 226 | + "jmreport:design:editEnhance", // 设计器-增强代码(JS/CSS/Python) | |
| 227 | + "jmreport:ai:tableMeta", // AI-表元数据管理 | |
| 228 | + "jimu:chat2bi:permEdit" // Chat2BI-权限编辑 | |
| 184 | 229 | }; |
| 185 | 230 | } |
| 186 | 231 | return null; | ... | ... |
yudao-module-report/src/test/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.report.framework.jmreport.core.service; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; | |
| 5 | +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; | |
| 6 | +import cn.iocoder.yudao.framework.security.config.SecurityProperties; | |
| 7 | +import cn.iocoder.yudao.framework.security.core.LoginUser; | |
| 8 | +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; | |
| 9 | +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | |
| 10 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 11 | +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; | |
| 12 | +import org.junit.jupiter.api.AfterEach; | |
| 13 | +import org.junit.jupiter.api.Test; | |
| 14 | +import org.mockito.InjectMocks; | |
| 15 | +import org.mockito.Mock; | |
| 16 | +import org.springframework.mock.web.MockHttpServletRequest; | |
| 17 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 18 | + | |
| 19 | +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; | |
| 20 | +import static org.junit.jupiter.api.Assertions.*; | |
| 21 | +import static org.mockito.Mockito.verifyNoInteractions; | |
| 22 | +import static org.mockito.Mockito.when; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * {@link JmReportTokenServiceImpl} 的单元测试类 | |
| 26 | + * | |
| 27 | + * @author 芋道源码 | |
| 28 | + */ | |
| 29 | +public class JmReportTokenServiceImplTest extends BaseMockitoUnitTest { | |
| 30 | + | |
| 31 | + @InjectMocks | |
| 32 | + private JmReportTokenServiceImpl jmReportTokenService; | |
| 33 | + | |
| 34 | + @Mock | |
| 35 | + private OAuth2TokenCommonApi oauth2TokenApi; | |
| 36 | + @Mock | |
| 37 | + private PermissionCommonApi permissionApi; | |
| 38 | + @Mock | |
| 39 | + private SecurityProperties securityProperties; | |
| 40 | + | |
| 41 | + @AfterEach | |
| 42 | + public void tearDown() { | |
| 43 | + SecurityContextHolder.clearContext(); | |
| 44 | + TenantContextHolder.clear(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + public void testGetPermissions_superAdmin() { | |
| 49 | + // 准备参数 | |
| 50 | + Long userId = randomLongId(); | |
| 51 | + Long tenantId = randomLongId(); | |
| 52 | + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), | |
| 53 | + new MockHttpServletRequest()); | |
| 54 | + // mock 方法 | |
| 55 | + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(true); | |
| 56 | + | |
| 57 | + // 调用 | |
| 58 | + String[] permissions = jmReportTokenService.getPermissions("token"); | |
| 59 | + // 断言:原有的大屏权限仍保留 | |
| 60 | + assertTrue(ArrayUtil.containsAll(permissions, "drag:datasource:testConnection", "drag:analysis:sql", | |
| 61 | + "onl:drag:page:delete")); | |
| 62 | + // 断言:大屏只校验权限、未配置时超级管理员会被拒绝的三处 | |
| 63 | + assertTrue(ArrayUtil.containsAll(permissions, "drag:datasource:queryById", "drag:datasource:deleteBatch", | |
| 64 | + "onl:drag:getRawTableData")); | |
| 65 | + // 断言:报表设计器的扩展权限 | |
| 66 | + assertTrue(ArrayUtil.containsAll(permissions, "jmreport:datasource:query", "jmreport:exportJob:export", | |
| 67 | + "jmreport:ai:tableMeta")); | |
| 68 | + assertEquals(permissions.length, ArrayUtil.distinct(permissions).length); | |
| 69 | + assertEquals(tenantId, TenantContextHolder.getTenantId()); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + public void testGetPermissions_notSuperAdmin() { | |
| 74 | + // 准备参数 | |
| 75 | + Long userId = randomLongId(); | |
| 76 | + Long tenantId = randomLongId(); | |
| 77 | + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), | |
| 78 | + new MockHttpServletRequest()); | |
| 79 | + // mock 方法 | |
| 80 | + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(false); | |
| 81 | + | |
| 82 | + // 调用 | |
| 83 | + String[] permissions = jmReportTokenService.getPermissions("token"); | |
| 84 | + // 断言 | |
| 85 | + assertNull(permissions); | |
| 86 | + assertEquals(tenantId, TenantContextHolder.getTenantId()); | |
| 87 | + } | |
| 88 | + | |
| 89 | + @Test | |
| 90 | + public void testGetPermissions_notLogin() { | |
| 91 | + // 调用 | |
| 92 | + String[] permissions = jmReportTokenService.getPermissions("token"); | |
| 93 | + // 断言 | |
| 94 | + assertNull(permissions); | |
| 95 | + assertNull(TenantContextHolder.getTenantId()); | |
| 96 | + verifyNoInteractions(permissionApi); | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Test | |
| 100 | + public void testGetRoles_superAdmin() { | |
| 101 | + // 准备参数 | |
| 102 | + Long userId = randomLongId(); | |
| 103 | + Long tenantId = randomLongId(); | |
| 104 | + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(tenantId), | |
| 105 | + new MockHttpServletRequest()); | |
| 106 | + // mock 方法 | |
| 107 | + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(true); | |
| 108 | + | |
| 109 | + // 调用 | |
| 110 | + String[] roles = jmReportTokenService.getRoles("token"); | |
| 111 | + // 断言 | |
| 112 | + assertArrayEquals(new String[]{"admin"}, roles); | |
| 113 | + assertEquals(tenantId, TenantContextHolder.getTenantId()); | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 117 | + public void testGetRoles_notSuperAdmin() { | |
| 118 | + // 准备参数 | |
| 119 | + Long userId = randomLongId(); | |
| 120 | + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId).setTenantId(randomLongId()), | |
| 121 | + new MockHttpServletRequest()); | |
| 122 | + // mock 方法 | |
| 123 | + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(false); | |
| 124 | + | |
| 125 | + // 调用 | |
| 126 | + String[] roles = jmReportTokenService.getRoles("token"); | |
| 127 | + // 断言 | |
| 128 | + assertNull(roles); | |
| 129 | + } | |
| 130 | + | |
| 131 | + @Test | |
| 132 | + public void testGetRoles_notLogin() { | |
| 133 | + // 调用 | |
| 134 | + String[] roles = jmReportTokenService.getRoles("token"); | |
| 135 | + // 断言 | |
| 136 | + assertNull(roles); | |
| 137 | + verifyNoInteractions(permissionApi); | |
| 138 | + } | |
| 139 | + | |
| 140 | +} | ... | ... |
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java
| ... | ... | @@ -21,6 +21,7 @@ import jakarta.validation.Valid; |
| 21 | 21 | import java.util.List; |
| 22 | 22 | |
| 23 | 23 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 24 | +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; | |
| 24 | 25 | |
| 25 | 26 | @Tag(name = "管理后台 - 部门") |
| 26 | 27 | @RestController |
| ... | ... | @@ -61,7 +62,7 @@ public class DeptController { |
| 61 | 62 | @Parameter(name = "ids", description = "编号列表", required = true) |
| 62 | 63 | @PreAuthorize("@ss.hasPermission('system:dept:delete')") |
| 63 | 64 | public CommonResult<Boolean> deleteDeptList(@RequestParam("ids") List<Long> ids) { |
| 64 | - deptService.deleteDeptList(ids); | |
| 65 | + deptService.deleteDeptList(ids, getLoginUserId()); | |
| 65 | 66 | return success(true); |
| 66 | 67 | } |
| 67 | 68 | ... | ... |
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
| ... | ... | @@ -57,6 +57,7 @@ public interface ErrorCodeConstants { |
| 57 | 57 | ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); |
| 58 | 58 | ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择"); |
| 59 | 59 | ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门"); |
| 60 | + ErrorCode DEPT_NOT_IN_SCOPE = new ErrorCode(1_002_004_008, "部门({})不在数据权限范围内,禁止操作"); | |
| 60 | 61 | |
| 61 | 62 | // ========== 岗位模块 1-002-005-000 ========== |
| 62 | 63 | ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在"); | ... | ... |
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java
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; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.hutool.core.util.ObjectUtil; |
| 5 | +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; | |
| 5 | 6 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 7 | 8 | import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission; |
| 9 | +import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder; | |
| 8 | 10 | import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; |
| 9 | 11 | import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; |
| 10 | 12 | import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; |
| 11 | 13 | import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; |
| 12 | 14 | import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants; |
| 15 | +import cn.iocoder.yudao.module.system.service.permission.PermissionService; | |
| 13 | 16 | import com.google.common.annotations.VisibleForTesting; |
| 14 | 17 | import lombok.extern.slf4j.Slf4j; |
| 15 | 18 | import org.springframework.cache.annotation.CacheEvict; |
| 16 | 19 | import org.springframework.cache.annotation.Cacheable; |
| 20 | +import org.springframework.context.annotation.Lazy; | |
| 17 | 21 | import org.springframework.stereotype.Service; |
| 18 | 22 | import org.springframework.validation.annotation.Validated; |
| 19 | 23 | |
| ... | ... | @@ -22,6 +26,7 @@ import java.util.*; |
| 22 | 26 | |
| 23 | 27 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 24 | 28 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; |
| 29 | +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck; | |
| 25 | 30 | import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; |
| 26 | 31 | |
| 27 | 32 | /** |
| ... | ... | @@ -37,6 +42,10 @@ public class DeptServiceImpl implements DeptService { |
| 37 | 42 | @Resource |
| 38 | 43 | private DeptMapper deptMapper; |
| 39 | 44 | |
| 45 | + @Resource | |
| 46 | + @Lazy // 延迟,避免循环依赖报错 | |
| 47 | + private PermissionService permissionService; | |
| 48 | + | |
| 40 | 49 | @Override |
| 41 | 50 | @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, |
| 42 | 51 | allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 |
| ... | ... | @@ -91,7 +100,9 @@ public class DeptServiceImpl implements DeptService { |
| 91 | 100 | @Override |
| 92 | 101 | @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, |
| 93 | 102 | allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 |
| 94 | - public void deleteDeptList(List<Long> ids) { | |
| 103 | + public void deleteDeptList(List<Long> ids, Long userId) { | |
| 104 | + // 校验数据权限 | |
| 105 | + validateDeptListDataScope(ids, userId); | |
| 95 | 106 | // 校验是否有子部门 |
| 96 | 107 | for (Long id : ids) { |
| 97 | 108 | if (deptMapper.selectCountByParentId(id) > 0) { |
| ... | ... | @@ -103,6 +114,41 @@ public class DeptServiceImpl implements DeptService { |
| 103 | 114 | deptMapper.deleteByIds(ids); |
| 104 | 115 | } |
| 105 | 116 | |
| 117 | + /** | |
| 118 | + * 校验部门们是否都在用户的数据权限范围内,任一不在则整体拒绝 | |
| 119 | + * | |
| 120 | + * 原因:数据权限插件只会删除范围内的部门,导致批量删除时“部分成功” | |
| 121 | + * 相关 PR:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1582 | |
| 122 | + * | |
| 123 | + * @param ids 部门编号数组 | |
| 124 | + * @param userId 用户编号 | |
| 125 | + */ | |
| 126 | + @VisibleForTesting | |
| 127 | + void validateDeptListDataScope(Collection<Long> ids, Long userId) { | |
| 128 | + if (CollUtil.isEmpty(ids)) { | |
| 129 | + return; | |
| 130 | + } | |
| 131 | + // 特殊:跨租户访问、忽略数据权限时,与数据权限插件保持一致,无需校验 | |
| 132 | + if (skipPermissionCheck()) { | |
| 133 | + return; | |
| 134 | + } | |
| 135 | + DataPermission dataPermission = DataPermissionContextHolder.get(); | |
| 136 | + if (dataPermission != null && !dataPermission.enable()) { | |
| 137 | + return; | |
| 138 | + } | |
| 139 | + | |
| 140 | + // 校验每个部门:与 DeptDataPermissionRule 对部门表的过滤保持一致 | |
| 141 | + DeptDataPermissionRespDTO deptDataPermission = permissionService.getDeptDataPermission(userId); | |
| 142 | + if (Boolean.TRUE.equals(deptDataPermission.getAll())) { | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + for (Long id : ids) { | |
| 146 | + if (!CollUtil.contains(deptDataPermission.getDeptIds(), id)) { | |
| 147 | + throw exception(DEPT_NOT_IN_SCOPE, id); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 106 | 152 | @VisibleForTesting |
| 107 | 153 | void validateDeptExists(Long id) { |
| 108 | 154 | if (id == null) { | ... | ... |
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.system.service.dept; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; | |
| 3 | 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; | |
| 4 | 6 | import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; |
| 7 | +import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils; | |
| 8 | +import cn.iocoder.yudao.framework.security.core.LoginUser; | |
| 9 | +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; | |
| 5 | 10 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; |
| 6 | 11 | import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; |
| 7 | 12 | import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; |
| 8 | 13 | import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; |
| 9 | 14 | import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; |
| 15 | +import cn.iocoder.yudao.module.system.service.permission.PermissionService; | |
| 10 | 16 | import org.junit.jupiter.api.Test; |
| 11 | 17 | import org.springframework.context.annotation.Import; |
| 18 | +import org.springframework.mock.web.MockHttpServletRequest; | |
| 19 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 20 | +import org.springframework.test.context.bean.override.mockito.MockitoBean; | |
| 12 | 21 | |
| 13 | 22 | import jakarta.annotation.Resource; |
| 14 | 23 | import java.util.Arrays; |
| 15 | 24 | import java.util.List; |
| 16 | 25 | import java.util.Set; |
| 17 | 26 | |
| 27 | +import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; | |
| 18 | 28 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; |
| 19 | 29 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 20 | 30 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; |
| 21 | 31 | import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; |
| 22 | 32 | import static java.util.Collections.singletonList; |
| 23 | 33 | import static org.junit.jupiter.api.Assertions.*; |
| 34 | +import static org.mockito.ArgumentMatchers.anyLong; | |
| 35 | +import static org.mockito.ArgumentMatchers.eq; | |
| 36 | +import static org.mockito.Mockito.*; | |
| 24 | 37 | |
| 25 | 38 | /** |
| 26 | 39 | * {@link DeptServiceImpl} 的单元测试类 |
| ... | ... | @@ -35,6 +48,9 @@ public class DeptServiceImplTest extends BaseDbUnitTest { |
| 35 | 48 | @Resource |
| 36 | 49 | private DeptMapper deptMapper; |
| 37 | 50 | |
| 51 | + @MockitoBean | |
| 52 | + private PermissionService permissionService; | |
| 53 | + | |
| 38 | 54 | @Test |
| 39 | 55 | public void testCreateDept() { |
| 40 | 56 | // 准备参数 |
| ... | ... | @@ -113,12 +129,19 @@ public class DeptServiceImplTest extends BaseDbUnitTest { |
| 113 | 129 | deptMapper.insert(deptDO2); |
| 114 | 130 | // 准备参数 |
| 115 | 131 | List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); |
| 132 | + Long userId = randomLongId(); | |
| 133 | + // mock 方法(数据权限包含全部部门) | |
| 134 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 135 | + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); | |
| 136 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 116 | 137 | |
| 117 | 138 | // 调用 |
| 118 | - deptService.deleteDeptList(ids); | |
| 139 | + deptService.deleteDeptList(ids, userId); | |
| 119 | 140 | // 校验数据不存在了 |
| 120 | 141 | assertNull(deptMapper.selectById(deptDO1.getId())); |
| 121 | 142 | assertNull(deptMapper.selectById(deptDO2.getId())); |
| 143 | + // 校验使用传入的用户编号,获取数据权限 | |
| 144 | + verify(permissionService).getDeptDataPermission(eq(userId)); | |
| 122 | 145 | } |
| 123 | 146 | |
| 124 | 147 | @Test |
| ... | ... | @@ -136,9 +159,138 @@ public class DeptServiceImplTest extends BaseDbUnitTest { |
| 136 | 159 | |
| 137 | 160 | // 准备参数(包含有子部门的 parentDept) |
| 138 | 161 | List<Long> ids = Arrays.asList(parentDept.getId(), anotherDept.getId()); |
| 162 | + Long userId = randomLongId(); | |
| 163 | + // mock 方法(数据权限为全部) | |
| 164 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 165 | + deptDataPermission.setAll(true); | |
| 166 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 167 | + | |
| 168 | + // 调用, 并断言异常 | |
| 169 | + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_EXITS_CHILDREN); | |
| 170 | + } | |
| 171 | + | |
| 172 | + @Test | |
| 173 | + public void testDeleteDeptList_notInScope() { | |
| 174 | + // mock 数据 | |
| 175 | + DeptDO deptDO1 = randomPojo(DeptDO.class); | |
| 176 | + deptMapper.insert(deptDO1); | |
| 177 | + DeptDO deptDO2 = randomPojo(DeptDO.class); | |
| 178 | + deptMapper.insert(deptDO2); | |
| 179 | + // 准备参数 | |
| 180 | + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); | |
| 181 | + Long userId = randomLongId(); | |
| 182 | + // mock 方法(数据权限只包含 deptDO1) | |
| 183 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 184 | + deptDataPermission.setDeptIds(asSet(deptDO1.getId())); | |
| 185 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 139 | 186 | |
| 140 | 187 | // 调用, 并断言异常 |
| 141 | - assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN); | |
| 188 | + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO2.getId()); | |
| 189 | + // 校验整体拒绝,范围内的部门也未被删除 | |
| 190 | + assertNotNull(deptMapper.selectById(deptDO1.getId())); | |
| 191 | + assertNotNull(deptMapper.selectById(deptDO2.getId())); | |
| 192 | + } | |
| 193 | + | |
| 194 | + @Test | |
| 195 | + public void testDeleteDeptList_selfScope() { | |
| 196 | + // mock 数据 | |
| 197 | + DeptDO deptDO = randomPojo(DeptDO.class); | |
| 198 | + deptMapper.insert(deptDO); | |
| 199 | + // 准备参数 | |
| 200 | + List<Long> ids = singletonList(deptDO.getId()); | |
| 201 | + Long userId = randomLongId(); | |
| 202 | + // mock 方法(数据权限为仅本人,部门表无对应的用户字段,无法操作任何部门) | |
| 203 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 204 | + deptDataPermission.setSelf(true); | |
| 205 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 206 | + | |
| 207 | + // 调用, 并断言异常 | |
| 208 | + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO.getId()); | |
| 209 | + assertNotNull(deptMapper.selectById(deptDO.getId())); | |
| 210 | + } | |
| 211 | + | |
| 212 | + @Test | |
| 213 | + public void testDeleteDeptList_allScope() { | |
| 214 | + // mock 数据 | |
| 215 | + DeptDO deptDO1 = randomPojo(DeptDO.class); | |
| 216 | + deptMapper.insert(deptDO1); | |
| 217 | + DeptDO deptDO2 = randomPojo(DeptDO.class); | |
| 218 | + deptMapper.insert(deptDO2); | |
| 219 | + // 准备参数 | |
| 220 | + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); | |
| 221 | + Long userId = randomLongId(); | |
| 222 | + // mock 方法(数据权限为全部,deptIds 为空) | |
| 223 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 224 | + deptDataPermission.setAll(true); | |
| 225 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 226 | + | |
| 227 | + // 调用 | |
| 228 | + deptService.deleteDeptList(ids, userId); | |
| 229 | + // 校验数据不存在了 | |
| 230 | + assertNull(deptMapper.selectById(deptDO1.getId())); | |
| 231 | + assertNull(deptMapper.selectById(deptDO2.getId())); | |
| 232 | + } | |
| 233 | + | |
| 234 | + @Test | |
| 235 | + public void testDeleteDeptList_duplicateIds() { | |
| 236 | + // mock 数据 | |
| 237 | + DeptDO deptDO1 = randomPojo(DeptDO.class); | |
| 238 | + deptMapper.insert(deptDO1); | |
| 239 | + DeptDO deptDO2 = randomPojo(DeptDO.class); | |
| 240 | + deptMapper.insert(deptDO2); | |
| 241 | + // 准备参数(deptDO1 重复) | |
| 242 | + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO1.getId(), deptDO2.getId()); | |
| 243 | + Long userId = randomLongId(); | |
| 244 | + // mock 方法 | |
| 245 | + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); | |
| 246 | + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); | |
| 247 | + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); | |
| 248 | + | |
| 249 | + // 调用 | |
| 250 | + deptService.deleteDeptList(ids, userId); | |
| 251 | + // 校验数据不存在了 | |
| 252 | + assertNull(deptMapper.selectById(deptDO1.getId())); | |
| 253 | + assertNull(deptMapper.selectById(deptDO2.getId())); | |
| 254 | + } | |
| 255 | + | |
| 256 | + @Test | |
| 257 | + public void testDeleteDeptList_visitTenant() { | |
| 258 | + // mock 数据 | |
| 259 | + DeptDO deptDO = randomPojo(DeptDO.class); | |
| 260 | + deptMapper.insert(deptDO); | |
| 261 | + // 准备参数 | |
| 262 | + List<Long> ids = singletonList(deptDO.getId()); | |
| 263 | + Long userId = randomLongId(); | |
| 264 | + // mock 登录用户(跨租户访问) | |
| 265 | + LoginUser loginUser = new LoginUser().setId(userId).setUserType(UserTypeEnum.ADMIN.getValue()) | |
| 266 | + .setTenantId(1L).setVisitTenantId(2L); | |
| 267 | + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); | |
| 268 | + | |
| 269 | + try { | |
| 270 | + // 调用 | |
| 271 | + deptService.deleteDeptList(ids, userId); | |
| 272 | + } finally { | |
| 273 | + SecurityContextHolder.clearContext(); | |
| 274 | + } | |
| 275 | + // 校验数据不存在了,且未获取目标租户的数据权限 | |
| 276 | + assertNull(deptMapper.selectById(deptDO.getId())); | |
| 277 | + verify(permissionService, never()).getDeptDataPermission(anyLong()); | |
| 278 | + } | |
| 279 | + | |
| 280 | + @Test | |
| 281 | + public void testDeleteDeptList_ignoreDataPermission() { | |
| 282 | + // mock 数据 | |
| 283 | + DeptDO deptDO = randomPojo(DeptDO.class); | |
| 284 | + deptMapper.insert(deptDO); | |
| 285 | + // 准备参数 | |
| 286 | + List<Long> ids = singletonList(deptDO.getId()); | |
| 287 | + Long userId = randomLongId(); | |
| 288 | + | |
| 289 | + // 调用(忽略数据权限) | |
| 290 | + DataPermissionUtils.executeIgnore(() -> deptService.deleteDeptList(ids, userId)); | |
| 291 | + // 校验数据不存在了 | |
| 292 | + assertNull(deptMapper.selectById(deptDO.getId())); | |
| 293 | + verify(permissionService, never()).getDeptDataPermission(anyLong()); | |
| 142 | 294 | } |
| 143 | 295 | |
| 144 | 296 | @Test | ... | ... |
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2ApproveServiceImplTest.java
| ... | ... | @@ -223,7 +223,7 @@ public class OAuth2ApproveServiceImplTest extends BaseDbUnitTest { |
| 223 | 223 | String clientId = randomString(); |
| 224 | 224 | String scope = randomString(); |
| 225 | 225 | Boolean approved = randomBoolean(); |
| 226 | - LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()); | |
| 226 | + LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()).withNano(0); | |
| 227 | 227 | // mock 方法 |
| 228 | 228 | |
| 229 | 229 | // 调用 |
| ... | ... | @@ -251,7 +251,7 @@ public class OAuth2ApproveServiceImplTest extends BaseDbUnitTest { |
| 251 | 251 | String clientId = approve.getClientId(); |
| 252 | 252 | String scope = approve.getScope(); |
| 253 | 253 | Boolean approved = randomBoolean(); |
| 254 | - LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()); | |
| 254 | + LocalDateTime expireTime = LocalDateTime.ofInstant(randomDay(1, 30).toInstant(), ZoneId.systemDefault()).withNano(0); | |
| 255 | 255 | // mock 方法 |
| 256 | 256 | |
| 257 | 257 | // 调用 | ... | ... |
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImplTest.java
| ... | ... | @@ -311,7 +311,7 @@ public class OAuth2TokenServiceImplTest extends BaseDbAndRedisUnitTest { |
| 311 | 311 | // 测试 userType 不匹配 |
| 312 | 312 | oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setClientId("it_client"))); |
| 313 | 313 | // 测试 expireTime 不匹配 |
| 314 | - oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setExpiresTime(LocalDateTimeUtil.now()))); | |
| 314 | + oauth2AccessTokenMapper.insert(cloneIgnoreId(dbAccessToken, o -> o.setExpiresTime(LocalDateTimeUtil.now().minusDays(1)))); | |
| 315 | 315 | // 准备参数 |
| 316 | 316 | OAuth2AccessTokenPageReqVO reqVO = new OAuth2AccessTokenPageReqVO(); |
| 317 | 317 | reqVO.setUserId(10L); | ... | ... |