feat(aihr): add tenant organization governance
This commit is contained in:
+3
-1
@@ -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)
|
||||
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+7
@@ -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,
|
||||
|
||||
+45
@@ -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) {
|
||||
}
|
||||
}
|
||||
+64
@@ -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<>();
|
||||
|
||||
+516
@@ -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) {
|
||||
}
|
||||
}
|
||||
+36
@@ -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<>();
|
||||
|
||||
|
||||
+120
@@ -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 设计图引用
|
||||
|
||||
以下设计图为本计划的后台交互基准。实现时应以其中的页面信息层级、状态表达和操作边界为准;文案可随真实接口字段微调,但不得改变独占范围与仅汇总的权限语义。
|
||||
|
||||
#### 绑定总览
|
||||
|
||||

|
||||
|
||||
图 1:平台超级管理员查看租户绑定状态、组织范围、审计信息和同步概览。
|
||||
|
||||
#### 冲突预检
|
||||
|
||||

|
||||
|
||||
图 2:选择租户与组织范围后,由服务端返回冲突明细;存在独占范围冲突时禁止直接生效。
|
||||
|
||||
#### 冲突处理
|
||||
|
||||

|
||||
|
||||
图 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' });
|
||||
}
|
||||
@@ -17,9 +17,9 @@
|
||||
</template>
|
||||
<div class="right-menu flex align-center">
|
||||
<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
|
||||
v-if="isSuperAdmin && tenantEnabled"
|
||||
v-if="showTenantContextSelector"
|
||||
v-model="selectedTenantId"
|
||||
class="min-w-244px mr-2"
|
||||
clearable
|
||||
@@ -142,6 +142,9 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
||||
const navType = computed(() => settingsStore.navType);
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo);
|
||||
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 tenantList = ref<TenantVO[]>([]);
|
||||
|
||||
@@ -252,6 +252,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
name: 'SystemWebSearch',
|
||||
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',
|
||||
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>
|
||||
@@ -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_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_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/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"
|
||||
|
||||
@@ -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';")"
|
||||
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"
|
||||
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');")"
|
||||
|
||||
Reference in New Issue
Block a user