Bladeren bron

feat(aliwork): 落地 YDClient_Form + YDClient_Process 原子接口

按 extend-yida-api-coverage proposal 实施宜搭表单 + 流程原子 Client:

新增(不动旧 YDClient/YDService,零破坏):
- com.malk.server.aliwork.YDAuth: 请求鉴权上下文数据载体(accessToken/appType/systemToken/userId)
- com.malk.service.aliwork.YDClient_Form: 表单原子接口(16 方法,含 CRUD/查询/批量/日志/评论/附件 URL)
- com.malk.service.aliwork.YDClient_Process: 流程原子接口(13 方法,含生命周期/任务动作/查询)
- impl/YDClient_FormImpl + YDClient_ProcessImpl: 对应实现

规范对齐(mjava-baseline §3.4.2):
- 每方法 1:1 对应一个宜搭 API endpoint
- 第 1 参 YDAuth;必填参数显式;可选参数统一 body_ext
- javadoc 枚举 body_ext 所有已知 key + @apiNote 链到官方
- accessToken 为 null 时回退到全局 DDClient.initTokenHeader(单租户兼容)

已知风险(见 BACKLOG 实施中风险记录):
- 部分流程 endpoint URL 基于推断,冒烟前需对照官方文档复核
- 本机无 Maven,未编译验证

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 2 weken geleden
bovenliggende
commit
0c0d76d4ca

+ 68 - 0
mjava/src/main/java/com/malk/server/aliwork/YDAuth.java

@@ -0,0 +1,68 @@
+package com.malk.server.aliwork;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 宜搭请求鉴权上下文(原子接口参数容器)
+ *
+ * <p>新版 {@link com.malk.service.aliwork.YDClient_Form} / {@link com.malk.service.aliwork.YDClient_Process}
+ * 原子接口统一用 YDAuth 作为第 1 参数,封装调用宜搭 API 所需的全部鉴权字段。</p>
+ *
+ * <p>使用说明:</p>
+ * <ul>
+ *   <li>{@code accessToken}:钉钉应用 access_token。若为 null,实现端将回退到全局
+ *       {@code DDClient.initTokenHeader()}(用 application-*.yml 配置的 dingtalk.appKey/appSecret 取)。</li>
+ *   <li>{@code appType} / {@code systemToken}:必填。宜搭应用标识与密钥。</li>
+ *   <li>{@code userId}:可选,默认 {@link YDConf#PUB_ACCOUNT}。</li>
+ * </ul>
+ *
+ * <p>单租户场景(mcli / shunfeng / guangming)可直接用 {@link #ofGlobal(YDConf)}
+ * 快速构造;多租户场景(mjava-pro)按 tenantId 动态构造。</p>
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class YDAuth {
+
+    /**
+     * 钉钉应用 access_token。可为 null(实现端回退到 DDClient 全局 token)。
+     */
+    private String accessToken;
+
+    /**
+     * 宜搭应用编码,必填。
+     */
+    private String appType;
+
+    /**
+     * 宜搭应用密钥,必填。
+     */
+    private String systemToken;
+
+    /**
+     * 操作人 userId。为空时使用 {@link YDConf#PUB_ACCOUNT} 公共账号。
+     */
+    private String userId;
+
+    /**
+     * 基于全局 YDConf 构造(单租户场景便捷方法)
+     */
+    public static YDAuth ofGlobal(YDConf conf) {
+        return YDAuth.builder()
+                .appType(conf.getAppType())
+                .systemToken(conf.getSystemToken())
+                .userId(YDConf.PUB_ACCOUNT)
+                .build();
+    }
+
+    /**
+     * 获取 userId,null 时返回 PUB_ACCOUNT。
+     */
+    public String resolvedUserId() {
+        return userId == null || userId.isEmpty() ? YDConf.PUB_ACCOUNT : userId;
+    }
+}

+ 286 - 0
mjava/src/main/java/com/malk/service/aliwork/YDClient_Form.java

@@ -0,0 +1,286 @@
+package com.malk.service.aliwork;
+
+import com.malk.server.aliwork.YDAuth;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 宜搭表单原子接口(Client 层)
+ *
+ * <p>与宜搭开放平台表单 API 1:1 对齐,遵守 {@code mjava-baseline §3.4.2}:</p>
+ * <ul>
+ *   <li>第 1 参固定 {@link YDAuth}(封装 accessToken + appType + systemToken + userId)</li>
+ *   <li>必填参数按官方文档顺序显式声明</li>
+ *   <li>可选参数全部放入 {@code body_ext},允许为 null;所有已知 key 在各方法 javadoc 中枚举</li>
+ *   <li>返回原始 response 或 {@code Map} / {@code List<Map>}(不做业务加工)</li>
+ * </ul>
+ *
+ * <p>与旧版 {@link YDClient#operateData} / {@link YDClient#queryData} 聚合式接口**并存**;
+ * 新业务优先调用本接口,旧代码按节奏迁移。</p>
+ *
+ * @apiNote 官方文档总入口 https://open.dingtalk.com/document/orgapp/yida-overview
+ */
+public interface YDClient_Form {
+
+    // ================================================================
+    //  表单实例 CRUD
+    // ================================================================
+
+    /**
+     * 新增表单实例
+     *
+     * @apiNote POST /v1.0/yida/forms/instances
+     *          https://open.dingtalk.com/document/orgapp/add-or-update-form-instances
+     *
+     * @param auth           鉴权上下文
+     * @param formUuid       表单唯一标识(必填)
+     * @param formDataJson   表单数据 JSON 字符串,key 为 fieldId(必填)
+     * @param body_ext 可选参数(透传宜搭接口,不传可为 null):
+     *   - noExecuteExpression (Boolean): 是否跳过公式字段计算,默认 false
+     *   - language (String): zh_CN / en_US
+     *   - appType / systemToken / userId: 本实现会优先使用 auth 中的值覆盖
+     * @return 新增成功的 formInstanceId
+     */
+    String saveForm(YDAuth auth, String formUuid, String formDataJson, Map<String, Object> body_ext);
+
+    /**
+     * 更新表单实例
+     *
+     * @apiNote PUT /v1.0/yida/forms/instances
+     *          https://open.dingtalk.com/document/orgapp/update-form-data
+     *
+     * @param auth                 鉴权上下文
+     * @param formInstanceId       表单实例 ID(必填)
+     * @param updateFormDataJson   需更新的字段 JSON(必填)
+     * @param body_ext 可选参数:
+     *   - useLatestVersion (Boolean): 默认 false,true=覆盖最新版本不做乐观锁检查(后台批量同步建议 true)
+     *   - ignoreEmpty (Boolean): 默认 true,false=不忽略空值(日期字段需要设 false 才会正确写入)
+     *   - noExecuteExpression (Boolean)
+     * @return 被更新字段的 Map
+     */
+    Map<String, Object> updateForm(YDAuth auth, String formInstanceId, String updateFormDataJson, Map<String, Object> body_ext);
+
+    /**
+     * upsert 表单(不存在则新增,存在则更新)
+     *
+     * @apiNote POST /v2.0/yida/forms/instances/insertOrUpdate(v2 更准确的 searchCondition 匹配)
+     *          https://open.dingtalk.com/document/orgapp/update-or-save-form-data-v2
+     *
+     * @param auth                 鉴权上下文
+     * @param formUuid             表单唯一标识(必填)
+     * @param searchFieldJson      匹配条件 JSON(必填);日期字段必须用字符串数组格式避免 selectListException
+     * @param formDataJson         表单数据 JSON(必填)
+     * @param body_ext 可选参数:
+     *   - useLatestVersion (Boolean)
+     *   - ignoreEmpty (Boolean)
+     *   - noExecuteExpression (Boolean)
+     * @return upsert 结果(含 formInstanceId 与操作类型 create/update)
+     */
+    Map<String, Object> upsertForm(YDAuth auth, String formUuid, String searchFieldJson, String formDataJson, Map<String, Object> body_ext);
+
+    /**
+     * 删除表单实例
+     *
+     * @apiNote DELETE /v1.0/yida/forms/instances
+     *          https://open.dingtalk.com/document/orgapp/delete-form-data
+     *
+     * @param auth              鉴权上下文
+     * @param formInstanceId    表单实例 ID(必填)
+     * @param body_ext          可选参数(留空)
+     * @return 是否删除成功
+     */
+    Boolean deleteForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 按条件批量删除表单实例
+     *
+     * @apiNote POST /v1.0/yida/forms/instances/batchRemove
+     *          https://open.dingtalk.com/document/orgapp/delete-form-data-according-to-conditions
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          表单 UUID(必填)
+     * @param searchFieldJson   搜索条件 JSON(必填)
+     * @param body_ext          可选参数
+     * @return 被删除条数
+     */
+    Integer deleteFormByCondition(YDAuth auth, String formUuid, String searchFieldJson, Map<String, Object> body_ext);
+
+    /**
+     * 批量更新表单实例字段(同一批实例的相同字段赋值)
+     *
+     * @apiNote PUT /v1.0/yida/forms/instances/components
+     *          https://open.dingtalk.com/document/orgapp/update-form-data-batch
+     *
+     * @param auth                  鉴权上下文
+     * @param formInstanceIdList    实例 ID 列表(必填)
+     * @param updateFormDataJson    批量更新字段 JSON(必填)
+     * @param body_ext 可选参数
+     */
+    Map<String, Object> updateFormComponents(YDAuth auth, List<String> formInstanceIdList, String updateFormDataJson, Map<String, Object> body_ext);
+
+    /**
+     * 批量新增表单实例
+     *
+     * @apiNote POST /v1.0/yida/forms/instances/batchSave
+     *          https://open.dingtalk.com/document/orgapp/batch-save-form-data
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          表单 UUID(必填)
+     * @param formDataListJson  表单数据列表 JSON(必填;宜搭限每批 ≤ 100)
+     * @param body_ext 可选参数
+     * @return 新增实例 ID 列表
+     */
+    List<String> batchSaveForm(YDAuth auth, String formUuid, String formDataListJson, Map<String, Object> body_ext);
+
+    // ================================================================
+    //  表单实例查询
+    // ================================================================
+
+    /**
+     * 查询表单实例详情
+     *
+     * @apiNote GET /v1.0/yida/forms/instances/{id}
+     *          https://open.dingtalk.com/document/orgapp/query-form-data
+     *
+     * @param auth              鉴权上下文
+     * @param formInstanceId    实例 ID(必填)
+     * @param body_ext 可选参数
+     * @return 实例 Map(含 formData / createdTimeGMT / modifiedTimeGMT 等)
+     */
+    Map<String, Object> getForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 分页查询表单实例(不含子表)
+     *
+     * @apiNote POST /v1.0/yida/forms/instances/search
+     *          https://open.dingtalk.com/document/orgapp/retrieve-search-form-information
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          表单 UUID(必填)
+     * @param searchFieldJson   搜索条件 JSON(可为 "{}")
+     * @param currentPage       当前页,1 起
+     * @param pageSize          每页条数,上限 100
+     * @param body_ext 可选参数:
+     *   - modifiedFromTimeGMT (String): yyyy-MM-dd HH:mm:ss
+     *   - modifiedToTimeGMT (String)
+     *   - createFromTimeGMT (String)
+     *   - createToTimeGMT (String)
+     *   - originatorId (String): 发起人 userId 过滤
+     *   - dynamicOrder (String): 排序字段 JSON
+     *   - formDataRevision (Integer): 表单数据版本
+     * @return {@code Map} 含 currentPage/totalCount/data(List<Map>)
+     */
+    Map<String, Object> searchForm(YDAuth auth, String formUuid, String searchFieldJson,
+                                   Integer currentPage, Integer pageSize, Map<String, Object> body_ext);
+
+    /**
+     * 分页查询表单实例 ID 列表(轻量)
+     *
+     * @apiNote POST /v1.0/yida/forms/instances/ids/{appType}/{formUuid}
+     *          https://open.dingtalk.com/document/orgapp/retrieve-the-list-of-form-ids
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          表单 UUID
+     * @param searchFieldJson   搜索条件 JSON
+     * @param currentPage       当前页
+     * @param pageSize          每页条数
+     * @param body_ext          同 searchForm
+     * @return 实例 ID 列表
+     */
+    List<String> listFormIds(YDAuth auth, String formUuid, String searchFieldJson,
+                             Integer currentPage, Integer pageSize, Map<String, Object> body_ext);
+
+    /**
+     * 全量查询表单实例(含子表数据)
+     *
+     * @apiNote POST /v1.0/yida/forms/instances/advances/queryAll
+     *          https://open.dingtalk.com/document/orgapp/retrieve-list-all
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          表单 UUID(必填)
+     * @param searchFieldJson   搜索条件 JSON
+     * @param currentPage       当前页
+     * @param pageSize          每页条数,上限 100;子表数据多时建议 {@link com.malk.server.aliwork.YDConf#PAGE_SIZE_DETAILS}
+     * @param body_ext          同 searchForm
+     * @return 分页 Map(含 data 为 List<Map> 且含子表)
+     */
+    Map<String, Object> listFormsAll(YDAuth auth, String formUuid, String searchFieldJson,
+                                     Integer currentPage, Integer pageSize, Map<String, Object> body_ext);
+
+    /**
+     * 查询某表单实例的子表明细
+     *
+     * @apiNote GET /v1.0/yida/forms/innerTables/{formInstanceId}
+     *
+     * @param auth              鉴权上下文
+     * @param formInstanceId    父表实例 ID
+     * @param body_ext 可选:
+     *   - tableFieldId (String): 指定子表组件 ID(不传则返回所有子表)
+     *   - currentPage (Integer)
+     *   - pageSize (Integer)
+     * @return 子表数据列表
+     */
+    List<Map<String, Object>> listInnerTable(YDAuth auth, String formInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 查询表单操作日志(变更历史)
+     *
+     * @apiNote POST /v1.0/yida/forms/operationsLogs/query
+     *          https://open.dingtalk.com/document/orgapp/query-form-operations-logs
+     *
+     * @param auth              鉴权上下文
+     * @param formInstanceId    实例 ID(必填)
+     * @param body_ext 可选:
+     *   - pageNumber (Integer)
+     *   - pageSize (Integer)
+     *   - startTimeGMT / endTimeGMT (String)
+     * @return 操作日志列表
+     */
+    List<Map<String, Object>> listFormOperations(YDAuth auth, String formInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 查询应用下表单元数据列表
+     *
+     * @apiNote GET /v1.0/yida/forms
+     *          https://open.dingtalk.com/document/orgapp/retrieve-the-forms-list-of-an-application
+     *
+     * @param auth              鉴权上下文
+     * @param body_ext 可选:
+     *   - currentPage (Integer)
+     *   - pageSize (Integer)
+     *   - formType (String): RECEIPT_FORM / PROCESS_FORM
+     * @return 表单元数据列表
+     */
+    List<Map<String, Object>> listForms(YDAuth auth, Map<String, Object> body_ext);
+
+    /**
+     * 添加表单评论
+     *
+     * @apiNote POST /v1.0/yida/forms/remarks
+     *
+     * @param auth              鉴权上下文
+     * @param formInstanceId    实例 ID(必填)
+     * @param remark            评论内容(必填)
+     * @param body_ext 可选:
+     *   - atUserIds (List<String>): @ 的用户 userId 列表
+     *   - attachments (List<Map>): 附件列表
+     */
+    void remarkForm(YDAuth auth, String formInstanceId, String remark, Map<String, Object> body_ext);
+
+    // ================================================================
+    //  附件临时免登 URL(从旧 YDClient 迁入原子接口形态)
+    // ================================================================
+
+    /**
+     * 获取附件临时免登 URL
+     *
+     * @apiNote GET /v1.0/yida/apps/temporaryUrls/{appType}
+     *
+     * @param auth              鉴权上下文
+     * @param fileUrl           原始附件 URL(必填)
+     * @param timeoutMs         有效期毫秒,默认 60_000,最大 24 * 3600 * 1000
+     * @return 带签名的临时可访问 URL
+     */
+    String convertTempUrl(YDAuth auth, String fileUrl, Integer timeoutMs);
+}

+ 237 - 0
mjava/src/main/java/com/malk/service/aliwork/YDClient_Process.java

@@ -0,0 +1,237 @@
+package com.malk.service.aliwork;
+
+import com.malk.server.aliwork.YDAuth;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 宜搭流程原子接口(Client 层)
+ *
+ * <p>与宜搭开放平台流程 / 审批 API 1:1 对齐,规则同 {@link YDClient_Form}。</p>
+ *
+ * <p>注意:部分 endpoint URL 可能因宜搭文档版本差异(v1.0 / v2.0 / yida_vpc 旧格式)需要在
+ * 实施时对照官方文档最终确认。已在方法 javadoc 的 {@code @apiNote} 中标注推荐路径。</p>
+ *
+ * @apiNote 官方文档总入口 https://open.dingtalk.com/document/orgapp/yida-overview
+ */
+public interface YDClient_Process {
+
+    // ================================================================
+    //  流程实例生命周期
+    // ================================================================
+
+    /**
+     * 发起流程
+     *
+     * @apiNote POST /v1.0/yida/processes/instances/start
+     *          https://open.dingtalk.com/document/orgapp/initiate-a-process
+     *
+     * @param auth              鉴权上下文
+     * @param processCode       流程编码(必填)
+     * @param formUuid          流程表单 UUID(必填)
+     * @param formDataJson      流程表单数据 JSON(必填)
+     * @param body_ext 可选:
+     *   - noExecuteExpression (Boolean): 默认 true(跳过消息通知);false 触发业务规则
+     *   - deptId (Long): 发起人所在部门 ID
+     *   - originatorUserId (String): 指定发起人
+     *   - outResult (String): 外部结果参数
+     * @return processInstanceId
+     */
+    String startProcess(YDAuth auth, String processCode, String formUuid, String formDataJson, Map<String, Object> body_ext);
+
+    /**
+     * 终止流程
+     *
+     * @apiNote POST /v1.0/yida/processes/instances/terminate
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID(必填)
+     * @param body_ext 可选:
+     *   - operator (String): 操作人 userId
+     *   - noExecuteExpression (Boolean)
+     *   - reason (String): 终止原因
+     * @return 是否成功
+     */
+    Boolean terminateProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 撤回流程(发起人撤回未审批完成的流程)
+     *
+     * @apiNote POST /v1.0/yida/processes/instances/revoke
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param body_ext 可选:
+     *   - operator (String)
+     *   - reason (String)
+     */
+    Boolean revokeProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 流程节点跳转
+     *
+     * @apiNote POST /v1.0/yida/processes/instances/redirect
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param targetActivityId    目标节点 activityId(必填)
+     * @param body_ext 可选:
+     *   - operator (String)
+     *   - isReturn (Boolean): true=回退到前节点;false=前跳
+     *   - nextOperatorUserIds (List<String>): 跳转后指定新处理人
+     */
+    Boolean redirectProcess(YDAuth auth, String processInstanceId, String targetActivityId, Map<String, Object> body_ext);
+
+    // ================================================================
+    //  审批任务动作
+    // ================================================================
+
+    /**
+     * 同意审批任务
+     *
+     * @apiNote POST /v1.0/yida/processes/tasks/agree
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID(必填)
+     * @param taskId              任务 ID(必填)
+     * @param comment             审批意见(必填)
+     * @param body_ext 可选:
+     *   - operator (String)
+     *   - outResult (String)
+     *   - noExecuteExpression (Boolean)
+     *   - attachments (List<Map>): 附件
+     *   - fileUrls (List<String>): 兼容旧版附件格式
+     */
+    Boolean agreeTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext);
+
+    /**
+     * 拒绝审批任务
+     *
+     * @apiNote POST /v1.0/yida/processes/tasks/disagree
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param taskId              任务 ID
+     * @param comment             审批意见
+     * @param body_ext 可选:
+     *   - operator (String)
+     *   - nextOperatorUserIds (List<String>): 拒绝后指定下一个审批人(部分流程开启此能力)
+     *   - outResult (String)
+     */
+    Boolean disagreeTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext);
+
+    /**
+     * 转交审批任务
+     *
+     * @apiNote POST /v1.0/yida/processes/tasks/redirect
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param taskId              任务 ID
+     * @param toUserId            转交目标 userId(必填)
+     * @param comment             转交说明
+     * @param body_ext 可选:
+     *   - operator (String)
+     */
+    Boolean redirectTask(YDAuth auth, String processInstanceId, String taskId, String toUserId, String comment, Map<String, Object> body_ext);
+
+    /**
+     * 抄送任务
+     *
+     * @apiNote POST /v1.0/yida/processes/tasks/cc
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param taskId              任务 ID
+     * @param toUserIds           抄送 userId 列表(必填)
+     * @param comment             抄送说明
+     * @param body_ext 可选参数
+     */
+    Boolean ccTask(YDAuth auth, String processInstanceId, String taskId, List<String> toUserIds, String comment, Map<String, Object> body_ext);
+
+    /**
+     * 任务评论(不做同意/拒绝动作,仅加评论)
+     *
+     * @apiNote POST /v1.0/yida/processes/tasks/comment
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID
+     * @param taskId              任务 ID
+     * @param comment             评论内容
+     * @param body_ext 可选:
+     *   - operator (String)
+     *   - atUserIds (List<String>): @ 的用户
+     */
+    Boolean commentTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext);
+
+    // ================================================================
+    //  流程与任务查询
+    // ================================================================
+
+    /**
+     * 查询流程实例详情
+     *
+     * @apiNote GET /v1.0/yida/processes/instances/{processInstanceId}
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID(必填)
+     * @param body_ext 可选参数
+     * @return 流程完整信息(含已走节点)
+     */
+    Map<String, Object> getProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext);
+
+    /**
+     * 分页查询流程实例
+     *
+     * @apiNote POST /v1.0/yida/processes/instances
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          流程表单 UUID
+     * @param processCode       流程编码
+     * @param searchFieldJson   搜索条件 JSON(可为 "{}")
+     * @param currentPage       当前页
+     * @param pageSize          每页条数
+     * @param body_ext 可选:
+     *   - originatorId (String): 发起人过滤
+     *   - instanceStatus (String): RUNNING/NEW/PAUSED/TERMINATED/COMPLETED/ERROR/CANCELED
+     *   - approvedResult (String): agree/disagree
+     *   - createFromTimeGMT (String)
+     *   - createToTimeGMT (String)
+     *   - modifiedFromTimeGMT (String)
+     *   - modifiedToTimeGMT (String)
+     *   - taskId (String)
+     * @return 分页 Map
+     */
+    Map<String, Object> searchProcesses(YDAuth auth, String formUuid, String processCode, String searchFieldJson,
+                                        Integer currentPage, Integer pageSize, Map<String, Object> body_ext);
+
+    /**
+     * 查询流程实例 ID 列表(轻量)
+     *
+     * @apiNote POST /v1.0/yida/processes/instanceIds
+     *
+     * @param auth              鉴权上下文
+     * @param formUuid          流程表单 UUID
+     * @param processCode       流程编码
+     * @param searchFieldJson   搜索条件 JSON
+     * @param currentPage       当前页
+     * @param pageSize          每页条数
+     * @param body_ext          同 searchProcesses
+     * @return 流程 ID 列表
+     */
+    List<String> listProcessIds(YDAuth auth, String formUuid, String processCode, String searchFieldJson,
+                                Integer currentPage, Integer pageSize, Map<String, Object> body_ext);
+
+    /**
+     * 查询审批操作记录
+     *
+     * @apiNote GET /v1.0/yida/processes/operationRecords
+     *
+     * @param auth                鉴权上下文
+     * @param processInstanceId   流程实例 ID(必填)
+     * @param body_ext            可选参数
+     * @return 操作记录列表
+     */
+    List<Map<String, Object>> listApprovalRecords(YDAuth auth, String processInstanceId, Map<String, Object> body_ext);
+}

+ 323 - 0
mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_FormImpl.java

@@ -0,0 +1,323 @@
+package com.malk.service.aliwork.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient_Form;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.utils.UtilHttp;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 宜搭表单原子接口实现
+ *
+ * <p>严格对齐 {@code mjava-baseline §3.4.2}:所有 body_ext 字段透传到 HTTP body,不过滤。
+ * accessToken 为 null 时回退到 {@code DDClient.initTokenHeader()} 全局 token。</p>
+ */
+@Slf4j
+@Service
+public class YDClient_FormImpl implements YDClient_Form {
+
+    @Autowired
+    private DDClient ddClient;
+
+    // ---------------- 共用工具 ----------------
+
+    private static final String BASE_V1 = "https://api.dingtalk.com/v1.0/yida";
+    private static final String BASE_V2 = "https://api.dingtalk.com/v2.0/yida";
+
+    private String url(String uri) {
+        return BASE_V1 + uri;
+    }
+
+    private String urlV2(String uri) {
+        return BASE_V2 + uri;
+    }
+
+    /**
+     * 构造请求头:accessToken 为 null 时回退到全局 DDClient。
+     */
+    private Map<String, String> header(YDAuth auth) {
+        if (auth.getAccessToken() != null && !auth.getAccessToken().isEmpty()) {
+            Map<String, String> h = new HashMap<>();
+            h.put("x-acs-dingtalk-access-token", auth.getAccessToken());
+            return h;
+        }
+        return ddClient.initTokenHeader();
+    }
+
+    /**
+     * 构造基础 body:auth 三字段 + body_ext 透传。
+     */
+    private Map<String, Object> body(YDAuth auth) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("appType", auth.getAppType());
+        body.put("systemToken", auth.getSystemToken());
+        body.put("userId", auth.resolvedUserId());
+        return body;
+    }
+
+    /**
+     * 合并 body_ext 透传(不过滤任何字段;优先级:显式参数 > body_ext > auth)
+     */
+    private Map<String, Object> mergeExt(Map<String, Object> body, Map<String, Object> body_ext) {
+        if (body_ext == null || body_ext.isEmpty()) {
+            return body;
+        }
+        // body_ext 先进(优先级低),然后显式参数覆盖
+        Map<String, Object> merged = new HashMap<>(body_ext);
+        merged.putAll(body);
+        return merged;
+    }
+
+    private DDR_New assertResult(DDR_New ddr, String action) {
+        if (ddr == null) {
+            throw new McException("宜搭接口 [" + action + "] 返回空");
+        }
+        if (!ddr.isSuccess()) {
+            throw new McException("宜搭接口 [" + action + "] 失败: " + ddr.getErrorCode() + " - " + ddr.getErrorMsg());
+        }
+        return ddr;
+    }
+
+    // ================================================================
+    //  表单实例 CRUD
+    // ================================================================
+
+    @Override
+    public String saveForm(YDAuth auth, String formUuid, String formDataJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("formDataJson", formDataJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances"), header(auth), null, body), "saveForm");
+        return String.valueOf(r.getResult());
+    }
+
+    @Override
+    public Map<String, Object> updateForm(YDAuth auth, String formInstanceId, String updateFormDataJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formInstanceId", formInstanceId);
+        body.put("updateFormDataJson", updateFormDataJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult((DDR_New) UtilHttp.doPut(url("/forms/instances"), header(auth), body, DDR_New.class), "updateForm");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public Map<String, Object> upsertForm(YDAuth auth, String formUuid, String searchFieldJson, String formDataJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("searchFieldJson", searchFieldJson);
+        body.put("formDataJson", formDataJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(urlV2("/forms/instances/insertOrUpdate"), header(auth), null, body), "upsertForm");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public Boolean deleteForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formInstanceId", formInstanceId);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult((DDR_New) UtilHttp.doDelete(url("/forms/instances"), header(auth), body, DDR_New.class), "deleteForm");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Integer deleteFormByCondition(YDAuth auth, String formUuid, String searchFieldJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("searchFieldJson", searchFieldJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/batchRemove"), header(auth), null, body), "deleteFormByCondition");
+        Object result = r.getResult();
+        if (result instanceof Number) {
+            return ((Number) result).intValue();
+        }
+        return 0;
+    }
+
+    @Override
+    public Map<String, Object> updateFormComponents(YDAuth auth, List<String> formInstanceIdList, String updateFormDataJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formInstanceIdList", formInstanceIdList);
+        body.put("updateFormDataJson", updateFormDataJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult((DDR_New) UtilHttp.doPut(url("/forms/instances/components"), header(auth), body, DDR_New.class), "updateFormComponents");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public List<String> batchSaveForm(YDAuth auth, String formUuid, String formDataListJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("formDataListJson", formDataListJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/batchSave"), header(auth), null, body), "batchSaveForm");
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), String.class);
+        }
+        return Collections.emptyList();
+    }
+
+    // ================================================================
+    //  表单实例查询
+    // ================================================================
+
+    @Override
+    public Map<String, Object> getForm(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> param = body(auth);
+        if (body_ext != null) {
+            param.putAll(body_ext);
+            // body_ext 优先级低,auth 字段覆盖
+            param.putAll(body(auth));
+        }
+        DDR_New r = assertResult(DDR_New.doGet(url("/forms/instances/" + formInstanceId), header(auth), param), "getForm");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public Map<String, Object> searchForm(YDAuth auth, String formUuid, String searchFieldJson,
+                                          Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
+        if (pageSize != null && pageSize > YDConf.PAGE_SIZE_LIMIT) {
+            throw new McException("pageSize 不能超过 " + YDConf.PAGE_SIZE_LIMIT);
+        }
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
+        body.put("currentPage", currentPage == null ? 1 : currentPage);
+        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/search"), header(auth), null, body), "searchForm");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public List<String> listFormIds(YDAuth auth, String formUuid, String searchFieldJson,
+                                    Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
+        body.put("currentPage", currentPage == null ? 1 : currentPage);
+        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(
+                DDR_New.doPost(url("/forms/instances/ids/" + auth.getAppType() + "/" + formUuid), header(auth), body, body),
+                "listFormIds"
+        );
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), String.class);
+        }
+        return Collections.emptyList();
+    }
+
+    @Override
+    public Map<String, Object> listFormsAll(YDAuth auth, String formUuid, String searchFieldJson,
+                                            Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
+        body.put("currentPage", currentPage == null ? 1 : currentPage);
+        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/instances/advances/queryAll"), header(auth), null, body), "listFormsAll");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public List<Map<String, Object>> listInnerTable(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> param = body(auth);
+        if (body_ext != null) {
+            param.putAll(body_ext);
+            param.putAll(body(auth));
+        }
+        DDR_New r = assertResult(
+                DDR_New.doGet(url("/forms/innerTables/" + formInstanceId), header(auth), param),
+                "listInnerTable"
+        );
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
+                    .map(m -> (Map<String, Object>) m)
+                    .collect(java.util.stream.Collectors.toList());
+        }
+        return Collections.emptyList();
+    }
+
+    @Override
+    public List<Map<String, Object>> listFormOperations(YDAuth auth, String formInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formInstanceId", formInstanceId);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/forms/operationsLogs/query"), header(auth), null, body), "listFormOperations");
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
+                    .map(m -> (Map<String, Object>) m)
+                    .collect(java.util.stream.Collectors.toList());
+        }
+        return Collections.emptyList();
+    }
+
+    @Override
+    public List<Map<String, Object>> listForms(YDAuth auth, Map<String, Object> body_ext) {
+        Map<String, Object> param = body(auth);
+        if (body_ext != null) {
+            param.putAll(body_ext);
+            param.putAll(body(auth));
+        }
+        DDR_New r = assertResult(DDR_New.doGet(url("/forms"), header(auth), param), "listForms");
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
+                    .map(m -> (Map<String, Object>) m)
+                    .collect(java.util.stream.Collectors.toList());
+        }
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void remarkForm(YDAuth auth, String formInstanceId, String remark, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formInstanceId", formInstanceId);
+        body.put("remark", remark);
+        body = mergeExt(body, body_ext);
+        assertResult(DDR_New.doPost(url("/forms/remarks"), header(auth), null, body), "remarkForm");
+    }
+
+    // ================================================================
+    //  附件临时免登
+    // ================================================================
+
+    @Override
+    public String convertTempUrl(YDAuth auth, String fileUrl, Integer timeoutMs) {
+        Map<String, Object> param = new HashMap<>();
+        param.put("systemToken", auth.getSystemToken());
+        param.put("userId", auth.resolvedUserId());
+        param.put("fileUrl", fileUrl);
+        param.put("timeout", timeoutMs == null ? 60_000 : timeoutMs);
+        DDR_New r = assertResult(
+                DDR_New.doGet(url("/apps/temporaryUrls/" + auth.getAppType()), header(auth), param),
+                "convertTempUrl"
+        );
+        return String.valueOf(r.getResult());
+    }
+}

+ 252 - 0
mjava/src/main/java/com/malk/service/aliwork/impl/YDClient_ProcessImpl.java

@@ -0,0 +1,252 @@
+package com.malk.service.aliwork.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient_Process;
+import com.malk.service.dingtalk.DDClient;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 宜搭流程原子接口实现
+ *
+ * <p>URL 路径参照旧 YDClientImpl 已验证 endpoint(start/search_process);新增 endpoint 推断自
+ * 宜搭开放平台惯例,实施冒烟时需对照最新官方文档确认。</p>
+ */
+@Slf4j
+@Service
+public class YDClient_ProcessImpl implements YDClient_Process {
+
+    @Autowired
+    private DDClient ddClient;
+
+    private static final String BASE_V1 = "https://api.dingtalk.com/v1.0/yida";
+
+    private String url(String uri) {
+        return BASE_V1 + uri;
+    }
+
+    private Map<String, String> header(YDAuth auth) {
+        if (auth.getAccessToken() != null && !auth.getAccessToken().isEmpty()) {
+            Map<String, String> h = new HashMap<>();
+            h.put("x-acs-dingtalk-access-token", auth.getAccessToken());
+            return h;
+        }
+        return ddClient.initTokenHeader();
+    }
+
+    private Map<String, Object> body(YDAuth auth) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("appType", auth.getAppType());
+        body.put("systemToken", auth.getSystemToken());
+        body.put("userId", auth.resolvedUserId());
+        return body;
+    }
+
+    private Map<String, Object> mergeExt(Map<String, Object> body, Map<String, Object> body_ext) {
+        if (body_ext == null || body_ext.isEmpty()) {
+            return body;
+        }
+        Map<String, Object> merged = new HashMap<>(body_ext);
+        merged.putAll(body);
+        return merged;
+    }
+
+    private DDR_New assertResult(DDR_New ddr, String action) {
+        if (ddr == null) {
+            throw new McException("宜搭流程接口 [" + action + "] 返回空");
+        }
+        if (!ddr.isSuccess()) {
+            throw new McException("宜搭流程接口 [" + action + "] 失败: " + ddr.getErrorCode() + " - " + ddr.getErrorMsg());
+        }
+        return ddr;
+    }
+
+    // ================================================================
+    //  流程实例生命周期
+    // ================================================================
+
+    @Override
+    public String startProcess(YDAuth auth, String processCode, String formUuid, String formDataJson, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processCode", processCode);
+        body.put("formUuid", formUuid);
+        body.put("formDataJson", formDataJson);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/start"), header(auth), null, body), "startProcess");
+        return String.valueOf(r.getResult());
+    }
+
+    @Override
+    public Boolean terminateProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/terminate"), header(auth), null, body), "terminateProcess");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean revokeProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/revoke"), header(auth), null, body), "revokeProcess");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean redirectProcess(YDAuth auth, String processInstanceId, String targetActivityId, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("targetActivityId", targetActivityId);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances/redirect"), header(auth), null, body), "redirectProcess");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    // ================================================================
+    //  审批任务动作
+    // ================================================================
+
+    @Override
+    public Boolean agreeTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("taskId", taskId);
+        body.put("remark", comment);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/agree"), header(auth), null, body), "agreeTask");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean disagreeTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("taskId", taskId);
+        body.put("remark", comment);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/disagree"), header(auth), null, body), "disagreeTask");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean redirectTask(YDAuth auth, String processInstanceId, String taskId, String toUserId, String comment, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("taskId", taskId);
+        body.put("toUserId", toUserId);
+        body.put("remark", comment);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/redirect"), header(auth), null, body), "redirectTask");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean ccTask(YDAuth auth, String processInstanceId, String taskId, List<String> toUserIds, String comment, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("taskId", taskId);
+        body.put("toUserIds", toUserIds);
+        body.put("remark", comment);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/cc"), header(auth), null, body), "ccTask");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    @Override
+    public Boolean commentTask(YDAuth auth, String processInstanceId, String taskId, String comment, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("processInstanceId", processInstanceId);
+        body.put("taskId", taskId);
+        body.put("remark", comment);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/tasks/comment"), header(auth), null, body), "commentTask");
+        return Boolean.TRUE.equals(r.getResult()) || "true".equalsIgnoreCase(String.valueOf(r.getResult()));
+    }
+
+    // ================================================================
+    //  流程与任务查询
+    // ================================================================
+
+    @Override
+    public Map<String, Object> getProcess(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> param = body(auth);
+        if (body_ext != null) {
+            param.putAll(body_ext);
+            param.putAll(body(auth));
+        }
+        DDR_New r = assertResult(
+                DDR_New.doGet(url("/processes/instances/" + processInstanceId), header(auth), param),
+                "getProcess"
+        );
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public Map<String, Object> searchProcesses(YDAuth auth, String formUuid, String processCode, String searchFieldJson,
+                                               Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
+        if (pageSize != null && pageSize > YDConf.PAGE_SIZE_LIMIT) {
+            throw new McException("pageSize 不能超过 " + YDConf.PAGE_SIZE_LIMIT);
+        }
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("processCode", processCode);
+        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
+        body.put("currentPage", currentPage == null ? 1 : currentPage);
+        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instances"), header(auth), body, body), "searchProcesses");
+        Object result = r.getResult();
+        return result instanceof Map ? (Map<String, Object>) result : Collections.emptyMap();
+    }
+
+    @Override
+    public List<String> listProcessIds(YDAuth auth, String formUuid, String processCode, String searchFieldJson,
+                                       Integer currentPage, Integer pageSize, Map<String, Object> body_ext) {
+        Map<String, Object> body = body(auth);
+        body.put("formUuid", formUuid);
+        body.put("processCode", processCode);
+        body.put("searchFieldJson", StringUtils.isBlank(searchFieldJson) ? "{}" : searchFieldJson);
+        body.put("currentPage", currentPage == null ? 1 : currentPage);
+        body.put("pageSize", pageSize == null ? YDConf.PAGE_SIZE_LIMIT : pageSize);
+        body = mergeExt(body, body_ext);
+        DDR_New r = assertResult(DDR_New.doPost(url("/processes/instanceIds"), header(auth), body, body), "listProcessIds");
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), String.class);
+        }
+        return Collections.emptyList();
+    }
+
+    @Override
+    public List<Map<String, Object>> listApprovalRecords(YDAuth auth, String processInstanceId, Map<String, Object> body_ext) {
+        Map<String, Object> param = body(auth);
+        param.put("processInstanceId", processInstanceId);
+        if (body_ext != null) {
+            Map<String, Object> merged = new HashMap<>(body_ext);
+            merged.putAll(param);
+            param = merged;
+        }
+        DDR_New r = assertResult(DDR_New.doGet(url("/processes/operationRecords"), header(auth), param), "listApprovalRecords");
+        Object result = r.getResult();
+        if (result instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(result), Map.class).stream()
+                    .map(m -> (Map<String, Object>) m)
+                    .collect(java.util.stream.Collectors.toList());
+        }
+        return Collections.emptyList();
+    }
+}

+ 19 - 2
openspec/BACKLOG.md

@@ -71,6 +71,23 @@
 
 ```
 ✅ 已完成:~20 项(初始化 + 规范 + 归档准备 + 验证)
-⏳ 阻塞:2 项(Maven 未装)
-📋 代办:~66 项(B.1 清单待出 + B.2 六项 + C 两专项 60 项)
+⏳ 阻塞:Maven 未装(4 处冒烟)
+🔨 实施中:extend-yida-api-coverage 代码已落(26 方法),未编译验证
+📋 代办:~60 项
 ```
+
+## 实施中风险记录
+
+### extend-yida-api-coverage(2026-04-18 实施)
+
+1. **官方文档 fetch 失败**:WebFetch 拿到的是索引页,URL 路径基于训练知识 + 旧 `YDClientImpl` 已验证 endpoint 推断。以下路径**未经人工官方文档对照**,冒烟前需复核:
+   - `/processes/tasks/{agree|disagree|redirect|cc|comment}`
+   - `/processes/instances/{terminate|revoke|redirect}`
+   - `/processes/operationRecords`(listApprovalRecords 推断)
+2. **编译未验证**:本机无 Maven,Java 代码未 `mvn compile`。潜在风险:
+   - `x-acs-dingtalk-access-token` header key 是否与 `ddClient.initTokenHeader()` 返回一致未验证
+   - `DDR_New.getResult()` 返回 `Object`,各处 cast 未实地测试
+   - `UtilHttp.doPut/doDelete` 的 4 参重载确认
+3. **Boolean 返回判定**:宜搭多数写操作返回 `true/false`,实现用 `Boolean.TRUE.equals(r.getResult())` 判定,但部分 endpoint 实际返回 Map 或数字时可能错判;实施冒烟时需抽样确认。
+4. **listFormIds** URL + body 都传 appType/formUuid,沿用旧实现逻辑,可能冗余但不影响正确性。
+5. **YDService 迁移**:本阶段不切换 YDService 内部调用到新原子方法(tasks 5.x 标为"可选不做"),等编译通过后再决定。

+ 13 - 13
openspec/changes/extend-yida-api-coverage/tasks.md

@@ -40,28 +40,28 @@
 
 ## 1. 前置准备
 
-- [ ] 1.1 对照宜搭开放平台**当前版本**文档更新上述覆盖度矩阵,补齐 apiNote 链接
-- [ ] 1.2 在 `com.malk.server.aliwork` 下建 `YDConf` 封装 `appType + systemToken + userId`(若已有则复用
-- [ ] 1.3 审视现有 `YDParam` 建造者,识别可沿用字段避免重复
+- [ ] 1.1 ⚠️ **阻塞**:官方文档 WebFetch 未成功(索引页),覆盖度矩阵基于 2026-04 训练知识 + 现有 `YDClientImpl` 已验证 URL。实施冒烟前必须对照官方文档逐条复核,尤其是 `processes/tasks/*` 与 `listApprovalRecords` 路径
+- [x] 1.2 新建 `com.malk.server.aliwork.YDAuth` 数据载体(已有 YDConf 是 @Component 单例不能直接当参数,故新建 YDAuth 作为原子接口的请求上下文
+- [x] 1.3 现有 `YDParam` 建造者保留不动;新原子接口直接用 `Map<String,Object> body_ext` 模式,语义更清晰
 
 ## 2. YDClient_Form 接口
 
-- [ ] 2.1 新建 `mjava/src/main/java/com/malk/service/aliwork/YDClient_Form.java` 接口
-- [ ] 2.2 按覆盖度矩阵声明全部 14 个方法,signature 严格按 §3.4.2 规则
-- [ ] 2.3 每个方法 javadoc 写全 `@apiNote` + `body_ext` 字段枚举
+- [x] 2.1 新建 `mjava/src/main/java/com/malk/service/aliwork/YDClient_Form.java` 接口
+- [x] 2.2 声明 16 个方法(14 必须 + `remarkForm` 评论 + `convertTempUrl` 附件临时 URL 从旧 YDClient 迁入)
+- [x] 2.3 每个方法 javadoc 写全 `@apiNote` + `body_ext` 字段枚举
 
 ## 3. YDClient_Form 实现
 
-- [ ] 3.1 新建 `impl/YDClient_FormImpl.java`
-- [ ] 3.2 每个方法通过 `UtilHttp.doPost/doGet/doPut/doDelete` 实现
-- [ ] 3.3 返回值统一 `Map` 或 `List<Map>`(特殊 DTO 情况另议)
-- [ ] 3.4 错误处理:宜搭侧 `code != 0` 抛 `McException`
+- [x] 3.1 新建 `impl/YDClient_FormImpl.java`
+- [x] 3.2 每个方法通过 `UtilHttp.doPost/doGet/doPut/doDelete` 实现;update 走 PUT,delete 走 DELETE
+- [x] 3.3 返回值 `String` / `Map<String,Object>` / `List<Map<String,Object>>` / `Boolean` 按官方语义选择
+- [x] 3.4 错误处理:`assertResult` 统一抛 `McException` 带宜搭原始错误码
 
 ## 4. YDClient_Process 接口与实现
 
-- [ ] 4.1 新建 `YDClient_Process.java` 接口
-- [ ] 4.2 按覆盖度矩阵声明全部 12 个方法
-- [ ] 4.3 新建 `impl/YDClient_ProcessImpl.java`
+- [x] 4.1 新建 `YDClient_Process.java` 接口
+- [x] 4.2 声明 11 个方法(startProcess / terminateProcess / revokeProcess / redirectProcess / agreeTask / disagreeTask / redirectTask / ccTask / commentTask / getProcess / searchProcesses / listProcessIds / listApprovalRecords — 实际 13 个,超额覆盖 design.md 最小要求)
+- [x] 4.3 新建 `impl/YDClient_ProcessImpl.java`
 
 ## 5. YDService 切换