feat(msgcenter): 卡片直批——MsgApprovalProxyService 定位待办办 approve/reject(B3 §2):身份全取会话防代批、bizId=办件 id 匹配 instanceId、0/>1 匹配明确文案、业务拒绝原文透出、不可达降级不阻塞
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userhome.config.MsgCenterProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 卡片直批代理(方案 §2,B3):消息卡片上的「同意/拒绝」在 UserHome 服务端完成
|
||||
* 消息定位 → flow-center 待办匹配 → 办理 → 标已读 全链路。
|
||||
* <ul>
|
||||
* <li><b>红线</b>:operatorId / schId / operatorName 一律由控制器从当前 CAS 会话取
|
||||
* (BoaUser),绝不接受前端传参——防越权代批;flow-center 侧再按任务办理人逐字复核,双层防护。</li>
|
||||
* <li>taskId 定位(§2.3):我的待办(tasks/todo,assigneeId=登录人)里按 bizId 精确匹配。
|
||||
* 实测契约:flow-center 待办消息 bizId=办件 id(instanceId),故匹配键 = instanceId,
|
||||
* bizId(业务记录 id)作为通用回落;bizType 过滤键用消息 jumpApp(=流程业务类型,
|
||||
* 消息 bizType「flow.task」是消息分类,与待办的 bizType 不同命名空间,勿混)。</li>
|
||||
* <li>匹配 0 条=已处理或已失效;>1 条(同办件多出口/多节点)=请前往详情页办理(一期不做歧义猜选)。</li>
|
||||
* <li>出站走 {@link YschOpenCall} 签名直调,目标 flowcenter(服务目录/枚举兜底,
|
||||
* flowcenter-base-url 显式覆盖优先)。flow-center 不可达降级为提示,不影响消息卡片其余功能。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MsgApprovalProxyService {
|
||||
|
||||
private static final String TARGET_FLOWCENTER = "flowcenter";
|
||||
|
||||
/** 我的待办分页拉取上限:5 页 × 100 条(todo 量级远小于此,防御性封顶) */
|
||||
private static final int TODO_MAX_PAGES = 5;
|
||||
private static final int TODO_PAGE_SIZE = 100;
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final MsgCenterProxyService msgProxy;
|
||||
private final MsgCenterProperties properties;
|
||||
|
||||
public MsgApprovalProxyService(YschOpenCall openCall, MsgCenterProxyService msgProxy,
|
||||
MsgCenterProperties properties) {
|
||||
this.openCall = openCall;
|
||||
this.msgProxy = msgProxy;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理消息对应的审批待办。action=approve|reject;reject 意见必填。
|
||||
*
|
||||
* @return {ok, message}——ok=false 时 message 为可直接展示的用户文案
|
||||
*/
|
||||
public Map<String, Object> handle(Integer userId, Integer schId, String operatorName,
|
||||
Long msgId, String action, String opinion) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
boolean approve = "approve".equals(action);
|
||||
if (!approve && !"reject".equals(action)) {
|
||||
return fail(out, "不支持的操作类型");
|
||||
}
|
||||
if (!approve && !StringUtils.hasText(opinion)) {
|
||||
return fail(out, "拒绝必须填写意见");
|
||||
}
|
||||
|
||||
JsonNode msg = msgProxy.findMessage(userId, msgId);
|
||||
if (msg == null) {
|
||||
return fail(out, "消息不存在或已失效");
|
||||
}
|
||||
String bizId = text(msg, "bizId");
|
||||
if (!StringUtils.hasText(bizId)) {
|
||||
return fail(out, "该消息不含可办理的业务标识");
|
||||
}
|
||||
String flowBizType = text(msg, "jumpApp");
|
||||
|
||||
List<JsonNode> matched = matchTodoTasks(schId, userId, flowBizType, bizId.trim());
|
||||
if (matched == null) {
|
||||
// flow-center 不可达 ≠ 已处理:降级提示,不误导用户
|
||||
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||
}
|
||||
if (matched.isEmpty()) {
|
||||
return fail(out, "该待办已处理或已失效");
|
||||
}
|
||||
if (matched.size() > 1) {
|
||||
return fail(out, "同一办件匹配到多个待办节点,请前往详情页办理");
|
||||
}
|
||||
long taskId = matched.get(0).path("taskId").asLong();
|
||||
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||
query.put("operatorId", String.valueOf(userId));
|
||||
query.put("operatorName", operatorName == null ? "" : operatorName);
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("opinion", opinion);
|
||||
postFlowcenter("/oapi/flow/task/" + taskId + "/" + action, query, body);
|
||||
} catch (DException e) {
|
||||
// flow-center 业务码拒绝(非办理人/多出口需 edgeName 等)——原文透出给用户
|
||||
log.info("[msgcenter] 直批被 flow-center 业务拒绝 msgId={} taskId={}:{}", msgId, taskId, e.getMessage());
|
||||
return fail(out, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] flow-center 直批不可达(降级提示)msgId={}:{}", msgId, e.toString());
|
||||
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||
}
|
||||
|
||||
msgProxy.read(userId, java.util.Collections.singletonList(msgId));
|
||||
out.put("ok", true);
|
||||
out.put("message", "办理成功");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的待办中精确匹配目标办件:先按 flowBizType(=消息 jumpApp)过滤(未声明则不过滤),
|
||||
* 再按 instanceId(flow-center 待办消息约定)或 bizId(业务方自发消息约定)匹配。
|
||||
*
|
||||
* @return 匹配行(可能为空);flow-center 不可达返回 null(区别于「已处理」的 0 匹配)
|
||||
*/
|
||||
private List<JsonNode> matchTodoTasks(Integer schId, Integer userId, String flowBizType, String bizId) {
|
||||
List<JsonNode> matched = new ArrayList<>();
|
||||
for (int page = 1; page <= TODO_MAX_PAGES; page++) {
|
||||
List<JsonNode> content;
|
||||
try {
|
||||
content = fetchTodoPage(schId, userId, flowBizType, page);
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] flow-center 待办查询不可达 msgId 定位失败:{}", e.toString());
|
||||
return null;
|
||||
}
|
||||
for (JsonNode task : content) {
|
||||
String taskBizType = text(task, "bizType");
|
||||
if (StringUtils.hasText(flowBizType) && !flowBizType.equals(taskBizType)) {
|
||||
continue;
|
||||
}
|
||||
String instanceId = task.has("instanceId") && task.get("instanceId").isNumber()
|
||||
? String.valueOf(task.get("instanceId").asLong()) : null;
|
||||
if (bizId.equals(instanceId) || bizId.equals(text(task, "bizId"))) {
|
||||
matched.add(task);
|
||||
}
|
||||
}
|
||||
if (content.size() < TODO_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
private List<JsonNode> fetchTodoPage(Integer schId, Integer userId, String flowBizType, int page) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||
query.put("assigneeId", String.valueOf(userId));
|
||||
if (StringUtils.hasText(flowBizType)) {
|
||||
query.put("bizType", flowBizType);
|
||||
}
|
||||
query.put("page", String.valueOf(page));
|
||||
query.put("size", String.valueOf(TODO_PAGE_SIZE));
|
||||
JsonNode data = getFlowcenter("/oapi/flow/tasks/todo", query);
|
||||
if (data != null && data.path("content").isArray()) {
|
||||
List<JsonNode> rows = new ArrayList<>();
|
||||
data.get("content").forEach(rows::add);
|
||||
return rows;
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 显式 base-url → ad-hoc TargetSpec(对齐 usermsg 通道形态);否则按名走服务目录/枚举兜底 */
|
||||
private JsonNode getFlowcenter(String path, Map<String, String> query) {
|
||||
String base = properties.getFlowcenterBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
|
||||
}
|
||||
return openCall.get(TARGET_FLOWCENTER, path, query, JsonNode.class);
|
||||
}
|
||||
|
||||
private JsonNode postFlowcenter(String path, Map<String, String> query, Object body) {
|
||||
String base = properties.getFlowcenterBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
|
||||
}
|
||||
return openCall.post(TARGET_FLOWCENTER, path, query, body, JsonNode.class);
|
||||
}
|
||||
|
||||
private static Map<String, Object> fail(Map<String, Object> out, String message) {
|
||||
out.put("ok", false);
|
||||
out.put("message", message);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user