Commit 09426f8fc601d4b82a0c6125e6764dfed50a4b8d
1 parent
4dba84b7
fix: 适配 CRM 列间 CSV 查询并完善 OA、MES 细节
Showing
16 changed files
with
510 additions
and
24 deletions
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java
| ... | ... | @@ -18,7 +18,7 @@ import java.util.regex.Pattern; |
| 18 | 18 | // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 |
| 19 | 19 | // TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 |
| 20 | 20 | /** |
| 21 | - * 日期统计、行数限制、分批删除 SQL 片段的方言工具类 | |
| 21 | + * 日期统计、逗号分隔列表关联、行数限制、分批删除 SQL 片段的方言工具类 | |
| 22 | 22 | * |
| 23 | 23 | * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 |
| 24 | 24 | * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 |
| ... | ... | @@ -405,6 +405,52 @@ public class SqlDialectUtils { |
| 405 | 405 | return dialect == Dialect.ORACLE ? "FROM DUAL" : ""; |
| 406 | 406 | } |
| 407 | 407 | |
| 408 | + // ========== 逗号分隔列表 ========== | |
| 409 | + | |
| 410 | + /** | |
| 411 | + * 两列之间的 FIND_IN_SET 关联条件:数值 ID 列的值是否为逗号分隔列表列中的一个元素;原有片段为 FIND_IN_SET(valueColumn, listColumn) | |
| 412 | + * | |
| 413 | + * 1. 列表按英文逗号分隔、元素两侧不含空格,与 FIND_IN_SET 一致;元素重复时,同一行仍只匹配一次 | |
| 414 | + * 2. 任一列为 NULL 时不匹配 | |
| 415 | + * 3. 原有片段在 MySQL 的 ON / WHERE 中按非 0 为真;其它沿用原有片段的数据库是否支持 FIND_IN_SET,未经验收 | |
| 416 | + * | |
| 417 | + * 列与参数值比较时,使用 {@link MyBatisUtils#findInSet(String, java.util.Collection)} | |
| 418 | + * | |
| 419 | + * @param valueColumn 数值类型的 ID 列名,例如 cc.id、contact_id | |
| 420 | + * @param listColumn 逗号分隔的列表列名,例如 cfur.contact_ids | |
| 421 | + * @return SQL 条件片段 | |
| 422 | + */ | |
| 423 | + public static String findInSetColumn(String valueColumn, String listColumn) { | |
| 424 | + return findInSetColumn(getDialect(), valueColumn, listColumn); | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * 按指定方言生成两列之间的 FIND_IN_SET 关联条件;两侧补充逗号,避免 1 匹配到 11、21 等相邻 ID。 | |
| 429 | + * | |
| 430 | + * @param dialect 已识别的数据库方言 | |
| 431 | + * @param valueColumn 代码中固定的数值 ID 列名,SQL Server 按 varchar(20) 转换,可容纳 bigint | |
| 432 | + * @param listColumn 代码中固定的逗号分隔列表列名 | |
| 433 | + * @return SQL 条件片段 | |
| 434 | + */ | |
| 435 | + static String findInSetColumn(Dialect dialect, String valueColumn, String listColumn) { | |
| 436 | + checkIdColumn(valueColumn); | |
| 437 | + checkColumn(listColumn); | |
| 438 | + switch (dialect) { | |
| 439 | + case POSTGRESQL: | |
| 440 | + return "POSITION(',' || CAST(" + valueColumn + " AS VARCHAR) || ',' IN ',' || " + listColumn + " || ',') > 0"; | |
| 441 | + case ORACLE: | |
| 442 | + // Oracle 拼接时 NULL 按空串处理,需要排除 NULL,否则会匹配到列表中的空元素 | |
| 443 | + return "(" + valueColumn + " IS NOT NULL AND INSTR(',' || " + listColumn + " || ',', ',' || " | |
| 444 | + + valueColumn + " || ',') > 0)"; | |
| 445 | + case SQLSERVER: | |
| 446 | + // SQL Server 2012–2016 可关闭 CONCAT_NULL_YIELDS_NULL,显式排除 NULL ID,避免匹配列表中的空元素 | |
| 447 | + return "(" + valueColumn + " IS NOT NULL AND CHARINDEX(',' + CAST(" + valueColumn | |
| 448 | + + " AS varchar(20)) + ',', ',' + " + listColumn + " + ',') > 0)"; | |
| 449 | + default: | |
| 450 | + return "FIND_IN_SET(" + valueColumn + ", " + listColumn + ")"; | |
| 451 | + } | |
| 452 | + } | |
| 453 | + | |
| 408 | 454 | // ========== 行数限制 ========== |
| 409 | 455 | |
| 410 | 456 | /** |
| ... | ... | @@ -660,6 +706,19 @@ public class SqlDialectUtils { |
| 660 | 706 | } |
| 661 | 707 | |
| 662 | 708 | /** |
| 709 | + * 校验代码中固定的 ID 列名:在列名校验的基础上,最后一段需要为 id 或以 _id 结尾,避免用于任意业务字符串列。 | |
| 710 | + * | |
| 711 | + * @param column 待校验的列名,可带表别名前缀 | |
| 712 | + */ | |
| 713 | + private static void checkIdColumn(String column) { | |
| 714 | + checkColumn(column); | |
| 715 | + String name = column.substring(column.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); | |
| 716 | + if (!name.equals("id") && !name.endsWith("_id")) { | |
| 717 | + throw new IllegalArgumentException("Invalid id column name: " + column); | |
| 718 | + } | |
| 719 | + } | |
| 720 | + | |
| 721 | + /** | |
| 663 | 722 | * 校验 MyBatis 参数名或属性路径,拒绝占位符逃逸与 SQL 片段。 |
| 664 | 723 | * |
| 665 | 724 | * @param param 待校验的参数名 | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java
| ... | ... | @@ -29,6 +29,7 @@ import java.sql.SQLException; |
| 29 | 29 | import java.time.LocalDateTime; |
| 30 | 30 | import java.util.Collections; |
| 31 | 31 | import java.util.HashMap; |
| 32 | +import java.util.LinkedHashMap; | |
| 32 | 33 | import java.util.List; |
| 33 | 34 | import java.util.Map; |
| 34 | 35 | import java.util.function.BiFunction; |
| ... | ... | @@ -213,6 +214,118 @@ public class SqlDialectUtilsTest { |
| 213 | 214 | } |
| 214 | 215 | } |
| 215 | 216 | |
| 217 | + // ========== 逗号分隔列表 ========== | |
| 218 | + | |
| 219 | + @Test | |
| 220 | + public void testFindInSetColumn() { | |
| 221 | + // 原有片段保持不变,不追加 <> 0 | |
| 222 | + assertEquals("FIND_IN_SET(cc.id, cfur.contact_ids)", | |
| 223 | + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "cc.id", "cfur.contact_ids")); | |
| 224 | + assertEquals("POSITION(',' || CAST(cc.id AS VARCHAR) || ',' IN ',' || cfur.contact_ids || ',') > 0", | |
| 225 | + SqlDialectUtils.findInSetColumn(Dialect.POSTGRESQL, "cc.id", "cfur.contact_ids")); | |
| 226 | + assertEquals("(cc.id IS NOT NULL AND INSTR(',' || cfur.contact_ids || ',', ',' || cc.id || ',') > 0)", | |
| 227 | + SqlDialectUtils.findInSetColumn(Dialect.ORACLE, "cc.id", "cfur.contact_ids")); | |
| 228 | + assertEquals("(cc.id IS NOT NULL AND CHARINDEX(',' + CAST(cc.id AS varchar(20)) + ',', ',' + cfur.contact_ids + ',') > 0)", | |
| 229 | + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "cc.id", "cfur.contact_ids")); | |
| 230 | + // 不带表别名、以 _id 结尾的 ID 列 | |
| 231 | + assertEquals("FIND_IN_SET(contact_id, contact_ids)", | |
| 232 | + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "contact_id", "contact_ids")); | |
| 233 | + assertEquals("(t.CONTACT_ID IS NOT NULL AND CHARINDEX(',' + CAST(t.CONTACT_ID AS varchar(20)) + ',', ',' + t.contact_ids + ',') > 0)", | |
| 234 | + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "t.CONTACT_ID", "t.contact_ids")); | |
| 235 | + } | |
| 236 | + | |
| 237 | + @Test | |
| 238 | + public void testFindInSetColumn_invalidColumn() { | |
| 239 | + for (Dialect dialect : Dialect.values()) { | |
| 240 | + // 值列:只能是 ID 列名,不能是表达式、字面量或业务字符串列 | |
| 241 | + assertThrows(IllegalArgumentException.class, | |
| 242 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id) OR (1=1", "cfur.contact_ids")); | |
| 243 | + assertThrows(IllegalArgumentException.class, | |
| 244 | + () -> SqlDialectUtils.findInSetColumn(dialect, "'1'", "cfur.contact_ids")); | |
| 245 | + assertThrows(IllegalArgumentException.class, | |
| 246 | + () -> SqlDialectUtils.findInSetColumn(dialect, "1", "cfur.contact_ids")); | |
| 247 | + assertThrows(IllegalArgumentException.class, | |
| 248 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.name", "cfur.contact_ids")); | |
| 249 | + assertThrows(IllegalArgumentException.class, | |
| 250 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.idx", "cfur.contact_ids")); | |
| 251 | + assertThrows(IllegalArgumentException.class, | |
| 252 | + () -> SqlDialectUtils.findInSetColumn(dialect, "id.name", "cfur.contact_ids")); | |
| 253 | + assertThrows(IllegalArgumentException.class, | |
| 254 | + () -> SqlDialectUtils.findInSetColumn(dialect, "", "cfur.contact_ids")); | |
| 255 | + assertThrows(IllegalArgumentException.class, | |
| 256 | + () -> SqlDialectUtils.findInSetColumn(dialect, null, "cfur.contact_ids")); | |
| 257 | + // 列表列 | |
| 258 | + assertThrows(IllegalArgumentException.class, | |
| 259 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids) OR (1=1")); | |
| 260 | + assertThrows(IllegalArgumentException.class, | |
| 261 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "'1,2,3'")); | |
| 262 | + assertThrows(IllegalArgumentException.class, | |
| 263 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids;drop table t")); | |
| 264 | + assertThrows(IllegalArgumentException.class, | |
| 265 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "")); | |
| 266 | + assertThrows(IllegalArgumentException.class, | |
| 267 | + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", null)); | |
| 268 | + } | |
| 269 | + } | |
| 270 | + | |
| 271 | + /** | |
| 272 | + * 与 CRM 跟进次数排行相同的 LEFT JOIN 条件,所有方言均需可被 JSqlParser 解析 | |
| 273 | + */ | |
| 274 | + @Test | |
| 275 | + public void testFindInSetColumn_parsedByJSqlParser() throws Exception { | |
| 276 | + for (Dialect dialect : Dialect.values()) { | |
| 277 | + String sql = "SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur" | |
| 278 | + + " LEFT JOIN crm_contact cc ON " + SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids") | |
| 279 | + + " WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id IN (?, ?)" | |
| 280 | + + " AND cfur.create_time BETWEEN ? AND ? GROUP BY cc.owner_user_id"; | |
| 281 | + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql); | |
| 282 | + } | |
| 283 | + } | |
| 284 | + | |
| 285 | + @Test | |
| 286 | + public void testBoundSql_findInSetColumn() throws Exception { | |
| 287 | + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); | |
| 288 | + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); | |
| 289 | + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); | |
| 290 | + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); | |
| 291 | + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql)); | |
| 292 | + // 与 CrmStatisticsRankMapper.xml 的 selectFollowCustomerCountRank 相同 | |
| 293 | + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), | |
| 294 | + "<script>SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur" | |
| 295 | + + " LEFT JOIN crm_contact cc ON " + P + "findInSetColumn('cc.id', 'cfur.contact_ids')}" | |
| 296 | + + " WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id in" | |
| 297 | + + " <foreach collection=\"userIds\" item=\"userId\" open=\"(\" close=\")\" separator=\",\">#{userId}</foreach>" | |
| 298 | + + " AND cfur.create_time between #{times[0],javaType=java.time.LocalDateTime}" | |
| 299 | + + " and #{times[1],javaType=java.time.LocalDateTime}" | |
| 300 | + + " GROUP BY cc.owner_user_id</script>", Map.class); | |
| 301 | + Map<String, Object> params = new HashMap<>(); | |
| 302 | + params.put("userIds", List.of(1L, 2L)); | |
| 303 | + params.put("times", new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), | |
| 304 | + LocalDateTime.of(2026, 1, 31, 23, 59, 59)}); | |
| 305 | + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL | |
| 306 | + Map<String, Dialect> routes = new LinkedHashMap<>(); | |
| 307 | + routes.put("master", Dialect.POSTGRESQL); | |
| 308 | + routes.put("oracle", Dialect.ORACLE); | |
| 309 | + routes.put("sqlserver", Dialect.SQLSERVER); | |
| 310 | + routes.put("mysql", Dialect.ORIGINAL); | |
| 311 | + | |
| 312 | + for (Map.Entry<String, Dialect> route : routes.entrySet()) { | |
| 313 | + DynamicDataSourceContextHolder.push(route.getKey()); | |
| 314 | + BoundSql boundSql = sqlSource.getBoundSql(params); | |
| 315 | + String sql = normalize(boundSql.getSql()); | |
| 316 | + String fragment = SqlDialectUtils.findInSetColumn(route.getValue(), "cc.id", "cfur.contact_ids"); | |
| 317 | + assertTrue(sql.startsWith("SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur" | |
| 318 | + + " LEFT JOIN crm_contact cc ON " + fragment + " WHERE cfur.deleted = 0 AND cc.deleted = 0"), sql); | |
| 319 | + assertTrue(sql.endsWith("AND cfur.create_time between ? and ? GROUP BY cc.owner_user_id"), sql); | |
| 320 | + // 列对列的片段不引入参数:只有 2 个用户编号与 2 个时间 | |
| 321 | + List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); | |
| 322 | + assertEquals(4, parameterMappings.size(), sql); | |
| 323 | + assertEquals("times[0]", parameterMappings.get(2).getProperty()); | |
| 324 | + assertEquals("times[1]", parameterMappings.get(3).getProperty()); | |
| 325 | + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 216 | 329 | // ========== 行数限制 ========== |
| 217 | 330 | |
| 218 | 331 | @Test | ... | ... |
yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml
| ... | ... | @@ -89,7 +89,7 @@ |
| 89 | 89 | resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> |
| 90 | 90 | SELECT COUNT(1) AS count, cc.owner_user_id |
| 91 | 91 | FROM crm_follow_up_record cfur |
| 92 | - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) | |
| 92 | + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')} | |
| 93 | 93 | WHERE cfur.deleted = 0 |
| 94 | 94 | AND cc.deleted = 0 |
| 95 | 95 | AND cc.owner_user_id in |
| ... | ... | @@ -104,7 +104,7 @@ |
| 104 | 104 | resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> |
| 105 | 105 | SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id |
| 106 | 106 | FROM crm_follow_up_record cfur |
| 107 | - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) | |
| 107 | + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')} | |
| 108 | 108 | WHERE cfur.deleted = 0 |
| 109 | 109 | AND cc.deleted = 0 |
| 110 | 110 | AND cc.owner_user_id in | ... | ... |
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java
| ... | ... | @@ -23,6 +23,7 @@ import java.util.Map; |
| 23 | 23 | |
| 24 | 24 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 25 | 25 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 26 | +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; | |
| 26 | 27 | |
| 27 | 28 | @Tag(name = "管理后台 - MES 计划班组关联") |
| 28 | 29 | @RestController |
| ... | ... | @@ -58,19 +59,14 @@ public class MesCalPlanTeamController { |
| 58 | 59 | public CommonResult<List<MesCalPlanTeamRespVO>> getPlanTeamListByPlan(@RequestParam("planId") Long planId) { |
| 59 | 60 | List<MesCalPlanTeamDO> list = planTeamService.getPlanTeamListByPlanId(planId); |
| 60 | 61 | List<MesCalPlanTeamRespVO> respList = BeanUtils.toBean(list, MesCalPlanTeamRespVO.class); |
| 61 | - // 拼装班组编码/名称 | |
| 62 | - // TODO @AI:if return | |
| 63 | - if (CollUtil.isNotEmpty(respList)) { | |
| 64 | - Map<Long, MesCalTeamDO> teamMap = teamService.getTeamMap( | |
| 65 | - convertList(respList, MesCalPlanTeamRespVO::getTeamId)); | |
| 66 | - respList.forEach(resp -> { | |
| 67 | - // TODO @AI:findand then | |
| 68 | - MesCalTeamDO team = teamMap.get(resp.getTeamId()); | |
| 69 | - if (team != null) { | |
| 70 | - resp.setTeamCode(team.getCode()).setTeamName(team.getName()); | |
| 71 | - } | |
| 72 | - }); | |
| 62 | + if (CollUtil.isEmpty(respList)) { | |
| 63 | + return success(respList); | |
| 73 | 64 | } |
| 65 | + // 拼装班组编码/名称 | |
| 66 | + Map<Long, MesCalTeamDO> teamMap = teamService.getTeamMap( | |
| 67 | + convertList(respList, MesCalPlanTeamRespVO::getTeamId)); | |
| 68 | + respList.forEach(resp -> findAndThen(teamMap, resp.getTeamId(), | |
| 69 | + team -> resp.setTeamCode(team.getCode()).setTeamName(team.getName()))); | |
| 74 | 70 | return success(respList); |
| 75 | 71 | } |
| 76 | 72 | ... | ... |
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.mes.controller.admin.cal.plan; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 4 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 5 | +import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.team.MesCalPlanTeamRespVO; | |
| 6 | +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO; | |
| 7 | +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamDO; | |
| 8 | +import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService; | |
| 9 | +import cn.iocoder.yudao.module.mes.service.cal.team.MesCalTeamService; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.mockito.InjectMocks; | |
| 12 | +import org.mockito.Mock; | |
| 13 | + | |
| 14 | +import java.util.Collections; | |
| 15 | +import java.util.List; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +import static org.junit.jupiter.api.Assertions.*; | |
| 19 | +import static org.mockito.Mockito.*; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * {@link MesCalPlanTeamController} 的单元测试类 | |
| 23 | + * | |
| 24 | + * @author 芋道源码 | |
| 25 | + */ | |
| 26 | +public class MesCalPlanTeamControllerTest extends BaseMockitoUnitTest { | |
| 27 | + | |
| 28 | + @InjectMocks | |
| 29 | + private MesCalPlanTeamController planTeamController; | |
| 30 | + | |
| 31 | + @Mock | |
| 32 | + private MesCalPlanTeamService planTeamService; | |
| 33 | + @Mock | |
| 34 | + private MesCalTeamService teamService; | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + public void testGetPlanTeamListByPlan_empty() { | |
| 38 | + // mock 方法 | |
| 39 | + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(Collections.emptyList()); | |
| 40 | + | |
| 41 | + // 调用 | |
| 42 | + CommonResult<List<MesCalPlanTeamRespVO>> result = planTeamController.getPlanTeamListByPlan(1L); | |
| 43 | + // 断言:空列表直接返回,不查询班组 | |
| 44 | + assertTrue(result.isSuccess()); | |
| 45 | + assertTrue(result.getData().isEmpty()); | |
| 46 | + verifyNoInteractions(teamService); | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Test | |
| 50 | + public void testGetPlanTeamListByPlan_fillTeam() { | |
| 51 | + // mock 方法:班组 20 不存在 | |
| 52 | + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(List.of( | |
| 53 | + MesCalPlanTeamDO.builder().id(3L).planId(1L).teamId(20L).remark("缺失").build(), | |
| 54 | + MesCalPlanTeamDO.builder().id(2L).planId(1L).teamId(10L).remark("存在").build())); | |
| 55 | + when(teamService.getTeamMap(List.of(20L, 10L))).thenReturn(Map.of( | |
| 56 | + 10L, MesCalTeamDO.builder().id(10L).code("T001").name("A组").build())); | |
| 57 | + | |
| 58 | + // 调用 | |
| 59 | + CommonResult<List<MesCalPlanTeamRespVO>> result = planTeamController.getPlanTeamListByPlan(1L); | |
| 60 | + // 断言:保持原列表顺序;存在的班组回填编码、名称,缺失的班组保持为空 | |
| 61 | + assertTrue(result.isSuccess()); | |
| 62 | + List<MesCalPlanTeamRespVO> list = result.getData(); | |
| 63 | + assertEquals(2, list.size()); | |
| 64 | + MesCalPlanTeamRespVO missing = list.get(0); | |
| 65 | + assertEquals(3L, missing.getId()); | |
| 66 | + assertEquals(20L, missing.getTeamId()); | |
| 67 | + assertEquals("缺失", missing.getRemark()); | |
| 68 | + assertNull(missing.getTeamCode()); | |
| 69 | + assertNull(missing.getTeamName()); | |
| 70 | + MesCalPlanTeamRespVO exists = list.get(1); | |
| 71 | + assertEquals(2L, exists.getId()); | |
| 72 | + assertEquals(10L, exists.getTeamId()); | |
| 73 | + assertEquals("T001", exists.getTeamCode()); | |
| 74 | + assertEquals("A组", exists.getTeamName()); | |
| 75 | + verify(teamService).getTeamMap(List.of(20L, 10L)); | |
| 76 | + } | |
| 77 | + | |
| 78 | +} | ... | ... |
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java
| ... | ... | @@ -179,6 +179,37 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { |
| 179 | 179 | } |
| 180 | 180 | |
| 181 | 181 | @Test |
| 182 | + public void testGetPlanListByStatusAndDay_boundary() { | |
| 183 | + Integer status = MesCalPlanStatusEnum.CONFIRMED.getStatus(); | |
| 184 | + // mock 数据:开始时间为当天午夜前的最后时刻(H2 timestamp 精度为微秒) | |
| 185 | + MesCalPlanDO lastMicro = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59, 999_999_000), | |
| 186 | + LocalDateTime.of(2026, 10, 31, 0, 0), status); | |
| 187 | + planMapper.insert(lastMicro); | |
| 188 | + MesCalPlanDO lastSecond = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59), | |
| 189 | + LocalDateTime.of(2026, 10, 31, 0, 0), status); | |
| 190 | + planMapper.insert(lastSecond); | |
| 191 | + // mock 数据:开始时间为次日零点,不包含当天 | |
| 192 | + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 20, 0, 0), | |
| 193 | + LocalDateTime.of(2026, 10, 31, 0, 0), status)); | |
| 194 | + // mock 数据:结束时间为当天零点,包含当天 | |
| 195 | + MesCalPlanDO endAtStart = randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0), | |
| 196 | + LocalDateTime.of(2026, 10, 19, 0, 0), status); | |
| 197 | + planMapper.insert(endAtStart); | |
| 198 | + // mock 数据:结束时间为当天零点之前,不包含当天 | |
| 199 | + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0), | |
| 200 | + LocalDateTime.of(2026, 10, 18, 23, 59, 59, 999_999_000), status)); | |
| 201 | + | |
| 202 | + // 调用 | |
| 203 | + List<MesCalPlanDO> list = planService.getPlanListByStatusAndDay(status, LocalDate.of(2026, 10, 19)); | |
| 204 | + | |
| 205 | + // 断言:午夜前的时刻、结束时间为当天零点均包含;次日零点、前一天均不包含;按 id 升序返回 | |
| 206 | + assertEquals(SetUtils.asSet(lastMicro.getId(), lastSecond.getId(), endAtStart.getId()), | |
| 207 | + convertSet(list, MesCalPlanDO::getId)); | |
| 208 | + List<Long> ids = list.stream().map(MesCalPlanDO::getId).toList(); | |
| 209 | + assertEquals(ids.stream().sorted().toList(), ids); | |
| 210 | + } | |
| 211 | + | |
| 212 | + @Test | |
| 182 | 213 | public void testDeletePlan_notCascadeDeleteTeamShift() { |
| 183 | 214 | // mock 数据:插入一条草稿状态的排班计划 |
| 184 | 215 | MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> { | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java
| ... | ... | @@ -37,9 +37,13 @@ public interface OaFileNodeMapper extends BaseMapperX<OaFileNodeDO> { |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | default Long selectSumSizeByCreatorAndType(String creator, Integer type) { |
| 40 | - // TODO @AI:不要写在一条里;分成多个;看看能不能去掉 COALESCE | |
| 41 | - return selectOne(new QueryWrapperX<OaFileNodeDO>().select("COALESCE(SUM(size), 0) AS size") | |
| 42 | - .eq("creator", creator).eq("type", type)).getSize(); | |
| 40 | + QueryWrapperX<OaFileNodeDO> query = new QueryWrapperX<>(); | |
| 41 | + query.select("SUM(size) AS size").eq("creator", creator).eq("type", type); | |
| 42 | + OaFileNodeDO sum = selectOne(query); | |
| 43 | + if (sum == null || sum.getSize() == null) { | |
| 44 | + return 0L; | |
| 45 | + } | |
| 46 | + return sum.getSize(); | |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | 49 | default PageResult<OaFileNodeDO> selectPageByParentIdAndCreatorAndStatus( | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.oa.job.meetingroom; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; | |
| 5 | +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; | |
| 6 | +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; | |
| 7 | +import jakarta.annotation.Resource; | |
| 8 | +import org.springframework.stereotype.Component; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * OA 会议室过期预定取消 Job | |
| 12 | + * | |
| 13 | + * 取消已过结束时间、审批通过但尚未开始的会议预定 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +@Component | |
| 18 | +public class OaMeetingRoomBookingCancelExpiredJob implements JobHandler { | |
| 19 | + | |
| 20 | + @Resource | |
| 21 | + private OaMeetingRoomBookingService meetingRoomBookingService; | |
| 22 | + | |
| 23 | + @Override | |
| 24 | + @TenantJob | |
| 25 | + public String execute(String param) { | |
| 26 | + int count = meetingRoomBookingService.cancelExpiredMeetingRoomBookings(); | |
| 27 | + return StrUtil.format("取消过期未开始会议 {} 条", count); | |
| 28 | + } | |
| 29 | + | |
| 30 | +} | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java
| ... | ... | @@ -7,9 +7,11 @@ import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingServic |
| 7 | 7 | import jakarta.annotation.Resource; |
| 8 | 8 | import org.springframework.stereotype.Component; |
| 9 | 9 | |
| 10 | -// TODO @AI:是不是分开 job 噢?因为毕竟是 remind; | |
| 11 | 10 | /** |
| 12 | - * OA 会议开始提醒与过期预定处理 Job | |
| 11 | + * OA 会议开始提醒与过期预定处理 Job(兼容入口) | |
| 12 | + * | |
| 13 | + * 按原顺序先取消过期预定,再发送会议提醒。新配置请使用 {@link OaMeetingRoomBookingCancelExpiredJob}、 | |
| 14 | + * {@link OaMeetingRoomBookingSendReminderJob};迁移时先停用本 Job 再启用两个新 Job,新旧 Job 不能同时启用 | |
| 13 | 15 | * |
| 14 | 16 | * @author 芋道源码 |
| 15 | 17 | */ | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.oa.job.meetingroom; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; | |
| 5 | +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; | |
| 6 | +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; | |
| 7 | +import jakarta.annotation.Resource; | |
| 8 | +import org.springframework.stereotype.Component; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * OA 会议开始提醒 Job | |
| 12 | + * | |
| 13 | + * 发送已通过且待使用会议的开始提醒 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +@Component | |
| 18 | +public class OaMeetingRoomBookingSendReminderJob implements JobHandler { | |
| 19 | + | |
| 20 | + @Resource | |
| 21 | + private OaMeetingRoomBookingService meetingRoomBookingService; | |
| 22 | + | |
| 23 | + @Override | |
| 24 | + @TenantJob | |
| 25 | + public String execute(String param) { | |
| 26 | + int count = meetingRoomBookingService.sendMeetingRoomBookingReminders(); | |
| 27 | + return StrUtil.format("发送会议提醒 {} 条", count); | |
| 28 | + } | |
| 29 | + | |
| 30 | +} | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java
| ... | ... | @@ -213,8 +213,7 @@ public class OaOfficialDocReceiveServiceImpl implements OaOfficialDocReceiveServ |
| 213 | 213 | // 1.2 校验收文归属 |
| 214 | 214 | validateOfficialDocReceiveOwner(receive, userId); |
| 215 | 215 | // 1.3 未提交、审批不通过和已取消的单据允许删除 |
| 216 | - // TODO @AI:notEqualsAny | |
| 217 | - if (!ObjectUtils.equalsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), | |
| 216 | + if (ObjectUtils.notEqualsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), | |
| 218 | 217 | BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { |
| 219 | 218 | throw exception(OFFICIAL_DOC_STATUS_INVALID); |
| 220 | 219 | } | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java
| ... | ... | @@ -96,8 +96,7 @@ public class OaOfficialDocSendServiceImpl implements OaOfficialDocSendService { |
| 96 | 96 | // 1.2 校验发文归属 |
| 97 | 97 | validateOfficialDocSendOwner(send, userId); |
| 98 | 98 | // 1.3 未提交、审批不通过和已取消的单据允许删除 |
| 99 | - // TODO @AI:notEqualsAny | |
| 100 | - if (!ObjectUtils.equalsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), | |
| 99 | + if (ObjectUtils.notEqualsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), | |
| 101 | 100 | BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { |
| 102 | 101 | throw exception(OFFICIAL_DOC_STATUS_INVALID); |
| 103 | 102 | } | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.oa.job.meetingroom; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.mockito.InjectMocks; | |
| 7 | +import org.mockito.Mock; | |
| 8 | + | |
| 9 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 10 | +import static org.mockito.Mockito.*; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * {@link OaMeetingRoomBookingCancelExpiredJob} 的单元测试类 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +public class OaMeetingRoomBookingCancelExpiredJobTest extends BaseMockitoUnitTest { | |
| 18 | + | |
| 19 | + @InjectMocks | |
| 20 | + private OaMeetingRoomBookingCancelExpiredJob job; | |
| 21 | + | |
| 22 | + @Mock | |
| 23 | + private OaMeetingRoomBookingService meetingRoomBookingService; | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + public void testExecute() { | |
| 27 | + // mock 方法 | |
| 28 | + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(2); | |
| 29 | + | |
| 30 | + // 调用 | |
| 31 | + String result = job.execute(null); | |
| 32 | + // 断言:只取消过期预定,不发送会议提醒 | |
| 33 | + assertEquals("取消过期未开始会议 2 条", result); | |
| 34 | + verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings(); | |
| 35 | + verifyNoMoreInteractions(meetingRoomBookingService); | |
| 36 | + } | |
| 37 | + | |
| 38 | +} | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.oa.job.meetingroom; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.mockito.InOrder; | |
| 7 | +import org.mockito.InjectMocks; | |
| 8 | +import org.mockito.Mock; | |
| 9 | + | |
| 10 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 11 | +import static org.mockito.Mockito.*; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * {@link OaMeetingRoomBookingReminderJob} 的单元测试类 | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 17 | + */ | |
| 18 | +public class OaMeetingRoomBookingReminderJobTest extends BaseMockitoUnitTest { | |
| 19 | + | |
| 20 | + @InjectMocks | |
| 21 | + private OaMeetingRoomBookingReminderJob job; | |
| 22 | + | |
| 23 | + @Mock | |
| 24 | + private OaMeetingRoomBookingService meetingRoomBookingService; | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + public void testExecute() { | |
| 28 | + // mock 方法 | |
| 29 | + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(1); | |
| 30 | + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(4); | |
| 31 | + | |
| 32 | + // 调用 | |
| 33 | + String result = job.execute(null); | |
| 34 | + // 断言:兼容入口仍按原顺序,先取消过期预定,再发送会议提醒 | |
| 35 | + assertEquals("取消过期未开始会议 1 条,发送会议提醒 4 条", result); | |
| 36 | + InOrder inOrder = inOrder(meetingRoomBookingService); | |
| 37 | + inOrder.verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings(); | |
| 38 | + inOrder.verify(meetingRoomBookingService).sendMeetingRoomBookingReminders(); | |
| 39 | + verifyNoMoreInteractions(meetingRoomBookingService); | |
| 40 | + } | |
| 41 | + | |
| 42 | +} | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.oa.job.meetingroom; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.mockito.InjectMocks; | |
| 7 | +import org.mockito.Mock; | |
| 8 | + | |
| 9 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 10 | +import static org.mockito.Mockito.*; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * {@link OaMeetingRoomBookingSendReminderJob} 的单元测试类 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +public class OaMeetingRoomBookingSendReminderJobTest extends BaseMockitoUnitTest { | |
| 18 | + | |
| 19 | + @InjectMocks | |
| 20 | + private OaMeetingRoomBookingSendReminderJob job; | |
| 21 | + | |
| 22 | + @Mock | |
| 23 | + private OaMeetingRoomBookingService meetingRoomBookingService; | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + public void testExecute() { | |
| 27 | + // mock 方法 | |
| 28 | + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(3); | |
| 29 | + | |
| 30 | + // 调用 | |
| 31 | + String result = job.execute(null); | |
| 32 | + // 断言:只发送会议提醒,不取消过期预定 | |
| 33 | + assertEquals("发送会议提醒 3 条", result); | |
| 34 | + verify(meetingRoomBookingService).sendMeetingRoomBookingReminders(); | |
| 35 | + verifyNoMoreInteractions(meetingRoomBookingService); | |
| 36 | + } | |
| 37 | + | |
| 38 | +} | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java
| ... | ... | @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileNodeMapper; |
| 13 | 13 | import cn.iocoder.yudao.module.oa.framework.config.OaProperties; |
| 14 | 14 | import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| 15 | 15 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 16 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 16 | 17 | import jakarta.annotation.Resource; |
| 17 | 18 | import org.junit.jupiter.api.BeforeEach; |
| 18 | 19 | import org.junit.jupiter.api.Test; |
| ... | ... | @@ -510,6 +511,32 @@ public class OaFileNodeServiceImplTest extends BaseDbUnitTest { |
| 510 | 511 | } |
| 511 | 512 | |
| 512 | 513 | @Test |
| 514 | + public void testGetFileStorage_usedSizeNullAndDeleted() { | |
| 515 | + // mock 数据:本人文件的 size 为 NULL,已删除的文件不计入 | |
| 516 | + OaFileNodeDO nullSize = randomFileNodeDO(1L, 0L, 0).setType(1); | |
| 517 | + nullSize.setCreator("1"); | |
| 518 | + fileNodeMapper.insert(nullSize); | |
| 519 | + fileNodeMapper.update(null, new LambdaUpdateWrapper<OaFileNodeDO>().setSql("size = NULL") | |
| 520 | + .eq(OaFileNodeDO::getId, 1L)); | |
| 521 | + OaFileNodeDO deleted = randomFileNodeDO(2L, 0L, 0).setType(1).setSize(500L); | |
| 522 | + deleted.setCreator("1"); | |
| 523 | + fileNodeMapper.insert(deleted); | |
| 524 | + fileNodeMapper.deleteById(2L); | |
| 525 | + assertNull(fileNodeMapper.selectById(1L).getSize()); | |
| 526 | + assertNull(fileNodeMapper.selectById(2L)); | |
| 527 | + | |
| 528 | + // 调用,并断言:size 均为 NULL 时为 0 | |
| 529 | + assertEquals(0L, fileNodeService.getFileStorage(1L).getUsedSize()); | |
| 530 | + | |
| 531 | + // mock 数据:NULL 与有值的文件并存 | |
| 532 | + OaFileNodeDO normal = randomFileNodeDO(3L, 0L, 0).setType(1).setSize(100L); | |
| 533 | + normal.setCreator("1"); | |
| 534 | + fileNodeMapper.insert(normal); | |
| 535 | + // 调用,并断言:只累加有值的文件 | |
| 536 | + assertEquals(100L, fileNodeService.getFileStorage(1L).getUsedSize()); | |
| 537 | + } | |
| 538 | + | |
| 539 | + @Test | |
| 513 | 540 | public void testGetFileStorage_sharedCounts() { |
| 514 | 541 | // mock 数据:本人目录多个接收人只计一次,共享给我的子文件不重复计数 |
| 515 | 542 | OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0); | ... | ... |