diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index b650e871..ce7ba025 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -2247,7 +2247,6 @@ public class AihrPracticeSeedService { FROM aihr_org_snapshot WHERE tenant_id = ? AND """ + identityWhere + """ AND employment_status = 'active' - LIMIT 1 """, (rs, rowNum) -> rs.getString("position_level"), args.toArray()); if (positionLevels.stream().noneMatch(AihrPracticeSeedService::canSeeProject)) { throw new ServiceException("无主管权限,无法访问主管功能"); @@ -2277,33 +2276,41 @@ public class AihrPracticeSeedService { FROM aihr_org_snapshot WHERE tenant_id = ? AND """ + ownerWhere + """ AND employment_status = 'active' - LIMIT 1 """, (rs, rowNum) -> new OrgScopeRow(rs.getString("project_code"), rs.getString("position_level")), ownerArgs.toArray()); if (owners.isEmpty()) { return TeamScope.scoped(List.of()); } - OrgScopeRow row = owners.stream() + List projectCodes = owners.stream() .filter(scope -> canSeeProject(scope.positionLevel())) - .findFirst() - .orElse(null); - if (row == null) { + .map(OrgScopeRow::projectCode) + .filter(projectCode -> !isBlank(projectCode)) + .distinct() + .toList(); + if (projectCodes.isEmpty()) { return TeamScope.scoped(List.of(owner)); } + String projectInClause = inClause(projectCodes.size()); + List projectArgs = new ArrayList<>(); + projectArgs.add(tenantId()); + projectArgs.addAll(projectCodes); List ids = jdbcTemplate.query(""" SELECT ext_party_id FROM aihr_org_snapshot - WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active' + WHERE tenant_id = ? AND project_code IN (%s) AND employment_status = 'active' ORDER BY position_level, person_name, ext_party_id - """, (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), row.projectCode()); + """.formatted(projectInClause), (rs, rowNum) -> rs.getString("ext_party_id"), projectArgs.toArray()); if (hasPhone) { ids = new ArrayList<>(new LinkedHashSet<>(ids)); + List phoneArgs = new ArrayList<>(); + phoneArgs.add(tenantId()); + phoneArgs.addAll(projectCodes); ids.addAll(jdbcTemplate.query(""" SELECT person_phone FROM aihr_org_snapshot - WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active' + WHERE tenant_id = ? AND project_code IN (%s) AND employment_status = 'active' AND person_phone IS NOT NULL AND person_phone <> '' ORDER BY position_level, person_name, ext_party_id - """, (rs, rowNum) -> rs.getString("person_phone"), tenantId(), row.projectCode())); + """.formatted(projectInClause), (rs, rowNum) -> rs.getString("person_phone"), phoneArgs.toArray())); ids = new ArrayList<>(new LinkedHashSet<>(ids)); } return TeamScope.scoped(ids); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index c4d2dded..0f0f8465 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -248,6 +248,7 @@ public class AihrPracticeSeedServiceTest { AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); assertEquals("13900001111", service.requireSupervisorIdentity("13900001111")); + assertFalse(jdbcTemplate.authorizationSql.contains("LIMIT 1")); } @Test @@ -358,6 +359,17 @@ public class AihrPracticeSeedServiceTest { assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.assignmentsArgs); } + @Test + public void practiceTeamSnapshotMergesAllSupervisorProjects() throws Exception { + TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true, true); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + invokePracticeTeamSnapshot(service, "SUP-PHONE", 200); + + assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?,?,?,?,?)")); + assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?,?,?,?,?)")); + } + @Test public void markReviewedInitializesAssignmentTableBeforeSessionUpdate() { ReviewAssignmentOrderJdbcTemplate jdbcTemplate = new ReviewAssignmentOrderJdbcTemplate(); @@ -749,7 +761,7 @@ public class AihrPracticeSeedServiceTest { assignmentSql = sql; return List.of(); } - if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) { + if (sql.contains("SELECT project_code")) { return mapRows(rowMapper, List.of(Map.of( "project_code", "P1", "position_level", ownerLevel @@ -803,7 +815,7 @@ public class AihrPracticeSeedServiceTest { reviewArgs = List.of(args); return List.of(); } - if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) { + if (sql.contains("SELECT project_code")) { ownerSql = sql; ownerArgs = List.of(args); if (ownerLevel == null) { @@ -933,6 +945,7 @@ public class AihrPracticeSeedServiceTest { private static final class TeamSnapshotJdbcTemplate extends JdbcTemplate { private final boolean orgSnapshotAvailable; + private final boolean multipleProjects; private String membersSql = ""; private List membersArgs = List.of(); private String recordsSql = ""; @@ -941,7 +954,12 @@ public class AihrPracticeSeedServiceTest { private List assignmentsArgs = List.of(); private TeamSnapshotJdbcTemplate(boolean orgSnapshotAvailable) { + this(orgSnapshotAvailable, false); + } + + private TeamSnapshotJdbcTemplate(boolean orgSnapshotAvailable, boolean multipleProjects) { this.orgSnapshotAvailable = orgSnapshotAvailable; + this.multipleProjects = multipleProjects; } @Override @@ -969,22 +987,38 @@ public class AihrPracticeSeedServiceTest { public List query(String sql, RowMapper rowMapper, Object... args) { try { if (sql.contains("SELECT project_code, position_level")) { - return mapRows(rowMapper, List.of(Map.of( - "project_code", "P1", - "position_level", "主管" - ))); + return mapRows(rowMapper, multipleProjects + ? List.of( + Map.of("project_code", "P1", "position_level", "主管"), + Map.of("project_code", "P2", "position_level", "项目经理") + ) + : List.of(Map.of("project_code", "P1", "position_level", "主管"))); } if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) { - return mapRows(rowMapper, List.of( - Map.of("ext_party_id", "EMP-SUP"), - Map.of("ext_party_id", "EMP-1") - )); + return mapRows(rowMapper, multipleProjects + ? List.of( + Map.of("ext_party_id", "EMP-SUP-1"), + Map.of("ext_party_id", "EMP-1"), + Map.of("ext_party_id", "EMP-SUP-2"), + Map.of("ext_party_id", "EMP-2") + ) + : List.of( + Map.of("ext_party_id", "EMP-SUP"), + Map.of("ext_party_id", "EMP-1") + )); } if (sql.contains("SELECT person_phone") && sql.contains("ORDER BY position_level")) { - return mapRows(rowMapper, List.of( - Map.of("person_phone", "SUP-PHONE"), - Map.of("person_phone", "EMP-PHONE-1") - )); + return mapRows(rowMapper, multipleProjects + ? List.of( + Map.of("person_phone", "SUP-PHONE-1"), + Map.of("person_phone", "EMP-PHONE-1"), + Map.of("person_phone", "SUP-PHONE-2"), + Map.of("person_phone", "EMP-PHONE-2") + ) + : List.of( + Map.of("person_phone", "SUP-PHONE"), + Map.of("person_phone", "EMP-PHONE-1") + )); } if (sql.contains("FROM aihr_org_snapshot") && sql.contains("AS name")) { membersSql = sql; diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index ceda24a7..4c1a4883 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -169,3 +169,4 @@ - 2026-07-14 BRD G3 移动端角色降级复核:`/api/aihr/mobile/me` 组织快照查询异常时原先会直接返回 500,与“组织身份不可用时安全降级员工端”的口径不一致;现捕获组织查询运行时异常并返回员工端默认身份,同时仅记录固定错误提示,不泄露外部/数据库错误详情;新增源码契约 marker,未修改生产环境。 - 2026-07-14 BRD G3 移动端公开首页复核:`/api/aihr/mobile/home/{role}` 保留未登录首屏能力,但匿名请求此前会读取员工训练统计和主管团队完训/低分/待复盘数据;现改为匿名只返回无租户业务统计的静态 seed,已登录 `mobile-uni` 自动携带 `Authorization/clientid` 后,员工只读取本人统计、主管只读取组织快照解析出的项目范围统计,普通员工请求主管角色直接拒绝;已补 service、controller、前端请求、源码契约和真实 HTTP 回归测试;未修改生产环境。 - 2026-07-14 BRD G3 公开入口扫描:AIHR 当前仅保留公开的移动端静态首页和岗位-SOP 导航两个 `@SaIgnore` 入口;训练、上传、搜索、复盘、模型、组织同步等控制器均有登录或角色门禁,未发现新的匿名写入或业务数据公开入口;未修改生产环境。 +- 2026-07-14 BRD G3 项目范围复核:发现主管身份和团队范围查询仍使用 `LIMIT 1`,同一手机号关联多个在职项目时可能只取首个项目,导致主管团队数据漏项;现改为收集全部主管/项目经理项目编码并合并其在职成员与手机号范围,保留一线员工仅查看本人数据的降级边界。新增多项目范围回归测试;AIHR 全量测试 `70/70` 通过,未修改生产环境。