Browse Source

新增访客同步与钉盘上传功能

CRK 2 weeks ago
parent
commit
f831bab2ee
22 changed files with 3468 additions and 15 deletions
  1. 84 0
      docs/dingtalk-drive-upload.md
  2. 3 2
      src/main/java/com/muzhi/tianpai/common/dingtalk/RosterSystemField.java
  3. 16 0
      src/main/java/com/muzhi/tianpai/config/DingDriveProperties.java
  4. 46 0
      src/main/java/com/muzhi/tianpai/config/HikvisionHttpClientConfig.java
  5. 32 0
      src/main/java/com/muzhi/tianpai/controller/DingDriveController.java
  6. 147 0
      src/main/java/com/muzhi/tianpai/controller/VisitorAppointmentController.java
  7. 15 0
      src/main/java/com/muzhi/tianpai/model/DriveUploadRequest.java
  8. 323 0
      src/main/java/com/muzhi/tianpai/service/DingDriveUploadService.java
  9. 65 0
      src/main/java/com/muzhi/tianpai/service/DriveGateway.java
  10. 670 0
      src/main/java/com/muzhi/tianpai/service/PersonSyncService.java
  11. 682 0
      src/main/java/com/muzhi/tianpai/service/VisitorAppointmentService.java
  12. 0 1
      src/main/java/com/muzhi/tianpai/service/impl/AccessTokenServiceImpl.java
  13. 187 0
      src/main/java/com/muzhi/tianpai/service/impl/DingDriveGateway.java
  14. 39 12
      src/main/resources/application.properties
  15. 19 0
      src/main/resources/hikvision-root-ca.pem
  16. 67 0
      src/test/java/com/muzhi/tianpai/controller/DingDriveControllerTest.java
  17. 124 0
      src/test/java/com/muzhi/tianpai/controller/VisitorAppointmentControllerTest.java
  18. 374 0
      src/test/java/com/muzhi/tianpai/service/DingDriveUploadServiceTest.java
  19. 305 0
      src/test/java/com/muzhi/tianpai/service/PersonSyncServiceTest.java
  20. 104 0
      src/test/java/com/muzhi/tianpai/service/VisitorAppointmentServiceTest.java
  21. 166 0
      src/test/java/com/muzhi/tianpai/service/impl/DingDriveGatewayTest.java
  22. BIN
      target/.DS_Store

File diff suppressed because it is too large
+ 84 - 0
docs/dingtalk-drive-upload.md


+ 3 - 2
src/main/java/com/muzhi/tianpai/common/dingtalk/RosterSystemField.java

@@ -22,12 +22,13 @@ public enum RosterSystemField {
 
     DUTY("zhiwu", "sys00", "0e3aed7e-2812-4851-87e6-aa6244deb5a9"),//NOTE 自定义字段
     RANK("zhiji", "sys01", "419d7ae9-1d42-4b55-aae4-7248afb1b936"),//NOTE 自定义字段
-    JOBTYPE("gangweileixing", "sys01", "520caa3b-d2e9-4bae-97ea-36b06862eaac"),//NOTE 自定义字段
+    JOBTYPE_IN("gangweileixingruzhiyong", "sys01", "3e9a3e83-2d91-4b5b-858f-7e4c2ea66dd0"),//NOTE 自定义字段【岗位类型(入职用)】
+    JOBTYPE_OUT("gangweileixinglizhiyong", "sys01", "520caa3b-d2e9-4bae-97ea-36b06862eaac"),//NOTE 自定义字段【岗位类型(离职用)】
 
     EMPLOYEE_TYPE("yuangongleixing", "sys01", "sys01-employeeType"),
     EMPLOYEE_STATUS("yuangongzhuangtai", "sys01", "sys01-employeeStatus"),
     PROBATION_PERIOD_TYPE("shiyongqi", "sys01", "sys01-probationPeriodType"),
-    REGULAR_TIME("zhuanzhengriqi", "sys01", "sys01-regularTime"),
+    REGULAR_TIME("zhuanzhengriqi", "sys01", "sys01-planRegularTime"),//计划转正日期
     POSITION_LEVEL("岗位职级", "sys01", "sys01-positionLevel"),
 
     REAL_NAME("shenfenzhengxingming", "sys02", "sys02-realName"),

+ 16 - 0
src/main/java/com/muzhi/tianpai/config/DingDriveProperties.java

@@ -0,0 +1,16 @@
+package com.muzhi.tianpai.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/** Reuses AccessTokenService credentials; only the drive target is configured here. */
+@Data
+@Component
+@ConfigurationProperties(prefix = "dingtalk.drive")
+public class DingDriveProperties {
+    private String operatorUserId = "32600011";
+    private String spaceId;
+    private String onboardingFolderId;
+    private long maxFileBytes = 20L * 1024 * 1024;
+}

+ 46 - 0
src/main/java/com/muzhi/tianpai/config/HikvisionHttpClientConfig.java

@@ -0,0 +1,46 @@
+package com.muzhi.tianpai.config;
+
+import okhttp3.OkHttpClient;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.ClassPathResource;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+import java.io.InputStream;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+public class HikvisionHttpClientConfig {
+
+    @Bean("hikvisionHttpClient")
+    public OkHttpClient hikvisionHttpClient() throws Exception {
+        CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
+        Certificate certificate;
+        try (InputStream inputStream = new ClassPathResource("hikvision-root-ca.pem").getInputStream()) {
+            certificate = certificateFactory.generateCertificate(inputStream);
+        }
+
+        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+        keyStore.load(null, null);
+        keyStore.setCertificateEntry("hikvision-root-ca", certificate);
+
+        TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
+                TrustManagerFactory.getDefaultAlgorithm());
+        trustManagerFactory.init(keyStore);
+        X509TrustManager trustManager = (X509TrustManager) trustManagerFactory.getTrustManagers()[0];
+
+        SSLContext sslContext = SSLContext.getInstance("TLS");
+        sslContext.init(null, new X509TrustManager[]{trustManager}, null);
+
+        return new OkHttpClient.Builder()
+                .sslSocketFactory(sslContext.getSocketFactory(), trustManager)
+                .connectTimeout(15, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .build();
+    }
+}

+ 32 - 0
src/main/java/com/muzhi/tianpai/controller/DingDriveController.java

@@ -0,0 +1,32 @@
+package com.muzhi.tianpai.controller;
+
+import com.muzhi.tianpai.common.R;
+import com.muzhi.tianpai.model.DriveUploadRequest;
+import com.muzhi.tianpai.service.DingDriveUploadService;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/dingtalk/drive")
+public class DingDriveController {
+    private final DingDriveUploadService service;
+
+    public DingDriveController(DingDriveUploadService service) {
+        this.service = service;
+    }
+
+    @PostMapping(value = "/onboarding/upload", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public R upload(@RequestBody DriveUploadRequest request) {
+        try {
+            return R.ok().message("上传成功").data(service.upload(request));
+        } catch (DingDriveUploadService.UploadUncertainException e) {
+            return R.error().message(e.getMessage()).data("outcome", "unknown")
+                    .data("spaceId", e.spaceId).data("parentId", e.parentId).data("fileId", e.fileId);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            return R.error().message(e.getMessage());
+        } catch (Exception e) {
+            // Never expose SDK exceptions, signed URLs, tokens, or file contents.
+            return R.error().message("云盘上传失败");
+        }
+    }
+}

+ 147 - 0
src/main/java/com/muzhi/tianpai/controller/VisitorAppointmentController.java

@@ -0,0 +1,147 @@
+package com.muzhi.tianpai.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.muzhi.tianpai.common.R;
+import com.muzhi.tianpai.service.VisitorAppointmentService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.util.StringUtils;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/visitor")
+public class VisitorAppointmentController {
+
+    @Autowired
+    private VisitorAppointmentService visitorAppointmentService;
+
+    @PostMapping(value = "/appointment", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public R createAppointmentByJson(@RequestBody Map<String, Object> data) {
+        return createAppointment(getInstanceId(data));
+    }
+
+    @PostMapping(value = "/appointment", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+    public R createAppointmentByForm(@RequestParam Map<String, String> data) {
+        return createAppointment(getInstanceId(data));
+    }
+
+    @PostMapping(value = "/appointment/cancel", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public R cancelAppointmentByJson(@RequestBody Map<String, Object> data) {
+        return cancelAppointment(getInstanceId(data));
+    }
+
+    @PostMapping(value = "/appointment/cancel", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+    public R cancelAppointmentByForm(@RequestParam Map<String, String> data) {
+        return cancelAppointment(getInstanceId(data));
+    }
+
+    @PostMapping(value = "/approver", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public JSONObject getApproverByJson(@RequestBody Map<String, Object> data) {
+        return getApprover(getUserId(data), getLevelName(data));
+    }
+
+    @PostMapping(value = "/approver", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+    public JSONObject getApproverByForm(@RequestParam Map<String, String> data) {
+        return getApprover(getUserId(data), getLevelName(data));
+    }
+
+    private String getInstanceId(Map<String, ?> data) {
+        Object value = data.get("instanceId");
+        if (value == null) {
+            value = data.get("formInstanceId");
+        }
+        if (value == null) {
+            value = data.get("dataId");
+        }
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private String getUserId(Map<String, ?> data) {
+        Object value = data.get("userid");
+        if (value == null) {
+            value = data.get("userId");
+        }
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private String getLevelName(Map<String, ?> data) {
+        Object value = data.get("Levelname");
+        if (value == null) {
+            value = data.get("levelname");
+        }
+        return value == null ? "当前" : String.valueOf(value).trim();
+    }
+
+    private JSONObject getApprover(String userId, String levelName) {
+        if (!StringUtils.hasText(userId)) {
+            return approverError("userid不能为空");
+        }
+        if (!"当前".equals(levelName) && !"上级".equals(levelName)) {
+            return approverError("Levelname只能为当前或上级");
+        }
+
+        try {
+            List<String> userIds = "上级".equals(levelName)
+                    ? visitorAppointmentService.findApproverUserIds(userId.trim(), levelName)
+                    : visitorAppointmentService.findApproverUserIds(userId.trim());
+            JSONObject response = new JSONObject();
+            response.put("result", userIds);
+            response.put("errorLevel", "0");
+            response.put("success", true);
+            return response;
+        } catch (Exception e) {
+            return approverError(e.getMessage());
+        }
+    }
+
+    private JSONObject approverError(String message) {
+        JSONObject response = new JSONObject();
+        response.put("result", java.util.Collections.emptyList());
+        response.put("errorLevel", "1");
+        response.put("success", false);
+        return response;
+    }
+
+    private R createAppointment(String instanceId) {
+        if (!StringUtils.hasText(instanceId)) {
+            return R.error().message("宜搭数据ID不能为空");
+        }
+
+        try {
+            JSONObject response = visitorAppointmentService.createAppointment(instanceId.trim());
+            if ("0".equals(response.getString("code"))) {
+                return R.ok().data("result", response);
+            }
+            String message = response.getString("msg");
+            return R.error().message(StringUtils.hasText(message) ? message : "预约失败")
+                    .data("result", response);
+        } catch (Exception e) {
+            return R.error().message(e.getMessage());
+        }
+    }
+
+    private R cancelAppointment(String instanceId) {
+        if (!StringUtils.hasText(instanceId)) {
+            return R.error().message("宜搭数据ID不能为空");
+        }
+
+        try {
+            JSONObject response = visitorAppointmentService.cancelAppointment(instanceId.trim());
+            if ("0".equals(response.getString("code")) && Boolean.TRUE.equals(response.getBoolean("data"))) {
+                return R.ok().data("result", response);
+            }
+            String message = response.getString("msg");
+            return R.error().message(StringUtils.hasText(message) ? message : "取消预约失败")
+                    .data("result", response);
+        } catch (Exception e) {
+            return R.error().message(e.getMessage());
+        }
+    }
+}

+ 15 - 0
src/main/java/com/muzhi/tianpai/model/DriveUploadRequest.java

@@ -0,0 +1,15 @@
+package com.muzhi.tianpai.model;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@JsonIgnoreProperties({"userId", "userid", "unionId"})
+public class DriveUploadRequest {
+    private String fileName;
+    @JsonAlias({"fileBase64"})
+    private String base64;
+}

+ 323 - 0
src/main/java/com/muzhi/tianpai/service/DingDriveUploadService.java

@@ -0,0 +1,323 @@
+package com.muzhi.tianpai.service;
+
+import com.muzhi.tianpai.config.DingDriveProperties;
+import com.muzhi.tianpai.model.DriveUploadRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.Clock;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+@Service
+public class DingDriveUploadService {
+    private final AccessTokenService tokens;
+    private final DriveGateway drive;
+    private final DingDriveProperties properties;
+    private final Clock clock;
+
+    @Autowired
+    public DingDriveUploadService(AccessTokenService tokens, DriveGateway drive, DingDriveProperties properties) {
+        this(tokens, drive, properties, Clock.systemUTC());
+    }
+
+    DingDriveUploadService(AccessTokenService tokens, DriveGateway drive, DingDriveProperties properties, Clock clock) {
+        this.tokens = tokens;
+        this.drive = drive;
+        this.properties = properties;
+        this.clock = clock;
+    }
+
+    public Map<String, Object> upload(DriveUploadRequest request) {
+        if (request == null) {
+            throw new IllegalArgumentException("请求不能为空");
+        }
+        String name = validateName(request.getFileName());
+        byte[] bytes = decode(request.getBase64());
+        String user = trim(properties.getOperatorUserId());
+        if (!StringUtils.hasText(user)) {
+            user = "32600011";
+        }
+        String union;
+        LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Shanghai")));
+        String datePath = today.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        String token;
+        String parent;
+        DriveGateway.UploadTicket ticket;
+        try {
+            token = tokens.getAccessToken();
+            if (!StringUtils.hasText(token)) {
+                throw new IllegalStateException();
+            }
+            union = drive.unionId(token, user);
+            if (!StringUtils.hasText(union)) {
+                throw new IllegalStateException();
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException("获取上传用户身份失败,请检查应用凭据、通讯录查询权限及默认用户32600011的授权范围");
+        }
+        UploadTarget uploadTarget = resolveTarget(token, union);
+        String space = uploadTarget.spaceId;
+        String root = uploadTarget.folderId;
+        try {
+            DriveGateway.Node target = drive.inspect(token, union, space, root);
+            requireNode(target, root, "入职资料", null, space, true);
+            parent = ensureDateFolders(token, union, space, root, datePath);
+            if (findChild(token, union, space, parent, name) != null) {
+                throw new NameConflictException();
+            }
+            ticket = drive.prepare(token, union, space);
+            if (ticket == null || !StringUtils.hasText(ticket.getUploadKey())) {
+                throw new IllegalStateException();
+            }
+            drive.put(ticket, bytes);
+        } catch (NameConflictException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("钉盘目录检查、创建或文件传输失败,请检查空间ID、入职资料文件夹ID及应用/操作人读写权限");
+        }
+
+        // Never blindly retry commit: a timeout can mean the remote file already exists.
+        DriveGateway.Node committed = null;
+        try {
+            committed = drive.commit(token, union, space, parent, name, ticket.getUploadKey(), bytes.length);
+            if (committed == null || !StringUtils.hasText(committed.getId())) {
+                throw new IllegalStateException();
+            }
+            DriveGateway.Node actual = drive.inspect(token, union, space, committed.getId());
+            requireNode(actual, committed.getId(), name, parent, space, false);
+            if (actual.getSize() == null || actual.getSize() != bytes.length) {
+                throw new IllegalStateException();
+            }
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("spaceId", space);
+            result.put("fileId", actual.getId());
+            result.put("fileName", actual.getName());
+            result.put("size", actual.getSize());
+            result.put("parentId", parent);
+            result.put("path", "团队文件/入职资料/" + datePath + "/" + name);
+            return result;
+        } catch (Exception e) {
+            throw new UploadUncertainException(space, parent, committed == null ? null : committed.getId());
+        }
+    }
+
+    // Serializes folder lookup/create within this application instance.
+    private synchronized String ensureDateFolders(String token, String union, String space,
+                                                  String root, String datePath) throws Exception {
+        String parent = root;
+        for (String segment : datePath.split("/")) {
+            DriveGateway.Node folder = findChild(token, union, space, parent, segment);
+            if (folder == null) {
+                try {
+                    folder = drive.createFolder(token, union, space, parent, segment);
+                    requireNode(folder, null, segment, parent, space, true);
+                    folder = drive.inspect(token, union, space, folder.getId());
+                } catch (Exception createFailure) {
+                    // A second instance might have created it. Read once; do not replay creation.
+                    folder = findChild(token, union, space, parent, segment);
+                    if (folder == null) {
+                        throw createFailure;
+                    }
+                }
+            }
+            requireNode(folder, null, segment, parent, space, true);
+            parent = folder.getId();
+        }
+        return parent;
+    }
+
+    private UploadTarget resolveTarget(String token, String union) {
+        String configuredSpace = trim(properties.getSpaceId());
+        String configuredFolder = trim(properties.getOnboardingFolderId());
+        if (StringUtils.hasText(configuredFolder)) {
+            if (!StringUtils.hasText(configuredSpace)) {
+                throw new IllegalStateException("指定入职资料文件夹ID时,需要同时配置所在团队空间ID");
+            }
+            return new UploadTarget(configuredSpace, configuredFolder);
+        }
+        try {
+            List<DriveGateway.Space> spaces = new ArrayList<>();
+            if (StringUtils.hasText(configuredSpace)) {
+                spaces.add(new DriveGateway.Space(configuredSpace, null));
+            } else {
+                String cursor = null;
+                Set<String> cursors = new HashSet<>();
+                Set<String> ids = new HashSet<>();
+                for (int pageNumber = 0; ; pageNumber++) {
+                    if (pageNumber >= 100) {
+                        throw new TargetLookupException("团队空间分页未完成,停止上传");
+                    }
+                    DriveGateway.SpacePage page = drive.teamSpaces(token, union, cursor);
+                    if (page == null || page.getSpaces() == null) {
+                        throw new TargetLookupException("团队空间列表不完整,停止上传");
+                    }
+                    for (DriveGateway.Space space : page.getSpaces()) {
+                        if (space == null || !StringUtils.hasText(space.getId()) || !ids.add(space.getId())
+                                || spaces.size() >= 100) {
+                            throw new TargetLookupException("团队空间列表重复、无效或超出100个上限,停止上传");
+                        }
+                        spaces.add(space);
+                    }
+                    cursor = page.getNextToken();
+                    if (!StringUtils.hasText(cursor)) {
+                        break;
+                    }
+                    if (!cursors.add(cursor)) {
+                        throw new TargetLookupException("团队空间分页游标重复,停止上传");
+                    }
+                }
+            }
+            UploadTarget match = null;
+            for (DriveGateway.Space space : spaces) {
+                DriveGateway.Node folder = findChild(token, union, space.getId(), null, "入职资料");
+                if (folder != null) {
+                    requireNode(folder, null, "入职资料", "0", space.getId(), true);
+                    if (match != null) {
+                        throw new TargetLookupException("多个团队空间存在入职资料文件夹,请在服务端指定目标空间ID");
+                    }
+                    match = new UploadTarget(space.getId(), folder.getId());
+                }
+            }
+            if (match == null) {
+                throw new TargetLookupException("团队文件下未找到入职资料文件夹,请确认用户32600011可见该目录");
+            }
+            return match;
+        } catch (TargetLookupException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new TargetLookupException("自动定位团队文件/入职资料失败,请开通Drive.File.Read及团队空间查询权限,并授权用户32600011访问目标目录");
+        }
+    }
+
+    private DriveGateway.Node findChild(String token, String union, String space,
+                                       String parent, String name) throws Exception {
+        String cursor = null;
+        Set<String> cursors = new HashSet<>();
+        DriveGateway.Node match = null;
+        int count = 0;
+        for (int pageNumber = 0; pageNumber < 100; pageNumber++) {
+            DriveGateway.Page page = parent == null ? drive.rootFiles(token, union, space, cursor)
+                    : drive.list(token, union, space, parent, cursor);
+            if (page == null || page.getNodes() == null) {
+                throw new IllegalStateException("目录列表不完整");
+            }
+            for (DriveGateway.Node node : page.getNodes()) {
+                if (++count > 5000 || node == null) {
+                    throw new IllegalStateException("目录列表不完整");
+                }
+                if (name.equals(node.getName())) {
+                    if (match != null) {
+                        throw new IllegalStateException("存在多个同名目录/文件,需先整理");
+                    }
+                    match = node;
+                }
+            }
+            cursor = page.getNextToken();
+            if (!StringUtils.hasText(cursor)) {
+                return match;
+            }
+            if (!cursors.add(cursor)) {
+                throw new IllegalStateException("目录分页游标重复");
+            }
+        }
+        throw new IllegalStateException("目录条目过多,未能完成查询");
+    }
+
+    private void requireNode(DriveGateway.Node node, String id, String name,
+                             String parent, String space, boolean folder) {
+        if (node == null || !StringUtils.hasText(node.getId())
+                || (id != null && !id.equals(node.getId())) || !name.equals(node.getName())
+                || (parent != null && !parent.equals(node.getParentId()))
+                || !space.equals(node.getSpaceId()) || folder != node.isFolder()) {
+            throw new IllegalStateException("钉盘节点校验失败");
+        }
+    }
+
+    private String validateName(String fileName) {
+        String name = trim(fileName);
+        if (!StringUtils.hasText(name) || name.length() > 200 || name.equals(".") || name.equals("..")
+                || name.matches(".*[\\\\/:*?\"<>|\\p{Cntrl}].*") || name.endsWith(".")) {
+            throw new IllegalArgumentException("fileName必填,最长200字符,不可包含路径、控制字符或非法文件名字符");
+        }
+        return name;
+    }
+
+    private byte[] decode(String value) {
+        long max = properties.getMaxFileBytes();
+        if (max <= 0 || max > 100L * 1024 * 1024) {
+            throw new IllegalStateException("max-file-bytes必须在1到104857600之间");
+        }
+        if (!StringUtils.hasText(value)) {
+            throw new IllegalArgumentException("base64不能为空");
+        }
+        long encodedLimit = ((max + 2) / 3) * 4;
+        if (value.length() > encodedLimit + 1024 * 1024) {
+            throw new IllegalArgumentException("文件超过上传大小限制");
+        }
+        String encoded = value.trim();
+        if (encoded.startsWith("data:")) {
+            int comma = encoded.indexOf(',');
+            if (comma < 0 || !encoded.substring(0, comma).endsWith(";base64")) {
+                throw new IllegalArgumentException("data URI必须使用base64编码");
+            }
+            encoded = encoded.substring(comma + 1);
+        }
+        encoded = encoded.replaceAll("[\\r\\n\\t ]", "");
+        if (encoded.length() > encodedLimit) {
+            throw new IllegalArgumentException("文件超过上传大小限制");
+        }
+        byte[] bytes;
+        try {
+            bytes = Base64.getDecoder().decode(encoded);
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException("base64格式不正确");
+        }
+        if (bytes.length == 0 || bytes.length > max) {
+            throw new IllegalArgumentException("文件内容为空或超过上传大小限制");
+        }
+        return bytes;
+    }
+
+    private String trim(String value) {
+        return value == null ? null : value.trim();
+    }
+
+    private static class UploadTarget {
+        final String spaceId;
+        final String folderId;
+
+        UploadTarget(String spaceId, String folderId) {
+            this.spaceId = spaceId;
+            this.folderId = folderId;
+        }
+    }
+
+    private static class TargetLookupException extends IllegalStateException {
+        TargetLookupException(String message) {
+            super(message);
+        }
+    }
+
+    private static class NameConflictException extends IllegalArgumentException {
+        NameConflictException() {
+            super("当日目录已有同名文件,请更换fileName");
+        }
+    }
+
+    public static class UploadUncertainException extends IllegalStateException {
+        public final String spaceId;
+        public final String parentId;
+        public final String fileId;
+
+        UploadUncertainException(String spaceId, String parentId, String fileId) {
+            super("文件提交或回读核验未完成,可能已写入;请先检查目标目录/文件,避免重复上传");
+            this.spaceId = spaceId;
+            this.parentId = parentId;
+            this.fileId = fileId;
+        }
+    }
+}

+ 65 - 0
src/main/java/com/muzhi/tianpai/service/DriveGateway.java

@@ -0,0 +1,65 @@
+package com.muzhi.tianpai.service;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+import java.util.Map;
+
+/** Small boundary around DingTalk and its signed binary upload. */
+public interface DriveGateway {
+    SpacePage teamSpaces(String token, String unionId, String cursor) throws Exception;
+    Page rootFiles(String token, String unionId, String spaceId, String cursor) throws Exception;
+    String unionId(String token, String userId) throws Exception;
+    Node inspect(String token, String unionId, String spaceId, String id) throws Exception;
+    Page list(String token, String unionId, String spaceId, String parentId, String cursor) throws Exception;
+    Node createFolder(String token, String unionId, String spaceId, String parentId, String name) throws Exception;
+    UploadTicket prepare(String token, String unionId, String spaceId) throws Exception;
+    void put(UploadTicket ticket, byte[] bytes) throws Exception;
+    Node commit(String token, String unionId, String spaceId, String parentId,
+                String fileName, String uploadKey, long size) throws Exception;
+
+    @Getter
+    @AllArgsConstructor
+    class Space {
+        private final String id;
+        private final String name;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    class SpacePage {
+        private final List<Space> spaces;
+        private final String nextToken;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    class Node {
+        private final String id;
+        private final String name;
+        private final String type;
+        private final String parentId;
+        private final String spaceId;
+        private final Long size;
+
+        public boolean isFolder() {
+            return "folder".equalsIgnoreCase(type);
+        }
+    }
+
+    @Getter
+    @AllArgsConstructor
+    class Page {
+        private final List<Node> nodes;
+        private final String nextToken;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    class UploadTicket {
+        private final String uploadKey;
+        private final List<String> urls;
+        private final Map<String, String> headers;
+    }
+}

+ 670 - 0
src/main/java/com/muzhi/tianpai/service/PersonSyncService.java

@@ -0,0 +1,670 @@
+package com.muzhi.tianpai.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.dingtalkyida_2_0.models.CreateOrUpdateFormDataHeaders;
+import com.aliyun.dingtalkyida_2_0.models.CreateOrUpdateFormDataRequest;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasHeaders;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasRequest;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponse;
+import com.aliyun.teaopenapi.models.Config;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.dingtalk.api.DefaultDingTalkClient;
+import com.dingtalk.api.request.OapiV2DepartmentGetRequest;
+import com.dingtalk.api.request.OapiV2DepartmentListsubidRequest;
+import com.dingtalk.api.request.OapiV2UserGetRequest;
+import com.dingtalk.api.request.OapiV2UserListRequest;
+import com.dingtalk.api.response.OapiV2DepartmentGetResponse;
+import com.dingtalk.api.response.OapiV2DepartmentListsubidResponse;
+import com.dingtalk.api.response.OapiV2UserGetResponse;
+import com.dingtalk.api.response.OapiV2UserListResponse;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+@Service
+@Slf4j
+public class PersonSyncService {
+
+    private static final String HIKVISION_API_PATH = "/api/resource/v2/person/personList";///api/resource/v2/person/advance/personList
+    private static final String HIKVISION_SIGN_PATH = "/artemis" + HIKVISION_API_PATH;
+    private static final String PERSON_INFO_API_PATH = "/api/resource/v1/person/condition/personInfo";
+    private static final String PERSON_INFO_SIGN_PATH = "/artemis" + PERSON_INFO_API_PATH;
+    private static final String SIGNATURE_HEADERS = "x-ca-key,x-ca-nonce,x-ca-timestamp";
+    private static final String CONTENT_TYPE = "application/json; charset=utf-8";
+    private static final MediaType JSON_MEDIA_TYPE = MediaType.parse(CONTENT_TYPE);
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final String YIDA_JOB_NUMBER_FIELD = "textField_ms5onsxa";
+    private static final String YIDA_SUPERVISOR_FIELD = "employeeField_mv6k4flb";
+    private static final String YIDA_SUPERVISOR_JOB_NUMBER_FIELD = "textField_mv6ky7t5";
+    private static final long DINGTALK_ROOT_DEPARTMENT_ID = 1L;
+    private static final long DINGTALK_USER_PAGE_SIZE = 100L;
+    private static final int MAX_DEPARTMENT_LEVELS = 20;
+
+    @Autowired
+    @org.springframework.beans.factory.annotation.Qualifier("hikvisionHttpClient")
+    private OkHttpClient httpClient;
+
+    @Autowired
+    private AccessTokenService accessTokenService;
+
+    @Value("${person-sync.hikvision.base-url}")
+    private String hikvisionBaseUrl;
+
+    @Value("${person-sync.hikvision.app-key}")
+    private String hikvisionAppKey;
+
+    @Value("${person-sync.hikvision.app-secret}")
+    private String hikvisionAppSecret;
+
+    @Value("${person-sync.hikvision.partner-code:}")
+    private String hikvisionPartnerCode;
+
+    @Value("${person-sync.hikvision.page-size:50}")
+    private int hikvisionPageSize;
+
+    @Value("${person-sync.yida.app-type}")
+    private String yidaAppType;
+
+    @Value("${person-sync.yida.system-token}")
+    private String yidaSystemToken;
+
+    @Value("${person-sync.yida.form-uuid}")
+    private String yidaFormUuid;
+
+    @Value("${person-sync.yida.user-id:yida_pub_account}")
+    private String yidaUserId;
+
+     @Scheduled(fixedDelay = 86400000L, initialDelay = 0L)
+    public void syncPersons() {
+        log.info("海康人员同步到宜搭开始,当前时间:{}", LocalDateTime.now().format(DATE_TIME_FORMATTER));
+        int added = 0;
+        int updated = 0;
+        int failed = 0;
+
+        String accessToken = accessTokenService.getAccessToken();
+        if (!StringUtils.hasText(accessToken)) {
+            log.error("海康人员同步失败:宜搭 accessToken 为空");
+            log.info("宜搭人员同步统计:新增 {},更新 {},失败 {}", added, updated, failed);
+            return;
+        }
+
+        final List<JSONObject> persons;
+        try {
+            persons = fetchAllPersons();
+            log.info("海康读取人员总数:{}", persons.size());
+        } catch (Exception e) {
+            log.error("海康人员分页读取失败,本轮同步终止");
+            log.info("宜搭人员同步统计:新增 {},更新 {},失败 {}", added, updated, failed);
+            return;
+        }
+
+        Map<String, String> dingTalkUserIdsByJobNumber = null;
+        try {
+            dingTalkUserIdsByJobNumber = loadDingTalkUserIdsByJobNumber(accessToken);
+            log.info("钉钉通讯录工号索引加载完成,共 {} 人", dingTalkUserIdsByJobNumber.size());
+        } catch (Exception e) {
+            log.warn("钉钉通讯录工号索引加载失败,继续同步基础档案并保留已有主管字段,原因:{}",
+                    StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName());
+        }
+
+        try {
+            com.aliyun.dingtalkyida_2_0.Client yidaClient = createYidaClient();
+            int current = 0;
+            for (JSONObject person : persons) {
+                current++;
+                String personId = person.getString("personId");
+                if (!StringUtils.hasText(personId)) {
+                    failed++;
+                    log.error("单条人员同步失败,人员ID为空");
+                    log.info("第 {} 条处理完成,当前条数/总条数:{}/{}", current, current, persons.size());
+                    continue;
+                }
+                try {
+                    String searchFieldJson = buildSearchFieldJson(personId);
+                    String searchCondition = buildSearchCondition(personId);
+                    boolean exists = existsInYida(yidaClient, accessToken, searchFieldJson);
+                    createInYida(yidaClient, accessToken, searchCondition, person,
+                            dingTalkUserIdsByJobNumber);
+                    if (exists) {
+                        updated++;
+                    } else {
+                        added++;
+                    }
+                } catch (Exception e) {
+                    failed++;
+                    log.error("单条人员同步失败,人员ID:{},原因:{}", personId,
+                            StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName());
+                }
+                log.info("第 {} 条处理完成,当前条数/总条数:{}/{}", current, current, persons.size());
+            }
+        } catch (Exception e) {
+            failed += persons.size();
+            log.error("宜搭客户端初始化失败,本轮人员写入终止");
+        }
+
+        log.info("宜搭人员同步统计:新增 {},更新 {},失败 {}", added, updated, failed);
+    }
+
+    protected List<JSONObject> fetchAllPersons() throws Exception {
+        List<JSONObject> allPersons = new ArrayList<>();
+        int pageNo = 1;
+        int total = Integer.MAX_VALUE;
+        while (allPersons.size() < total) {
+            JSONObject requestJson = new JSONObject();
+            requestJson.put("pageNo", pageNo);
+            requestJson.put("pageSize", hikvisionPageSize);
+            JSONObject responseJson = executeHikvisionRequest(requestJson.toJSONString(), HIKVISION_SIGN_PATH);
+            if (!"0".equals(responseJson.getString("code"))) {
+                throw new IllegalStateException("海康接口返回非成功状态");
+            }
+            JSONObject data = responseJson.getJSONObject("data");
+            if (data == null) {
+                throw new IllegalStateException("海康接口未返回 data");
+            }
+            total = data.getIntValue("total");
+            JSONArray list = data.getJSONArray("list");
+            if (list == null) {
+                list = data.getJSONArray("rows");
+            }
+            if (list == null || list.isEmpty()) {
+                break;
+            }
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject source = list.getJSONObject(i);
+                JSONObject person = new JSONObject();
+                person.put("personId", source.getString("personId"));
+                person.put("personName", source.getString("personName"));
+                person.put("orgIndexCode", source.getString("orgIndexCode"));
+                person.put("phoneNo", source.getString("phoneNo"));
+                person.put("jobNo", source.getString("jobNo"));
+                person.put("certificateNo", source.getString("certificateNo"));
+                person.put("orgPathName", source.getString("orgPathName"));
+                allPersons.add(person);
+            }
+            pageNo++;
+        }
+        return allPersons;
+    }
+
+    private JSONObject executeHikvisionRequest(String requestBody, String signPath) throws Exception {
+        String timestamp = String.valueOf(System.currentTimeMillis());
+        String nonce = UUID.randomUUID().toString();
+        String contentMd5 = createContentMd5(requestBody);
+        String signature = createSignature(timestamp, nonce, contentMd5, signPath);
+        String url = trimTrailingSlash(hikvisionBaseUrl) + signPath;
+
+        Request.Builder builder = new Request.Builder()
+                .url(url)
+                .post(RequestBody.create(JSON_MEDIA_TYPE, requestBody))
+                .header("Accept", "*/*")
+                .header("Content-MD5", contentMd5)
+                .header("Content-Type", CONTENT_TYPE)
+                .header("x-ca-key", hikvisionAppKey)
+                .header("x-ca-timestamp", timestamp)
+                .header("x-ca-nonce", nonce)
+                .header("x-ca-signature-headers", SIGNATURE_HEADERS)
+                .header("x-ca-signature", signature);
+        if (StringUtils.hasText(hikvisionPartnerCode)) {
+            builder.header("x-partner-code", hikvisionPartnerCode);
+        }
+
+        System.out.println("海康接口请求地址:" + url);
+        System.out.println("海康接口请求参数:" + requestBody);
+        long startTime = System.currentTimeMillis();
+        try (Response response = httpClient.newCall(builder.build()).execute()) {
+            String responseBody = response.body() == null ? null : response.body().string();
+            System.out.println("海康接口响应状态码:" + response.code());
+            System.out.println("海康接口响应参数:" + responseBody);
+            if (!response.isSuccessful() || responseBody == null) {
+                throw new IllegalStateException("海康接口 HTTP 请求失败,状态码 " + response.code());
+            }
+            return JSON.parseObject(responseBody);
+        } finally {
+            log.info("海康人员接口调用耗时:{} 毫秒", System.currentTimeMillis() - startTime);
+        }
+    }
+
+    private String createContentMd5(String requestBody) throws Exception {
+        MessageDigest md5 = MessageDigest.getInstance("MD5");
+        byte[] digest = md5.digest(requestBody.getBytes(StandardCharsets.UTF_8));
+        return Base64.getEncoder().encodeToString(digest);
+    }
+
+    private String createSignature(String timestamp, String nonce, String contentMd5, String signPath) throws Exception {
+        String stringToSign = "POST\n*/*\n" + contentMd5 + "\n" + CONTENT_TYPE + "\n"
+                + "x-ca-key:" + hikvisionAppKey + "\n"
+                + "x-ca-nonce:" + nonce + "\n"
+                + "x-ca-timestamp:" + timestamp + "\n"
+                + signPath;
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(hikvisionAppSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+        return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    protected com.aliyun.dingtalkyida_2_0.Client createYidaClient() throws Exception {
+        Config config = new Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        return new com.aliyun.dingtalkyida_2_0.Client(config);
+    }
+
+    private String buildSearchFieldJson(String personId) {
+        JSONObject searchField = new JSONObject();
+        searchField.put("textField_ms5onsx3", personId);
+        return searchField.toJSONString();
+    }
+
+    private String buildSearchCondition(String personId) {
+        JSONObject condition = new JSONObject();
+        condition.put("componentName", "TextField");
+        condition.put("type", "TEXT");
+        condition.put("value", personId);
+        condition.put("key", "textField_ms5onsx3");
+        condition.put("operator", "eq");
+        JSONArray conditions = new JSONArray();
+        conditions.add(condition);
+        return conditions.toJSONString();
+    }
+
+    protected boolean existsInYida(com.aliyun.dingtalkyida_2_0.Client client, String accessToken,
+                                 String searchFieldJson) throws Exception {
+        SearchFormDatasHeaders headers = new SearchFormDatasHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        SearchFormDatasRequest request = new SearchFormDatasRequest()
+                .setSystemToken(yidaSystemToken)
+                .setSearchFieldJson(searchFieldJson)
+                .setCurrentPage(1)
+                .setPageSize(1)
+                .setFormUuid(yidaFormUuid)
+                .setUserId(yidaUserId)
+                .setAppType(yidaAppType);
+        SearchFormDatasResponse response = client.searchFormDatasWithOptions(request, headers, new RuntimeOptions());
+        return response.getBody() != null && response.getBody().getData() != null
+                && !response.getBody().getData().isEmpty();
+    }
+
+    private void createInYida(com.aliyun.dingtalkyida_2_0.Client client, String accessToken,
+                              String searchCondition, JSONObject person,
+                              Map<String, String> dingTalkUserIdsByJobNumber) throws Exception {
+        SupervisorInfo supervisor = findSupervisorBestEffort(person, accessToken, dingTalkUserIdsByJobNumber);
+        String orgPathName = fetchOrgPathNameBestEffort(person.getString("personId"),
+                person.getString("orgPathName"));
+        JSONObject formData = buildPersonFormData(person, orgPathName, supervisor);
+        CreateOrUpdateFormDataHeaders headers = new CreateOrUpdateFormDataHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        CreateOrUpdateFormDataRequest request = new CreateOrUpdateFormDataRequest()
+                .setSystemToken(yidaSystemToken)
+                .setFormUuid(yidaFormUuid)
+                .setUserId(yidaUserId)
+                .setSearchCondition(searchCondition)
+                .setAppType(yidaAppType)
+                .setFormDataJson(formData.toJSONString());
+        client.createOrUpdateFormDataWithOptions(request, headers, new RuntimeOptions());
+    }
+
+    private SupervisorInfo findSupervisorBestEffort(JSONObject person, String accessToken,
+                                                    Map<String, String> userIdsByJobNumber) {
+        String jobNumber = person.getString("jobNo");
+        if (userIdsByJobNumber == null || !StringUtils.hasText(jobNumber)) {
+            return null;
+        }
+        try {
+            return findSupervisor(jobNumber.trim(), accessToken, userIdsByJobNumber);
+        } catch (Exception e) {
+            log.warn("主管查询失败,继续同步基础档案并保留已有主管字段,人员ID:{},工号:{},原因:{}",
+                    person.getString("personId"), jobNumber,
+                    StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName());
+            return null;
+        }
+    }
+
+    // null 表示查询失败或无法定位员工,省略主管字段以保留宜搭已有值。
+    private JSONObject buildPersonFormData(JSONObject person, String orgPathName, SupervisorInfo supervisor) {
+        JSONObject formData = buildPersonFormData(person, orgPathName,
+                supervisor == null ? null : supervisor.getUserId(),
+                supervisor == null ? null : supervisor.getJobNumber());
+        if (supervisor == null) {
+            formData.remove(YIDA_SUPERVISOR_FIELD);
+            formData.remove(YIDA_SUPERVISOR_JOB_NUMBER_FIELD);
+        }
+        return formData;
+    }
+
+    JSONObject buildPersonFormData(JSONObject person, String orgPathName, String supervisorUserId) {
+        return buildPersonFormData(person, orgPathName, supervisorUserId, null);
+    }
+
+    JSONObject buildPersonFormData(JSONObject person, String orgPathName, String supervisorUserId,
+                                   String supervisorJobNumber) {
+        JSONObject formData = new JSONObject();
+        formData.put("textField_ms5onsx3", person.getString("personId"));
+        formData.put("textField_ms5onsx4", person.getString("personName"));
+        formData.put("textField_ms5onsx5", person.getString("orgIndexCode"));
+        formData.put("textField_ms5onsx7", person.getString("phoneNo"));
+        formData.put(YIDA_JOB_NUMBER_FIELD, person.getString("jobNo"));
+        formData.put("employeeField_mu0lsyn1", person.getString("jobNo"));
+        formData.put("textField_ms5onsxc", person.getString("certificateNo"));
+        formData.put("textField_msy09ao8", orgPathName);
+        JSONArray supervisorUserIds = new JSONArray();
+        if (StringUtils.hasText(supervisorUserId)) {
+            supervisorUserIds.add(supervisorUserId.trim());
+        }
+        formData.put(YIDA_SUPERVISOR_FIELD, supervisorUserIds);
+        formData.put(YIDA_SUPERVISOR_JOB_NUMBER_FIELD,
+                StringUtils.hasText(supervisorJobNumber) ? supervisorJobNumber.trim() : "");
+        return formData;
+    }
+
+    SupervisorInfo findSupervisor(String jobNumber, String accessToken,
+                                  Map<String, String> dingTalkUserIdsByJobNumber) throws Exception {
+        String employeeUserId = dingTalkUserIdsByJobNumber.get(jobNumber);
+        if (!StringUtils.hasText(employeeUserId)) {
+            log.warn("钉钉通讯录未找到工号对应用户,保留已有主管字段,工号:{}", jobNumber);
+            return null;
+        }
+
+        OapiV2UserGetResponse.UserGetResponse employee = getDingTalkUser(employeeUserId, accessToken);
+        String supervisorUserId = findSupervisorUserId(employee, accessToken);
+        if (!StringUtils.hasText(supervisorUserId)) {
+            log.warn("钉钉通讯录未找到用户的上级主管,工号:{},userId:{}", jobNumber, employeeUserId);
+            return SupervisorInfo.empty();
+        }
+
+        OapiV2UserGetResponse.UserGetResponse supervisor = getDingTalkUser(supervisorUserId, accessToken);
+        String supervisorJobNumber = supervisor == null ? null : supervisor.getJobNumber();
+        if (!StringUtils.hasText(supervisorJobNumber)) {
+            log.warn("钉钉主管未配置工号,主管 userId:{}", supervisorUserId);
+        }
+        return new SupervisorInfo(supervisorUserId, supervisorJobNumber);
+    }
+
+    String findSupervisorUserId(String jobNumber, String accessToken) throws Exception {
+        OapiV2UserGetResponse.UserGetResponse user = findDingTalkUserByJobNumber(jobNumber, accessToken);
+        if (user == null) {
+            log.warn("钉钉通讯录未找到工号对应用户,工号:{}", jobNumber);
+            return null;
+        }
+
+        return findSupervisorUserId(user, accessToken);
+    }
+
+    private String findSupervisorUserId(OapiV2UserGetResponse.UserGetResponse user,
+                                        String accessToken) throws Exception {
+        String directManagerUserId = user.getManagerUserid();
+        if (isUsableSupervisor(directManagerUserId, user.getUserid())) {
+            return directManagerUserId.trim();
+        }
+        return findDepartmentSupervisorUserId(user, accessToken);
+    }
+
+    protected OapiV2UserGetResponse.UserGetResponse findDingTalkUserByJobNumber(
+            String jobNumber, String accessToken) throws Exception {
+        String userId = loadDingTalkUserIdsByJobNumber(accessToken).get(jobNumber);
+        if (!StringUtils.hasText(userId)) {
+            return null;
+        }
+        return getDingTalkUser(userId, accessToken);
+    }
+
+    Map<String, String> loadDingTalkUserIdsByJobNumber(String accessToken) throws Exception {
+        Map<String, String> userIdsByJobNumber = new HashMap<>();
+        Deque<Long> pendingDepartmentIds = new ArrayDeque<>();
+        Set<Long> visitedDepartmentIds = new LinkedHashSet<>();
+        pendingDepartmentIds.add(DINGTALK_ROOT_DEPARTMENT_ID);
+
+        while (!pendingDepartmentIds.isEmpty()) {
+            Long departmentId = pendingDepartmentIds.removeFirst();
+            if (departmentId == null || !visitedDepartmentIds.add(departmentId)) {
+                continue;
+            }
+
+            List<OapiV2UserListResponse.ListUserResponse> users =
+                    listDingTalkDepartmentUsers(departmentId, accessToken);
+            for (OapiV2UserListResponse.ListUserResponse user : users) {
+                addDingTalkUserToJobNumberIndex(userIdsByJobNumber, user);
+            }
+
+            List<Long> childDepartmentIds = listDingTalkSubDepartmentIds(departmentId, accessToken);
+            for (Long childDepartmentId : childDepartmentIds) {
+                if (childDepartmentId != null && !visitedDepartmentIds.contains(childDepartmentId)) {
+                    pendingDepartmentIds.addLast(childDepartmentId);
+                }
+            }
+        }
+
+        if (userIdsByJobNumber.isEmpty()) {
+            throw new IllegalStateException("钉钉通讯录未返回任何带工号的用户");
+        }
+        return userIdsByJobNumber;
+    }
+
+    private void addDingTalkUserToJobNumberIndex(Map<String, String> userIdsByJobNumber,
+                                                  OapiV2UserListResponse.ListUserResponse user) {
+        if (user == null || !StringUtils.hasText(user.getJobNumber())
+                || !StringUtils.hasText(user.getUserid())) {
+            return;
+        }
+        String jobNumber = user.getJobNumber().trim();
+        String userId = user.getUserid().trim();
+        String existingUserId = userIdsByJobNumber.putIfAbsent(jobNumber, userId);
+        if (StringUtils.hasText(existingUserId) && !existingUserId.equals(userId)) {
+            throw new IllegalStateException("钉钉通讯录存在重复工号:" + jobNumber);
+        }
+    }
+
+    protected List<Long> listDingTalkSubDepartmentIds(Long departmentId,
+                                                       String accessToken) throws Exception {
+        DefaultDingTalkClient client = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/v2/department/listsubid");
+        OapiV2DepartmentListsubidRequest request = new OapiV2DepartmentListsubidRequest();
+        request.setDeptId(departmentId);
+        OapiV2DepartmentListsubidResponse response = client.execute(request, accessToken);
+        if (response == null || !response.isSuccess() || response.getResult() == null) {
+            throw new IllegalStateException("钉钉子部门查询失败,部门ID:" + departmentId);
+        }
+        List<Long> departmentIds = response.getResult().getDeptIdList();
+        return departmentIds == null ? new ArrayList<Long>() : departmentIds;
+    }
+
+    protected List<OapiV2UserListResponse.ListUserResponse> listDingTalkDepartmentUsers(
+            Long departmentId, String accessToken) throws Exception {
+        List<OapiV2UserListResponse.ListUserResponse> users = new ArrayList<>();
+        long cursor = 0L;
+        boolean hasMore;
+        do {
+            DefaultDingTalkClient client = new DefaultDingTalkClient(
+                    "https://oapi.dingtalk.com/topapi/v2/user/list");
+            OapiV2UserListRequest request = new OapiV2UserListRequest();
+            request.setDeptId(departmentId);
+            request.setCursor(cursor);
+            request.setSize(DINGTALK_USER_PAGE_SIZE);
+            request.setContainAccessLimit(false);
+            OapiV2UserListResponse response = client.execute(request, accessToken);
+            if (response == null || !response.isSuccess() || response.getResult() == null) {
+                throw new IllegalStateException("钉钉部门成员查询失败,部门ID:" + departmentId);
+            }
+            if (response.getResult().getList() != null) {
+                users.addAll(response.getResult().getList());
+            }
+            hasMore = Boolean.TRUE.equals(response.getResult().getHasMore());
+            Long nextCursor = response.getResult().getNextCursor();
+            if (hasMore && (nextCursor == null || nextCursor == cursor)) {
+                throw new IllegalStateException("钉钉部门成员分页游标无效,部门ID:" + departmentId);
+            }
+            if (nextCursor != null) {
+                cursor = nextCursor;
+            }
+        } while (hasMore);
+        return users;
+    }
+
+    private String findDepartmentSupervisorUserId(OapiV2UserGetResponse.UserGetResponse user,
+                                                   String accessToken) throws Exception {
+        Set<Long> currentDepartmentIds = new LinkedHashSet<>();
+        if (user.getDeptIdList() != null) {
+            currentDepartmentIds.addAll(user.getDeptIdList());
+        }
+        Set<Long> visitedDepartmentIds = new LinkedHashSet<>();
+
+        for (int level = 0; level < MAX_DEPARTMENT_LEVELS && !currentDepartmentIds.isEmpty(); level++) {
+            Set<Long> parentDepartmentIds = new LinkedHashSet<>();
+            for (Long departmentId : currentDepartmentIds) {
+                if (departmentId == null || !visitedDepartmentIds.add(departmentId)) {
+                    continue;
+                }
+                OapiV2DepartmentGetResponse.DeptGetResponse department =
+                        getDingTalkDepartment(departmentId, accessToken);
+                if (department == null) {
+                    continue;
+                }
+                String managerUserId = firstUsableSupervisor(
+                        department.getDeptManagerUseridList(), user.getUserid());
+                if (managerUserId != null) {
+                    return managerUserId;
+                }
+                Long parentId = department.getParentId();
+                if (parentId != null && parentId > 0 && !parentId.equals(departmentId)
+                        && !visitedDepartmentIds.contains(parentId)) {
+                    parentDepartmentIds.add(parentId);
+                }
+            }
+            currentDepartmentIds = parentDepartmentIds;
+        }
+        return null;
+    }
+
+    protected OapiV2DepartmentGetResponse.DeptGetResponse getDingTalkDepartment(
+            Long departmentId, String accessToken) throws Exception {
+        DefaultDingTalkClient client = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/v2/department/get");
+        OapiV2DepartmentGetRequest request = new OapiV2DepartmentGetRequest();
+        request.setDeptId(departmentId);
+        OapiV2DepartmentGetResponse response = client.execute(request, accessToken);
+        if (response == null || !response.isSuccess() || response.getResult() == null) {
+            throw new IllegalStateException("钉钉部门详情查询失败,部门ID:" + departmentId);
+        }
+        return response.getResult();
+    }
+
+    private String firstUsableSupervisor(List<String> managerUserIds, String employeeUserId) {
+        if (managerUserIds == null) {
+            return null;
+        }
+        for (String managerUserId : managerUserIds) {
+            if (isUsableSupervisor(managerUserId, employeeUserId)) {
+                return managerUserId.trim();
+            }
+        }
+        return null;
+    }
+
+    private boolean isUsableSupervisor(String managerUserId, String employeeUserId) {
+        return StringUtils.hasText(managerUserId)
+                && (!StringUtils.hasText(employeeUserId) || !managerUserId.trim().equals(employeeUserId.trim()));
+    }
+
+    protected OapiV2UserGetResponse.UserGetResponse getDingTalkUser(
+            String userId, String accessToken) throws Exception {
+        DefaultDingTalkClient client = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/v2/user/get");
+        OapiV2UserGetRequest request = new OapiV2UserGetRequest();
+        request.setUserid(userId);
+        OapiV2UserGetResponse response = client.execute(request, accessToken);
+        if (response == null || !response.isSuccess() || response.getResult() == null) {
+            throw new IllegalStateException("钉钉用户详情查询失败,userId:" + userId);
+        }
+        return response.getResult();
+    }
+
+    static final class SupervisorInfo {
+        private final String userId;
+        private final String jobNumber;
+
+        SupervisorInfo(String userId, String jobNumber) {
+            this.userId = userId;
+            this.jobNumber = jobNumber;
+        }
+
+        static SupervisorInfo empty() {
+            return new SupervisorInfo(null, null);
+        }
+
+        String getUserId() {
+            return userId;
+        }
+
+        String getJobNumber() {
+            return jobNumber;
+        }
+    }
+
+    /**
+     * 通过 personId 调海康人员详情接口获取 orgPathName,取值失败时回退到人员列表原值
+     */
+    protected String fetchOrgPathNameBestEffort(String personId, String fallbackOrgPathName) {
+        try {
+            String orgPathName = fetchOrgPathName(personId);
+            if (StringUtils.hasText(orgPathName)) {
+                return orgPathName;
+            }
+            log.warn("海康人员详情未返回 orgPathName,人员ID:{},使用人员列表原值", personId);
+        } catch (Exception e) {
+            log.error("海康人员详情查询失败,人员ID:{},使用人员列表原值,原因:{}", personId,
+                    StringUtils.hasText(e.getMessage()) ? e.getMessage() : e.getClass().getSimpleName());
+        }
+        return fallbackOrgPathName;
+    }
+
+    private String fetchOrgPathName(String personId) throws Exception {
+        JSONArray paramValue = new JSONArray();
+        paramValue.add(personId);
+        JSONObject requestJson = new JSONObject();
+        requestJson.put("paramName", "personId");
+        requestJson.put("paramValue", paramValue);
+
+        JSONObject responseJson = executeHikvisionRequest(requestJson.toJSONString(), PERSON_INFO_SIGN_PATH);
+        if (!"0".equals(responseJson.getString("code"))) {
+            throw new IllegalStateException("海康人员详情接口返回非成功状态");
+        }
+        JSONObject data = responseJson.getJSONObject("data");
+        JSONArray list = data == null ? null : data.getJSONArray("list");
+        if (list == null || list.isEmpty() || !(list.get(0) instanceof JSONObject)) {
+            throw new IllegalStateException("海康人员详情接口未返回 data.list[0]");
+        }
+        return list.getJSONObject(0).getString("orgPathName");
+    }
+
+    private String trimTrailingSlash(String value) {
+        while (value.endsWith("/")) {
+            value = value.substring(0, value.length() - 1);
+        }
+        return value;
+    }
+}

+ 682 - 0
src/main/java/com/muzhi/tianpai/service/VisitorAppointmentService.java

@@ -0,0 +1,682 @@
+package com.muzhi.tianpai.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONException;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.dingtalkcontact_1_0.models.GetUserHeaders;
+import com.aliyun.dingtalkcontact_1_0.models.GetUserResponse;
+import com.aliyun.dingtalkcontact_1_0.models.SearchUserHeaders;
+import com.aliyun.dingtalkcontact_1_0.models.SearchUserRequest;
+import com.aliyun.dingtalkcontact_1_0.models.SearchUserResponse;
+import com.aliyun.dingtalkyida_2_0.models.GetFormDataByIDHeaders;
+import com.aliyun.dingtalkyida_2_0.models.GetFormDataByIDRequest;
+import com.aliyun.dingtalkyida_2_0.models.GetFormDataByIDResponse;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasHeaders;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasRequest;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponse;
+import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataHeaders;
+import com.aliyun.dingtalkyida_2_0.models.UpdateFormDataRequest;
+import com.aliyun.teaopenapi.models.Config;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.dingtalk.api.DefaultDingTalkClient;
+import com.dingtalk.api.request.OapiUserGetbyunionidRequest;
+import com.dingtalk.api.request.OapiV2UserGetRequest;
+import com.dingtalk.api.response.OapiUserGetbyunionidResponse;
+import com.dingtalk.api.response.OapiV2UserGetResponse;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+@Service
+@Slf4j
+public class VisitorAppointmentService {
+
+    private static final String CREATE_HIKVISION_PATH = "/artemis/api/iccm/v2/appointment/";//
+    private static final String CANCEL_HIKVISION_PATH = "/artemis/api/iccm/v1/appointment/cancel";
+    private static final String SIGNATURE_HEADERS = "x-ca-key,x-ca-nonce,x-ca-timestamp";
+    private static final String CONTENT_TYPE = "application/json; charset=utf-8";
+    private static final MediaType JSON_MEDIA_TYPE = MediaType.parse(CONTENT_TYPE);
+    private static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter HIKVISION_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
+    private static final String PERSON_ID_FIELD = "textField_ms5onsx3";
+    private static final String APPROVER_FIELD = "employeeField_mu0lsyn1";
+    private static final String APPROVER_ID_FIELD = APPROVER_FIELD + "_id";
+    private static final String JOB_NUMBER_FIELD = "textField_ms5onsxa";
+    private static final String SUPERVISOR_JOB_NUMBER_FIELD = "textField_mv6ky7t5";
+
+    @Autowired
+    @org.springframework.beans.factory.annotation.Qualifier("hikvisionHttpClient")
+    private OkHttpClient httpClient;
+
+    @Autowired
+    private AccessTokenService accessTokenService;
+
+    @Value("${visitor-appointment.yida.app-type}")
+    private String yidaAppType;
+
+    @Value("${visitor-appointment.yida.system-token}")
+    private String yidaSystemToken;
+
+    @Value("${visitor-appointment.yida.form-uuid}")
+    private String yidaFormUuid;
+
+    @Value("${visitor-appointment.yida.cancel-form-uuid}")
+    private String cancelFormUuid;
+
+    @Value("${visitor-appointment.yida.user-id:yida_pub_account}")
+    private String yidaUserId;
+
+    @Value("${person-sync.hikvision.base-url}")
+    private String hikvisionBaseUrl;
+
+    @Value("${person-sync.hikvision.app-key}")
+    private String hikvisionAppKey;
+
+    @Value("${person-sync.hikvision.app-secret}")
+    private String hikvisionAppSecret;
+
+    @Value("${person-sync.hikvision.partner-code:}")
+    private String hikvisionPartnerCode;
+
+    @Value("${person-sync.yida.app-type}")
+    private String personYidaAppType;
+
+    @Value("${person-sync.yida.system-token}")
+    private String personYidaSystemToken;
+
+    @Value("${person-sync.yida.form-uuid}")
+    private String personYidaFormUuid;
+
+    @Value("${person-sync.yida.user-id:yida_pub_account}")
+    private String personYidaUserId;
+
+    public List<String> findApproverUserIds(String userId) throws Exception {
+        return findApproverUserIds(userId, "当前");
+    }
+
+    public List<String> findApproverUserIds(String userId, String levelName) throws Exception {
+        if (!"当前".equals(levelName) && !"上级".equals(levelName)) {
+            throw new IllegalArgumentException("Levelname只能为当前或上级");
+        }
+        String accessToken = accessTokenService.getAccessToken();
+        if (!StringUtils.hasText(accessToken)) {
+            throw new IllegalStateException("获取宜搭 accessToken 失败,accessToken 为空");
+        }
+
+        JSONObject searchField = new JSONObject();
+        searchField.put(PERSON_ID_FIELD, userId);
+
+        SearchFormDatasHeaders headers = new SearchFormDatasHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        SearchFormDatasRequest request = new SearchFormDatasRequest()
+                .setSystemToken(personYidaSystemToken)
+                .setSearchFieldJson(searchField.toJSONString())
+                .setCurrentPage(1)
+                .setPageSize(2)
+                .setFormUuid(personYidaFormUuid)
+                .setUserId(personYidaUserId)
+                .setAppType(personYidaAppType);
+
+        com.aliyun.dingtalkyida_2_0.Client client = createYidaClient();
+        SearchFormDatasResponse response = client.searchFormDatasWithOptions(request, headers, new RuntimeOptions());
+        JSONObject body = response == null || response.getBody() == null
+                ? null : JSON.parseObject(JSON.toJSONString(response.getBody()));
+        JSONArray records = body == null ? null : body.getJSONArray("data");
+        if (records == null || records.isEmpty()) {
+            throw new IllegalArgumentException("未找到userid对应的人员数据");
+        }
+        if (records.size() > 1 || body.getIntValue("totalCount") > 1) {
+            throw new IllegalStateException("userid匹配到多条人员数据,无法确定审批人");
+        }
+
+        JSONObject record = records.getJSONObject(0);
+        JSONObject formData = record == null ? null : asJsonObject(record.get("formData"));
+        if (formData == null) {
+            throw new IllegalStateException("人员数据中缺少formData");
+        }
+        if ("上级".equals(levelName)) {
+            String jobNumber = optionalText(formData, SUPERVISOR_JOB_NUMBER_FIELD);
+            if (!StringUtils.hasText(jobNumber)) {
+                jobNumber = requiredText(formData, JOB_NUMBER_FIELD, "当前用户工号");
+            }
+            return Collections.singletonList(jobNumber);
+        }
+        List<String> userIds = normalizeEmployeeUserIds(formData.get(APPROVER_ID_FIELD));
+        if (!userIds.isEmpty()) {
+            return userIds;
+        }
+
+        List<String> memberValues = normalizeEmployeeUserIds(formData.get(APPROVER_FIELD));
+        if (memberValues.isEmpty()) {
+            throw new IllegalStateException("人员数据中未配置审批人");
+        }
+        return resolveDingTalkUserIds(memberValues, accessToken);
+    }
+
+    public JSONObject createAppointment(String formInstanceId) throws Exception {
+        log.info("开始处理访客预约,formInstanceId:{}", formInstanceId);
+        boolean failureWriteAttempted = false;
+        try {
+            JSONObject formData = getYidaFormData(formInstanceId, yidaFormUuid);
+            String visitorName = requiredText(formData, "textField_mqqcfnh9", "访客/拜访人姓名");
+            String phoneNo = requiredText(formData, "textField_mqqh0coq", "手机号");
+            if (!phoneNo.matches("\\d+")) {
+                throw new IllegalArgumentException("手机号必须为纯数字");
+            }
+            String receptionistId = requiredText(formData, "textField_ms5sgadu", "被访人唯一标识");
+            String visitorWorkUnit = requiredText(formData, "textField_mqqcfnhg", "来访单位");
+            String gendername = requiredText(formData, "selectField_msdz1qof", "性别");
+            long startMillis = requiredTimestamp(formData, "dateField_ms5sgadz", "预计来访时间");
+            long endMillis = requiredTimestamp(formData, "dateField_ms5sgae0", "预计离开时间");
+//            if (endMillis <= startMillis) {
+//                throw new IllegalArgumentException("预计离开时间必须晚于预计来访时间");
+//            }
+//            if (endMillis <= System.currentTimeMillis()) {
+//                throw new IllegalArgumentException("预计离开时间必须晚于当前时间");
+//            }
+
+            String plateNo = optionalText(formData, "textField_mqqh0cor");
+            String visitPurpose = optionalText(formData, "textareaField_mqqcfnhb");//拜访事由
+
+
+            JSONObject visitor = new JSONObject();
+            visitor.put("visitorWorkUnit", visitorWorkUnit);
+            visitor.put("visitorName", visitorName);
+            visitor.put("phoneNo", phoneNo);
+            if (StringUtils.hasText(plateNo)) {
+                visitor.put("plateNo", plateNo);
+            }
+            visitor.put("gender", "男".equals(gendername) ? 1 : 2);
+            JSONArray visitors = new JSONArray();
+            visitors.add(visitor);
+
+            JSONObject request = new JSONObject();
+            request.put("receptionistId", receptionistId);
+            request.put("visitStartTime", formatTime(startMillis));
+            request.put("visitEndTime", formatTime(endMillis));
+            if (StringUtils.hasText(visitPurpose)) {
+                request.put("visitReasonRemark", visitPurpose);
+            }
+            request.put("visitorInfoList", visitors);
+
+            JSONObject response = executeHikvisionRequest(request.toJSONString(), CREATE_HIKVISION_PATH);
+            log.info("海康访客预约响应,code:{},msg:{}", response.getString("code"), response.getString("msg"));
+            if (!"0".equals(response.getString("code"))) {
+                JSONObject updateData = new JSONObject();
+                updateData.put("textareaField_ms5tdw39", failureText(response.toJSONString()));
+                failureWriteAttempted = true;
+                updateYidaFormData(formInstanceId, updateData);
+                return response;
+            }
+
+            JSONObject data = response.getJSONObject("data");
+            JSONArray appointmentInfoList = data == null ? null : data.getJSONArray("appointmentInfoList");
+            if (appointmentInfoList == null || appointmentInfoList.isEmpty()
+                    || !(appointmentInfoList.get(0) instanceof JSONObject)) {
+                throw new IllegalStateException("海康接口成功响应中缺少 data.appointmentInfoList[0]");
+            }
+            JSONObject appointmentInfo = appointmentInfoList.getJSONObject(0);
+            JSONObject updateData = new JSONObject();
+            updateData.put("textField_mse08tjf", data.get("orderId"));//访客单号
+            updateData.put("textField_ms5tdw2w", appointmentInfo.get("verificationCode"));//访客码
+            updateData.put("textField_ms5tdw31", appointmentInfo.get("QRCode"));//二维码信息
+            updateData.put("textField_ms5tdw32",  data.get("appointRecordId"));//访客预约记录id
+            updateData.put("textField_ms5tdw37", appointmentInfo.get("picUri"));//访客头像,图片的相对URL
+            updateData.put("textField_ms5tdw38", appointmentInfo.get("svrIndexCode"));//图片存储服务的唯一标识
+            updateData.put("textareaField_ms5tdw39", "预约成功");
+            updateYidaFormData(formInstanceId, updateData);
+            return response;
+        } catch (Exception e) {
+            if (failureWriteAttempted) {
+                log.error("宜搭预约失败状态回写失败,formInstanceId:{},原因:{}", formInstanceId, exceptionMessage(e));
+            } else {
+                writeFailureBestEffort(formInstanceId, e);
+            }
+            throw e;
+        }
+    }
+
+    public JSONObject cancelAppointment(String formInstanceId) throws Exception {
+        log.info("开始处理取消访客预约,formInstanceId:{}", formInstanceId);
+        try {
+            JSONObject formData = getYidaFormData(formInstanceId, cancelFormUuid);
+            String appointRecordId = requiredText(formData, "textField_ms5tdw32", "appointRecordId");
+
+            JSONArray appointRecordIds = new JSONArray();
+            appointRecordIds.add(appointRecordId);
+            JSONObject request = new JSONObject();
+            request.put("appointRecordIds", appointRecordIds);
+
+            JSONObject response = executeHikvisionRequest(request.toJSONString(), CANCEL_HIKVISION_PATH);
+            log.info("海康取消访客预约响应,code:{},msg:{}", response.getString("code"), response.getString("msg"));
+            if ("0".equals(response.getString("code")) && Boolean.TRUE.equals(response.getBoolean("data"))) {
+                JSONObject updateData = new JSONObject();
+                updateData.put("textareaField_ms5tdw39", "取消成功");
+                updateYidaFormData(formInstanceId, updateData);
+            } else {
+                writeCancelStatusBestEffort(formInstanceId, response.toJSONString());
+            }
+            return response;
+        } catch (Exception e) {
+            writeCancelStatusBestEffort(formInstanceId, exceptionMessage(e));
+            throw e;
+        }
+    }
+
+    private void writeCancelStatusBestEffort(String formInstanceId, String detail) {
+        JSONObject updateData = new JSONObject();
+        updateData.put("textareaField_ms5tdw39", limitText(detail));
+        try {
+            updateYidaFormData(formInstanceId, updateData);
+        } catch (Exception writeException) {
+            log.error("宜搭取消预约状态回写失败,formInstanceId:{},原因:{}",
+                    formInstanceId, exceptionMessage(writeException));
+        }
+    }
+
+    private String limitText(String text) {
+        return text.length() > 2000 ? text.substring(0, 2000) : text;
+    }
+
+    private void updateYidaFormData(String formInstanceId, JSONObject updateData) throws Exception {
+        String accessToken = accessTokenService.getAccessToken();
+        if (!StringUtils.hasText(accessToken)) {
+            throw new IllegalStateException("获取宜搭 accessToken 失败:accessToken 为空");
+        }
+
+        Config config = new Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        com.aliyun.dingtalkyida_2_0.Client client = new com.aliyun.dingtalkyida_2_0.Client(config);
+        UpdateFormDataHeaders headers = new UpdateFormDataHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        UpdateFormDataRequest request = new UpdateFormDataRequest()
+                .setSystemToken(yidaSystemToken)
+                .setFormInstanceId(formInstanceId)
+                .setUserId(yidaUserId)
+                .setUseLatestVersion(true)
+                .setAppType(yidaAppType)
+                .setUpdateFormDataJson(updateData.toJSONString());
+        client.updateFormDataWithOptions(request, headers, new RuntimeOptions());
+    }
+
+    protected com.aliyun.dingtalkyida_2_0.Client createYidaClient() throws Exception {
+        Config config = new Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        return new com.aliyun.dingtalkyida_2_0.Client(config);
+    }
+
+    private void writeFailureBestEffort(String formInstanceId, Exception originalException) {
+        JSONObject updateData = new JSONObject();
+        updateData.put("textareaField_ms5tdw39", failureText(exceptionMessage(originalException)));
+        try {
+            updateYidaFormData(formInstanceId, updateData);
+        } catch (Exception writeException) {
+            log.error("宜搭预约失败状态回写失败,formInstanceId:{},原因:{}",
+                    formInstanceId, exceptionMessage(writeException));
+        }
+    }
+
+    private String failureText(String detail) {
+        String text = "预约失败:" + detail;
+        return text.length() > 2000 ? text.substring(0, 2000) : text;
+    }
+
+    private String exceptionMessage(Exception exception) {
+        return StringUtils.hasText(exception.getMessage())
+                ? exception.getMessage() : exception.getClass().getSimpleName();
+    }
+
+    private JSONObject getYidaFormData(String formInstanceId, String expectedFormUuid) throws Exception {
+        String accessToken = accessTokenService.getAccessToken();
+        if (!StringUtils.hasText(accessToken)) {
+            throw new IllegalStateException("获取宜搭 accessToken 失败:accessToken 为空");
+        }
+
+        Config config = new Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        com.aliyun.dingtalkyida_2_0.Client client = new com.aliyun.dingtalkyida_2_0.Client(config);
+        GetFormDataByIDHeaders headers = new GetFormDataByIDHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        GetFormDataByIDRequest request = new GetFormDataByIDRequest()
+                .setSystemToken(yidaSystemToken)
+                .setUserId(yidaUserId)
+                .setAppType(yidaAppType);
+        GetFormDataByIDResponse response = client.getFormDataByIDWithOptions(
+                formInstanceId, request, headers, new RuntimeOptions());
+        if (response == null || response.getBody() == null) {
+            throw new IllegalStateException("宜搭未返回表单详情");
+        }
+
+        JSONObject body = JSON.parseObject(JSON.toJSONString(response.getBody()));
+        String responseFormUuid = findFormUuid(body);
+        if (StringUtils.hasText(responseFormUuid) && !expectedFormUuid.equals(responseFormUuid)) {
+            throw new IllegalArgumentException("宜搭数据不属于指定访客预约表单");
+        }
+        JSONObject formData = findFormData(body);
+        if (formData == null) {
+            throw new IllegalStateException("宜搭表单详情中缺少 formData");
+        }
+        return formData;
+    }
+
+    private String findFormUuid(JSONObject body) {
+        String formUuid = body.getString("formUuid");
+        if (StringUtils.hasText(formUuid)) {
+            return formUuid;
+        }
+        JSONObject result = body.getJSONObject("result");
+        if (result != null && StringUtils.hasText(result.getString("formUuid"))) {
+            return result.getString("formUuid");
+        }
+        JSONObject data = body.getJSONObject("data");
+        return data == null ? null : data.getString("formUuid");
+    }
+
+    private JSONObject findFormData(JSONObject body) {
+        JSONObject formData = asJsonObject(body.get("formData"));
+        if (formData != null) {
+            return formData;
+        }
+        JSONObject result = body.getJSONObject("result");
+        if (result != null) {
+            formData = asJsonObject(result.get("formData"));
+            if (formData != null) {
+                return formData;
+            }
+        }
+        JSONObject data = body.getJSONObject("data");
+        return data == null ? null : asJsonObject(data.get("formData"));
+    }
+
+    private JSONObject asJsonObject(Object value) {
+        if (value instanceof JSONObject) {
+            return (JSONObject) value;
+        }
+        if (value instanceof String && StringUtils.hasText((String) value)) {
+            try {
+                return JSON.parseObject((String) value);
+            } catch (JSONException ignored) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    private List<String> resolveDingTalkUserIds(List<String> memberValues, String accessToken) throws Exception {
+        Set<String> userIds = new LinkedHashSet<>();
+        for (String memberValue : memberValues) {
+            userIds.add(resolveDingTalkUserId(memberValue, accessToken));
+        }
+        return new java.util.ArrayList<>(userIds);
+    }
+
+    private String resolveDingTalkUserId(String memberValue, String accessToken) throws Exception {
+        List<String> exactUnionIds = null;
+        Exception searchException = null;
+        try {
+            exactUnionIds = searchExactDingTalkUnionIds(memberValue, accessToken);
+        } catch (Exception e) {
+            searchException = e;
+        }
+
+        if (exactUnionIds != null && exactUnionIds.size() > 1) {
+            throw new IllegalStateException("审批人姓名存在重名,无法唯一确定成员:" + memberValue);
+        }
+        if (exactUnionIds != null && exactUnionIds.size() == 1) {
+            return convertUnionIdToUserId(exactUnionIds.get(0), accessToken);
+        }
+
+        try {
+            if (isDingTalkUserId(memberValue, accessToken)) {
+                return memberValue;
+            }
+        } catch (Exception e) {
+            if (searchException == null) {
+                searchException = e;
+            }
+        }
+
+        if (searchException != null) {
+            throw new IllegalStateException("钉钉成员解析失败:" + exceptionMessage(searchException));
+        }
+        throw new IllegalStateException("审批人字段值无法匹配钉钉成员:" + memberValue);
+    }
+
+    private List<String> searchExactDingTalkUnionIds(String name, String accessToken) throws Exception {
+        com.aliyun.dingtalkcontact_1_0.Client client = createDingTalkContactClient();
+        SearchUserHeaders headers = new SearchUserHeaders();
+        headers.xAcsDingtalkAccessToken = accessToken;
+        SearchUserRequest request = new SearchUserRequest()
+                .setQueryWord(name)
+                .setOffset(0)
+                .setSize(50);
+        SearchUserResponse response = client.searchUserWithOptions(request, headers, new RuntimeOptions());
+        List<String> unionIds = response == null || response.getBody() == null
+                ? null : response.getBody().getList();
+        List<String> exactUnionIds = new java.util.ArrayList<>();
+        if (unionIds == null) {
+            return exactUnionIds;
+        }
+
+        for (String unionId : unionIds) {
+            GetUserHeaders getUserHeaders = new GetUserHeaders();
+            getUserHeaders.xAcsDingtalkAccessToken = accessToken;
+            GetUserResponse userResponse = client.getUserWithOptions(
+                    unionId, getUserHeaders, new RuntimeOptions());
+            if (userResponse != null && userResponse.getBody() != null
+                    && name.equals(userResponse.getBody().getNick())) {
+                exactUnionIds.add(unionId);
+            }
+        }
+        return exactUnionIds;
+    }
+
+    private com.aliyun.dingtalkcontact_1_0.Client createDingTalkContactClient() throws Exception {
+        Config config = new Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        return new com.aliyun.dingtalkcontact_1_0.Client(config);
+    }
+
+    private String convertUnionIdToUserId(String unionId, String accessToken) throws Exception {
+        DefaultDingTalkClient client = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/user/getbyunionid");
+        OapiUserGetbyunionidRequest request = new OapiUserGetbyunionidRequest();
+        request.setUnionid(unionId);
+        OapiUserGetbyunionidResponse response = client.execute(request, accessToken);
+        if (response == null || !response.isSuccess() || response.getResult() == null
+                || !StringUtils.hasText(response.getResult().getUserid())) {
+            throw new IllegalStateException("钉钉unionId转换userId失败");
+        }
+        return response.getResult().getUserid();
+    }
+
+    private boolean isDingTalkUserId(String userId, String accessToken) throws Exception {
+        DefaultDingTalkClient client = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/v2/user/get");
+        OapiV2UserGetRequest request = new OapiV2UserGetRequest();
+        request.setUserid(userId);
+        OapiV2UserGetResponse response = client.execute(request, accessToken);
+        return response != null && response.isSuccess() && response.getResult() != null;
+    }
+
+    private List<String> normalizeEmployeeUserIds(Object value) {
+        JSONArray values = toJsonArray(value);
+        Set<String> userIds = new LinkedHashSet<>();
+        for (Object item : values) {
+            String userId = extractEmployeeUserId(item);
+            if (StringUtils.hasText(userId)) {
+                userIds.add(userId.trim());
+            }
+        }
+        return new java.util.ArrayList<>(userIds);
+    }
+
+    private JSONArray toJsonArray(Object value) {
+        if (value == null) {
+            return new JSONArray();
+        }
+        if (value instanceof JSONArray) {
+            return (JSONArray) value;
+        }
+        if (value instanceof Collection) {
+            return JSON.parseArray(JSON.toJSONString(value));
+        }
+        if (value instanceof String) {
+            String text = ((String) value).trim();
+            if (!StringUtils.hasText(text)) {
+                return new JSONArray();
+            }
+            if (text.startsWith("[")) {
+                try {
+                    return JSON.parseArray(text);
+                } catch (JSONException ignored) {
+                    return new JSONArray();
+                }
+            }
+        }
+        JSONArray values = new JSONArray();
+        values.add(value);
+        return values;
+    }
+
+    private String extractEmployeeUserId(Object value) {
+        if (value instanceof String || value instanceof Number) {
+            return String.valueOf(value);
+        }
+        JSONObject employee = asJsonObject(value);
+        if (employee == null) {
+            return null;
+        }
+        String userId = employee.getString("value");
+        if (!StringUtils.hasText(userId)) {
+            userId = employee.getString("userId");
+        }
+        return userId;
+    }
+
+    private String requiredText(JSONObject formData, String key, String name) {
+        String value = optionalText(formData, key);
+        if (!StringUtils.hasText(value)) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        return value;
+    }
+
+    private String optionalText(JSONObject formData, String key) {
+        Object value = formData.get(key);
+        return value == null ? null : String.valueOf(value).trim();
+    }
+
+    private long requiredTimestamp(JSONObject formData, String key, String name) {
+        Object value = formData.get(key);
+        if (value == null || !StringUtils.hasText(String.valueOf(value))) {
+            throw new IllegalArgumentException(name + "不能为空");
+        }
+        if (!(value instanceof Number) && !(value instanceof String)) {
+            throw new IllegalArgumentException(name + "必须为毫秒时间戳");
+        }
+        try {
+            return value instanceof Number ? ((Number) value).longValue() : Long.parseLong(((String) value).trim());
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(name + "必须为毫秒时间戳");
+        }
+    }
+
+    private String formatTime(long epochMillis) {
+        OffsetDateTime dateTime = Instant.ofEpochMilli(epochMillis).atZone(SHANGHAI_ZONE).toOffsetDateTime();
+        return HIKVISION_TIME_FORMATTER.format(dateTime);
+    }
+
+    private JSONObject executeHikvisionRequest(String requestBody, String path) throws Exception {
+        String timestamp = String.valueOf(System.currentTimeMillis());
+        String nonce = UUID.randomUUID().toString();
+        String contentMd5 = createContentMd5(requestBody);
+        String signature = createSignature(timestamp, nonce, contentMd5, path);
+        String url = trimTrailingSlash(hikvisionBaseUrl) + path;
+
+        Request.Builder builder = new Request.Builder()
+                .url(url)
+                .post(RequestBody.create(JSON_MEDIA_TYPE, requestBody))
+                .header("Accept", "*/*")
+                .header("Content-MD5", contentMd5)
+                .header("Content-Type", CONTENT_TYPE)
+                .header("x-ca-key", hikvisionAppKey)
+                .header("x-ca-nonce", nonce)
+                .header("x-ca-timestamp", timestamp)
+                .header("x-ca-signature-headers", SIGNATURE_HEADERS)
+                .header("x-ca-signature", signature);
+        if (StringUtils.hasText(hikvisionPartnerCode)) {
+            builder.header("x-partner-code", hikvisionPartnerCode);
+        }
+
+        long startTime = System.currentTimeMillis();
+        try (Response response = httpClient.newCall(builder.build()).execute()) {
+            String responseBody = response.body() == null ? null : response.body().string();
+            if (!response.isSuccessful()) {
+                throw new IllegalStateException("海康接口 HTTP 请求失败,状态码 " + response.code());
+            }
+            if (!StringUtils.hasText(responseBody)) {
+                throw new IllegalStateException("海康接口返回内容为空");
+            }
+            try {
+                return JSON.parseObject(responseBody);
+            } catch (JSONException e) {
+                throw new IllegalStateException("海康接口返回内容不是有效 JSON");
+            }
+        } finally {
+            log.info("海康访客预约接口调用耗时:{} 毫秒", System.currentTimeMillis() - startTime);
+        }
+    }
+
+    private String createContentMd5(String requestBody) throws Exception {
+        MessageDigest md5 = MessageDigest.getInstance("MD5");
+        return Base64.getEncoder().encodeToString(md5.digest(requestBody.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private String createSignature(String timestamp, String nonce, String contentMd5, String path) throws Exception {
+        String stringToSign = "POST\n*/*\n" + contentMd5 + "\n" + CONTENT_TYPE + "\n"
+                + "x-ca-key:" + hikvisionAppKey + "\n"
+                + "x-ca-nonce:" + nonce + "\n"
+                + "x-ca-timestamp:" + timestamp + "\n"
+                + path;
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(hikvisionAppSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+        return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private String trimTrailingSlash(String value) {
+        while (value.endsWith("/")) {
+            value = value.substring(0, value.length() - 1);
+        }
+        return value;
+    }
+}

+ 0 - 1
src/main/java/com/muzhi/tianpai/service/impl/AccessTokenServiceImpl.java

@@ -68,7 +68,6 @@ public class AccessTokenServiceImpl implements AccessTokenService {
         try {
             OapiGettokenResponse response = client.execute(request);
             DingtalkAccessToken = response.getAccessToken();
-            System.out.println("天派钉钉AccessToken:" + DingtalkAccessToken);
         } catch (ApiException e) {
             e.printStackTrace();
         }

+ 187 - 0
src/main/java/com/muzhi/tianpai/service/impl/DingDriveGateway.java

@@ -0,0 +1,187 @@
+package com.muzhi.tianpai.service.impl;
+
+import com.aliyun.dingtalkstorage_1_0.Client;
+import com.aliyun.dingtalkstorage_1_0.models.*;
+import com.aliyun.teaopenapi.models.Config;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.dingtalk.api.DefaultDingTalkClient;
+import com.dingtalk.api.request.OapiV2UserGetRequest;
+import com.dingtalk.api.response.OapiV2UserGetResponse;
+import com.muzhi.tianpai.service.DriveGateway;
+import okhttp3.*;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class DingDriveGateway implements DriveGateway {
+    // Normal certificate validation; never use the Hikvision-specific TLS client.
+    private final OkHttpClient http;
+
+    public DingDriveGateway() {
+        this(new OkHttpClient.Builder()
+            .connectTimeout(20, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
+            .writeTimeout(120, TimeUnit.SECONDS).callTimeout(180, TimeUnit.SECONDS)
+            .followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false).build());
+    }
+
+    DingDriveGateway(OkHttpClient http) {
+        this.http = http;
+    }
+
+    protected Client client() throws Exception {
+        return new Client(new Config().setProtocol("https").setRegionId("central"));
+    }
+
+    protected com.aliyun.dingtalkdrive_1_0.Client driveClient() throws Exception {
+        return new com.aliyun.dingtalkdrive_1_0.Client(
+                new Config().setProtocol("https").setRegionId("central"));
+    }
+
+    private RuntimeOptions runtime() {
+        return new RuntimeOptions().setAutoretry(false).setReadTimeout(30000).setConnectTimeout(15000);
+    }
+
+    @Override
+    public SpacePage teamSpaces(String token, String unionId, String cursor) throws Exception {
+        com.aliyun.dingtalkdrive_1_0.models.ListSpacesResponseBody body = driveClient().listSpacesWithOptions(
+                new com.aliyun.dingtalkdrive_1_0.models.ListSpacesRequest()
+                        .setUnionId(unionId).setSpaceType("org").setMaxResults(50).setNextToken(cursor),
+                new com.aliyun.dingtalkdrive_1_0.models.ListSpacesHeaders().setXAcsDingtalkAccessToken(token),
+                runtime()).getBody();
+        if (body == null || body.getSpaces() == null) {
+            throw new IOException("钉盘未返回团队空间列表");
+        }
+        List<Space> spaces = new ArrayList<>();
+        for (com.aliyun.dingtalkdrive_1_0.models.ListSpacesResponseBody.ListSpacesResponseBodySpaces space : body.getSpaces()) {
+            if (!"org".equals(space.getSpaceType())) {
+                throw new IOException("钉盘返回了非团队空间");
+            }
+            spaces.add(new Space(space.getSpaceId(), space.getSpaceName()));
+        }
+        return new SpacePage(spaces, body.getNextToken());
+    }
+
+    @Override
+    public Page rootFiles(String token, String unionId, String spaceId, String cursor) throws Exception {
+        com.aliyun.dingtalkdrive_1_0.models.ListFilesResponseBody body = driveClient().listFilesWithOptions(spaceId,
+                new com.aliyun.dingtalkdrive_1_0.models.ListFilesRequest()
+                        .setUnionId(unionId).setParentId("0").setMaxResults(50).setNextToken(cursor),
+                new com.aliyun.dingtalkdrive_1_0.models.ListFilesHeaders().setXAcsDingtalkAccessToken(token),
+                runtime()).getBody();
+        if (body == null || body.getFiles() == null) {
+            throw new IOException("钉盘未返回团队空间根目录列表");
+        }
+        List<Node> nodes = new ArrayList<>();
+        for (com.aliyun.dingtalkdrive_1_0.models.ListFilesResponseBody.ListFilesResponseBodyFiles file : body.getFiles()) {
+            nodes.add(new Node(file.getFileId(), file.getFileName(), file.getFileType(), file.getParentId(),
+                    file.getSpaceId(), file.getFileSize()));
+        }
+        return new Page(nodes, body.getNextToken());
+    }
+
+    @Override
+    public String unionId(String token, String userId) throws Exception {
+        OapiV2UserGetRequest request = new OapiV2UserGetRequest();
+        request.setUserid(userId);
+        OapiV2UserGetResponse response = new DefaultDingTalkClient(
+                "https://oapi.dingtalk.com/topapi/v2/user/get").execute(request, token);
+        if (!response.isSuccess() || response.getResult() == null) {
+            throw new IOException("无法获取钉钉操作人,请检查通讯录权限及userId");
+        }
+        return response.getResult().getUnionid();
+    }
+
+    @Override
+    public Node inspect(String token, String unionId, String spaceId, String id) throws Exception {
+        GetDentryResponseBody body = client().getDentryWithOptions(spaceId, id,
+                new GetDentryRequest().setUnionId(unionId),
+                new GetDentryHeaders().setXAcsDingtalkAccessToken(token), runtime()).getBody();
+        if (body == null || body.getDentry() == null) {
+            throw new IOException("钉盘未返回文件信息");
+        }
+        GetDentryResponseBody.GetDentryResponseBodyDentry n = body.getDentry();
+        return new Node(n.getId(), n.getName(), n.getType(), n.getParentId(), n.getSpaceId(), n.getSize());
+    }
+
+    @Override
+    public Page list(String token, String unionId, String spaceId, String parentId, String cursor) throws Exception {
+        ListDentriesResponseBody body = client().listDentriesWithOptions(spaceId,
+                new ListDentriesRequest().setUnionId(unionId).setParentId(parentId)
+                        .setMaxResults(50).setNextToken(cursor),
+                new ListDentriesHeaders().setXAcsDingtalkAccessToken(token), runtime()).getBody();
+        if (body == null || body.getDentries() == null) {
+            throw new IOException("钉盘未返回目录列表");
+        }
+        List<Node> nodes = new ArrayList<>();
+        for (ListDentriesResponseBody.ListDentriesResponseBodyDentries n : body.getDentries()) {
+            nodes.add(new Node(n.getId(), n.getName(), n.getType(), n.getParentId(), n.getSpaceId(), n.getSize()));
+        }
+        return new Page(nodes, body.getNextToken());
+    }
+
+    @Override
+    public Node createFolder(String token, String unionId, String spaceId, String parentId, String name) throws Exception {
+        AddFolderResponseBody body = client().addFolderWithOptions(spaceId, parentId,
+                new AddFolderRequest().setUnionId(unionId).setName(name),
+                new AddFolderHeaders().setXAcsDingtalkAccessToken(token), runtime()).getBody();
+        if (body == null || body.getDentry() == null) {
+            throw new IOException("钉盘未返回创建目录结果");
+        }
+        AddFolderResponseBody.AddFolderResponseBodyDentry n = body.getDentry();
+        return new Node(n.getId(), n.getName(), n.getType(), n.getParentId(), n.getSpaceId(), n.getSize());
+    }
+
+    @Override
+    public UploadTicket prepare(String token, String unionId, String spaceId) throws Exception {
+        GetFileUploadInfoResponseBody body = client().getFileUploadInfoWithOptions(spaceId,
+                new GetFileUploadInfoRequest().setUnionId(unionId)
+                        .setProtocol("HEADER_SIGNATURE").setMultipart(false),
+                new GetFileUploadInfoHeaders().setXAcsDingtalkAccessToken(token), runtime()).getBody();
+        if (body == null || !"HEADER_SIGNATURE".equals(body.getProtocol())
+                || body.getHeaderSignatureInfo() == null) {
+            throw new IOException("钉盘未返回单段上传凭证");
+        }
+        return new UploadTicket(body.getUploadKey(), body.getHeaderSignatureInfo().getResourceUrls(),
+                body.getHeaderSignatureInfo().getHeaders());
+    }
+
+    @Override
+    public void put(UploadTicket ticket, byte[] bytes) throws Exception {
+        if (ticket.getUrls() == null || ticket.getUrls().size() != 1 || ticket.getHeaders() == null) {
+            throw new IOException("上传凭证不完整或不是单段上传");
+        }
+        HttpUrl url = HttpUrl.parse(ticket.getUrls().get(0));
+        if (url == null || !url.isHttps()) {
+            throw new IOException("钉盘返回了无效的上传地址");
+        }
+        Request.Builder request = new Request.Builder().url(url)
+                .put(RequestBody.create(bytes, (MediaType) null));
+        for (Map.Entry<String, String> header : ticket.getHeaders().entrySet()) {
+            request.header(header.getKey(), header.getValue());
+        }
+        try (Response response = http.newCall(request.build()).execute()) {
+            if (!response.isSuccessful()) {
+                throw new IOException("二进制上传失败,HTTP状态码:" + response.code());
+            }
+        }
+    }
+
+    @Override
+    public Node commit(String token, String unionId, String spaceId, String parentId,
+                       String name, String uploadKey, long size) throws Exception {
+        CommitFileResponseBody body = client().commitFileWithOptions(spaceId,
+                new CommitFileRequest().setUnionId(unionId).setParentId(parentId).setName(name)
+                        .setUploadKey(uploadKey).setOption(new CommitFileRequest.CommitFileRequestOption().setSize(size)),
+                new CommitFileHeaders().setXAcsDingtalkAccessToken(token), runtime()).getBody();
+        if (body == null || body.getDentry() == null) {
+            throw new IOException("钉盘未返回提交结果");
+        }
+        CommitFileResponseBody.CommitFileResponseBodyDentry n = body.getDentry();
+        return new Node(n.getId(), n.getName(), n.getType(), n.getParentId(), n.getSpaceId(), n.getSize());
+    }
+}

+ 39 - 12
src/main/resources/application.properties

@@ -7,6 +7,26 @@ server.tomcat.uri-encoding=UTF-8
 myapp.yida.appType=APP_LP7KZD7WKJH43RK4HW5G
 myapp.yida.appSecret=0MD66P814X16B2I4NZL8V5QHN5RG23YLTFMPMKE8
 
+# Hikvision person sync. Replace the default base URL with the enterprise iSecure Center OpenAPI gateway in deployment.
+person-sync.fixed-delay=${PERSON_SYNC_FIXED_DELAY:3600000}
+person-sync.initial-delay=${PERSON_SYNC_INITIAL_DELAY:${PERSON_SYNC_FIXED_DELAY:3600000}}
+person-sync.hikvision.base-url=${HIKVISION_BASE_URL:https://183.245.32.143:8443}
+person-sync.hikvision.app-key=${HIKVISION_APP_KEY:20640207}
+person-sync.hikvision.app-secret=${HIKVISION_APP_SECRET:pWyAbULJKGdWp0zwtVDB}
+person-sync.hikvision.partner-code=${HIKVISION_PARTNER_CODE:9iTwgxzH6TNI7YQE9IXh4yXD8fUtae3x}
+person-sync.hikvision.page-size=${HIKVISION_PAGE_SIZE:50}
+person-sync.yida.app-type=${YIDA_PERSON_SYNC_APP_TYPE:APP_IMMFN5QNEMW9763DVS6A}
+person-sync.yida.system-token=${YIDA_PERSON_SYNC_SYSTEM_TOKEN:EIG666D12WU6T47VMJCU2482CSB83QPKJCQQM7Q3}
+person-sync.yida.form-uuid=${YIDA_PERSON_SYNC_FORM_UUID:FORM-F204287A753245FBAE7F0AB30997550C20B2}
+person-sync.yida.user-id=${YIDA_PERSON_SYNC_USER_ID:yida_pub_account}
+
+# Visitor appointment YIDA form
+visitor-appointment.yida.app-type=${VISITOR_APPOINTMENT_YIDA_APP_TYPE:APP_IMMFN5QNEMW9763DVS6A}
+visitor-appointment.yida.system-token=${VISITOR_APPOINTMENT_YIDA_SYSTEM_TOKEN:EIG666D12WU6T47VMJCU2482CSB83QPKJCQQM7Q3}
+visitor-appointment.yida.form-uuid=${VISITOR_APPOINTMENT_YIDA_FORM_UUID:FORM-6AD8B36BD124451CB3FAD91B66F649D4H8JR}
+visitor-appointment.yida.cancel-form-uuid=${VISITOR_APPOINTMENT_YIDA_CANCEL_FORM_UUID:FORM-6AC45FA5221C4BDD9EF6644580082C5FMAKB}
+visitor-appointment.yida.user-id=${VISITOR_APPOINTMENT_YIDA_USER_ID:yida_pub_account}
+
 #ALIYUN
 myapp.aliyun.accessKeyId=
 myapp.aliyun.accessKeySecret=
@@ -24,37 +44,37 @@ ribbon:
     ReadTimeoutMillis: 5000
     ConnectTimeoutMillis: 5000
 
-## 服务名
+## 锟斤拷锟斤拷锟斤拷
 #spring.application.name=simaner-yd
-## 环境设置:dev、test、prod(开发环境、测试环境、生产环境)
+## 锟斤拷锟斤拷锟斤拷锟矫o拷dev锟斤拷test锟斤拷prod锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟皆伙拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷
 #spring.profiles.active=dev
 
-# mysql数据库连接
-# 本地测试数据库
+# mysql锟斤拷锟捷匡拷锟斤拷锟斤拷
+# 锟斤拷锟截诧拷锟斤拷锟斤拷锟捷匡拷
 #spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
 #spring.datasource.url=jdbc:mysql://localhost:3306/dingtalk?serverTimezone=GMT%2B8
 #spring.datasource.username=root
 #spring.datasource.password=123456
 
-#定时同步
+#锟斤拷时同锟斤拷
 
-#配置日志,当前为默认的控制台输出,也可以用log4j
+#锟斤拷锟斤拷锟斤拷志锟斤拷锟斤拷前为默锟较的匡拷锟斤拷台锟斤拷锟斤拷锟揭诧拷锟斤拷锟斤拷锟絣og4j
 #mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
 
-#控制台显示sql
+#锟斤拷锟斤拷台锟斤拷示sql
 #spring.jpa.show-sql=true
-#更新或者创建数据表结构
+#锟斤拷锟铰伙拷锟竭达拷锟斤拷锟斤拷锟捷憋拷锟结构
 #spring.jpa.hibernate.ddl-auto=update
 
-#返回json的全局时间格式
+#����json��ȫ��ʱ���ʽ
 spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
 spring.jackson.time-zone=GMT+8
-#配置mapper xml文件的路径
+#锟斤拷锟斤拷mapper xml锟侥硷拷锟斤拷路锟斤拷
 #mybatis-plus.mapper-locations=classpath:com/muzhi/lz/mapper/xml/*.xml
 
 #mybatis-plus.config-location=classpath:mybatis/mybatis-config.xml
 
-#禁用缓存
+#锟斤拷锟矫伙拷锟斤拷
 spring.thymeleaf.cache=false
 #spring.thymeleaf.mode= LEGACYHTML5
 #spring.resources.chain.strategy.content.enabled=true
@@ -66,7 +86,14 @@ spring.thymeleaf.cache=false
 #spring.thymeleaf.prefix = classpath:/static/
 #spring.thymeleaf.suffix = .html
 
-#定义日期提交的格式(日期的格式化):SpingMVC将页面提交的值需要转换为指定类型;默认的是yyyy/MM/dd
+#锟斤拷锟斤拷锟斤拷锟斤拷锟结交锟侥革拷式锟斤拷锟斤拷锟节的革拷式锟斤拷锟斤拷锟斤拷SpingMVC锟斤拷页锟斤拷锟结交锟斤拷值锟斤拷要转锟斤拷为指锟斤拷锟斤拷锟酵o拷默锟较碉拷锟斤拷yyyy/MM/dd
 #spring.mvc.date-format=yyyy-MM-dd
 
 spring.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,classpath:/templates/
+
+# DingTalk drive: reuse AccessTokenService app credentials. Values are supplied by deployment.
+# Default upload user; blank target IDs enable discovery of the onboarding folder in team files.
+dingtalk.drive.operator-user-id=${DINGTALK_DRIVE_OPERATOR_USER_ID:32600011}
+dingtalk.drive.space-id=${DINGTALK_DRIVE_SPACE_ID:}
+dingtalk.drive.onboarding-folder-id=${DINGTALK_DRIVE_ONBOARDING_FOLDER_ID:}
+dingtalk.drive.max-file-bytes=${DINGTALK_DRIVE_MAX_FILE_BYTES:20971520}

+ 19 - 0
src/main/resources/hikvision-root-ca.pem

@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDrzCCApegAwIBAgIUEdF2A6S5dKxLoW4upH3UrWBCD+MwDQYJKoZIhvcNAQELBQAwZzELMAkG
+A1UEBhMCQ04xETAPBgNVBAgMCFpoZUppYW5nMREwDwYDVQQHDAhIYW5nWmhvdTEMMAoGA1UECgwD
+QklDMQswCQYDVQQLDAJHTjEXMBUGA1UEAwwOQklDLUdOLVJPT1QtVjEwHhcNMjQwNDA5MDQ0MjM0
+WhcNMzQwNDA3MDQ0MjM0WjBnMQswCQYDVQQGEwJDTjERMA8GA1UECAwIWmhlSmlhbmcxETAPBgNV
+BAcMCEhhbmdaaG91MQwwCgYDVQQKDANCSUMxCzAJBgNVBAsMAkdOMRcwFQYDVQQDDA5CSUMtR04t
+Uk9PVC1WMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANBndbbUPE9hu/InxgnPWMxH
+6takFiVFtOdHe29YVTZkLXIROqHe7nfa28g3Qj213klKYg63rfWb5dDv9abALjQwlXtI9L/YkGCA
+vv0RMAXCcEG4Hja+4JvG5cbM6ZWtcjyrT/68kBqeo7Jq+Zhx1d5s0q6FZ+OOhppj4wXZNYxVc81r
+HVsN8o17ZU7MRGzKxxodVSqJGYe0ZrrB2uoP0i3v+zQb2x7nwZ1uMZdOA9UYhD9LYKtALDdCh9X+
+cUjIrnUPEMbTYa8g3klJz98v06Wgl4N7gbRgfBsOGI7X38lmoryH2lBZa2czmw0PiZkD+hU8nucx
+0hF7TyE64669Mx8CAwEAAaNTMFEwHQYDVR0OBBYEFGlmpCz17XMj9VyDl2JnvvR2d327MB8GA1Ud
+IwQYMBaAFGlmpCz17XMj9VyDl2JnvvR2d327MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL
+BQADggEBAJpLFRnUGdNHjzO8lS3BO6qVPnpEVkvuJNPRIKLEDUXkUHT11Y322DbmuR0EtxRayZAk
+8USKu2ZViWZPEdJ4gjGRTgMYqxx3lpA6/1iuJWiXjBsJYMcgBEiHhi9IAQlYHQKzNjQjDPPOz3tC
+Z6VkS0Pt7Xy7jj0YfTjxLC2ohieebgB3b2d2ML2xQGhyxuMx8PMNVL4vXmL1K1euuhqYNKWedBQZ
+ujAEjKRlp5dV9TW2IfyXs567jz58/CqaFX1s+U3jqWntwfDIf8n4QmgHFu1uZT/B28oeDnrlN4qZ
+HyVgPxWGwWJz6OxHtzdDO4k29wgdT1fYYxzoblsOUXKABgg=
+-----END CERTIFICATE-----

+ 67 - 0
src/test/java/com/muzhi/tianpai/controller/DingDriveControllerTest.java

@@ -0,0 +1,67 @@
+package com.muzhi.tianpai.controller;
+
+import com.muzhi.tianpai.model.DriveUploadRequest;
+import com.muzhi.tianpai.service.DingDriveUploadService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class DingDriveControllerTest {
+    @Test
+    void acceptsDocumentedJsonAndReturnsStandardResult() throws Exception {
+        DingDriveUploadService service = mock(DingDriveUploadService.class);
+        MockMvc mvc = MockMvcBuilders.standaloneSetup(new DingDriveController(service)).build();
+        when(service.upload(any())).thenReturn(Collections.singletonMap("fileId", "real-file-id"));
+        mvc.perform(post("/dingtalk/drive/onboarding/upload").contentType(MediaType.APPLICATION_JSON)
+                .content("{\"fileName\":\"onboarding.pdf\",\"base64\":\"YQ==\"}"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true))
+                .andExpect(jsonPath("$.data.fileId").value("real-file-id"));
+        ArgumentCaptor<DriveUploadRequest> captor = ArgumentCaptor.forClass(DriveUploadRequest.class);
+        verify(service).upload(captor.capture());
+        assertEquals("onboarding.pdf", captor.getValue().getFileName());
+        assertEquals("YQ==", captor.getValue().getBase64());
+    }
+
+    @Test
+    void supportsFileBase64AliasAndIgnoresCallerIdentityFields() throws Exception {
+        DingDriveUploadService service = mock(DingDriveUploadService.class);
+        MockMvc mvc = MockMvcBuilders.standaloneSetup(new DingDriveController(service)).build();
+        mvc.perform(post("/dingtalk/drive/onboarding/upload").contentType(MediaType.APPLICATION_JSON)
+                .content("{\"fileName\":\"a.txt\",\"fileBase64\":\"YQ==\",\"userid\":\"untrusted\",\"userId\":\"untrusted\",\"unionId\":\"untrusted\"}"))
+                .andExpect(status().isOk());
+        ArgumentCaptor<DriveUploadRequest> captor = ArgumentCaptor.forClass(DriveUploadRequest.class);
+        verify(service).upload(captor.capture());
+        assertEquals("YQ==", captor.getValue().getBase64());
+        assertEquals("a.txt", captor.getValue().getFileName());
+    }
+
+    @Test
+    void returnsValidationErrorWithoutClaimingSuccess() throws Exception {
+        DingDriveUploadService service = mock(DingDriveUploadService.class);
+        MockMvc mvc = MockMvcBuilders.standaloneSetup(new DingDriveController(service)).build();
+        when(service.upload(any())).thenThrow(new IllegalArgumentException("base64格式不正确"));
+        mvc.perform(post("/dingtalk/drive/onboarding/upload").contentType(MediaType.APPLICATION_JSON)
+                .content("{\"fileName\":\"a.txt\",\"base64\":\"%%%\"}"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(false))
+                .andExpect(jsonPath("$.message").value("base64格式不正确"));
+    }
+
+    @Test
+    void unexpectedErrorsDoNotExposeCredentials() throws Exception {
+        DingDriveUploadService service = mock(DingDriveUploadService.class);
+        MockMvc mvc = MockMvcBuilders.standaloneSetup(new DingDriveController(service)).build();
+        when(service.upload(any())).thenThrow(new RuntimeException("signed-url-secret"));
+        mvc.perform(post("/dingtalk/drive/onboarding/upload").contentType(MediaType.APPLICATION_JSON).content("{}"))
+                .andExpect(jsonPath("$.success").value(false))
+                .andExpect(jsonPath("$.message").value("云盘上传失败"));
+    }
+}

+ 124 - 0
src/test/java/com/muzhi/tianpai/controller/VisitorAppointmentControllerTest.java

@@ -0,0 +1,124 @@
+package com.muzhi.tianpai.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.muzhi.tianpai.service.VisitorAppointmentService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class VisitorAppointmentControllerTest {
+
+    @Mock
+    private VisitorAppointmentService visitorAppointmentService;
+
+    @InjectMocks
+    private VisitorAppointmentController controller;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void exposesApproverUserIdsForConnector() throws Exception {
+        List<String> approverUserIds = Arrays.asList("29182745242398230", "01432910392321237660");
+        when(visitorAppointmentService.findApproverUserIds("employee-1")).thenReturn(approverUserIds);
+
+        mockMvc.perform(post("/visitor/approver")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(new ObjectMapper().writeValueAsString(
+                        Collections.singletonMap("userid", "employee-1"))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.success").value(true))
+                .andExpect(jsonPath("$.result[0]").value("29182745242398230"))
+                .andExpect(jsonPath("$.result[1]").value("01432910392321237660"))
+                .andExpect(jsonPath("$.errorLevel").value("0"))
+                .andExpect(jsonPath("$.data").doesNotExist())
+                .andExpect(jsonPath("$.error").doesNotExist())
+                .andExpect(jsonPath("$.code").doesNotExist());
+        verify(visitorAppointmentService).findApproverUserIds("employee-1");
+    }
+    @ParameterizedTest
+    @ValueSource(strings = {"Levelname", "levelname"})
+    void explicitCurrentKeepsExistingBehavior(String parameterName) throws Exception {
+        when(visitorAppointmentService.findApproverUserIds("employee-1"))
+                .thenReturn(Collections.singletonList("29182745242398230"));
+        Map<String, String> request = new HashMap<>();
+        request.put("userid", "employee-1");
+        request.put(parameterName, "当前");
+
+        mockMvc.perform(post("/visitor/approver").contentType(MediaType.APPLICATION_JSON)
+                .content(new ObjectMapper().writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.result[0]").value("29182745242398230"))
+                .andExpect(jsonPath("$.success").value(true));
+        verify(visitorAppointmentService).findApproverUserIds("employee-1");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"Levelname", "levelname"})
+    void acceptsSupervisorLevelFromJson(String parameterName) throws Exception {
+        when(visitorAppointmentService.findApproverUserIds("employee-1", "上级"))
+                .thenReturn(Collections.singletonList("0041200098"));
+        Map<String, String> request = new HashMap<>();
+        request.put("userid", " employee-1 ");
+        request.put(parameterName, "上级");
+
+        mockMvc.perform(post("/visitor/approver").contentType(MediaType.APPLICATION_JSON)
+                .content(new ObjectMapper().writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.result[0]").value("0041200098"))
+                .andExpect(jsonPath("$.errorLevel").value("0"))
+                .andExpect(jsonPath("$.success").value(true));
+        verify(visitorAppointmentService).findApproverUserIds("employee-1", "上级");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"Levelname", "levelname"})
+    void acceptsSupervisorLevelFromForm(String parameterName) throws Exception {
+        when(visitorAppointmentService.findApproverUserIds("employee-1", "上级"))
+                .thenReturn(Collections.singletonList("41200098"));
+
+        mockMvc.perform(post("/visitor/approver").contentType(MediaType.APPLICATION_FORM_URLENCODED)
+                .param("userId", "employee-1").param(parameterName, "上级"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.result[0]").value("41200098"))
+                .andExpect(jsonPath("$.errorLevel").value("0"))
+                .andExpect(jsonPath("$.success").value(true));
+        verify(visitorAppointmentService).findApproverUserIds("employee-1", "上级");
+    }
+
+    @Test
+    void rejectsUnknownLevelWithoutQueryingYida() throws Exception {
+        mockMvc.perform(post("/visitor/approver").contentType(MediaType.APPLICATION_FORM_URLENCODED)
+                .param("userid", "employee-1").param("Levelname", "其他"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.result").isEmpty())
+                .andExpect(jsonPath("$.errorLevel").value("1"))
+                .andExpect(jsonPath("$.success").value(false));
+        verifyNoInteractions(visitorAppointmentService);
+    }
+}

+ 374 - 0
src/test/java/com/muzhi/tianpai/service/DingDriveUploadServiceTest.java

@@ -0,0 +1,374 @@
+package com.muzhi.tianpai.service;
+
+import com.muzhi.tianpai.config.DingDriveProperties;
+import com.muzhi.tianpai.model.DriveUploadRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class DingDriveUploadServiceTest {
+    private final AccessTokenService tokens = mock(AccessTokenService.class);
+    private final DriveGateway drive = mock(DriveGateway.class);
+    private DingDriveProperties properties;
+    private DingDriveUploadService service;
+    private final byte[] content = "入职资料".getBytes(StandardCharsets.UTF_8);
+    private DriveGateway.UploadTicket ticket;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        properties = new DingDriveProperties();
+        properties.setSpaceId("team-space");
+        properties.setOnboardingFolderId("onboarding");
+        // UTC is October 18; Shanghai is already October 19.
+        service = new DingDriveUploadService(tokens, drive, properties,
+                Clock.fixed(Instant.parse("2026-10-18T16:30:00Z"), ZoneOffset.UTC));
+        ticket = new DriveGateway.UploadTicket("upload-key", Collections.singletonList("https://example.invalid"),
+                Collections.emptyMap());
+        when(tokens.getAccessToken()).thenReturn("token");
+        when(drive.unionId("token", "32600011")).thenReturn("operator");
+        when(drive.inspect("token", "operator", "team-space", "onboarding"))
+                .thenReturn(folder("onboarding", "入职资料", "0"));
+        when(drive.list(anyString(), anyString(), anyString(), anyString(), nullable(String.class)))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), null));
+        when(drive.prepare("token", "operator", "team-space")).thenReturn(ticket);
+        when(drive.commit(eq("token"), eq("operator"), eq("team-space"), eq("day"),
+                eq("资料.pdf"), eq("upload-key"), eq((long) content.length)))
+                .thenReturn(file("file", (long) content.length));
+        when(drive.inspect("token", "operator", "team-space", "file"))
+                .thenReturn(file("file", (long) content.length));
+    }
+
+    @Test
+    void createsOnlyMissingFoldersAndReadsBackUploadedFileInShanghaiDate() throws Exception {
+        mockCreate("onboarding", "2026", "year");
+        mockCreate("year", "10", "month");
+        mockCreate("month", "19", "day");
+        Map<String, Object> result = service.upload(request());
+        assertEquals("团队文件/入职资料/2026/10/19/资料.pdf", result.get("path"));
+        assertEquals((long) content.length, result.get("size"));
+        assertEquals("file", result.get("fileId"));
+        verify(drive).put(ticket, content);
+        verify(drive).inspect("token", "operator", "team-space", "file");
+        verify(drive).unionId("token", "32600011");
+    }
+
+    @Test
+    void reusesFoldersAndDecodesDataUriWithLineBreaks() throws Exception {
+        mockExistingFolders();
+        DriveUploadRequest request = request();
+        request.setBase64("data:application/pdf;base64,\r\n" + request.getBase64() + "\n");
+        service.upload(request);
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+        verify(drive).put(ticket, content);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", "%%%", "data:text/plain,abc", "data:text/plain;base64,", "!!!!aGVsbG8="})
+    void invalidBase64NeverCallsRemote(String base64) {
+        DriveUploadRequest request = request();
+        request.setBase64(base64);
+        assertThrows(IllegalArgumentException.class, () -> service.upload(request));
+        verifyNoInteractions(tokens, drive);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"../test.pdf", "C:\\test.pdf", "bad\nname.pdf", "..", ""})
+    void invalidFilenameNeverCallsRemote(String name) {
+        DriveUploadRequest request = request();
+        request.setFileName(name);
+        assertThrows(IllegalArgumentException.class, () -> service.upload(request));
+        verifyNoInteractions(tokens, drive);
+    }
+
+    @Test
+    void decodedSizeIsCheckedEvenWhenBase64LengthFits() {
+        properties.setMaxFileBytes(1);
+        DriveUploadRequest request = request();
+        request.setBase64("YWI=");
+        assertThrows(IllegalArgumentException.class, () -> service.upload(request));
+        verifyNoInteractions(tokens, drive);
+    }
+
+    @Test
+    void defaultDestinationIsDiscoveredFromTeamRoot() throws Exception {
+        mockExistingFolders();
+        properties.setSpaceId(null);
+        properties.setOnboardingFolderId(null);
+        when(drive.teamSpaces("token", "operator", null)).thenReturn(new DriveGateway.SpacePage(
+                Collections.singletonList(new DriveGateway.Space("team-space", "团队空间")), null));
+        when(drive.rootFiles("token", "operator", "team-space", null))
+                .thenReturn(page(folder("onboarding", "入职资料", "0")));
+        assertEquals("file", service.upload(request()).get("fileId"));
+        verify(drive).teamSpaces("token", "operator", null);
+        verify(drive).rootFiles("token", "operator", "team-space", null);
+    }
+
+    @Test
+    void findsOnboardingInLaterSpaceAndLaterRootPage() throws Exception {
+        mockExistingFolders();
+        properties.setSpaceId(null);
+        properties.setOnboardingFolderId(null);
+        when(drive.teamSpaces("token", "operator", null)).thenReturn(new DriveGateway.SpacePage(
+                Collections.singletonList(new DriveGateway.Space("other-space", "其它空间")), "space-page-2"));
+        when(drive.teamSpaces("token", "operator", "space-page-2")).thenReturn(new DriveGateway.SpacePage(
+                Collections.singletonList(new DriveGateway.Space("team-space", "目标空间")), null));
+        when(drive.rootFiles("token", "operator", "other-space", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), null));
+        when(drive.rootFiles("token", "operator", "team-space", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), "root-page-2"));
+        when(drive.rootFiles("token", "operator", "team-space", "root-page-2"))
+                .thenReturn(page(folder("onboarding", "入职资料", "0")));
+        assertEquals("file", service.upload(request()).get("fileId"));
+        verify(drive).rootFiles("token", "operator", "other-space", null);
+    }
+
+    @Test
+    void multipleTeamFoldersNeverChooseFirstMatch() throws Exception {
+        mockDiscoveryWithOneFolder();
+        when(drive.teamSpaces("token", "operator", null)).thenReturn(new DriveGateway.SpacePage(Arrays.asList(
+                new DriveGateway.Space("team-space", "第一个"), new DriveGateway.Space("other-space", "第二个")), null));
+        when(drive.rootFiles("token", "operator", "other-space", null)).thenReturn(page(
+                new DriveGateway.Node("other-folder", "入职资料", "folder", "0", "other-space", null)));
+        assertTrue(assertThrows(IllegalStateException.class, () -> service.upload(request()))
+                .getMessage().contains("多个团队空间"));
+        verify(drive, never()).prepare(anyString(), anyString(), anyString());
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void missingOnboardingRootIsNotAutomaticallyCreated() throws Exception {
+        mockDiscoveryWithOneFolder();
+        when(drive.rootFiles("token", "operator", "team-space", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), null));
+        assertTrue(assertThrows(IllegalStateException.class, () -> service.upload(request()))
+                .getMessage().contains("未找到"));
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void permissionFailureStopsDiscoveryWithoutExposingRemoteDetails() throws Exception {
+        mockDiscoveryWithOneFolder();
+        when(drive.rootFiles("token", "operator", "team-space", null))
+                .thenThrow(new IOException("signed-url-must-not-escape"));
+        IllegalStateException error = assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        assertTrue(error.getMessage().contains("Drive.File.Read"));
+        assertFalse(error.getMessage().contains("signed-url"));
+        verify(drive, never()).put(any(), any());
+    }
+
+    @Test
+    void missingTeamCollectionIsNotTreatedAsAnEmptySearch() throws Exception {
+        mockDiscoveryWithOneFolder();
+        when(drive.teamSpaces("token", "operator", null)).thenReturn(new DriveGateway.SpacePage(null, null));
+        assertTrue(assertThrows(IllegalStateException.class, () -> service.upload(request()))
+                .getMessage().contains("不完整"));
+        verify(drive, never()).rootFiles(anyString(), anyString(), anyString(), nullable(String.class));
+    }
+
+    @Test
+    void sameNameFileCannotBeUsedAsOnboardingFolder() throws Exception {
+        mockDiscoveryWithOneFolder();
+        when(drive.rootFiles("token", "operator", "team-space", null)).thenReturn(page(
+                new DriveGateway.Node("not-folder", "入职资料", "file", "0", "team-space", 1L)));
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).prepare(anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void configuredSpaceCanDiscoverFolderWithoutListingOtherSpaces() throws Exception {
+        mockDiscoveryWithOneFolder();
+        mockExistingFolders();
+        properties.setSpaceId("team-space");
+        assertEquals("file", service.upload(request()).get("fileId"));
+        verify(drive, never()).teamSpaces(anyString(), anyString(), nullable(String.class));
+    }
+
+    private void mockDiscoveryWithOneFolder() throws Exception {
+        properties.setSpaceId(null);
+        properties.setOnboardingFolderId(null);
+        when(drive.teamSpaces("token", "operator", null)).thenReturn(new DriveGateway.SpacePage(
+                Collections.singletonList(new DriveGateway.Space("team-space", "团队空间")), null));
+        when(drive.rootFiles("token", "operator", "team-space", null))
+                .thenReturn(page(folder("onboarding", "入职资料", "0")));
+    }
+
+    @Test
+    void resolvesConfiguredUserWithoutAdministratorLookup() throws Exception {
+        mockExistingFolders();
+        properties.setOperatorUserId("configured-user");
+        when(drive.unionId("token", "configured-user")).thenReturn("operator");
+        service.upload(request());
+        verify(drive).unionId("token", "configured-user");
+    }
+
+    @Test
+    void uses32600011WhenOperatorConfigurationIsBlank() throws Exception {
+        mockExistingFolders();
+        properties.setOperatorUserId(" ");
+        service.upload(request());
+        verify(drive).unionId("token", "32600011");
+        verify(drive).put(ticket, content);
+    }
+
+    @Test
+    void failedUserLookupStopsBeforeAnyDriveWrites() throws Exception {
+        when(drive.unionId("token", "32600011")).thenThrow(new IOException("user-permission-error"));
+        IllegalStateException error = assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        assertTrue(error.getMessage().contains("32600011"));
+        assertFalse(error.getMessage().contains("user-permission-error"));
+        verify(drive, never()).inspect(anyString(), anyString(), anyString(), anyString());
+        verify(drive, never()).put(any(), any());
+    }
+
+    @Test
+    void missingUnionIdStopsBeforeDriveLookup() throws Exception {
+        when(drive.unionId("token", "32600011")).thenReturn(" ");
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).prepare(anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void rejectsWrongRootAndNeverCreatesAnything() throws Exception {
+        when(drive.inspect("token", "operator", "team-space", "onboarding"))
+                .thenReturn(folder("onboarding", "其它目录", "team-root"));
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).list(anyString(), anyString(), anyString(), anyString(), nullable(String.class));
+        verify(drive, never()).prepare(anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void checksAllPagesBeforeReusingFolder() throws Exception {
+        mockExistingFolders();
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), "page-2"));
+        when(drive.list("token", "operator", "team-space", "onboarding", "page-2"))
+                .thenReturn(page(folder("year", "2026", "onboarding")));
+        service.upload(request());
+        verify(drive).list("token", "operator", "team-space", "onboarding", "page-2");
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void missingCollectionIsNotTreatedAsMissingDirectory() throws Exception {
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(new DriveGateway.Page(null, null));
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void repeatedCursorStopsWithoutCreatingDirectories() throws Exception {
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), "same"));
+        when(drive.list("token", "operator", "team-space", "onboarding", "same"))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), "same"));
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void duplicateDirectoryNameIsNotSilentlyChosen() throws Exception {
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(new DriveGateway.Page(Arrays.asList(folder("a", "2026", "onboarding"),
+                        folder("b", "2026", "onboarding")), null));
+        assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        verify(drive, never()).createFolder(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void recoversFolderCreationWithOneReadWhenAnotherInstanceCreatedIt() throws Exception {
+        mockExistingFolders();
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(new DriveGateway.Page(Collections.emptyList(), null))
+                .thenReturn(page(folder("year", "2026", "onboarding")));
+        when(drive.createFolder("token", "operator", "team-space", "onboarding", "2026"))
+                .thenThrow(new IOException("conflict"));
+        service.upload(request());
+        verify(drive, times(1)).createFolder("token", "operator", "team-space", "onboarding", "2026");
+    }
+
+    @Test
+    void existingFilePreventsBinaryUpload() throws Exception {
+        mockExistingFolders();
+        when(drive.list("token", "operator", "team-space", "day", null))
+                .thenReturn(page(file("existing", 1L)));
+        assertThrows(IllegalArgumentException.class, () -> service.upload(request()));
+        verify(drive, never()).prepare(anyString(), anyString(), anyString());
+        verify(drive, never()).put(any(), any());
+    }
+
+    @Test
+    void binaryFailureNeverCommitsAndDoesNotExposeSignedUrl() throws Exception {
+        mockExistingFolders();
+        doThrow(new IOException("https://signed-url?secret=must-not-escape"))
+                .when(drive).put(any(), any());
+        IllegalStateException error = assertThrows(IllegalStateException.class, () -> service.upload(request()));
+        assertFalse(error.getMessage().contains("secret"));
+        assertNull(error.getCause());
+        verify(drive, never()).commit(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
+    }
+
+    @Test
+    void sizeMismatchReturnsUnknownOutcomeWithFileId() throws Exception {
+        mockExistingFolders();
+        when(drive.inspect("token", "operator", "team-space", "file")).thenReturn(file("file", 999L));
+        DingDriveUploadService.UploadUncertainException error = assertThrows(
+                DingDriveUploadService.UploadUncertainException.class, () -> service.upload(request()));
+        assertEquals("file", error.fileId);
+        assertEquals("day", error.parentId);
+    }
+
+    @Test
+    void commitTimeoutIsNotRetried() throws Exception {
+        mockExistingFolders();
+        when(drive.commit(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()))
+                .thenThrow(new IOException("timeout"));
+        assertThrows(DingDriveUploadService.UploadUncertainException.class, () -> service.upload(request()));
+        verify(drive, times(1)).commit(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
+    }
+
+    private void mockExistingFolders() throws Exception {
+        when(drive.list("token", "operator", "team-space", "onboarding", null))
+                .thenReturn(page(folder("year", "2026", "onboarding")));
+        when(drive.list("token", "operator", "team-space", "year", null))
+                .thenReturn(page(folder("month", "10", "year")));
+        when(drive.list("token", "operator", "team-space", "month", null))
+                .thenReturn(page(folder("day", "19", "month")));
+    }
+
+    private void mockCreate(String parent, String name, String id) throws Exception {
+        when(drive.createFolder("token", "operator", "team-space", parent, name)).thenReturn(folder(id, name, parent));
+        when(drive.inspect("token", "operator", "team-space", id)).thenReturn(folder(id, name, parent));
+    }
+
+    private DriveUploadRequest request() {
+        DriveUploadRequest request = new DriveUploadRequest();
+        request.setFileName("资料.pdf");
+        request.setBase64(Base64.getEncoder().encodeToString(content));
+        return request;
+    }
+
+    private DriveGateway.Node folder(String id, String name, String parent) {
+        return new DriveGateway.Node(id, name, "FOLDER", parent, "team-space", null);
+    }
+
+    private DriveGateway.Node file(String id, Long size) {
+        return new DriveGateway.Node(id, "资料.pdf", "FILE", "day", "team-space", size);
+    }
+
+    private DriveGateway.Page page(DriveGateway.Node node) {
+        return new DriveGateway.Page(Collections.singletonList(node), null);
+    }
+}

+ 305 - 0
src/test/java/com/muzhi/tianpai/service/PersonSyncServiceTest.java

@@ -0,0 +1,305 @@
+package com.muzhi.tianpai.service;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.dingtalkyida_2_0.models.CreateOrUpdateFormDataRequest;
+import com.dingtalk.api.response.OapiV2DepartmentGetResponse;
+import com.dingtalk.api.response.OapiV2UserGetResponse;
+import com.dingtalk.api.response.OapiV2UserListResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+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.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PersonSyncServiceTest {
+
+    @Test
+    void usesDirectManagerFromDingTalkUser() throws Exception {
+        StubPersonSyncService service = new StubPersonSyncService();
+        service.user = user("employee-1", "manager-1", 10L);
+
+        assertEquals("manager-1", service.findSupervisorUserId("A001", "token"));
+        assertEquals(0, service.departmentLookups);
+    }
+
+    @Test
+    void fallsBackToParentDepartmentManager() throws Exception {
+        StubPersonSyncService service = new StubPersonSyncService();
+        service.user = user("employee-1", null, 10L);
+        service.departments.put(10L, department(10L, 20L, "employee-1"));
+        service.departments.put(20L, department(20L, 1L, "manager-2"));
+
+        assertEquals("manager-2", service.findSupervisorUserId("A001", "token"));
+        assertEquals(2, service.departmentLookups);
+    }
+
+    @Test
+    void writesSupervisorIdAndJobNumberToYidaFields() {
+        PersonSyncService service = new PersonSyncService();
+        JSONObject person = new JSONObject();
+        person.put("personId", "person-1");
+        person.put("jobNo", "A001");
+
+        JSONObject formData = service.buildPersonFormData(
+                person, "总部/研发部", "manager-1", "M001");
+
+        JSONArray supervisors = formData.getJSONArray("employeeField_mv6k4flb");
+        assertEquals(1, supervisors.size());
+        assertEquals("manager-1", supervisors.getString(0));
+        assertEquals("M001", formData.getString("textField_mv6ky7t5"));
+    }
+
+    @Test
+    void indexesDingTalkUserByExactJobNumberAcrossDepartments() throws Exception {
+        StubPersonSyncService service = new StubPersonSyncService();
+        service.childDepartments.put(1L, Arrays.asList(10L));
+        service.childDepartments.put(10L, Collections.<Long>emptyList());
+        service.departmentUsers.put(1L, Collections.<OapiV2UserListResponse.ListUserResponse>emptyList());
+        service.departmentUsers.put(10L,
+                Arrays.asList(directoryUser("employee-41200097", "41200097")));
+
+        Map<String, String> index = service.loadDingTalkUserIdsByJobNumber("token");
+
+        assertEquals("employee-41200097", index.get("41200097"));
+    }
+
+    @Test
+    void resolvesSupervisorUserIdAndSupervisorJobNumber() throws Exception {
+        StubPersonSyncService service = new StubPersonSyncService();
+        service.usersById.put("employee-1", user("employee-1", "manager-1", 10L, "41200097"));
+        service.usersById.put("manager-1", user("manager-1", null, 20L, "M001"));
+        Map<String, String> index = new HashMap<>();
+        index.put("41200097", "employee-1");
+
+        PersonSyncService.SupervisorInfo supervisor =
+                service.findSupervisor("41200097", "token", index);
+
+        assertEquals("manager-1", supervisor.getUserId());
+        assertEquals("M001", supervisor.getJobNumber());
+    }
+
+    @Test
+    void writesAllBaseProfilesWhenDirectoryIndexFails() throws Exception {
+        SyncFixture fixture = new SyncFixture();
+        JSONObject secondPerson = new JSONObject();
+        secondPerson.put("personId", "person-2");
+        secondPerson.put("personName", "第二位员工");
+        secondPerson.put("jobNo", "41200098");
+        doReturn(Arrays.asList(fixture.person, secondPerson)).when(fixture.service).fetchAllPersons();
+        doThrow(new IllegalStateException("department access denied"))
+                .when(fixture.service).loadDingTalkUserIdsByJobNumber("token");
+
+        fixture.service.syncPersons();
+
+        List<CreateOrUpdateFormDataRequest> requests = fixture.writtenRequests(2);
+        assertBaseProfilePreservesSupervisor(requests.get(0), "person-1", "41200097");
+        assertBaseProfilePreservesSupervisor(requests.get(1), "person-2", "41200098");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"employee", "manager", "department"})
+    void writesBaseProfileWhenSupervisorLookupFails(String failureStage) throws Exception {
+        SyncFixture fixture = new SyncFixture();
+        fixture.stubUserDetails();
+        if ("employee".equals(failureStage)) {
+            doThrow(new IllegalStateException("employee lookup failed"))
+                    .when(fixture.service).getDingTalkUser("employee-1", "token");
+        } else if ("manager".equals(failureStage)) {
+            doThrow(new IllegalStateException("manager lookup failed"))
+                    .when(fixture.service).getDingTalkUser("manager-1", "token");
+        } else {
+            doReturn(user("employee-1", null, 10L, "41200097"))
+                    .when(fixture.service).getDingTalkUser("employee-1", "token");
+            doThrow(new IllegalStateException("department lookup failed"))
+                    .when(fixture.service).getDingTalkDepartment(10L, "token");
+        }
+
+        fixture.service.syncPersons();
+
+        assertBaseProfilePreservesSupervisor(fixture.writtenRequests(1).get(0), "person-1", "41200097");
+    }
+
+    @Test
+    void preservesSupervisorWhenJobNumberDoesNotMatchOrIsMissing() throws Exception {
+        SyncFixture fixture = new SyncFixture();
+        doReturn(Collections.<String, String>emptyMap()).when(fixture.service)
+                .loadDingTalkUserIdsByJobNumber("token");
+        JSONObject personWithoutJobNumber = new JSONObject();
+        personWithoutJobNumber.put("personId", "person-2");
+        doReturn(Arrays.asList(fixture.person, personWithoutJobNumber))
+                .when(fixture.service).fetchAllPersons();
+
+        fixture.service.syncPersons();
+
+        List<CreateOrUpdateFormDataRequest> requests = fixture.writtenRequests(2);
+        assertBaseProfilePreservesSupervisor(requests.get(0), "person-1", "41200097");
+        assertBaseProfilePreservesSupervisor(requests.get(1), "person-2", null);
+    }
+
+    @Test
+    void writesSupervisorFieldsWhenLookupSucceeds() throws Exception {
+        SyncFixture fixture = new SyncFixture();
+        fixture.stubUserDetails();
+
+        fixture.service.syncPersons();
+
+        JSONObject formData = JSONObject.parseObject(fixture.writtenRequests(1).get(0).getFormDataJson());
+        assertEquals("manager-1", formData.getJSONArray("employeeField_mv6k4flb").getString(0));
+        assertEquals("M001", formData.getString("textField_mv6ky7t5"));
+    }
+
+    @Test
+    void clearsSupervisorOnlyWhenLookupConfirmsNoSupervisor() throws Exception {
+        SyncFixture fixture = new SyncFixture();
+        doReturn(user("employee-1", null, 10L, "41200097"))
+                .when(fixture.service).getDingTalkUser("employee-1", "token");
+        doReturn(department(10L, null)).when(fixture.service).getDingTalkDepartment(10L, "token");
+
+        fixture.service.syncPersons();
+
+        JSONObject formData = JSONObject.parseObject(fixture.writtenRequests(1).get(0).getFormDataJson());
+        assertTrue(formData.getJSONArray("employeeField_mv6k4flb").isEmpty());
+        assertEquals("", formData.getString("textField_mv6ky7t5"));
+    }
+
+    private static void assertBaseProfilePreservesSupervisor(CreateOrUpdateFormDataRequest request,
+                                                             String personId, String jobNumber) {
+        JSONObject formData = JSONObject.parseObject(request.getFormDataJson());
+        assertEquals(personId, formData.getString("textField_ms5onsx3"));
+        assertEquals(jobNumber, formData.getString("textField_ms5onsxa"));
+        assertFalse(formData.containsKey("employeeField_mv6k4flb"));
+        assertFalse(formData.containsKey("textField_mv6ky7t5"));
+    }
+
+    private static class SyncFixture {
+        private final PersonSyncService service = spy(new PersonSyncService());
+        private final com.aliyun.dingtalkyida_2_0.Client yidaClient =
+                mock(com.aliyun.dingtalkyida_2_0.Client.class);
+        private final JSONObject person = new JSONObject();
+
+        SyncFixture() throws Exception {
+            AccessTokenService tokens = mock(AccessTokenService.class);
+            when(tokens.getAccessToken()).thenReturn("token");
+            ReflectionTestUtils.setField(service, "accessTokenService", tokens);
+            person.put("personId", "person-1");
+            person.put("personName", "测试员工");
+            person.put("jobNo", "41200097");
+            doReturn(Collections.singletonList(person)).when(service).fetchAllPersons();
+            doReturn(Collections.singletonMap("41200097", "employee-1"))
+                    .when(service).loadDingTalkUserIdsByJobNumber("token");
+            doReturn(yidaClient).when(service).createYidaClient();
+            doReturn(false).when(service).existsInYida(eq(yidaClient), eq("token"), anyString());
+            doReturn("总部/研发部").when(service).fetchOrgPathNameBestEffort(anyString(), any());
+        }
+
+        void stubUserDetails() throws Exception {
+            doReturn(user("employee-1", "manager-1", 10L, "41200097"))
+                    .when(service).getDingTalkUser("employee-1", "token");
+            doReturn(user("manager-1", null, 20L, "M001"))
+                    .when(service).getDingTalkUser("manager-1", "token");
+        }
+
+        List<CreateOrUpdateFormDataRequest> writtenRequests(int count) throws Exception {
+            ArgumentCaptor<CreateOrUpdateFormDataRequest> captor =
+                    ArgumentCaptor.forClass(CreateOrUpdateFormDataRequest.class);
+            verify(yidaClient, times(count)).createOrUpdateFormDataWithOptions(captor.capture(), any(), any());
+            return captor.getAllValues();
+        }
+    }
+
+    private static OapiV2UserGetResponse.UserGetResponse user(
+            String userId, String managerUserId, Long departmentId) {
+        return user(userId, managerUserId, departmentId, null);
+    }
+
+    private static OapiV2UserGetResponse.UserGetResponse user(
+            String userId, String managerUserId, Long departmentId, String jobNumber) {
+        OapiV2UserGetResponse.UserGetResponse user = new OapiV2UserGetResponse.UserGetResponse();
+        user.setUserid(userId);
+        user.setManagerUserid(managerUserId);
+        user.setDeptIdList(Arrays.asList(departmentId));
+        user.setJobNumber(jobNumber);
+        return user;
+    }
+
+    private static OapiV2UserListResponse.ListUserResponse directoryUser(
+            String userId, String jobNumber) {
+        OapiV2UserListResponse.ListUserResponse user = new OapiV2UserListResponse.ListUserResponse();
+        user.setUserid(userId);
+        user.setJobNumber(jobNumber);
+        return user;
+    }
+
+    private static OapiV2DepartmentGetResponse.DeptGetResponse department(
+            Long departmentId, Long parentId, String... managerUserIds) {
+        OapiV2DepartmentGetResponse.DeptGetResponse department =
+                new OapiV2DepartmentGetResponse.DeptGetResponse();
+        department.setDeptId(departmentId);
+        department.setParentId(parentId);
+        department.setDeptManagerUseridList(Arrays.asList(managerUserIds));
+        return department;
+    }
+
+    private static class StubPersonSyncService extends PersonSyncService {
+        private OapiV2UserGetResponse.UserGetResponse user;
+        private final Map<Long, OapiV2DepartmentGetResponse.DeptGetResponse> departments = new HashMap<>();
+        private final Map<String, OapiV2UserGetResponse.UserGetResponse> usersById = new HashMap<>();
+        private final Map<Long, List<Long>> childDepartments = new HashMap<>();
+        private final Map<Long, List<OapiV2UserListResponse.ListUserResponse>> departmentUsers = new HashMap<>();
+        private int departmentLookups;
+
+        @Override
+        protected OapiV2UserGetResponse.UserGetResponse findDingTalkUserByJobNumber(
+                String jobNumber, String accessToken) {
+            return user;
+        }
+
+        @Override
+        protected OapiV2DepartmentGetResponse.DeptGetResponse getDingTalkDepartment(
+                Long departmentId, String accessToken) {
+            departmentLookups++;
+            return departments.get(departmentId);
+        }
+
+        @Override
+        protected OapiV2UserGetResponse.UserGetResponse getDingTalkUser(
+                String userId, String accessToken) {
+            return usersById.get(userId);
+        }
+
+        @Override
+        protected List<Long> listDingTalkSubDepartmentIds(Long departmentId, String accessToken) {
+            List<Long> departmentIds = childDepartments.get(departmentId);
+            return departmentIds == null ? Collections.<Long>emptyList() : departmentIds;
+        }
+
+        @Override
+        protected List<OapiV2UserListResponse.ListUserResponse> listDingTalkDepartmentUsers(
+                Long departmentId, String accessToken) {
+            List<OapiV2UserListResponse.ListUserResponse> users = departmentUsers.get(departmentId);
+            return users == null
+                    ? Collections.<OapiV2UserListResponse.ListUserResponse>emptyList() : users;
+        }
+    }
+}

+ 104 - 0
src/test/java/com/muzhi/tianpai/service/VisitorAppointmentServiceTest.java

@@ -0,0 +1,104 @@
+package com.muzhi.tianpai.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.dingtalkyida_2_0.Client;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasRequest;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponse;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponseBody;
+import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponseBody.SearchFormDatasResponseBodyData;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class VisitorAppointmentServiceTest {
+
+    private VisitorAppointmentService service;
+    private Client client;
+    private JSONObject formData;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = spy(new VisitorAppointmentService());
+        client = mock(Client.class);
+        AccessTokenService tokens = mock(AccessTokenService.class);
+        when(tokens.getAccessToken()).thenReturn("token");
+        ReflectionTestUtils.setField(service, "accessTokenService", tokens);
+        ReflectionTestUtils.setField(service, "personYidaFormUuid", "FORM-F204287A753245FBAE7F0AB30997550C20B2");
+        doReturn(client).when(service).createYidaClient();
+        formData = new JSONObject();
+        formData.put("textField_ms5onsxa", "41200097");
+    }
+
+    @Test
+    void supervisorLevelReturnsSupervisorJobNumberAndUsesOriginalPersonIdLookup() throws Exception {
+        formData.put("textField_mv6ky7t5", " 0041200098 ");
+        stubRecord();
+
+        assertEquals(Collections.singletonList("0041200098"),
+                service.findApproverUserIds("hikvision-person-1", "上级"));
+
+        ArgumentCaptor<SearchFormDatasRequest> captor = ArgumentCaptor.forClass(SearchFormDatasRequest.class);
+        verify(client).searchFormDatasWithOptions(captor.capture(), any(), any());
+        assertEquals("hikvision-person-1",
+                JSON.parseObject(captor.getValue().getSearchFieldJson()).getString("textField_ms5onsx3"));
+        assertEquals("FORM-F204287A753245FBAE7F0AB30997550C20B2", captor.getValue().getFormUuid());
+    }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"   "})
+    void supervisorLevelFallsBackToCurrentJobNumberWhenSupervisorIsEmpty(String supervisorJobNumber)
+            throws Exception {
+        formData.put("textField_mv6ky7t5", supervisorJobNumber);
+        stubRecord();
+
+        assertEquals(Collections.singletonList("41200097"),
+                service.findApproverUserIds("hikvision-person-1", "上级"));
+    }
+
+    @Test
+    void currentLevelStillReturnsMemberUserIds() throws Exception {
+        formData.put("employeeField_mu0lsyn1_id", Arrays.asList("29182745242398230", "01432910392321237660"));
+        formData.put("textField_mv6ky7t5", "41200098");
+        stubRecord();
+
+        assertEquals(Arrays.asList("29182745242398230", "01432910392321237660"),
+                service.findApproverUserIds("hikvision-person-1", "当前"));
+        assertEquals(Arrays.asList("29182745242398230", "01432910392321237660"),
+                service.findApproverUserIds("hikvision-person-1"));
+    }
+
+    @Test
+    void missingSupervisorAndCurrentJobNumbersFailRatherThanReturningPersonId() throws Exception {
+        formData.remove("textField_ms5onsxa");
+        stubRecord();
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.findApproverUserIds("hikvision-person-1", "上级"));
+    }
+
+    private void stubRecord() throws Exception {
+        SearchFormDatasResponseBodyData record = new SearchFormDatasResponseBodyData().setFormData(formData);
+        SearchFormDatasResponseBody body = new SearchFormDatasResponseBody()
+                .setData(Collections.singletonList(record)).setTotalCount(1L);
+        when(client.searchFormDatasWithOptions(any(), any(), any()))
+                .thenReturn(new SearchFormDatasResponse().setBody(body));
+    }
+}

+ 166 - 0
src/test/java/com/muzhi/tianpai/service/impl/DingDriveGatewayTest.java

@@ -0,0 +1,166 @@
+package com.muzhi.tianpai.service.impl;
+
+import com.aliyun.dingtalkstorage_1_0.Client;
+import com.aliyun.dingtalkstorage_1_0.models.*;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.muzhi.tianpai.service.DriveGateway;
+import com.aliyun.dingtalkdrive_1_0.models.ListSpacesRequest;
+import com.aliyun.dingtalkdrive_1_0.models.ListSpacesResponse;
+import com.aliyun.dingtalkdrive_1_0.models.ListSpacesResponseBody;
+import com.aliyun.dingtalkdrive_1_0.models.ListFilesRequest;
+import com.aliyun.dingtalkdrive_1_0.models.ListFilesResponse;
+import com.aliyun.dingtalkdrive_1_0.models.ListFilesResponseBody;
+import okhttp3.*;
+import okio.Buffer;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class DingDriveGatewayTest {
+    @Test
+    void discoversOnlyTeamSpacesWithCursor() throws Exception {
+        com.aliyun.dingtalkdrive_1_0.Client client = mock(com.aliyun.dingtalkdrive_1_0.Client.class);
+        when(client.listSpacesWithOptions(any(), any(), any())).thenReturn(new ListSpacesResponse()
+                .setBody(new ListSpacesResponseBody().setNextToken("more").setSpaces(Collections.singletonList(
+                        new ListSpacesResponseBody.ListSpacesResponseBodySpaces()
+                                .setSpaceId("space").setSpaceName("团队空间").setSpaceType("org")))));
+        DriveGateway.SpacePage result = discoveryGateway(client).teamSpaces("token", "operator", "cursor");
+        assertEquals("space", result.getSpaces().get(0).getId());
+        assertEquals("more", result.getNextToken());
+        ArgumentCaptor<ListSpacesRequest> request = ArgumentCaptor.forClass(ListSpacesRequest.class);
+        verify(client).listSpacesWithOptions(request.capture(), any(), any());
+        assertEquals("org", request.getValue().getSpaceType());
+        assertEquals("operator", request.getValue().getUnionId());
+        assertEquals("cursor", request.getValue().getNextToken());
+    }
+
+    @Test
+    void rootListingUsesApiRootAndReturnsRealFolderId() throws Exception {
+        com.aliyun.dingtalkdrive_1_0.Client client = mock(com.aliyun.dingtalkdrive_1_0.Client.class);
+        when(client.listFilesWithOptions(eq("space"), any(), any(), any())).thenReturn(new ListFilesResponse()
+                .setBody(new ListFilesResponseBody().setFiles(Collections.singletonList(
+                        new ListFilesResponseBody.ListFilesResponseBodyFiles().setFileId("real-folder")
+                                .setFileName("入职资料").setFileType("folder").setParentId("0").setSpaceId("space")))));
+        DriveGateway.Page result = discoveryGateway(client).rootFiles("token", "operator", "space", "cursor");
+        assertEquals("real-folder", result.getNodes().get(0).getId());
+        assertTrue(result.getNodes().get(0).isFolder());
+        ArgumentCaptor<ListFilesRequest> request = ArgumentCaptor.forClass(ListFilesRequest.class);
+        verify(client).listFilesWithOptions(eq("space"), request.capture(), any(), any());
+        assertEquals("0", request.getValue().getParentId());
+        assertEquals("cursor", request.getValue().getNextToken());
+        assertEquals("operator", request.getValue().getUnionId());
+    }
+
+    @Test
+    void missingTeamSpaceCollectionDoesNotMeanEmpty() throws Exception {
+        com.aliyun.dingtalkdrive_1_0.Client client = mock(com.aliyun.dingtalkdrive_1_0.Client.class);
+        when(client.listSpacesWithOptions(any(), any(), any())).thenReturn(new ListSpacesResponse()
+                .setBody(new ListSpacesResponseBody()));
+        assertThrows(IOException.class, () -> discoveryGateway(client).teamSpaces("token", "operator", null));
+    }
+
+    private DingDriveGateway discoveryGateway(com.aliyun.dingtalkdrive_1_0.Client client) {
+        return new DingDriveGateway() {
+            @Override
+            protected com.aliyun.dingtalkdrive_1_0.Client driveClient() {
+                return client;
+            }
+        };
+    }
+
+    @Test
+    void putsOriginalBinaryWithAllSignedHeadersAndClosesResponse() throws Exception {
+        OkHttpClient http = mock(OkHttpClient.class);
+        Call call = mock(Call.class);
+        ResponseBody body = mock(ResponseBody.class);
+        Request responseRequest = new Request.Builder().url("https://upload.example.invalid/object").build();
+        when(http.newCall(any())).thenReturn(call);
+        when(call.execute()).thenReturn(new Response.Builder().request(responseRequest).protocol(Protocol.HTTP_1_1)
+                .code(200).message("OK").body(body).build());
+        Map<String, String> headers = new LinkedHashMap<>();
+        headers.put("Content-Type", "application/octet-stream");
+        headers.put("x-oss-date", "signed-date");
+        headers.put("Authorization", "signed-authorization");
+        byte[] bytes = new byte[] {0, 1, -1, 42};
+        new DingDriveGateway(http).put(new DriveGateway.UploadTicket("key",
+                Collections.singletonList("https://upload.example.invalid/object?signature=fake"), headers), bytes);
+        ArgumentCaptor<Request> request = ArgumentCaptor.forClass(Request.class);
+        verify(http).newCall(request.capture());
+        assertEquals("PUT", request.getValue().method());
+        assertEquals("signed-authorization", request.getValue().header("Authorization"));
+        assertEquals("signed-date", request.getValue().header("x-oss-date"));
+        assertEquals("application/octet-stream", request.getValue().header("Content-Type"));
+        Buffer buffer = new Buffer();
+        request.getValue().body().writeTo(buffer);
+        assertArrayEquals(bytes, buffer.readByteArray());
+        verify(body).close();
+    }
+
+    @Test
+    void rejectsUnencryptedUploadWithoutSendingFile() {
+        OkHttpClient http = mock(OkHttpClient.class);
+        assertThrows(IOException.class, () -> new DingDriveGateway(http).put(new DriveGateway.UploadTicket("key",
+                Collections.singletonList("http://upload.example.invalid"), Collections.emptyMap()), new byte[] {1}));
+        verifyNoInteractions(http);
+    }
+
+    @Test
+    void nonSuccessfulUploadFails() throws Exception {
+        OkHttpClient http = mock(OkHttpClient.class);
+        Call call = mock(Call.class);
+        when(http.newCall(any())).thenReturn(call);
+        Request request = new Request.Builder().url("https://upload.example.invalid").build();
+        when(call.execute()).thenReturn(new Response.Builder().request(request).protocol(Protocol.HTTP_1_1)
+                .code(403).message("Forbidden").body(ResponseBody.create(new byte[0], null)).build());
+        assertThrows(IOException.class, () -> new DingDriveGateway(http).put(new DriveGateway.UploadTicket("key",
+                Collections.singletonList("https://upload.example.invalid"), Collections.emptyMap()), new byte[] {1}));
+    }
+
+    @Test
+    void requestsSinglePartHeaderSignatureAndCommitsWithoutOverwriteId() throws Exception {
+        Client client = mock(Client.class);
+        DingDriveGateway gateway = new DingDriveGateway() {
+            @Override
+            protected Client client() {
+                return client;
+            }
+        };
+        GetFileUploadInfoResponseBody upload = new GetFileUploadInfoResponseBody()
+                .setProtocol("HEADER_SIGNATURE").setUploadKey("key")
+                .setHeaderSignatureInfo(new GetFileUploadInfoResponseBody.GetFileUploadInfoResponseBodyHeaderSignatureInfo()
+                        .setHeaders(Collections.singletonMap("Authorization", "signature"))
+                        .setResourceUrls(Collections.singletonList("https://upload.example.invalid")));
+        when(client.getFileUploadInfoWithOptions(eq("space"), any(), any(), any()))
+                .thenReturn(new GetFileUploadInfoResponse().setBody(upload));
+        DriveGateway.UploadTicket ticket = gateway.prepare("token", "operator", "space");
+        assertEquals("key", ticket.getUploadKey());
+        ArgumentCaptor<GetFileUploadInfoRequest> prepare = ArgumentCaptor.forClass(GetFileUploadInfoRequest.class);
+        ArgumentCaptor<GetFileUploadInfoHeaders> headers = ArgumentCaptor.forClass(GetFileUploadInfoHeaders.class);
+        ArgumentCaptor<RuntimeOptions> runtime = ArgumentCaptor.forClass(RuntimeOptions.class);
+        verify(client).getFileUploadInfoWithOptions(eq("space"), prepare.capture(), headers.capture(), runtime.capture());
+        assertEquals("operator", prepare.getValue().getUnionId());
+        assertEquals("HEADER_SIGNATURE", prepare.getValue().getProtocol());
+        assertFalse(prepare.getValue().getMultipart());
+        assertEquals("token", headers.getValue().getXAcsDingtalkAccessToken());
+        assertFalse(runtime.getValue().getAutoretry());
+
+        when(client.commitFileWithOptions(eq("space"), any(), any(), any())).thenReturn(new CommitFileResponse()
+                .setBody(new CommitFileResponseBody().setDentry(new CommitFileResponseBody.CommitFileResponseBodyDentry()
+                        .setId("file").setName("a.txt").setType("FILE").setParentId("day").setSpaceId("space").setSize(1L))));
+        assertEquals("file", gateway.commit("token", "operator", "space", "day", "a.txt", "key", 1L).getId());
+        ArgumentCaptor<CommitFileRequest> commit = ArgumentCaptor.forClass(CommitFileRequest.class);
+        verify(client).commitFileWithOptions(eq("space"), commit.capture(), any(), any());
+        assertEquals("day", commit.getValue().getParentId());
+        assertEquals("key", commit.getValue().getUploadKey());
+        assertEquals(1L, commit.getValue().getOption().getSize());
+        assertNull(commit.getValue().getOverwriteDentryId());
+    }
+}

BIN
target/.DS_Store