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.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.aihr.service.AihrTenantOrgGovernanceService;
import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -24,11 +25,12 @@ public class AihrOrgSyncController {
private static final String HR_OPERATOR_ROLE = "hr_operator"; private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrOrgSyncService orgSyncService; private final AihrOrgSyncService orgSyncService;
private final AihrTenantOrgGovernanceService governanceService;
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/sync") @PostMapping("/sync")
public R<SyncResponse> sync(@RequestBody(required = true) SyncRequest request) { 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) @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( public record OrgSnapshotResponse(
int total, int total,
int active, 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.OrgPersonRow;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgProjectOption; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgProjectOption;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse; 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.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.dromara.common.tenant.helper.TenantHelper; 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, static boolean hasUnsafeReplaceData(int employeeCount, int rowCount, int skipped,
int phoneLinked, int maskedPhone, int suspectText) { int phoneLinked, int maskedPhone, int suspectText) {
return employeeCount != rowCount 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, private List<JsonNode> fetchSnapshot(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages) { int pageSize, int maxPages) {
List<JsonNode> items = new ArrayList<>(); 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 static final class SnapshotJdbcTemplate extends JdbcTemplate {
private final List<String> queries = new ArrayList<>(); 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='租户与组织范围绑定';
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -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` 历史数据来完成结构切换。
@@ -0,0 +1,101 @@
import request from '@/utils/request';
type ApiResult<T> = {
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<ApiResult<OrgDirectoryTree>> {
return request({ url: '/api/aihr/platform/org-directory/tree', method: 'get' });
}
export function refreshOrgDirectory(): Promise<ApiResult<{ sourceCode: string; directoryVersion: string; nodeCount: number; warnings: string[] }>> {
return request({ url: '/api/aihr/platform/org-directory/refresh', method: 'post', headers: { repeatSubmit: false } });
}
export function listTenantOrgBindings(): Promise<ApiResult<TenantOrgBindingOverview>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'get' });
}
export function precheckTenantOrgBinding(data: TenantOrgBindingRequest): Promise<ApiResult<BindingPrecheck>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings/precheck', method: 'post', data, headers: { repeatSubmit: false } });
}
export function createTenantOrgBinding(data: TenantOrgBindingRequest): Promise<ApiResult<TenantOrgBinding>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'post', data, headers: { repeatSubmit: false } });
}
export function updateTenantOrgBinding(id: number, data: TenantOrgBindingRequest): Promise<ApiResult<TenantOrgBinding>> {
return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}`, method: 'patch', data, headers: { repeatSubmit: false } });
}
export function getTenantOrgAggregate(id: number): Promise<ApiResult<AggregateView>> {
return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}/aggregate`, method: 'get' });
}
+5 -2
View File
@@ -17,9 +17,9 @@
</template> </template>
<div class="right-menu flex align-center"> <div class="right-menu flex align-center">
<template v-if="appStore.device !== 'mobile'"> <template v-if="appStore.device !== 'mobile'">
<el-tag v-if="isSuperAdmin && tenantEnabled && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag> <el-tag v-if="showTenantContextSelector && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag>
<el-select <el-select
v-if="isSuperAdmin && tenantEnabled" v-if="showTenantContextSelector"
v-model="selectedTenantId" v-model="selectedTenantId"
class="min-w-244px mr-2" class="min-w-244px mr-2"
clearable clearable
@@ -142,6 +142,9 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const navType = computed(() => settingsStore.navType); const navType = computed(() => settingsStore.navType);
const showLogo = computed(() => settingsStore.sidebarLogo); const showLogo = computed(() => settingsStore.sidebarLogo);
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin')); const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
const showTenantContextSelector = computed(
() => isSuperAdmin.value && tenantEnabled.value && router.currentRoute.value.path !== '/system/tenant-org-binding'
);
const selectedTenantId = ref<string | number>(); const selectedTenantId = ref<string | number>();
const tenantList = ref<TenantVO[]>([]); const tenantList = ref<TenantVO[]>([]);
+6
View File
@@ -252,6 +252,12 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'SystemWebSearch', name: 'SystemWebSearch',
meta: { title: '全网AI配置', icon: 'search' } meta: { title: '全网AI配置', icon: 'search' }
}, },
{
path: 'tenant-org-binding',
component: () => import('@/views/system/tenant-org-binding.vue'),
name: 'SystemTenantOrgBinding',
meta: { title: '租户与组织绑定', icon: 'tree', roles: tenantGovernanceRoles }
},
{ {
path: 'tenant', path: 'tenant',
component: () => import('@/views/system/tenant/index.vue'), component: () => import('@/views/system/tenant/index.vue'),
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
describe('tenant organization binding governance contract', () => {
it('keeps the platform page superadmin-only and exposes the four required interaction states', () => {
const router = src('router', 'index.ts');
const page = src('views', 'system', 'tenant-org-binding.vue');
expect(router).toContain("path: 'tenant-org-binding'");
expect(router).toContain("title: '租户与组织绑定'");
expect(router).toContain('roles: tenantGovernanceRoles');
expect(page).toContain('租户与组织绑定');
expect(page).toContain('执行预检');
expect(page).toContain('发现 ${precheckResult.conflicts.length} 项独占范围冲突');
expect(page).toContain('采用仅汇总方案');
expect(src('layout', 'components', 'Navbar.vue')).toContain("router.currentRoute.value.path !== '/system/tenant-org-binding'");
});
it('does not offer a browser-supplied synchronization scope and labels aggregate-only correctly', () => {
const api = src('api', 'aihr', 'tenant-org-governance.ts');
const page = src('views', 'system', 'tenant-org-binding.vue');
expect(api).toContain('/api/aihr/platform/tenant-org-bindings/precheck');
expect(api).not.toContain('groupId');
expect(api).not.toContain('companyId');
expect(api).not.toContain('departmentId');
expect(page).toContain('仅汇总范围不保存人员明细,且没有组织同步入口');
expect(page).toContain('当前开放 API 只支持集团、公司或部门作为同步边界');
});
});
@@ -0,0 +1,312 @@
<template>
<div class="tenant-org-binding-page">
<section class="page-head">
<div>
<div class="platform-label">平台控制台</div>
<h1>租户与组织绑定</h1>
<p>由平台超级管理员维护租户的数据边界与外部组织范围;租户只可在已授予的独占范围内刷新组织快照。</p>
</div>
<div class="head-actions">
<el-button :icon="Refresh" :loading="directoryRefreshing" @click="refreshDirectory">刷新组织目录</el-button>
<el-button type="primary" :icon="Plus" @click="openBindingDialog()">新建绑定</el-button>
</div>
</section>
<section class="metric-grid">
<article class="metric-card metric-card--blue"><span>有效租户</span><strong>{{ overview?.activeTenantCount ?? 0 }}</strong><small>当前可管理的租户主体</small></article>
<article class="metric-card metric-card--green"><span>已绑定</span><strong>{{ overview?.boundTenantCount ?? 0 }}</strong><small>已建立生效组织范围</small></article>
<article class="metric-card metric-card--amber"><span>待绑定</span><strong>{{ overview?.pendingTenantCount ?? 0 }}</strong><small>尚不可执行组织同步</small></article>
<article class="metric-card metric-card--red"><span>范围冲突</span><strong>{{ overview?.conflictCount ?? 0 }}</strong><small>独占范围不能重叠</small></article>
</section>
<section class="workspace-grid">
<article class="panel tenant-panel">
<div class="panel-title"><div><h2>租户列表</h2><p>选择租户查看其受控组织边界</p></div></div>
<el-input v-model="tenantKeyword" :prefix-icon="Search" clearable placeholder="搜索租户" class="tenant-search" />
<el-table v-loading="loading" :data="filteredBindings" height="510" highlight-current-row @current-change="selectBinding">
<el-table-column prop="tenantName" label="租户" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="94">
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="组织范围" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.rootName || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="62" align="right">
<template #default="{ row }"><el-button link type="primary" @click.stop="openBindingDialog(row)">{{ row.id ? '调整' : '绑定' }}</el-button></template>
</el-table-column>
</el-table>
</article>
<article class="panel tree-panel">
<div class="panel-title"><div><h2>绑定范围</h2><p>{{ selectedBinding?.rootName || '选择租户后查看组织范围' }}</p></div><el-tag v-if="selectedBinding?.mode" :type="selectedBinding.mode === 'EXCLUSIVE' ? 'success' : 'info'">{{ modeLabel(selectedBinding.mode) }}</el-tag></div>
<el-alert v-if="selectedBinding?.mode === 'EXCLUSIVE'" type="success" :closable="false" show-icon title="该租户可在服务端限定的独占范围内刷新组织快照" />
<el-alert v-else-if="selectedBinding?.mode === 'AGGREGATE_ONLY'" type="info" :closable="false" show-icon title="仅汇总范围不保存人员明细,且没有组织同步入口" />
<el-empty v-if="!directoryTree?.roots?.length" description="尚未加载组织目录,请由平台超级管理员刷新" />
<el-tree v-else :data="directoryTree.roots" node-key="externalId" :props="treeProps" :default-expanded-keys="selectedBinding?.rootExternalId ? [selectedBinding.rootExternalId] : []" highlight-current :expand-on-click-node="false">
<template #default="{ data }"><span class="tree-node"><span class="tree-node__name">{{ data.name }}</span><small>{{ nodeTypeLabel(data.nodeType) }}</small></span></template>
</el-tree>
<div class="tree-footnote">项目节点用于展示集团层级;当前开放组织 API 只支持集团、公司和部门作为可同步边界。</div>
</article>
<aside class="governance-column">
<article class="panel governance-panel">
<div class="panel-title"><div><h2>治理与审计</h2><p>当前操作角色</p></div></div>
<el-tag type="primary" effect="plain"><el-icon><Lock /></el-icon> 平台超级管理员</el-tag>
<ul class="audit-list">
<li><el-icon color="#22a06b"><CircleCheck /></el-icon><span>绑定新建、调整、停用和目录刷新均进入系统操作日志。</span></li>
<li><el-icon color="#1677ff"><InfoFilled /></el-icon><span>范围预检由服务端重新读取目录,不采信浏览器传入的范围参数。</span></li>
<li><el-icon color="#d97706"><WarningFilled /></el-icon><span>独占范围与祖先、子孙或相同节点重叠时禁止生效。</span></li>
</ul>
<el-button v-if="selectedBinding?.id && selectedBinding.status === 'ACTIVE'" plain class="disable-button" @click="disableBinding">停用当前绑定</el-button>
</article>
<article v-if="selectedBinding?.id && selectedBinding.mode === 'AGGREGATE_ONLY'" class="panel aggregate-panel">
<div class="panel-title"><div><h2>同步概览</h2><p>仅授权的聚合指标</p></div><el-button link type="primary" :loading="aggregateLoading" @click="loadAggregate">刷新</el-button></div>
<div class="aggregate-values"><div><strong>{{ aggregate?.coveredTenantCount ?? 0 }}</strong><span>覆盖独占租户</span></div><div><strong>{{ aggregate?.syncedPersonCount ?? 0 }}</strong><span>已同步人员合计</span></div></div>
</article>
</aside>
</section>
<el-dialog v-model="dialogVisible" :title="editingBindingId ? '调整租户组织绑定' : '新建租户组织绑定'" width="760px" destroy-on-close @closed="resetDialog">
<el-steps :active="precheckResult ? 3 : 2" finish-status="success" align-center class="binding-steps"><el-step title="选择租户" /><el-step title="选择组织范围" /><el-step title="冲突预检" /></el-steps>
<el-form label-position="top" class="binding-form">
<div class="form-grid">
<el-form-item label="目标租户" required>
<el-select v-model="bindingForm.tenantId" filterable placeholder="请选择租户" :disabled="Boolean(editingBindingId)">
<el-option v-for="item in tenantOptions" :key="item.tenantId" :label="item.tenantName" :value="item.tenantId" />
</el-select>
</el-form-item>
<el-form-item label="范围模式" required>
<el-radio-group v-model="bindingForm.mode"><el-radio-button label="EXCLUSIVE">独占范围</el-radio-button><el-radio-button label="AGGREGATE_ONLY">仅汇总</el-radio-button></el-radio-group>
</el-form-item>
</div>
<el-form-item label="组织根节点" required>
<el-alert v-if="!directoryTree?.roots?.length" type="warning" :closable="false" title="尚无组织目录,请先关闭窗口并刷新组织目录。" />
<el-tree v-else :data="dialogDirectoryNodes" node-key="externalId" :props="dialogTreeProps" highlight-current :expand-on-click-node="false" @node-click="selectBindingRoot">
<template #default="{ data }"><span class="dialog-tree-node"><span>{{ data.name }}</span><el-tag v-if="!data.bindable" size="small" type="info">仅展示</el-tag><small>{{ nodeTypeLabel(data.nodeType) }}</small></span></template>
</el-tree>
<div v-if="bindingForm.rootExternalId" class="selected-root">已选择:{{ selectedDialogRoot?.name || bindingForm.rootExternalId }}</div>
</el-form-item>
</el-form>
<section v-if="precheckResult" class="precheck-result" :class="{ 'precheck-result--conflict': !precheckResult.canActivate }">
<div class="precheck-title"><el-icon><CircleCheck v-if="precheckResult.canActivate" /><WarningFilled v-else /></el-icon><strong>{{ precheckResult.canActivate ? '范围预检通过,可以生效绑定' : `发现 ${precheckResult.conflicts.length} 项独占范围冲突` }}</strong></div>
<el-table v-if="precheckResult.conflicts.length" :data="precheckResult.conflicts" size="small"><el-table-column prop="tenantName" label="已绑定租户" /><el-table-column prop="rootName" label="冲突组织" /><el-table-column prop="reason" label="冲突原因" min-width="240" /></el-table>
<div v-if="!precheckResult.canActivate && bindingForm.mode === 'EXCLUSIVE'" class="resolution-card"><div><strong>推荐:保留子公司独占租户,集团改为仅汇总</strong><p>子公司保持人员与业务数据隔离;集团仅查看授权聚合指标,不能同步或读取子公司明细。</p></div><el-button type="primary" plain @click="applyAggregateRecommendation">采用仅汇总方案</el-button></div>
</section>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :loading="prechecking" :disabled="!bindingForm.tenantId || !bindingForm.rootExternalId" @click="precheck">执行预检</el-button>
<el-button type="primary" :loading="saving" :disabled="!precheckResult?.canActivate" @click="saveBinding">确认绑定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="TenantOrgBinding" lang="ts">
import { CircleCheck, InfoFilled, Lock, Plus, Refresh, Search, WarningFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createTenantOrgBinding,
getOrgDirectoryTree,
getTenantOrgAggregate,
listTenantOrgBindings,
precheckTenantOrgBinding,
refreshOrgDirectory,
updateTenantOrgBinding,
type AggregateView,
type BindingPrecheck,
type OrgDirectoryNode,
type OrgDirectoryTree,
type TenantOrgBinding,
type TenantOrgBindingOverview
} from '@/api/aihr/tenant-org-governance';
type DialogNode = OrgDirectoryNode & { disabled: boolean; children: DialogNode[] };
const loading = ref(false);
const directoryRefreshing = ref(false);
const overview = ref<TenantOrgBindingOverview>();
const directoryTree = ref<OrgDirectoryTree>();
const selectedBinding = ref<TenantOrgBinding>();
const tenantKeyword = ref('');
const aggregate = ref<AggregateView>();
const aggregateLoading = ref(false);
const dialogVisible = ref(false);
const editingBindingId = ref<number>();
const prechecking = ref(false);
const saving = ref(false);
const precheckResult = ref<BindingPrecheck>();
const bindingForm = reactive({ tenantId: '', rootExternalId: '', mode: 'EXCLUSIVE' as 'EXCLUSIVE' | 'AGGREGATE_ONLY' });
const treeProps = { label: 'name', children: 'children' };
const dialogTreeProps = { label: 'name', children: 'children', disabled: 'disabled' };
const filteredBindings = computed(() => {
const keyword = tenantKeyword.value.trim().toLowerCase();
return (overview.value?.bindings || []).filter((item) => !keyword || `${item.tenantName}${item.tenantId}`.toLowerCase().includes(keyword));
});
const tenantOptions = computed(() => overview.value?.bindings || []);
const dialogDirectoryNodes = computed<DialogNode[]>(() => markDisabled(directoryTree.value?.roots || []));
const selectedDialogRoot = computed(() => findNode(directoryTree.value?.roots || [], bindingForm.rootExternalId));
const statusLabel = (status: TenantOrgBinding['status']) => ({ UNBOUND: '待绑定', DRAFT: '草稿', ACTIVE: '已绑定', DISABLED: '已停用' })[status];
const statusType = (status: TenantOrgBinding['status']) => ({ UNBOUND: 'warning', DRAFT: 'info', ACTIVE: 'success', DISABLED: 'info' })[status] as 'success' | 'warning' | 'info';
const modeLabel = (mode?: string) => mode === 'EXCLUSIVE' ? '独占范围' : '仅汇总';
const nodeTypeLabel = (type: string) => ({ GROUP: '集团', COMPANY: '公司', PROJECT: '项目', DEPARTMENT: '部门' })[type] || type;
const load = async () => {
loading.value = true;
try {
const [tree, bindingList] = await Promise.all([getOrgDirectoryTree(), listTenantOrgBindings()]);
directoryTree.value = tree.data;
overview.value = bindingList.data;
const current = selectedBinding.value && bindingList.data.bindings.find((item) => item.tenantId === selectedBinding.value?.tenantId);
selectedBinding.value = current || bindingList.data.bindings[0];
if (selectedBinding.value?.mode === 'AGGREGATE_ONLY') await loadAggregate();
} catch {
ElMessage.error('租户组织治理数据加载失败,请确认迁移已执行且当前账号为平台超级管理员');
} finally {
loading.value = false;
}
};
const refreshDirectory = async () => {
directoryRefreshing.value = true;
try {
const result = await refreshOrgDirectory();
ElMessage.success(`组织目录已刷新,共 ${result.data.nodeCount} 个节点`);
if (result.data.warnings.length) ElMessage.warning(result.data.warnings.join(';'));
await load();
} catch {
ElMessage.error('组织目录刷新失败,请检查平台连接器配置与授权范围');
} finally {
directoryRefreshing.value = false;
}
};
const selectBinding = (binding?: TenantOrgBinding) => {
selectedBinding.value = binding;
aggregate.value = undefined;
if (binding?.mode === 'AGGREGATE_ONLY') void loadAggregate();
};
const openBindingDialog = (binding?: TenantOrgBinding) => {
editingBindingId.value = binding?.id;
bindingForm.tenantId = binding?.tenantId || '';
bindingForm.rootExternalId = binding?.rootExternalId || '';
bindingForm.mode = binding?.mode || 'EXCLUSIVE';
precheckResult.value = undefined;
dialogVisible.value = true;
};
const selectBindingRoot = (node: DialogNode) => {
if (!node.bindable) {
ElMessage.warning('项目节点仅用于展示;当前开放 API 只支持集团、公司或部门作为同步边界');
return;
}
bindingForm.rootExternalId = node.externalId;
precheckResult.value = undefined;
};
const precheck = async () => {
prechecking.value = true;
try {
const result = await precheckTenantOrgBinding({ ...bindingForm, status: 'ACTIVE' });
precheckResult.value = result.data;
if (result.data.canActivate) ElMessage.success('范围预检通过');
} catch {
ElMessage.error('范围预检失败,请刷新组织目录后重试');
} finally {
prechecking.value = false;
}
};
const applyAggregateRecommendation = async () => {
bindingForm.mode = 'AGGREGATE_ONLY';
precheckResult.value = undefined;
await precheck();
};
const saveBinding = async () => {
saving.value = true;
try {
const payload = { ...bindingForm, status: 'ACTIVE' as const };
const result = editingBindingId.value ? await updateTenantOrgBinding(editingBindingId.value, payload) : await createTenantOrgBinding(payload);
selectedBinding.value = result.data;
ElMessage.success('租户组织绑定已生效');
dialogVisible.value = false;
await load();
} catch {
ElMessage.error('绑定保存失败;若范围已变化,请重新执行预检');
} finally {
saving.value = false;
}
};
const disableBinding = async () => {
const binding = selectedBinding.value;
if (!binding?.id || !binding.rootExternalId || !binding.mode) return;
try {
await ElMessageBox.confirm('停用后该租户不能再执行组织同步,是否继续?', '停用组织绑定', { type: 'warning' });
await updateTenantOrgBinding(binding.id, { tenantId: binding.tenantId, rootExternalId: binding.rootExternalId, mode: binding.mode, status: 'DISABLED' });
ElMessage.success('绑定已停用');
await load();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('绑定停用失败');
}
};
const loadAggregate = async () => {
if (!selectedBinding.value?.id || selectedBinding.value.mode !== 'AGGREGATE_ONLY') return;
aggregateLoading.value = true;
try {
aggregate.value = (await getTenantOrgAggregate(selectedBinding.value.id)).data;
} catch {
ElMessage.error('汇总指标加载失败');
} finally {
aggregateLoading.value = false;
}
};
const resetDialog = () => {
editingBindingId.value = undefined;
precheckResult.value = undefined;
};
const markDisabled = (nodes: OrgDirectoryNode[]): DialogNode[] => nodes.map((node) => ({ ...node, disabled: !node.bindable, children: markDisabled(node.children || []) }));
const findNode = (nodes: OrgDirectoryNode[], id: string): OrgDirectoryNode | undefined => {
for (const node of nodes) {
if (node.externalId === id) return node;
const found = findNode(node.children || [], id);
if (found) return found;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.tenant-org-binding-page { min-height: 100%; padding: 22px; background: #f5f7fb; color: #1d2939; }
.page-head, .panel, .metric-card { background: #fff; border: 1px solid #e7ecf3; border-radius: 12px; box-shadow: 0 5px 18px rgb(16 24 40 / 4%); }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px 24px; }
.platform-label { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #175cd3; background: #eff8ff; font-size: 12px; font-weight: 600; }
h1, h2, p { margin: 0; }
h1 { margin-top: 7px; font-size: 25px; line-height: 1.2; }
.page-head p, .panel-title p { margin-top: 7px; color: #667085; font-size: 13px; }
.head-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
.metric-card { position: relative; overflow: hidden; padding: 17px 18px; }
.metric-card::before { position: absolute; top: 0; left: 0; width: 4px; height: 100%; background: currentcolor; content: ''; }
.metric-card span { display: block; color: #667085; font-size: 13px; }
.metric-card strong { display: block; margin-top: 4px; font-size: 28px; line-height: 1.1; }
.metric-card small { display: block; margin-top: 5px; color: #98a2b3; }
.metric-card--blue { color: #1677ff; }.metric-card--green { color: #12b76a; }.metric-card--amber { color: #f79009; }.metric-card--red { color: #f04438; }
.workspace-grid { display: grid; grid-template-columns: minmax(300px, 1.05fr) minmax(360px, 1.25fr) minmax(260px, .9fr); gap: 16px; margin-top: 16px; align-items: start; }
.panel { padding: 18px; }.panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }.panel-title h2 { font-size: 17px; }
.tenant-search { margin-bottom: 12px; }.tree-panel :deep(.el-alert) { margin-bottom: 12px; }.tree-panel :deep(.el-tree) { min-height: 370px; }.tree-node, .dialog-tree-node { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }.tree-node__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tree-node small, .dialog-tree-node small { color: #98a2b3; font-size: 12px; }.tree-footnote { margin-top: 12px; padding: 10px 12px; border-radius: 8px; color: #7a5d00; background: #fff9e8; font-size: 12px; line-height: 1.6; }
.governance-column { display: grid; gap: 16px; }.governance-panel :deep(.el-tag .el-icon) { margin-right: 4px; }.audit-list { display: grid; gap: 14px; padding: 0; margin: 18px 0; list-style: none; }.audit-list li { display: flex; gap: 9px; color: #475467; font-size: 13px; line-height: 1.55; }.audit-list .el-icon { flex: 0 0 auto; margin-top: 2px; }.disable-button { width: 100%; }.aggregate-values { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }.aggregate-values div { padding: 12px; border-radius: 8px; background: #f8fbff; }.aggregate-values strong { display: block; color: #175cd3; font-size: 22px; }.aggregate-values span { color: #667085; font-size: 12px; }
.binding-steps { margin: 0 0 22px; }.binding-form :deep(.el-form-item) { margin-bottom: 18px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.form-grid :deep(.el-select) { width: 100%; }.binding-form :deep(.el-tree) { max-height: 220px; padding: 10px; overflow: auto; border: 1px solid #e4e7ed; border-radius: 8px; }.dialog-tree-node { width: 100%; justify-content: space-between; padding-right: 10px; }.selected-root { margin-top: 8px; color: #175cd3; font-size: 13px; }.precheck-result { padding: 14px; border: 1px solid #b7ebc6; border-radius: 10px; background: #f6ffed; }.precheck-result--conflict { border-color: #ffccc7; background: #fff2f0; }.precheck-title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: #1d6f42; }.precheck-result--conflict .precheck-title { color: #cf1322; }.resolution-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-top: 12px; padding: 13px; border: 1px solid #91caff; border-radius: 8px; background: #f0f7ff; }.resolution-card p { margin-top: 5px; color: #475467; font-size: 12px; line-height: 1.55; }
@media (max-width: 1280px) { .workspace-grid { grid-template-columns: 1fr 1fr; }.governance-column { grid-column: span 2; grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { .tenant-org-binding-page { padding: 12px; }.page-head, .resolution-card { align-items: flex-start; flex-direction: column; }.workspace-grid, .governance-column, .metric-grid, .form-grid { grid-template-columns: 1fr; }.governance-column { grid-column: auto; }.head-actions { width: 100%; }.head-actions .el-button { flex: 1; }.binding-steps :deep(.el-step__title) { font-size: 11px; } }
</style>
+1
View File
@@ -26,6 +26,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql"
@@ -158,6 +158,17 @@ mysql "$DB" < "$org_multi_project_migration"
org_party_unique="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND index_name='uk_aihr_org_party';")" org_party_unique="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND index_name='uk_aihr_org_party';")"
test "$org_party_unique" = 'tenant_id,project_code,ext_party_id' test "$org_party_unique" = 'tenant_id,project_code,ext_party_id'
tenant_org_governance_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql"
test -f "$tenant_org_governance_migration"
mysql "$DB" < "$tenant_org_governance_migration"
mysql "$DB" < "$tenant_org_governance_migration"
tenant_org_governance_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_org_directory','aihr_tenant_org_binding');")"
test "$tenant_org_governance_tables" = 'aihr_org_directory,aihr_tenant_org_binding'
tenant_org_binding_unique="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_tenant_org_binding' AND index_name='uk_aihr_tenant_org_binding_tenant';")"
test "$tenant_org_binding_unique" = 'tenant_id'
tenant_org_binding_tenant_collation="$(mysql "$DB" -N -B -e "SELECT CONCAT(character_set_name,'|',collation_name) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_tenant_org_binding' AND column_name='tenant_id';")"
test "$tenant_org_binding_tenant_collation" = 'utf8mb4|utf8mb4_general_ci'
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql"
position_sop_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_onboard_task','aihr_position_responsibility','aihr_qualification_gate','aihr_sop_applicability');")" position_sop_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_onboard_task','aihr_position_responsibility','aihr_qualification_gate','aihr_sop_applicability');")"