Selaa lähdekoodia

接口开发11.5 员工花名册添加一级部门字段

ht 5 tuntia sitten
vanhempi
commit
9b309872a0

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 8 - 1
README.md


+ 6 - 0
src/main/java/com/malk/minglei/dto/EmployeeRosterInfo.java

@@ -114,6 +114,12 @@ public class EmployeeRosterInfo {
         this.department = department;
     }
 
+    /**
+     * 返回本地数据库的一级部门名称,未配置时仍返回 null。
+     *
+     * @return 一级部门名称
+     */
+    @JsonInclude(JsonInclude.Include.ALWAYS)
     public String getFirstDepartment() {
         return firstDepartment;
     }

+ 19 - 0
src/main/java/com/malk/minglei/service/employee/DingTalkEmployeeRosterClient.java

@@ -8,6 +8,25 @@ import java.util.Map;
  */
 public interface DingTalkEmployeeRosterClient {
 
+    /**
+     * 从用户父部门路径解析根部门下的一级部门 ID。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param userId 员工钉钉用户 ID
+     * @param departmentId 员工部门列表首个部门 ID,多部门时优先使用其所在路径
+     * @return 一级部门 ID;无父部门或仅属于根部门时返回 null
+     */
+    Long getFirstDepartmentId(String accessToken, String userId, Long departmentId);
+
+    /**
+     * 查询部门名称,接口失败或名称缺失时抛出异常。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param departmentId 钉钉部门 ID
+     * @return 部门名称
+     */
+    String getDepartmentName(String accessToken, Long departmentId);
+
     /**
      * 查询用户详情中的直属主管钉钉 ID。
      *

+ 87 - 0
src/main/java/com/malk/minglei/service/impl/employee/DingTalkEmployeeRosterClientImpl.java

@@ -28,6 +28,10 @@ public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterC
             "https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/field/grouplist?access_token={accessToken}";
     private static final String USER_DETAILS_URL =
             "https://oapi.dingtalk.com/topapi/v2/user/get?access_token={accessToken}";
+    private static final String USER_PARENT_DEPARTMENTS_URL =
+            "https://oapi.dingtalk.com/topapi/v2/department/listparentbyuser?access_token={accessToken}";
+    private static final String DEPARTMENT_DETAILS_URL =
+            "https://oapi.dingtalk.com/topapi/v2/department/get?access_token={accessToken}";
 
     private final RestTemplate restTemplate;
     private final DDClient_Personnel personnelClient;
@@ -124,6 +128,89 @@ public class DingTalkEmployeeRosterClientImpl implements DingTalkEmployeeRosterC
         return normalized.isEmpty() ? null : normalized;
     }
 
+    /**
+     * 按用户父部门路径解析一级部门,多部门时优先匹配已同步的首个部门。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param userId 员工钉钉用户 ID
+     * @param departmentId 员工部门列表首个部门 ID
+     * @return 根部门 1 下的一级部门 ID;不存在时为 null
+     */
+    @Override
+    public Long getFirstDepartmentId(String accessToken, String userId, Long departmentId) {
+        Map<String, Object> request = new LinkedHashMap<String, Object>();
+        request.put("userid", userId);
+        Map<?, ?> result = queryDepartmentApi(USER_PARENT_DEPARTMENTS_URL, accessToken, request);
+        Object parents = result.get("parent_list");
+        if (!(parents instanceof List)) {
+            throw new IllegalStateException("DingTalk user parent departments response is invalid for userId=" + userId);
+        }
+        List<Long> selectedPath = null;
+        for (Object parent : (List<?>) parents) {
+            Object ids = parent instanceof Map ? ((Map<?, ?>) parent).get("parent_dept_id_list") : null;
+            if (!(ids instanceof List)) {
+                throw new IllegalStateException("DingTalk user parent department path is invalid for userId=" + userId);
+            }
+            List<Long> path = new ArrayList<Long>();
+            for (Object id : (List<?>) ids) {
+                path.add(Long.valueOf(String.valueOf(id)));
+            }
+            if (selectedPath == null) {
+                selectedPath = path;
+            }
+            if (departmentId != null && path.contains(departmentId)) {
+                selectedPath = path;
+                break;
+            }
+        }
+        if (selectedPath == null) {
+            return null;
+        }
+        // 接口路径由当前部门向根部门排列,最后一个非根部门即一级部门。
+        for (int index = selectedPath.size() - 1; index >= 0; index--) {
+            Long id = selectedPath.get(index);
+            if (id.longValue() > 1L) {
+                return id;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 根据部门 ID 获取名称,用于花名册存储可读的一级部门名称。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param departmentId 钉钉部门 ID
+     * @return 部门名称
+     */
+    @Override
+    public String getDepartmentName(String accessToken, Long departmentId) {
+        Map<String, Object> request = new LinkedHashMap<String, Object>();
+        request.put("dept_id", departmentId);
+        request.put("language", "zh_CN");
+        Object name = queryDepartmentApi(DEPARTMENT_DETAILS_URL, accessToken, request).get("name");
+        if (name == null || String.valueOf(name).trim().isEmpty()) {
+            throw new IllegalStateException("DingTalk department name is missing for departmentId=" + departmentId);
+        }
+        return String.valueOf(name).trim();
+    }
+
+    /**
+     * 调用部门接口并校验业务结果,避免将权限或网络错误误当作无部门。
+     */
+    private Map<?, ?> queryDepartmentApi(String url, String accessToken, Map<String, Object> request) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        Map<String, Object> body = restTemplate.exchange(url, HttpMethod.POST,
+                new HttpEntity<Map<String, Object>>(request, headers),
+                new ParameterizedTypeReference<Map<String, Object>>() { }, accessToken).getBody();
+        if (body == null || !"0".equals(String.valueOf(body.get("errcode")))
+                || !(body.get("result") instanceof Map)) {
+            throw new IllegalStateException("DingTalk department request failed");
+        }
+        return (Map<?, ?>) body.get("result");
+    }
+
     private void assertDingTalkSuccess(Map<String, Object> responseBody) {
         if (responseBody == null) {
             throw new IllegalStateException("DingTalk roster field group response is empty");

+ 11 - 1
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java

@@ -92,6 +92,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         Map<String, Map> rosterByUserId = fetchRosterByUserId(accessToken, agentId, users, fieldCodes);
 
         List<EmployeeRosterRecord> records = new ArrayList<EmployeeRosterRecord>();
+        Map<Long, String> firstDepartmentNames = new LinkedHashMap<Long, String>();
         for (Map user : users) {
             String userId = extractUserId(user);
             if (userId == null) {
@@ -100,6 +101,16 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
             EmployeeRosterRecord record = createRecord(userId, user, rosterByUserId.get(userId), fieldNamesByCode);
             // 部门人员列表不保证包含直属主管,按去重后的员工逐一查询用户详情。
             record.setManagerUserId(rosterClient.getManagerUserId(accessToken, userId));
+            Long firstDepartmentId = rosterClient.getFirstDepartmentId(accessToken, userId, record.getDepartmentId());
+            if (firstDepartmentId == null) {
+                record.setFirstDepartment(null);
+            } else {
+                // 同一次同步内共享部门名称查询,多个员工属于同一一级部门时只请求一次。
+                if (!firstDepartmentNames.containsKey(firstDepartmentId)) {
+                    firstDepartmentNames.put(firstDepartmentId, rosterClient.getDepartmentName(accessToken, firstDepartmentId));
+                }
+                record.setFirstDepartment(firstDepartmentNames.get(firstDepartmentId));
+            }
             records.add(record);
         }
 
@@ -333,7 +344,6 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         record.setJobGrade(findRosterLabel(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
         record.setJobGradeId(findRosterValue(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
         record.setDepartment(findRosterValue(rosterValues, "部门", "所在部门", "部门名称", "department"));
-        record.setFirstDepartment(findRosterValue(rosterValues, "一级部门", "一级部门名称", "first_department"));
         record.setDepartmentId(firstDepartmentId(user.get("dept_id_list")));
         record.setBankCardNo(findRosterValue(rosterValues, "银行卡号", "bank_card_no"));
         record.setBankName(findRosterValue(rosterValues, "开户行", "bank_name"));

+ 6 - 0
src/test/java/com/malk/minglei/controller/EmployeeRosterControllerTest.java

@@ -19,12 +19,16 @@ import org.springframework.http.ResponseEntity;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
 
 class EmployeeRosterControllerTest {
 
+    /**
+     * 验证用户花名册响应返回数据库信息,一级部门仅使用 firstDepartment 字段名。
+     */
     @ParameterizedTest
     @NullSource
     @ValueSource(strings = {"manager-001"})
@@ -65,6 +69,8 @@ class EmployeeRosterControllerTest {
         assertEquals("10040", result.get("jobTitleId"));
         assertEquals("10", result.get("jobGrade"));
         assertEquals("10010", result.get("jobGradeId"));
+        assertFalse(result.containsKey("first_department"));
+        assertEquals("钉钉顾问", result.get("firstDepartment"));
         assertTrue(result.containsKey("managerUserId"));
         assertEquals(managerUserId, result.get("managerUserId"));
     }

+ 29 - 0
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepositoryTest.java

@@ -33,6 +33,35 @@ import static org.mockito.Mockito.verify;
 
 class EmployeeRosterRepositoryTest {
 
+    /**
+     * 一级部门从数据库读取,响应仅包含 firstDepartment,数据库为空时仍返回该字段。
+     */
+    @ParameterizedTest
+    @NullSource
+    @ValueSource(strings = {"数据库中的研发中心"})
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void returnsFirstDepartmentFromDatabaseIncludingNull(String firstDepartment) throws Exception {
+        JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
+        ObjectProvider<JdbcTemplate> provider = mock(ObjectProvider.class);
+        given(provider.getIfAvailable()).willReturn(jdbcTemplate);
+        ResultSet row = mock(ResultSet.class);
+        given(row.getString("first_department")).willReturn(firstDepartment);
+        given(jdbcTemplate.query(any(String.class), any(PreparedStatementSetter.class), any(RowMapper.class)))
+                .willAnswer(invocation -> Collections.singletonList(
+                        ((RowMapper<?>) invocation.getArgument(2)).mapRow(row, 0)));
+        EmployeeRosterRepository repository = new EmployeeRosterRepository(provider, new EmployeeRosterProperties());
+
+        EmployeeRosterInfo info = repository.findByDingtalkId("user-1");
+        Map result = new ObjectMapper().convertValue(info, Map.class);
+
+        assertFalse(result.containsKey("first_department"));
+        assertTrue(result.containsKey("firstDepartment"));
+        assertEquals(firstDepartment, result.get("firstDepartment"));
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).query(sql.capture(), any(PreparedStatementSetter.class), any(RowMapper.class));
+        assertTrue(sql.getValue().contains("first_department"));
+    }
+
     @ParameterizedTest
     @NullSource
     @ValueSource(strings = {"manager-001"})

+ 52 - 1
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -37,6 +37,9 @@ import static org.mockito.Mockito.never;
 
 class EmployeeRosterServiceImplTest {
 
+    /**
+     * 验证银行信息与部门接口取得的一级部门名称都写入对应数据库参数。
+     */
     @ParameterizedTest
     @ValueSource(booleans = {true, false})
     @SuppressWarnings({"rawtypes", "unchecked"})
@@ -54,6 +57,8 @@ class EmployeeRosterServiceImplTest {
         String bankCode = useFieldNames ? "custom-bank" : "bank_name";
         String branchCode = useFieldNames ? "custom-branch" : "bank_branch_code";
         given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(rosterClient.getFirstDepartmentId("access-token", "user-1", 1L)).willReturn(123L);
+        given(rosterClient.getDepartmentName("access-token", 123L)).willReturn("研发中心");
         given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(1L), anyMap())).willReturn(mapOf(
                 "list", Collections.singletonList(user("user-1", "Alice", "1001")), "has_more", false));
         given(rosterClient.listRosterFieldGroups("access-token", 123456L)).willReturn(Collections.singletonList(mapOf(
@@ -81,6 +86,8 @@ class EmployeeRosterServiceImplTest {
         assertTrue(sql.getValue().contains("bank_branch_code = VALUES(bank_branch_code)"));
         PreparedStatement statement = mock(PreparedStatement.class);
         setter.getValue().setValues(statement);
+        assertTrue(sql.getValue().contains("first_department = VALUES(first_department)"));
+        verify(statement).setString(9, "研发中心");
         verify(statement).setLong(10, 1L);
         verify(statement).setString(13, "6222020000000000000");
         verify(statement).setString(14, "Test Bank");
@@ -106,6 +113,9 @@ class EmployeeRosterServiceImplTest {
         verify(repository).findByEmployeeNo("1001");
     }
 
+    /**
+     * 验证去重员工按各自部门查询一级部门,同一部门共享名称查询,无部门时清空旧花名册值。
+     */
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     void syncCollectsDepartmentUsersAndUpsertsRosterRecords() {
@@ -142,6 +152,10 @@ class EmployeeRosterServiceImplTest {
         given(rosterClient.getManagerUserId("access-token", "user-1")).willReturn("manager-001");
         given(rosterClient.getManagerUserId("access-token", "user-2")).willReturn(null);
         given(rosterClient.getManagerUserId("access-token", "user-3")).willReturn("manager-002");
+        given(rosterClient.getFirstDepartmentId("access-token", "user-1", 101L)).willReturn(10L);
+        given(rosterClient.getFirstDepartmentId("access-token", "user-2", 1L)).willReturn(10L);
+        given(rosterClient.getFirstDepartmentId("access-token", "user-3", 1L)).willReturn(null);
+        given(rosterClient.getDepartmentName("access-token", 10L)).willReturn("新研发中心");
 
         EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
                 dingTalkClient,
@@ -170,7 +184,9 @@ class EmployeeRosterServiceImplTest {
         assertEquals("P6", records.get(0).getJobGrade());
         assertEquals("10010", records.get(0).getJobGradeId());
         assertEquals("技术部", records.get(0).getDepartment());
-        assertEquals("研发中心", records.get(0).getFirstDepartment());
+        assertEquals("新研发中心", records.get(0).getFirstDepartment());
+        assertEquals("新研发中心", records.get(1).getFirstDepartment());
+        assertNull(records.get(2).getFirstDepartment());
         assertEquals(Long.valueOf(101L), records.get(0).getDepartmentId());
         assertEquals("manager-001", records.get(0).getManagerUserId());
         assertNull(records.get(1).getManagerUserId());
@@ -178,6 +194,41 @@ class EmployeeRosterServiceImplTest {
         verify(rosterClient).getManagerUserId("access-token", "user-1");
         verify(rosterClient).getManagerUserId("access-token", "user-2");
         verify(rosterClient).getManagerUserId("access-token", "user-3");
+        verify(rosterClient).getFirstDepartmentId("access-token", "user-1", 101L);
+        verify(rosterClient).getFirstDepartmentId("access-token", "user-2", 1L);
+        verify(rosterClient).getFirstDepartmentId("access-token", "user-3", 1L);
+        verify(rosterClient).getDepartmentName("access-token", 10L);
+    }
+
+    /**
+     * 父部门或部门详情查询失败时不执行数据库写入,保留已同步数据。
+     */
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void departmentLookupFailureStopsSyncBeforeDatabaseWrites(boolean failParentLookup) {
+        DDClient dingTalkClient = mock(DDClient.class);
+        DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
+        DingTalkEmployeeRosterClient rosterClient = mock(DingTalkEmployeeRosterClient.class);
+        EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        DDConf configuration = new DDConf();
+        configuration.setAgentId(123456L);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(1L), anyMap()))
+                .willReturn(mapOf("list", Collections.singletonList(user("user-1", "Alice", "1001")), "has_more", false));
+        if (failParentLookup) {
+            given(rosterClient.getFirstDepartmentId("access-token", "user-1", 1L))
+                    .willThrow(new IllegalStateException("DingTalk parent departments request failed"));
+        } else {
+            given(rosterClient.getFirstDepartmentId("access-token", "user-1", 1L)).willReturn(123L);
+            given(rosterClient.getDepartmentName("access-token", 123L))
+                    .willThrow(new IllegalStateException("DingTalk department details request failed"));
+        }
+        EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(dingTalkClient, contactsClient,
+                configuration, rosterClient, repository, new EmployeeRosterProperties());
+
+        assertThrows(IllegalStateException.class, () -> service.syncFull());
+        verify(repository, never()).upsertAll(anyList());
     }
 
     @Test