feat(aihr): add tenant organization governance

This commit is contained in:
2026-07-23 15:58:38 +08:00
parent 6a90fd977c
commit 06322c994b
20 changed files with 1619 additions and 3 deletions
@@ -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<SyncResponse> 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)
@@ -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<DirectoryTreeResponse> tree() {
return R.ok(governanceService.directoryTree());
}
@Log(title = "平台组织目录刷新", businessType = BusinessType.IMPORT)
@PostMapping("/org-directory/refresh")
public R<DirectoryRefreshResponse> refreshDirectory() {
return R.ok(governanceService.refreshDirectory());
}
@GetMapping("/tenant-org-bindings")
public R<BindingOverview> bindings() {
return R.ok(governanceService.bindings());
}
@Log(title = "租户组织绑定预检", businessType = BusinessType.OTHER)
@PostMapping("/tenant-org-bindings/precheck")
public R<BindingPrecheckResponse> precheck(@RequestBody BindingRequest request) {
return R.ok(governanceService.precheck(request));
}
@Log(title = "租户组织绑定", businessType = BusinessType.GRANT)
@PostMapping("/tenant-org-bindings")
public R<BindingView> create(@RequestBody BindingRequest request) {
return R.ok(governanceService.create(request));
}
@Log(title = "租户组织绑定调整", businessType = BusinessType.UPDATE)
@PatchMapping("/tenant-org-bindings/{id}")
public R<BindingView> update(@PathVariable Long id, @RequestBody BindingRequest request) {
return R.ok(governanceService.update(id, request));
}
@GetMapping("/tenant-org-bindings/{id}/aggregate")
public R<AggregateView> aggregate(@PathVariable Long id) {
return R.ok(governanceService.aggregate(id));
}
}
@@ -35,6 +35,13 @@ public final class AihrOrgSyncDto {
) {
}
/** A non-sensitive, server-side view of the global external organization directory. */
public record ExternalDirectorySnapshot(List<ExternalDirectoryNode> nodes, List<String> warnings) {
}
public record ExternalDirectoryNode(String externalId, String parentExternalId, String nodeType, String name) {
}
public record OrgSnapshotResponse(
int total,
int active,
@@ -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<DirectoryNode> roots) {
}
public record DirectoryNode(String externalId, String parentExternalId, String nodeType, String name,
boolean bindable, List<DirectoryNode> children) {
}
public record DirectoryRefreshResponse(String sourceCode, String directoryVersion, int nodeCount,
List<String> 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<BindingView> bindings) {
}
public record ScopeConflict(String tenantId, String tenantName, String rootExternalId,
String rootName, String reason) {
}
public record BindingPrecheckResponse(boolean canActivate, String rootName, String rootNodeType,
List<ScopeConflict> conflicts) {
}
public record AggregateView(Long bindingId, int coveredTenantCount, int syncedPersonCount) {
}
}
@@ -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<String> warnings = new ArrayList<>();
List<ExternalDirectoryNode> 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<ExternalDirectoryNode> target, List<JsonNode> 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<JsonNode> fetchSnapshot(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages) {
List<JsonNode> items = new ArrayList<>();
@@ -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<String> BINDABLE_NODE_TYPES = Set.of("GROUP", "COMPANY", "DEPARTMENT");
private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private final AihrOrgSyncService orgSyncService;
public DirectoryTreeResponse directoryTree() {
List<DirectoryRow> rows = directoryRows();
Map<String, DirectoryRow> byId = new HashMap<>();
Map<String, List<DirectoryRow>> children = new HashMap<>();
List<DirectoryRow> 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<DirectoryRow> 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<DirectoryRow> 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<BindingView> 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<ScopeConflict> 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<String> 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<BindingRow> active = activeBindings(true);
List<ScopeConflict> 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<ScopeConflict> conflicts(BindingInput input, DirectoryRow root, List<BindingRow> active,
String ownTenantId) {
if (!ACTIVE.equals(input.status()) || !EXCLUSIVE.equals(input.mode())) {
return List.of();
}
List<ScopeConflict> 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<ScopeConflict> activeExclusiveConflicts() {
List<BindingRow> active = activeBindings(false).stream().filter(item -> EXCLUSIVE.equals(item.mode())).toList();
List<ScopeConflict> 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<BindingRow> 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<BindingRow> 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<BindingRow> 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<DirectoryRow> 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<DirectoryRow> 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<String> 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<DirectoryNode> tree(List<DirectoryRow> rows, Map<String, List<DirectoryRow>> 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<DirectoryRow> normalizeDirectory(List<ExternalDirectoryNode> sourceNodes, String version) {
Map<String, DirectoryRow> 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<String, String> paths = new HashMap<>();
List<DirectoryRow> 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<String, DirectoryRow> rows, Map<String, String> paths, Set<String> 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<TenantRow> 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<String> 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) {
}
}
@@ -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<String> queries = new ArrayList<>();
@@ -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<SyncRequest> 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 <T> List<T> query(String sql, RowMapper<T> 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 <T> List<T> query(String sql, RowMapper<T> 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);
}
}
}
}
@@ -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='租户与组织范围绑定';