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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user