diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java index d9f24007..ef66f54a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java @@ -7,6 +7,7 @@ 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.aihr.service.AihrTenantOrgGovernanceService; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.springframework.web.bind.annotation.GetMapping; @@ -24,11 +25,12 @@ public class AihrOrgSyncController { private static final String HR_OPERATOR_ROLE = "hr_operator"; private final AihrOrgSyncService orgSyncService; + private final AihrTenantOrgGovernanceService governanceService; @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/sync") public R sync(@RequestBody(required = true) SyncRequest request) { - return R.ok(orgSyncService.sync(request)); + return R.ok(governanceService.syncCurrentTenant(request)); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrTenantOrgGovernanceController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrTenantOrgGovernanceController.java new file mode 100644 index 00000000..9433eb1c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrTenantOrgGovernanceController.java @@ -0,0 +1,71 @@ +package org.dromara.aihr.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.AggregateView; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingOverview; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingPrecheckResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingRequest; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingView; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.DirectoryRefreshResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.DirectoryTreeResponse; +import org.dromara.aihr.service.AihrTenantOrgGovernanceService; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/aihr/platform") +@SaCheckRole(TenantConstants.SUPER_ADMIN_ROLE_KEY) +public class AihrTenantOrgGovernanceController { + + private final AihrTenantOrgGovernanceService governanceService; + + @GetMapping("/org-directory/tree") + public R tree() { + return R.ok(governanceService.directoryTree()); + } + + @Log(title = "平台组织目录刷新", businessType = BusinessType.IMPORT) + @PostMapping("/org-directory/refresh") + public R refreshDirectory() { + return R.ok(governanceService.refreshDirectory()); + } + + @GetMapping("/tenant-org-bindings") + public R bindings() { + return R.ok(governanceService.bindings()); + } + + @Log(title = "租户组织绑定预检", businessType = BusinessType.OTHER) + @PostMapping("/tenant-org-bindings/precheck") + public R precheck(@RequestBody BindingRequest request) { + return R.ok(governanceService.precheck(request)); + } + + @Log(title = "租户组织绑定", businessType = BusinessType.GRANT) + @PostMapping("/tenant-org-bindings") + public R create(@RequestBody BindingRequest request) { + return R.ok(governanceService.create(request)); + } + + @Log(title = "租户组织绑定调整", businessType = BusinessType.UPDATE) + @PatchMapping("/tenant-org-bindings/{id}") + public R update(@PathVariable Long id, @RequestBody BindingRequest request) { + return R.ok(governanceService.update(id, request)); + } + + @GetMapping("/tenant-org-bindings/{id}/aggregate") + public R aggregate(@PathVariable Long id) { + return R.ok(governanceService.aggregate(id)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java index 58c5a657..965380cc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java @@ -35,6 +35,13 @@ public final class AihrOrgSyncDto { ) { } + /** A non-sensitive, server-side view of the global external organization directory. */ + public record ExternalDirectorySnapshot(List nodes, List warnings) { + } + + public record ExternalDirectoryNode(String externalId, String parentExternalId, String nodeType, String name) { + } + public record OrgSnapshotResponse( int total, int active, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrTenantOrgGovernanceDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrTenantOrgGovernanceDto.java new file mode 100644 index 00000000..aa8211a0 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrTenantOrgGovernanceDto.java @@ -0,0 +1,45 @@ +package org.dromara.aihr.domain; + +import java.time.LocalDateTime; +import java.util.List; + +public final class AihrTenantOrgGovernanceDto { + + private AihrTenantOrgGovernanceDto() { + } + + public record DirectoryTreeResponse(String sourceCode, String directoryVersion, int nodeCount, + List roots) { + } + + public record DirectoryNode(String externalId, String parentExternalId, String nodeType, String name, + boolean bindable, List children) { + } + + public record DirectoryRefreshResponse(String sourceCode, String directoryVersion, int nodeCount, + List warnings) { + } + + public record BindingRequest(String tenantId, String rootExternalId, String mode, String status) { + } + + public record BindingView(Long id, String tenantId, String tenantName, String sourceCode, + String rootExternalId, String rootName, String rootNodeType, + String mode, String status, String directoryVersion, LocalDateTime lastSyncAt) { + } + + public record BindingOverview(int activeTenantCount, int boundTenantCount, int pendingTenantCount, + int conflictCount, List bindings) { + } + + public record ScopeConflict(String tenantId, String tenantName, String rootExternalId, + String rootName, String reason) { + } + + public record BindingPrecheckResponse(boolean canActivate, String rootName, String rootNodeType, + List conflicts) { + } + + public record AggregateView(Long bindingId, int coveredTenantCount, int syncedPersonCount) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index 97e12df4..054ce95a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgProjectOption; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse; +import org.dromara.aihr.domain.AihrOrgSyncDto.ExternalDirectoryNode; +import org.dromara.aihr.domain.AihrOrgSyncDto.ExternalDirectorySnapshot; import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; import org.dromara.common.tenant.helper.TenantHelper; @@ -177,6 +179,28 @@ public class AihrOrgSyncService { ); } + /** + * Loads the platform-wide directory from the existing global connector. + * This deliberately has no caller supplied scope: binding scope is resolved + * later by tenant governance, never by a browser request. + */ + public ExternalDirectorySnapshot directorySnapshot() { + String baseUrl = normalizeBaseUrl(configuredBaseUrl); + if (baseUrl.isBlank()) { + throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); + } + String token = accessToken(baseUrl); + SyncRequest unscoped = new SyncRequest(true, false, DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, + null, null, null, null); + List warnings = new ArrayList<>(); + List nodes = new ArrayList<>(); + appendDirectoryNodes(nodes, fetchOptional(baseUrl, token, unscoped, "group", DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, warnings), "GROUP"); + appendDirectoryNodes(nodes, fetchOptional(baseUrl, token, unscoped, "company", DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, warnings), "COMPANY"); + appendDirectoryNodes(nodes, fetchOptional(baseUrl, token, unscoped, "project", DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, warnings), "PROJECT"); + appendDirectoryNodes(nodes, fetchOptional(baseUrl, token, unscoped, "department", DEFAULT_PAGE_SIZE, DEFAULT_MAX_PAGES, warnings), "DEPARTMENT"); + return new ExternalDirectorySnapshot(List.copyOf(nodes), List.copyOf(warnings)); + } + static boolean hasUnsafeReplaceData(int employeeCount, int rowCount, int skipped, int phoneLinked, int maskedPhone, int suspectText) { return employeeCount != rowCount @@ -268,6 +292,46 @@ public class AihrOrgSyncService { } } + private static void appendDirectoryNodes(List target, List items, String nodeType) { + for (JsonNode item : items) { + ExternalDirectoryNode node = directoryNode(item, nodeType); + if (node != null) { + target.add(node); + } + } + } + + private static ExternalDirectoryNode directoryNode(JsonNode item, String nodeType) { + String externalId = switch (nodeType) { + case "GROUP" -> firstNonBlank(text(item, "id", "group_id", "groupId")); + case "COMPANY" -> firstNonBlank(text(item, "id", "company_id", "companyId")); + case "PROJECT" -> firstNonBlank(text(item, "id", "project_id", "projectId")); + case "DEPARTMENT" -> firstNonBlank(text(item, "id", "department_id", "departmentId", "dept_id", "deptId")); + default -> ""; + }; + if (externalId.isBlank()) { + return null; + } + String parentExternalId = switch (nodeType) { + case "GROUP" -> firstNonBlank(text(item, "parent_group_id", "parentGroupId", "parent_id", "parentId")); + case "COMPANY" -> firstNonBlank(text(item, "group_id", "groupId", "parent_group_id", "parentGroupId", "parent_id", "parentId")); + case "PROJECT" -> firstNonBlank(text(item, "company_id", "companyId", "group_id", "groupId", "parent_id", "parentId")); + case "DEPARTMENT" -> firstNonBlank(text(item, "project_id", "projectId", "company_id", "companyId", "group_id", "groupId", "parent_id", "parentId")); + default -> ""; + }; + if (externalId.length() > 128 || parentExternalId.length() > 128) { + throw new IllegalStateException("外部组织稳定 ID 超过 128 字符,已拒绝写入目录"); + } + String name = switch (nodeType) { + case "GROUP" -> firstNonBlank(text(item, "name", "group_name", "groupName"), externalId); + case "COMPANY" -> firstNonBlank(text(item, "name", "company_name", "companyName"), externalId); + case "PROJECT" -> firstNonBlank(text(item, "name", "project_name", "projectName"), externalId); + case "DEPARTMENT" -> firstNonBlank(text(item, "name", "department_name", "departmentName", "dept_name", "deptName"), externalId); + default -> externalId; + }; + return new ExternalDirectoryNode(externalId, parentExternalId, nodeType, truncate(name, 200)); + } + private List fetchSnapshot(String baseUrl, String token, SyncRequest req, String resourceType, int pageSize, int maxPages) { List items = new ArrayList<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrTenantOrgGovernanceService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrTenantOrgGovernanceService.java new file mode 100644 index 00000000..1d8e967c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrTenantOrgGovernanceService.java @@ -0,0 +1,516 @@ +package org.dromara.aihr.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrOrgSyncDto.ExternalDirectoryNode; +import org.dromara.aihr.domain.AihrOrgSyncDto.ExternalDirectorySnapshot; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.AggregateView; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingOverview; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingPrecheckResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingRequest; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.BindingView; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.DirectoryNode; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.DirectoryRefreshResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.DirectoryTreeResponse; +import org.dromara.aihr.domain.AihrTenantOrgGovernanceDto.ScopeConflict; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AihrTenantOrgGovernanceService { + + public static final String SOURCE_CODE = "OPEN_PLATFORM"; + public static final String EXCLUSIVE = "EXCLUSIVE"; + public static final String AGGREGATE_ONLY = "AGGREGATE_ONLY"; + public static final String ACTIVE = "ACTIVE"; + public static final String DRAFT = "DRAFT"; + public static final String DISABLED = "DISABLED"; + + private static final String DEFAULT_TENANT_ID = "000000"; + private static final Set BINDABLE_NODE_TYPES = Set.of("GROUP", "COMPANY", "DEPARTMENT"); + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + private final AihrOrgSyncService orgSyncService; + + public DirectoryTreeResponse directoryTree() { + List rows = directoryRows(); + Map byId = new HashMap<>(); + Map> children = new HashMap<>(); + List roots = new ArrayList<>(); + for (DirectoryRow row : rows) { + byId.put(row.externalId(), row); + } + for (DirectoryRow row : rows) { + if (hasText(row.parentExternalId()) && byId.containsKey(row.parentExternalId())) { + children.computeIfAbsent(row.parentExternalId(), ignored -> new ArrayList<>()).add(row); + } else { + roots.add(row); + } + } + Comparator order = Comparator.comparing(DirectoryRow::name).thenComparing(DirectoryRow::externalId); + roots.sort(order); + children.values().forEach(value -> value.sort(order)); + return new DirectoryTreeResponse(SOURCE_CODE, directoryVersion(), rows.size(), tree(roots, children)); + } + + public DirectoryRefreshResponse refreshDirectory() { + ExternalDirectorySnapshot source = orgSyncService.directorySnapshot(); + String version = UUID.randomUUID().toString(); + List rows = normalizeDirectory(source.nodes(), version); + transactionTemplate.executeWithoutResult(status -> { + jdbcTemplate.update("update aihr_org_directory set active = 0, update_time = now() where source_code = ?", SOURCE_CODE); + jdbcTemplate.batchUpdate(""" + insert into aihr_org_directory + (source_code, external_id, parent_external_id, node_type, name, path, directory_version, active, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, 1, now(), now()) + on duplicate key update + parent_external_id = values(parent_external_id), node_type = values(node_type), name = values(name), + path = values(path), directory_version = values(directory_version), active = 1, update_time = now() + """, rows.stream().map(row -> new Object[] { + SOURCE_CODE, row.externalId(), row.parentExternalId(), row.nodeType(), row.name(), row.path(), version + }).toList()); + }); + return new DirectoryRefreshResponse(SOURCE_CODE, version, rows.size(), source.warnings()); + } + + public BindingOverview bindings() { + List bindings = jdbcTemplate.query(""" + select t.tenant_id, t.company_name, b.id binding_id, b.source_code, b.root_external_id, + b.mode, b.status, b.directory_version, b.last_sync_at, + d.name root_name, d.node_type root_node_type + from sys_tenant t + left join aihr_tenant_org_binding b on b.tenant_id = t.tenant_id + left join aihr_org_directory d + on d.source_code = b.source_code and d.external_id = b.root_external_id + where t.status = '0' + order by t.company_name, t.tenant_id + """, (rs, rowNum) -> new BindingView( + rs.getObject("binding_id", Long.class), rs.getString("tenant_id"), rs.getString("company_name"), + rs.getString("source_code"), rs.getString("root_external_id"), rs.getString("root_name"), + rs.getString("root_node_type"), rs.getString("mode"), + rs.getObject("binding_id") == null ? "UNBOUND" : rs.getString("status"), + rs.getString("directory_version"), localDateTime(rs.getTimestamp("last_sync_at")))); + int bound = (int) bindings.stream().filter(binding -> ACTIVE.equals(binding.status())).count(); + return new BindingOverview(bindings.size(), bound, bindings.size() - bound, + activeExclusiveConflicts().size(), bindings); + } + + public BindingPrecheckResponse precheck(BindingRequest request) { + BindingInput input = input(request, ACTIVE); + TenantRow tenant = requireTenant(input.tenantId()); + DirectoryRow root = requireBindableRoot(input.rootExternalId()); + List conflicts = conflicts(input, root, activeBindings(false), tenant.tenantId()); + return new BindingPrecheckResponse(conflicts.isEmpty(), root.name(), root.nodeType(), conflicts); + } + + @Transactional(rollbackFor = Exception.class) + public BindingView create(BindingRequest request) { + return save(input(request, ACTIVE), null); + } + + @Transactional(rollbackFor = Exception.class) + public BindingView update(Long id, BindingRequest request) { + BindingRow existing = requireBinding(id, true); + if (request != null && hasText(request.tenantId()) && !existing.tenantId().equals(request.tenantId().trim())) { + throw new ServiceException("租户绑定创建后不可变更目标租户", HttpStatus.BAD_REQUEST); + } + BindingInput input = input(new BindingRequest( + existing.tenantId(), + hasText(request == null ? null : request.rootExternalId()) ? request.rootExternalId() : existing.rootExternalId(), + hasText(request == null ? null : request.mode()) ? request.mode() : existing.mode(), + hasText(request == null ? null : request.status()) ? request.status() : existing.status() + ), existing.status()); + return save(input, id); + } + + public AggregateView aggregate(Long id) { + BindingRow binding = requireBinding(id, false); + if (!ACTIVE.equals(binding.status()) || !AGGREGATE_ONLY.equals(binding.mode())) { + throw new ServiceException("仅汇总范围才可查看聚合指标", HttpStatus.FORBIDDEN); + } + List tenantIds = activeBindings(false).stream() + .filter(item -> EXCLUSIVE.equals(item.mode()) && isAncestorOrSame(binding.path(), item.path())) + .map(BindingRow::tenantId) + .distinct() + .toList(); + if (tenantIds.isEmpty()) { + return new AggregateView(id, 0, 0); + } + String placeholders = String.join(",", java.util.Collections.nCopies(tenantIds.size(), "?")); + Integer syncedPeople = jdbcTemplate.queryForObject( + "select count(*) from aihr_org_snapshot where tenant_id in (" + placeholders + ")", + Integer.class, tenantIds.toArray()); + return new AggregateView(id, tenantIds.size(), syncedPeople == null ? 0 : syncedPeople); + } + + public SyncResponse syncCurrentTenant(SyncRequest requested) { + String tenantId = currentTenantId(); + BindingRow binding = activeBindingForTenant(tenantId); + if (binding == null) { + throw new ServiceException("TENANT_ORG_BINDING_REQUIRED", HttpStatus.CONFLICT); + } + if (AGGREGATE_ONLY.equals(binding.mode())) { + throw new ServiceException("AGGREGATE_ONLY_CANNOT_SYNC", HttpStatus.CONFLICT); + } + SyncRequest scoped = scopedRequest(requested, binding); + SyncResponse response = orgSyncService.sync(scoped); + if (!response.dryRun()) { + jdbcTemplate.update("update aihr_tenant_org_binding set last_sync_at = now(), update_time = now() where id = ?", binding.id()); + } + return response; + } + + static boolean isAncestorOrSame(String ancestorPath, String descendantPath) { + return hasText(ancestorPath) && hasText(descendantPath) + && (ancestorPath.equals(descendantPath) || descendantPath.startsWith(ancestorPath + "/")); + } + + static boolean exclusiveScopeConflicts(String candidatePath, String existingPath) { + return isAncestorOrSame(candidatePath, existingPath) || isAncestorOrSame(existingPath, candidatePath); + } + + private BindingView save(BindingInput input, Long expectedId) { + TenantRow tenant = requireTenant(input.tenantId()); + DirectoryRow root = requireBindableRoot(input.rootExternalId()); + Long operatorId = LoginHelper.getUserId(); + List active = activeBindings(true); + List conflicts = conflicts(input, root, active, tenant.tenantId()); + if (ACTIVE.equals(input.status()) && !conflicts.isEmpty()) { + throw new ServiceException("TENANT_ORG_SCOPE_CONFLICT", HttpStatus.CONFLICT); + } + BindingRow existing = bindingForTenant(tenant.tenantId(), true); + if (expectedId != null && (existing == null || !expectedId.equals(existing.id()))) { + throw new ServiceException("租户组织绑定不存在", HttpStatus.NOT_FOUND); + } + if (existing == null) { + jdbcTemplate.update(""" + insert into aihr_tenant_org_binding + (tenant_id, source_code, root_external_id, mode, status, directory_version, create_by, create_time, update_by, update_time) + values (?, ?, ?, ?, ?, ?, ?, now(), ?, now()) + """, tenant.tenantId(), SOURCE_CODE, root.externalId(), input.mode(), input.status(), root.directoryVersion(), operatorId, operatorId); + } else { + jdbcTemplate.update(""" + update aihr_tenant_org_binding + set source_code = ?, root_external_id = ?, mode = ?, status = ?, directory_version = ?, update_by = ?, update_time = now() + where id = ? + """, SOURCE_CODE, root.externalId(), input.mode(), input.status(), root.directoryVersion(), operatorId, existing.id()); + } + BindingRow saved = bindingForTenant(tenant.tenantId(), false); + return bindingView(saved); + } + + private SyncRequest scopedRequest(SyncRequest requested, BindingRow binding) { + if (binding == null || !hasText(binding.rootExternalId()) || !hasText(binding.rootNodeType())) { + throw new ServiceException("TENANT_ORG_BINDING_REQUIRED", HttpStatus.CONFLICT); + } + String groupId = null; + String companyId = null; + String departmentId = null; + switch (binding.rootNodeType()) { + case "GROUP" -> groupId = binding.rootExternalId(); + case "COMPANY" -> companyId = binding.rootExternalId(); + case "DEPARTMENT" -> departmentId = binding.rootExternalId(); + default -> throw new ServiceException("ORG_SCOPE_TYPE_NOT_SYNCABLE", HttpStatus.CONFLICT); + } + SyncRequest source = requested == null + ? new SyncRequest(null, null, null, null, null, null, null, null) + : requested; + return new SyncRequest(source.dryRun(), source.replaceExisting(), source.pageSize(), source.maxPages(), + groupId, companyId, departmentId, source.allowPartialReplace()); + } + + private List conflicts(BindingInput input, DirectoryRow root, List active, + String ownTenantId) { + if (!ACTIVE.equals(input.status()) || !EXCLUSIVE.equals(input.mode())) { + return List.of(); + } + List conflicts = new ArrayList<>(); + for (BindingRow existing : active) { + if (ownTenantId.equals(existing.tenantId()) || !EXCLUSIVE.equals(existing.mode()) + || !exclusiveScopeConflicts(root.path(), existing.path())) { + continue; + } + String reason = root.path().equals(existing.path()) ? "与已绑定租户范围相同" + : isAncestorOrSame(root.path(), existing.path()) ? "目标范围覆盖已绑定租户的下级范围" + : "目标范围属于已绑定租户的上级范围"; + conflicts.add(new ScopeConflict(existing.tenantId(), existing.tenantName(), existing.rootExternalId(), + existing.rootName(), reason)); + } + return List.copyOf(conflicts); + } + + private List activeExclusiveConflicts() { + List active = activeBindings(false).stream().filter(item -> EXCLUSIVE.equals(item.mode())).toList(); + List conflicts = new ArrayList<>(); + for (int left = 0; left < active.size(); left++) { + for (int right = left + 1; right < active.size(); right++) { + BindingRow first = active.get(left); + BindingRow second = active.get(right); + if (exclusiveScopeConflicts(first.path(), second.path())) { + conflicts.add(new ScopeConflict(second.tenantId(), second.tenantName(), second.rootExternalId(), + second.rootName(), "与租户 " + first.tenantName() + " 的独占范围重叠")); + } + } + } + return conflicts; + } + + private List activeBindings(boolean lockForUpdate) { + String lock = lockForUpdate ? " for update" : ""; + return jdbcTemplate.query(""" + select b.id, b.tenant_id, b.source_code, b.root_external_id, b.mode, b.status, b.directory_version, + b.last_sync_at, coalesce(t.company_name, b.tenant_id) tenant_name, + d.name root_name, d.node_type root_node_type, d.path root_path + from aihr_tenant_org_binding b + left join sys_tenant t on t.tenant_id = b.tenant_id + left join aihr_org_directory d on d.source_code = b.source_code and d.external_id = b.root_external_id + where b.source_code = ? and b.status = 'ACTIVE' + """ + lock, (rs, rowNum) -> bindingRow(rs), SOURCE_CODE); + } + + private BindingRow activeBindingForTenant(String tenantId) { + BindingRow binding = bindingForTenant(tenantId, false); + return binding != null && ACTIVE.equals(binding.status()) ? binding : null; + } + + private BindingRow bindingForTenant(String tenantId, boolean lockForUpdate) { + List rows = jdbcTemplate.query(""" + select b.id, b.tenant_id, b.source_code, b.root_external_id, b.mode, b.status, b.directory_version, + b.last_sync_at, coalesce(t.company_name, b.tenant_id) tenant_name, + d.name root_name, d.node_type root_node_type, d.path root_path + from aihr_tenant_org_binding b + left join sys_tenant t on t.tenant_id = b.tenant_id + left join aihr_org_directory d on d.source_code = b.source_code and d.external_id = b.root_external_id + where b.tenant_id = ? + """ + (lockForUpdate ? " for update" : ""), (rs, rowNum) -> bindingRow(rs), tenantId); + return rows.isEmpty() ? null : rows.get(0); + } + + private BindingRow requireBinding(Long id, boolean lockForUpdate) { + if (id == null) { + throw new ServiceException("租户组织绑定不存在", HttpStatus.NOT_FOUND); + } + List rows = jdbcTemplate.query(""" + select b.id, b.tenant_id, b.source_code, b.root_external_id, b.mode, b.status, b.directory_version, + b.last_sync_at, coalesce(t.company_name, b.tenant_id) tenant_name, + d.name root_name, d.node_type root_node_type, d.path root_path + from aihr_tenant_org_binding b + left join sys_tenant t on t.tenant_id = b.tenant_id + left join aihr_org_directory d on d.source_code = b.source_code and d.external_id = b.root_external_id + where b.id = ? + """ + (lockForUpdate ? " for update" : ""), (rs, rowNum) -> bindingRow(rs), id); + if (rows.isEmpty()) { + throw new ServiceException("租户组织绑定不存在", HttpStatus.NOT_FOUND); + } + return rows.get(0); + } + + private BindingRow bindingRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new BindingRow(rs.getLong("id"), rs.getString("tenant_id"), rs.getString("tenant_name"), + rs.getString("source_code"), rs.getString("root_external_id"), rs.getString("root_name"), + rs.getString("root_node_type"), rs.getString("root_path"), rs.getString("mode"), + rs.getString("status"), rs.getString("directory_version"), localDateTime(rs.getTimestamp("last_sync_at"))); + } + + private BindingView bindingView(BindingRow row) { + return new BindingView(row.id(), row.tenantId(), row.tenantName(), row.sourceCode(), row.rootExternalId(), + row.rootName(), row.rootNodeType(), row.mode(), row.status(), row.directoryVersion(), row.lastSyncAt()); + } + + private DirectoryRow requireBindableRoot(String externalId) { + String id = required(externalId, "组织范围"); + List rows = jdbcTemplate.query(""" + select external_id, parent_external_id, node_type, name, path, directory_version + from aihr_org_directory + where source_code = ? and external_id = ? and active = 1 + """, (rs, rowNum) -> directoryRow(rs), SOURCE_CODE, id); + if (rows.isEmpty()) { + throw new ServiceException("组织范围不存在或目录已过期,请先刷新组织目录", HttpStatus.BAD_REQUEST); + } + DirectoryRow row = rows.get(0); + if (!BINDABLE_NODE_TYPES.contains(row.nodeType())) { + throw new ServiceException("当前开放接口只支持集团、公司或部门作为同步范围", HttpStatus.BAD_REQUEST); + } + return row; + } + + private List directoryRows() { + return jdbcTemplate.query(""" + select external_id, parent_external_id, node_type, name, path, directory_version + from aihr_org_directory + where source_code = ? and active = 1 + order by path, name, external_id + """, (rs, rowNum) -> directoryRow(rs), SOURCE_CODE); + } + + private DirectoryRow directoryRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new DirectoryRow(rs.getString("external_id"), rs.getString("parent_external_id"), + rs.getString("node_type"), rs.getString("name"), rs.getString("path"), rs.getString("directory_version")); + } + + private String directoryVersion() { + List versions = jdbcTemplate.query(""" + select directory_version from aihr_org_directory + where source_code = ? and active = 1 + order by update_time desc limit 1 + """, (rs, rowNum) -> rs.getString("directory_version"), SOURCE_CODE); + return versions.isEmpty() ? "" : versions.get(0); + } + + private List tree(List rows, Map> children) { + return rows.stream().map(row -> new DirectoryNode(row.externalId(), row.parentExternalId(), row.nodeType(), row.name(), + BINDABLE_NODE_TYPES.contains(row.nodeType()), tree(children.getOrDefault(row.externalId(), List.of()), children))).toList(); + } + + private List normalizeDirectory(List sourceNodes, String version) { + Map rows = new LinkedHashMap<>(); + for (ExternalDirectoryNode sourceNode : sourceNodes) { + if (sourceNode == null || !hasText(sourceNode.externalId())) { + continue; + } + String id = requiredLength(sourceNode.externalId(), "外部组织 ID", 128); + if (rows.containsKey(id)) { + throw new ServiceException("外部组织目录存在重复稳定 ID,已拒绝刷新", HttpStatus.BAD_REQUEST); + } + rows.put(id, new DirectoryRow(id, text(sourceNode.parentExternalId(), 128), + option(sourceNode.nodeType(), Set.of("GROUP", "COMPANY", "PROJECT", "DEPARTMENT"), "节点类型"), + requiredLength(sourceNode.name(), "组织名称", 200), "", version)); + } + Map paths = new HashMap<>(); + List normalized = new ArrayList<>(); + for (DirectoryRow row : rows.values()) { + normalized.add(row.withPath(path(row, rows, paths, new HashSet<>()))); + } + return normalized; + } + + private String path(DirectoryRow row, Map rows, Map paths, Set visiting) { + String known = paths.get(row.externalId()); + if (known != null) { + return known; + } + if (!visiting.add(row.externalId())) { + throw new ServiceException("外部组织目录存在循环父子关系,已拒绝刷新", HttpStatus.BAD_REQUEST); + } + DirectoryRow parent = rows.get(row.parentExternalId()); + String value = parent == null ? safePathSegment(row.externalId()) + : path(parent, rows, paths, visiting) + "/" + safePathSegment(row.externalId()); + visiting.remove(row.externalId()); + paths.put(row.externalId(), value); + return value; + } + + private TenantRow requireTenant(String tenantId) { + List rows = jdbcTemplate.query(""" + select tenant_id, company_name, status + from sys_tenant where tenant_id = ? + """, (rs, rowNum) -> new TenantRow(rs.getString("tenant_id"), rs.getString("company_name"), rs.getString("status")), tenantId); + if (rows.isEmpty()) { + throw new ServiceException("目标租户不存在", HttpStatus.NOT_FOUND); + } + TenantRow tenant = rows.get(0); + if (!"0".equals(tenant.status())) { + throw new ServiceException("停用租户不能建立组织绑定", HttpStatus.BAD_REQUEST); + } + return tenant; + } + + private BindingInput input(BindingRequest request, String defaultStatus) { + if (request == null) { + throw new ServiceException("租户、组织范围和范围模式均为必填", HttpStatus.BAD_REQUEST); + } + return new BindingInput(required(request.tenantId(), "目标租户"), required(request.rootExternalId(), "组织范围"), + option(request.mode(), Set.of(EXCLUSIVE, AGGREGATE_ONLY), "范围模式"), + option(hasText(request.status()) ? request.status() : defaultStatus, Set.of(ACTIVE, DRAFT, DISABLED), "绑定状态")); + } + + private static String currentTenantId() { + String tenantId = TenantHelper.getTenantId(); + return hasText(tenantId) ? tenantId.trim() : DEFAULT_TENANT_ID; + } + + private static String safePathSegment(String value) { + return value.replace("/", "%2F"); + } + + private static String required(String value, String label) { + if (!hasText(value)) { + throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST); + } + return value.trim(); + } + + private static String requiredLength(String value, String label, int maxLength) { + String text = required(value, label); + if (text.length() > maxLength) { + throw new ServiceException(label + "长度不能超过 " + maxLength, HttpStatus.BAD_REQUEST); + } + return text; + } + + private static String text(String value, int maxLength) { + if (!hasText(value)) { + return ""; + } + String text = value.trim(); + if (text.length() > maxLength) { + throw new ServiceException("外部组织 ID 长度不能超过 " + maxLength, HttpStatus.BAD_REQUEST); + } + return text; + } + + private static String option(String value, Set allowed, String label) { + String option = required(value, label).toUpperCase(); + if (!allowed.contains(option)) { + throw new ServiceException(label + "不合法", HttpStatus.BAD_REQUEST); + } + return option; + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static LocalDateTime localDateTime(Timestamp value) { + return value == null ? null : value.toLocalDateTime(); + } + + private record DirectoryRow(String externalId, String parentExternalId, String nodeType, String name, + String path, String directoryVersion) { + DirectoryRow withPath(String value) { + return new DirectoryRow(externalId, parentExternalId, nodeType, name, value, directoryVersion); + } + } + + private record BindingRow(Long id, String tenantId, String tenantName, String sourceCode, String rootExternalId, + String rootName, String rootNodeType, String path, String mode, String status, + String directoryVersion, LocalDateTime lastSyncAt) { + } + + private record BindingInput(String tenantId, String rootExternalId, String mode, String status) { + } + + private record TenantRow(String tenantId, String companyName, String status) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index 5bc95e47..a185d03c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -291,6 +291,42 @@ public class AihrOrgSyncServiceTest { } } + @Test + public void directorySnapshotBuildsStableHierarchyWithoutBrowserScope() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/open/v1/sync/snapshot", exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + String items = query.contains("resource_type=group") ? "[{\"id\":\"G-1\",\"name\":\"银城集团\"}]" + : query.contains("resource_type=company") ? "[{\"id\":\"C-1\",\"group_id\":\"G-1\",\"name\":\"南京公司\"}]" + : query.contains("resource_type=project") ? "[{\"id\":\"P-1\",\"company_id\":\"C-1\",\"name\":\"翡翠湾项目\"}]" + : query.contains("resource_type=department") ? "[{\"id\":\"D-1\",\"project_id\":\"P-1\",\"name\":\"客服部\"}]" : "[]"; + byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":1,\"has_more\":false}}").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + try { + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "configuredBaseUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/api/open/v1"); + ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token"); + ReflectionTestUtils.setField(service, "configuredClientId", ""); + ReflectionTestUtils.setField(service, "configuredClientSecret", ""); + ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); + + var snapshot = service.directorySnapshot(); + + assertEquals(4, snapshot.nodes().size()); + assertTrue(snapshot.nodes().stream().anyMatch(node -> "GROUP".equals(node.nodeType()) && "G-1".equals(node.externalId()))); + assertTrue(snapshot.nodes().stream().anyMatch(node -> "DEPARTMENT".equals(node.nodeType()) + && "P-1".equals(node.parentExternalId()))); + } finally { + server.stop(0); + } + } + private static final class SnapshotJdbcTemplate extends JdbcTemplate { private final List queries = new ArrayList<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrTenantOrgGovernanceServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrTenantOrgGovernanceServiceTest.java new file mode 100644 index 00000000..7bdb9517 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrTenantOrgGovernanceServiceTest.java @@ -0,0 +1,120 @@ +package org.dromara.aihr.service; + +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrTenantOrgGovernanceServiceTest { + + @Test + void exclusiveScopeRejectsSameAncestorAndDescendantButAllowsSiblings() { + assertTrue(AihrTenantOrgGovernanceService.exclusiveScopeConflicts("G/C", "G/C")); + assertTrue(AihrTenantOrgGovernanceService.exclusiveScopeConflicts("G/C", "G/C/D")); + assertTrue(AihrTenantOrgGovernanceService.exclusiveScopeConflicts("G/C/D", "G/C")); + assertFalse(AihrTenantOrgGovernanceService.exclusiveScopeConflicts("G/C1", "G/C2")); + } + + @Test + void syncRequiresActiveBindingBeforeItTouchesTheExternalConnector() { + AihrOrgSyncService orgSyncService = mock(AihrOrgSyncService.class); + AihrTenantOrgGovernanceService service = new AihrTenantOrgGovernanceService( + new EmptyJdbcTemplate(), mock(TransactionTemplate.class), orgSyncService); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.syncCurrentTenant(new SyncRequest(true, false, 20, 1, "forged", "forged", "forged", false))); + + assertEquals(409, error.getCode()); + assertEquals("TENANT_ORG_BINDING_REQUIRED", error.getMessage()); + verifyNoInteractions(orgSyncService); + } + + @Test + void aggregateOnlyBindingCannotStartRawSnapshotSync() { + AihrOrgSyncService orgSyncService = mock(AihrOrgSyncService.class); + AihrTenantOrgGovernanceService service = new AihrTenantOrgGovernanceService( + new BindingJdbcTemplate("AGGREGATE_ONLY"), mock(TransactionTemplate.class), orgSyncService); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.syncCurrentTenant(new SyncRequest(true, false, 20, 1, "forged", "forged", "forged", false))); + + assertEquals(409, error.getCode()); + assertEquals("AGGREGATE_ONLY_CANNOT_SYNC", error.getMessage()); + verifyNoInteractions(orgSyncService); + } + + @Test + void syncIgnoresBrowserSuppliedScopeAndUsesTheBindingRoot() { + AihrOrgSyncService orgSyncService = mock(AihrOrgSyncService.class); + when(orgSyncService.sync(any())).thenReturn(new SyncResponse(true, false, "source", 0, 0, 0, 0, 0, 0, 0, 0, List.of())); + AihrTenantOrgGovernanceService service = new AihrTenantOrgGovernanceService( + new BindingJdbcTemplate("EXCLUSIVE"), mock(TransactionTemplate.class), orgSyncService); + + service.syncCurrentTenant(new SyncRequest(true, false, 20, 1, "FORGED-GROUP", "FORGED-COMPANY", "FORGED-DEPT", false)); + + ArgumentCaptor request = ArgumentCaptor.forClass(SyncRequest.class); + verify(orgSyncService).sync(request.capture()); + assertEquals("C-1", request.getValue().companyId()); + assertEquals(null, request.getValue().groupId()); + assertEquals(null, request.getValue().departmentId()); + } + + private static final class EmptyJdbcTemplate extends JdbcTemplate { + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + return List.of(); + } + } + + private static final class BindingJdbcTemplate extends JdbcTemplate { + private final String mode; + + private BindingJdbcTemplate(String mode) { + this.mode = mode; + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + if (!sql.contains("aihr_tenant_org_binding")) { + return List.of(); + } + try { + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.getLong("id")).thenReturn(7L); + when(resultSet.getString("tenant_id")).thenReturn("000000"); + when(resultSet.getString("tenant_name")).thenReturn("平台租户"); + when(resultSet.getString("source_code")).thenReturn(AihrTenantOrgGovernanceService.SOURCE_CODE); + when(resultSet.getString("root_external_id")).thenReturn("C-1"); + when(resultSet.getString("root_name")).thenReturn("南京公司"); + when(resultSet.getString("root_node_type")).thenReturn("COMPANY"); + when(resultSet.getString("root_path")).thenReturn("G-1/C-1"); + when(resultSet.getString("mode")).thenReturn(mode); + when(resultSet.getString("status")).thenReturn(AihrTenantOrgGovernanceService.ACTIVE); + when(resultSet.getString("directory_version")).thenReturn("v1"); + return List.of(rowMapper.mapRow(resultSet, 0)); + } catch (SQLException error) { + throw new IllegalStateException(error); + } + } + } +} diff --git a/backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql b/backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql new file mode 100644 index 00000000..9e7f2a41 --- /dev/null +++ b/backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql @@ -0,0 +1,39 @@ +-- 租户与组织两级治理第一期:平台组织目录与租户绑定。 +-- 仅新增治理元数据;不修改、迁移或删除 aihr_org_snapshot 历史快照。 + +CREATE TABLE IF NOT EXISTS `aihr_org_directory` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `source_code` varchar(64) NOT NULL COMMENT '平台组织连接器标识', + `external_id` varchar(128) NOT NULL COMMENT '外部组织稳定ID', + `parent_external_id` varchar(128) DEFAULT NULL COMMENT '外部父组织稳定ID', + `node_type` varchar(32) NOT NULL COMMENT 'GROUP/COMPANY/PROJECT/DEPARTMENT', + `name` varchar(200) NOT NULL COMMENT '组织展示名称', + `path` varchar(2000) NOT NULL COMMENT '稳定ID祖先路径', + `directory_version` char(36) NOT NULL COMMENT '目录刷新版本', + `active` tinyint NOT NULL DEFAULT 1 COMMENT '是否仍在外部组织源有效', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_org_directory_source_external` (`source_code`, `external_id`), + KEY `idx_aihr_org_directory_source_parent` (`source_code`, `parent_external_id`, `active`), + KEY `idx_aihr_org_directory_source_path` (`source_code`, `path`(255), `active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台外部组织目录'; + +CREATE TABLE IF NOT EXISTS `aihr_tenant_org_binding` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '目标租户编号', + `source_code` varchar(64) NOT NULL COMMENT '平台组织连接器标识', + `root_external_id` varchar(128) NOT NULL COMMENT '绑定根组织稳定ID', + `mode` varchar(32) NOT NULL COMMENT 'EXCLUSIVE/AGGREGATE_ONLY', + `status` varchar(32) NOT NULL COMMENT 'DRAFT/ACTIVE/DISABLED', + `directory_version` char(36) NOT NULL COMMENT '预检所依据的目录版本', + `last_sync_at` datetime DEFAULT NULL COMMENT '最近一次独占范围同步时间', + `create_by` bigint DEFAULT NULL COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` bigint DEFAULT NULL COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_tenant_org_binding_tenant` (`tenant_id`), + KEY `idx_aihr_tenant_org_binding_source_status` (`source_code`, `status`), + KEY `idx_aihr_tenant_org_binding_root` (`source_code`, `root_external_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户与组织范围绑定'; diff --git a/docs/assets/tenant-org-governance/tenant-org-binding-conflict-precheck.png b/docs/assets/tenant-org-governance/tenant-org-binding-conflict-precheck.png new file mode 100644 index 00000000..45d48302 Binary files /dev/null and b/docs/assets/tenant-org-governance/tenant-org-binding-conflict-precheck.png differ diff --git a/docs/assets/tenant-org-governance/tenant-org-binding-conflict-resolution.png b/docs/assets/tenant-org-governance/tenant-org-binding-conflict-resolution.png new file mode 100644 index 00000000..5d0155b2 Binary files /dev/null and b/docs/assets/tenant-org-governance/tenant-org-binding-conflict-resolution.png differ diff --git a/docs/assets/tenant-org-governance/tenant-org-binding-overview.png b/docs/assets/tenant-org-governance/tenant-org-binding-overview.png new file mode 100644 index 00000000..c0313173 Binary files /dev/null and b/docs/assets/tenant-org-governance/tenant-org-binding-overview.png differ diff --git a/docs/租户与组织两级治理实施计划-20260723.md b/docs/租户与组织两级治理实施计划-20260723.md new file mode 100644 index 00000000..a04717ec --- /dev/null +++ b/docs/租户与组织两级治理实施计划-20260723.md @@ -0,0 +1,250 @@ +# 租户与组织两级治理实施计划 + +> 日期:2026-07-23 +> 适用范围:银城帮道管理后台、组织同步服务与多租户数据治理 + +## 1. 建设目标 + +将“租户”与“外部组织架构”明确为两个独立但受控关联的领域: + +- **租户**是数据、用户、知识空间、业务配置和权限的隔离边界。 +- **组织架构**是来自外部组织平台的集团、公司、项目、部门、人员层级目录。 +- **平台层**负责外部组织源、组织目录和租户绑定规则;**租户层**只在平台授予的范围内使用组织数据。 + +形成正式、可审计、可扩展的两级治理模型,而不是让各租户自行配置外部接口并任意导入组织数据。 + +```mermaid +flowchart LR + A[外部组织开放平台] --> B[平台组织目录] + B --> C[平台租户-组织绑定] + C --> D[独占范围租户组织快照] + C --> E[仅汇总范围聚合指标] + D --> F[租户内人员、权限与业务数据] + E --> G[跨租户汇总看板] +``` + +## 2. 治理边界与角色 + +### 平台超级管理员 + +- 配置并维护全局外部组织连接器,包括接口地址、Token、Client ID/Secret 等敏感凭据。 +- 刷新平台组织目录,维护租户与组织范围的绑定。 +- 执行范围冲突校验、启停绑定、查看操作审计和汇总指标。 +- 处理跨租户边界调整等高风险变更。 + +### 租户管理员 / HR 管理员 + +- 只能查看平台已分配给本租户的组织范围。 +- 仅可在“独占范围”绑定下发起组织刷新;刷新范围由服务端依据绑定关系确定。 +- 不能修改连接器、绑定根节点、范围模式或其他租户的组织边界。 + +## 3. 不可变业务规则 + +### 3.1 范围模式 + +| 模式 | 可同步组织快照 | 可查看内容 | 与其他租户的关系 | +| --- | --- | --- | --- | +| `EXCLUSIVE`(独占范围) | 可以 | 本租户范围内的原始组织、人员及业务数据 | 不得与任何已生效独占范围存在相同、祖先或子孙节点重叠 | +| `AGGREGATE_ONLY`(仅汇总) | 不可以 | 已授权的跨租户聚合指标 | 可覆盖多个子租户的独占范围,但不可读取明细、不可写入子租户数据 | + +### 3.2 冲突判定 + +- 两个独占范围相同,冲突。 +- 一个独占范围是另一个独占范围的上级或下级,冲突。 +- 两个独占范围为互不包含的兄弟节点,不冲突。 +- 集团根节点若要覆盖已独占绑定的子公司,必须使用“仅汇总”,不能再以“独占范围同步全部下级”。 +- 首期每个租户只允许一个已生效绑定,避免一租户多范围造成权限解释歧义。 + +### 3.3 数据隔离 + +- 外部组织节点以外部稳定 ID 为准,组织名称仅用于展示和人工校验。 +- `AGGREGATE_ONLY` 不落地子租户原始组织快照,不返回人员明细,不允许跨租户写入。 +- 组织同步请求不再信任客户端传入的 `groupId`、`companyId`、`departmentId`;服务端必须从当前租户的有效绑定解析同步根节点。 +- 当前开放组织接口只支持集团、公司、部门作为同步范围,因此“项目”节点首期仅用于展示层级,不可作为绑定根节点;这样可避免上游忽略项目过滤后扩大同步范围。 +- 未建立有效绑定的租户,不允许执行组织同步或使用依赖组织范围的管理能力。 + +## 4. 数据模型 + +首期新增一份 MySQL migration;不删除、不迁移现有 `aihr_org_snapshot` 数据。 + +### 4.1 平台组织目录:`aihr_org_directory` + +| 字段 | 说明 | +| --- | --- | +| `source_code` | 组织连接器标识 | +| `external_id` | 外部组织稳定 ID | +| `parent_external_id` | 外部父节点稳定 ID | +| `node_type` | 集团、公司、项目、部门等节点类型 | +| `name` | 节点展示名称 | +| `path` | 外部 ID 路径,用于祖先/子孙冲突校验 | +| `directory_version` | 本次目录版本 | +| `active` | 是否仍在外部源有效 | +| `created_at` / `updated_at` | 维护时间 | + +唯一约束:`(source_code, external_id)`。 + +### 4.2 租户组织绑定:`aihr_tenant_org_binding` + +| 字段 | 说明 | +| --- | --- | +| `id` | 主键 | +| `tenant_id` | 目标租户 | +| `source_code` | 关联的组织连接器 | +| `root_external_id` | 绑定根组织节点 | +| `mode` | `EXCLUSIVE` 或 `AGGREGATE_ONLY` | +| `status` | 草稿、已生效、已停用 | +| `directory_version` | 绑定校验所依据的目录版本 | +| `last_sync_at` | 最近一次独占范围同步时间 | +| `created_by` / `updated_by` | 平台操作人 | +| `created_at` / `updated_at` | 维护时间 | + +约束:首期一个 `tenant_id` 最多一个已生效记录;绑定生效前必须通过服务端冲突预检。 + +### 4.3 审计 + +复用现有系统操作日志,记录以下关键信息:操作人、租户、连接器、根节点、旧/新模式、目录版本、预检结果、确认动作和失败原因。首期不另建平行审计体系。 + +## 5. 后端实施 + +### 5.1 平台治理 API + +仅平台超级管理员可访问: + +| 接口 | 用途 | +| --- | --- | +| `GET /api/aihr/platform/org-directory/tree` | 获取平台组织目录树 | +| `POST /api/aihr/platform/org-directory/refresh` | 从全局连接器刷新目录 | +| `GET /api/aihr/platform/tenant-org-bindings` | 查询绑定列表和状态 | +| `POST /api/aihr/platform/tenant-org-bindings/precheck` | 提交前校验范围冲突和影响 | +| `POST /api/aihr/platform/tenant-org-bindings` | 创建草稿或生效绑定 | +| `PATCH /api/aihr/platform/tenant-org-bindings/{id}` | 调整或停用绑定 | +| `GET /api/aihr/platform/tenant-org-bindings/{id}/aggregate` | 获取仅汇总范围的授权指标 | + +### 5.2 改造既有组织同步 + +既有 `POST /api/aihr/org/sync` 调整为: + +1. 从当前认证身份获取租户,不再从客户端接收或信任同步范围。 +2. 查询该租户的有效 `aihr_tenant_org_binding`。 +3. 无绑定时返回 `409 TENANT_ORG_BINDING_REQUIRED`。 +4. 绑定模式为 `AGGREGATE_ONLY` 时返回 `409 AGGREGATE_ONLY_CANNOT_SYNC`。 +5. 绑定模式为 `EXCLUSIVE` 时,由服务端解析 `root_external_id` 和允许的下级范围,再写入本租户的 `aihr_org_snapshot`。 +6. 记录目录版本、同步结果和审计日志。 + +### 5.3 授权与安全 + +- 全局连接器密钥只保存在服务端配置或密钥管理系统,绝不下发浏览器和小程序。 +- 平台接口必须校验平台超级管理员;不能以普通租户管理员身份绕过。 +- 所有组织和人员明细查询须同时校验租户、绑定模式和当前身份权限。 +- 汇总接口只返回已定义的聚合指标,禁止用筛选参数变相导出其他租户明细。 + +## 6. 管理后台实施 + +新增平台超级管理员专属菜单:**平台设置 / 租户与组织绑定**。该页面使用“平台控制台”上下文,不使用普通租户切换器。 + +### 6.1 绑定总览 + +- 指标卡:有效租户、已绑定、待绑定、范围冲突。 +- 租户列表:租户、绑定状态、组织根节点、范围模式、最近同步、操作入口。 +- 组织树:展示当前绑定范围;独占范围清晰标注“可同步”,仅汇总范围标注“仅查看授权汇总”。 +- 治理侧栏:当前角色、操作审计、最近目录刷新、同步统计和边界说明。 + +### 6.2 新建绑定向导 + +1. 选择尚未生效绑定的租户。 +2. 从平台组织目录选择根节点,并选择“独占范围”或“仅汇总”。 +3. 服务端执行冲突预检,展示范围、影响数据量和冲突明细。 +4. 无冲突时明确确认并生效;有冲突时阻止直接提交,进入冲突处理页。 + +### 6.3 冲突处理 + +对于“集团范围覆盖已独占的子公司租户”的常见场景,默认推荐: + +> 保留子公司的独占租户,集团改为仅汇总。 + +结果为:子公司继续隔离并可同步;集团只能看跨租户授权聚合指标,不能访问子公司人员明细,也不能写入子公司业务数据。 + +另一种“解除子公司绑定,统一归入集团独占租户”属于跨租户边界迁移,首期只展示为后续高风险能力,不开放实际执行。未来实现时必须增加二次确认、迁移方案、影响预览、审批和完整审计。 + +### 6.4 设计图引用 + +以下设计图为本计划的后台交互基准。实现时应以其中的页面信息层级、状态表达和操作边界为准;文案可随真实接口字段微调,但不得改变独占范围与仅汇总的权限语义。 + +#### 绑定总览 + +![租户与组织绑定总览](assets/tenant-org-governance/tenant-org-binding-overview.png) + +图 1:平台超级管理员查看租户绑定状态、组织范围、审计信息和同步概览。 + +#### 冲突预检 + +![新建租户组织绑定的冲突预检](assets/tenant-org-governance/tenant-org-binding-conflict-precheck.png) + +图 2:选择租户与组织范围后,由服务端返回冲突明细;存在独占范围冲突时禁止直接生效。 + +#### 冲突处理 + +![范围冲突处理方案](assets/tenant-org-governance/tenant-org-binding-conflict-resolution.png) + +图 3:默认推荐保留子公司独占租户、集团改为仅汇总;高风险的边界迁移仅作为后续能力展示。 + +## 7. 迁移与上线步骤 + +1. 新增组织目录和租户绑定表,不修改现有组织快照表结构和历史快照。 +2. 由平台超级管理员刷新外部组织目录,确认外部稳定 ID、父子关系和目录版本正确。 +3. 为每个存量租户创建草稿绑定,逐一执行冲突预检;不得通过名称自动猜测绑定关系。 +4. 对通过预检的租户显式生效绑定;先运行一次受控的组织同步,再核对快照、人数和审计日志。 +5. 启用同步守卫:未绑定租户和仅汇总租户均无法调用原始组织同步。 +6. 逐步将依赖组织范围的管理能力接入绑定校验;每次启用前保留回退开关和数据核验记录。 + +## 8. 验收标准 + +### 规则与数据 + +- 相同、祖先、子孙独占范围均被拒绝。 +- 互不包含的兄弟独占范围允许绑定。 +- 集团“仅汇总”覆盖多个独占子公司时允许绑定。 +- 仅汇总范围不产生子租户的原始组织快照。 +- 外部节点改名不影响已建立绑定;外部稳定 ID 变化必须显式告警。 + +### 接口与安全 + +- 无有效绑定调用组织同步,收到 `TENANT_ORG_BINDING_REQUIRED`。 +- 仅汇总绑定调用组织同步,收到 `AGGREGATE_ONLY_CANNOT_SYNC`。 +- 客户端伪造 `groupId`、`companyId`、`departmentId` 不会扩大实际同步范围。 +- 普通租户管理员不能调用平台目录、绑定管理和跨租户明细接口。 +- 汇总接口无法返回人员、组织或业务明细。 + +### 前端与审计 + +- 浏览器验证绑定总览、绑定向导、冲突预检、冲突处理四种状态。 +- 已生效独占范围显示最近同步与同步结果;仅汇总范围显示授权汇总状态,且无“同步”入口。 +- 所有绑定新建、修改、停用、预检和同步操作均可在操作日志追溯。 + +## 9. 分期范围 + +### 第一阶段:安全治理基础 + +- 平台组织目录、租户组织绑定、范围冲突校验。 +- 既有组织同步的服务端范围收口。 +- 平台绑定管理页和基础审计。 + +### 第二阶段:汇总治理能力 + +- 仅汇总范围的指标口径、授权控制和集团看板。 +- 完善目录刷新、异常告警和同步运行记录。 + +### 第三阶段:受控扩展 + +- 多组织连接器。 +- 租户自有连接器的申请、审批、密钥托管和隔离执行。 +- 跨租户边界迁移工作流、审批与可回滚方案。 +- 外部组织事件订阅与增量同步。 + +## 10. 明确不在首期范围内 + +- 不允许每个租户自行保存外部 API 地址、Token 或 Client Secret。 +- 不自动根据组织名称推断并生效绑定。 +- 不允许集团仅汇总范围同步或写入下属独占租户原始数据。 +- 不直接执行“解除子公司租户、归入集团”的高风险边界迁移。 +- 不删除现有 `aihr_org_snapshot` 历史数据来完成结构切换。 diff --git a/frontend/src/api/aihr/tenant-org-governance.ts b/frontend/src/api/aihr/tenant-org-governance.ts new file mode 100644 index 00000000..953ee3d0 --- /dev/null +++ b/frontend/src/api/aihr/tenant-org-governance.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; + +type ApiResult = { + code: number; + msg: string; + data: T; +}; + +export type OrgDirectoryNode = { + externalId: string; + parentExternalId?: string; + nodeType: 'GROUP' | 'COMPANY' | 'PROJECT' | 'DEPARTMENT'; + name: string; + bindable: boolean; + children: OrgDirectoryNode[]; +}; + +export type OrgDirectoryTree = { + sourceCode: string; + directoryVersion: string; + nodeCount: number; + roots: OrgDirectoryNode[]; +}; + +export type TenantOrgBinding = { + id?: number; + tenantId: string; + tenantName: string; + sourceCode?: string; + rootExternalId?: string; + rootName?: string; + rootNodeType?: string; + mode?: 'EXCLUSIVE' | 'AGGREGATE_ONLY'; + status: 'UNBOUND' | 'DRAFT' | 'ACTIVE' | 'DISABLED'; + directoryVersion?: string; + lastSyncAt?: string; +}; + +export type TenantOrgBindingOverview = { + activeTenantCount: number; + boundTenantCount: number; + pendingTenantCount: number; + conflictCount: number; + bindings: TenantOrgBinding[]; +}; + +export type TenantOrgBindingRequest = { + tenantId?: string; + rootExternalId?: string; + mode?: 'EXCLUSIVE' | 'AGGREGATE_ONLY'; + status?: 'DRAFT' | 'ACTIVE' | 'DISABLED'; +}; + +export type ScopeConflict = { + tenantId: string; + tenantName: string; + rootExternalId: string; + rootName: string; + reason: string; +}; + +export type BindingPrecheck = { + canActivate: boolean; + rootName: string; + rootNodeType: string; + conflicts: ScopeConflict[]; +}; + +export type AggregateView = { + bindingId: number; + coveredTenantCount: number; + syncedPersonCount: number; +}; + +export function getOrgDirectoryTree(): Promise> { + return request({ url: '/api/aihr/platform/org-directory/tree', method: 'get' }); +} + +export function refreshOrgDirectory(): Promise> { + return request({ url: '/api/aihr/platform/org-directory/refresh', method: 'post', headers: { repeatSubmit: false } }); +} + +export function listTenantOrgBindings(): Promise> { + return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'get' }); +} + +export function precheckTenantOrgBinding(data: TenantOrgBindingRequest): Promise> { + return request({ url: '/api/aihr/platform/tenant-org-bindings/precheck', method: 'post', data, headers: { repeatSubmit: false } }); +} + +export function createTenantOrgBinding(data: TenantOrgBindingRequest): Promise> { + return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'post', data, headers: { repeatSubmit: false } }); +} + +export function updateTenantOrgBinding(id: number, data: TenantOrgBindingRequest): Promise> { + return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}`, method: 'patch', data, headers: { repeatSubmit: false } }); +} + +export function getTenantOrgAggregate(id: number): Promise> { + return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}/aggregate`, method: 'get' }); +} diff --git a/frontend/src/layout/components/Navbar.vue b/frontend/src/layout/components/Navbar.vue index d422b329..27a3f9ec 100644 --- a/frontend/src/layout/components/Navbar.vue +++ b/frontend/src/layout/components/Navbar.vue @@ -17,9 +17,9 @@