fix(aihr): close pre-merge auth and pilot metric gaps

Allow sys_user admin console to use unscoped mobile review APIs, restrict
org snapshot/sync to superadmin/hr_operator, count completed pilot people
with the 10-session rule, and let employees self-read assignments without
org team membership.
This commit is contained in:
2026-07-11 01:24:57 +08:00
parent 19788bedb0
commit 98177725cd
4 changed files with 69 additions and 2 deletions
@@ -128,7 +128,15 @@ public class AihrMobileController {
return ownMobileExtPartyId(requested);
}
/**
* APP supervisor tokens are org-scoped; management console sys_user keeps unscoped access
* so admin review/growth pages continue to work against the same mobile APIs.
*/
private String supervisorScopeExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
return mobileSeedService.requireSupervisorIdentity(currentAppUsername());
}
@@ -1,10 +1,13 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,13 +21,17 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/aihr/org")
public class AihrOrgSyncController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrOrgSyncService orgSyncService;
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/sync")
public R<SyncResponse> sync(@RequestBody(required = false) SyncRequest request) {
return R.ok(orgSyncService.sync(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/snapshot")
public R<OrgSnapshotResponse> snapshot(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String projectCode,
@@ -952,9 +952,11 @@ public class AihrPracticeSeedService {
return count("""
SELECT COUNT(*)
FROM (
SELECT DISTINCT ext_party_id
SELECT ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
GROUP BY ext_party_id
HAVING COUNT(*) >= 10
) completed_people
""", TENANT_ID);
}
@@ -1338,7 +1340,12 @@ public class AihrPracticeSeedService {
public List<PracticeAssignmentResponse> assignments(String extPartyId, String supervisorExtPartyId, int limit) {
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
String supervisor = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim();
if (!isBlank(party)) {
// Self-read must not depend on org team membership (SMS users may lack org snapshot rows).
if (!supervisor.isEmpty() && supervisor.equals(party)) {
return assignments(party, limit);
}
return inTeamScope(supervisorExtPartyId, party) ? assignments(party, limit) : List.of();
}
TeamScope scope = teamScope(supervisorExtPartyId);
@@ -354,11 +354,33 @@ public class AihrPracticeSeedServiceTest {
assertTrue(controllerSource.contains("mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("private String supervisorScopeExtPartyId()"));
assertTrue(controllerSource.contains("mobileSeedService.requireSupervisorIdentity(currentAppUsername())"));
assertTrue(controllerSource.contains("UserType.SYS_USER.getUserType().equals(loginUser.getUserType())"));
assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()"));
assertFalse(controllerSource.contains("supervisorScopeExtPartyId() {\n return \"\";"));
assertFalse(controllerSource.contains("supervisor H5 keeps team-scope"));
}
@Test
public void assignmentsSelfReadBypassesTeamScope() {
AssignmentJdbcTemplate jdbcTemplate = new AssignmentJdbcTemplate(null, List.of());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
service.assignments("13900009999", "13900009999", 10);
assertTrue(jdbcTemplate.assignmentSql.contains("SELECT id, ext_party_id"));
assertTrue(jdbcTemplate.assignmentSql.contains("ext_party_id = ?"));
assertFalse(jdbcTemplate.assignmentSql.contains("ext_party_id IN"));
}
@Test
public void completedPilotPeopleCountRequiresTenSessions() {
CountingJdbcTemplate jdbcTemplate = new CountingJdbcTemplate();
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertEquals(2, service.mobileCompletedPeopleCount());
assertTrue(jdbcTemplate.lastCountSql.contains("HAVING COUNT(*) >= 10"));
assertTrue(jdbcTemplate.lastCountSql.contains("GROUP BY ext_party_id"));
}
@Test
public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
@@ -971,4 +993,27 @@ public class AihrPracticeSeedServiceTest {
}
}
private static final class CountingJdbcTemplate extends JdbcTemplate {
private String lastCountSql = "";
@Override
public void execute(String sql) {
// Schema setup is irrelevant for count query tests.
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (requiredType == Integer.class) {
lastCountSql = sql;
return requiredType.cast(2);
}
return null;
}
}
}