Commit 21201e122486b09edc69265834ba1e116445de14

Authored by YunaiV
2 parents 54601606 c5633a37

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 &lt;= #{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&lt;MemberUserDO&gt; {
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 &quot;member_user&quot;;
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 &quot;member_user&quot;
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 &quot;member_brokerage_record&quot;
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
... ... @@ -46,6 +46,7 @@ public class OaVehicleDO extends BaseDO {
46 46 *
47 47 * 关联 {@link DeptRespDTO#getId()}
48 48 */
  49 + @TableField(updateStrategy = FieldStrategy.ALWAYS)
49 50 private Long deptId;
50 51 /**
51 52 * 车型
... ...
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
... ... @@ -40,8 +40,9 @@ public interface DeptService {
40 40 * 批量删除部门
41 41 *
42 42 * @param ids 部门编号数组
  43 + * @param userId 操作的用户编号,用于校验数据权限
43 44 */
44   - void deleteDeptList(List<Long> ids);
  45 + void deleteDeptList(List<Long> ids, Long userId);
45 46  
46 47 /**
47 48 * 获得部门信息
... ...
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);
... ...