瀏覽代碼

待办待阅对接

yuanzhi_kuang 1 天之前
父節點
當前提交
dc2982af62

+ 30 - 21
src/com/kingdee/eas/custom/weaverTodo/WeaverMessageWebServiceDao.java

@@ -8,25 +8,24 @@ import com.kingdee.eas.base.message.webservice.WfrAssignMessage;
 import com.kingdee.eas.base.message.webservice.WfrProcMessage;
 import com.kingdee.eas.custom.weaverTodo.client.WeaverOpenApiTokenManager;
 import com.kingdee.eas.custom.weaverTodo.client.WeaverReceiveTodoClient;
+import com.kingdee.eas.custom.weaverTodo.client.WeaverSendMsgClient;
 import com.kingdee.eas.custom.weaverTodo.config.WeaverTodoConfig;
 import com.kingdee.eas.custom.weaverTodo.server.WeaverToBeReadMessageServer;
 import com.kingdee.eas.custom.weaverTodo.server.WeaverTodoMessageServer;
 import com.kingdee.eas.custom.weaverTodo.util.WeaverPushTrace;
 import com.kingdee.eas.custom.weaverTodo.util.WeaverTodoSupport;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.log4j.Logger;
 
 /**
- * 平台 MessageWebServiceDao 实现:待办/待阅推送到泛微统一待办中心
+ * 平台 MessageWebServiceDao:流程待办走统一待办;BMC 通知走消息中心 sendMsg
  *
  * 平台触发(据 bs_message-server MCInvoke + WfMsgToOtherSys):
- * - AssignReadListener 新建任务 -> wsImplRead -> addMessage
- * - 任务完成/取消 setState -> pushAssign4OtherSys -> wsImplReadCplAssign -> addCompletedMessage
- * - 任务删除/撤销列表 -> wsImplRemoveAss -> removeMessage
- * - BMC 删除 -> wsImplRemoveBMC -> removeMessage
- * - 流程实例推送 -> wsImplReadMyProcInfo -> addInitiatorMessage
- * - 单条更新 -> wsImplUpdate -> updateMessage
- * - 批量已读/未读 -> wsImplUpdateMulti -> updateMessages
- * - BMC 新建钩子(BeanEx) -> updateMessages(UNREADED) -> 待阅 isremark=8
+ * - AssignReadListener 新建任务 -> addMessage(任务)
+ * - 任务完成/取消 -> addCompletedMessage
+ * - 任务删除 -> removeMessage
+ * - BMC 新建钩子 -> updateMessages(UNREADED) -> sendMsg
+ * - addMessage 且 type=通知 -> 跳过(避免再推 isremark=0)
  *
  * coyle
  * 20260714
@@ -51,12 +50,14 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
             try {
                 WeaverTodoSupport support = new WeaverTodoSupport(latest);
                 WeaverOpenApiTokenManager tokenManager = new WeaverOpenApiTokenManager(latest);
-                WeaverReceiveTodoClient client = new WeaverReceiveTodoClient(latest, tokenManager);
-                this.todoServer = new WeaverTodoMessageServer(latest, support, client);
-                this.toBeReadServer = new WeaverToBeReadMessageServer(latest, support, client);
+                WeaverReceiveTodoClient todoClient = new WeaverReceiveTodoClient(latest, tokenManager);
+                WeaverSendMsgClient sendMsgClient = new WeaverSendMsgClient(latest, tokenManager);
+                this.todoServer = new WeaverTodoMessageServer(latest, support, todoClient);
+                this.toBeReadServer = new WeaverToBeReadMessageServer(latest, support, sendMsgClient);
                 this.config = latest;
                 logger.info("[WEAVER-TODO] Dao 初始化完成 enabled=" + latest.isEnabled()
-                        + " syscode=" + latest.getSyscode());
+                        + " syscode=" + latest.getSyscode()
+                        + " sendMsg=" + latest.getSendMsgPath());
             } catch (Exception e) {
                 logger.error("[WEAVER-TODO] Dao 初始化失败,推送禁用", e);
                 this.config = null;
@@ -77,11 +78,19 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
         return true;
     }
 
+    private static boolean isNotifyType(WSMessage message) {
+        if (message == null || message.getType() == null) {
+            return false;
+        }
+        String alias = message.getType().getAlias();
+        return StringUtils.isNotBlank(alias) && alias.contains("通知");
+    }
+
     @Override
     public boolean addMessage(WSMessage message) {
         String traceId = WeaverPushTrace.nextId();
         logger.info("[WEAVER-TODO][" + traceId + "] SPI=addMessage ENTER"
-                + " tip=通常=工作流新任务/部分通知新建"
+                + " tip=通常=工作流新任务;通知类应跳过"
                 + " msgId=" + (message == null ? null : message.getMsgID())
                 + " procInstId=" + (message == null ? null : message.getProcInstId())
                 + " billId=" + (message == null ? null : message.getBillId())
@@ -98,6 +107,10 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
         if (!guardEnabled("addMessage", traceId)) {
             return false;
         }
+        if (isNotifyType(message)) {
+            logger.info("[WEAVER-TODO][" + traceId + "] SPI=addMessage 跳过:type=通知,改由 BMC updateMessages->sendMsg");
+            return true;
+        }
         boolean ok = todoServer.addMessage(message, traceId);
         logger.info("[WEAVER-TODO][" + traceId + "] SPI=addMessage EXIT ok=" + ok);
         return ok;
@@ -156,8 +169,6 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
                 + " assignId=" + assignId
                 + " expectIsremark=-1(删除)"
                 + " caller=" + WeaverPushTrace.callerHint());
-        // 不再直接调有 ctx 的重载以免二次生成不同 trace;委托并让有 ctx 版再打一轮会混淆
-        // 这里直接进业务,复用本 traceId
         if (!guardEnabled("removeMessage(noCtx)", traceId)) {
             return false;
         }
@@ -170,12 +181,11 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
     public boolean updateMessages(String id, MsgStatus msgStatus, Context ctx) {
         String traceId = WeaverPushTrace.nextId();
         logger.info("[WEAVER-TODO][" + traceId + "] SPI=updateMessages(ctx) ENTER"
-                + " tip=通常=消息中心设已读/未读;BMC新建钩子也会 UNREADED"
+                + " tip=BMC新建未读走 sendMsg"
                 + " id=" + id
                 + " msgStatus=" + (msgStatus == null ? null : msgStatus.getAlias())
                 + " msgStatusValue=" + (msgStatus == null ? null : String.valueOf(msgStatus.getValue()))
                 + " hasCtx=" + (ctx != null)
-                + " expectIsremark=8(待阅) viewtype随已读/未读"
                 + " caller=" + WeaverPushTrace.callerHint());
         if (!guardEnabled("updateMessages(ctx)", traceId)) {
             return false;
@@ -192,7 +202,6 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
                 + " tip=无Context重载,内部转 updateMessages(ctx=null)"
                 + " id=" + id
                 + " msgStatus=" + (msgStatus == null ? null : msgStatus.getAlias())
-                + " expectIsremark=8(待阅)"
                 + " caller=" + WeaverPushTrace.callerHint());
         if (!guardEnabled("updateMessages(noCtx)", traceId)) {
             return false;
@@ -206,7 +215,7 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
     public boolean updateMessage(String id) {
         String traceId = WeaverPushTrace.nextId();
         logger.info("[WEAVER-TODO][" + traceId + "] SPI=updateMessage(noCtx) ENTER"
-                + " tip=平台 wsImplUpdate 单条更新本实现暂不推泛微"
+                + " tip=平台 wsImplUpdate 单条更新本实现暂不推泛微"
                 + " id=" + id
                 + " action=NOOP return=false"
                 + " caller=" + WeaverPushTrace.callerHint());
@@ -217,7 +226,7 @@ public class WeaverMessageWebServiceDao implements MessageWebServiceDao {
     public boolean updateMessage(String id, Context context) {
         String traceId = WeaverPushTrace.nextId();
         logger.info("[WEAVER-TODO][" + traceId + "] SPI=updateMessage(ctx) ENTER"
-                + " tip=平台 wsImplUpdate 单条更新本实现暂不推泛微"
+                + " tip=平台 wsImplUpdate 单条更新本实现暂不推泛微"
                 + " id=" + id
                 + " hasCtx=" + (context != null)
                 + " action=NOOP return=false"

+ 89 - 27
src/com/kingdee/eas/custom/weaverTodo/client/WeaverOpenApiTokenManager.java

@@ -10,45 +10,99 @@ import java.util.Map;
 
 /**
  * 泛微开放平台 access_token(与用户 SSO oauth 凭证分离)。
+ * token 按 host+corpid+appKey 做 JVM 级缓存,避免每次 new Dao 都重新 authorize。
  */
 public class WeaverOpenApiTokenManager {
     private static final Logger logger = Logger.getLogger(WeaverOpenApiTokenManager.class);
 
-    private final WeaverTodoConfig config;
+    private static final Object TOKEN_LOCK = new Object();
+    private static volatile String cachedKey;
+    private static volatile String cachedToken;
+    private static volatile long cachedExpireAtMs;
+
+    private static final int AUTH_RETRY_TIMES = 3;
+    private static final long AUTH_RETRY_BASE_MS = 800L;
 
-    private volatile String accessToken;
-    private volatile long expireAtMs;
+    private final WeaverTodoConfig config;
 
     public WeaverOpenApiTokenManager(WeaverTodoConfig config) {
         this.config = config;
     }
 
-    public synchronized String getAccessToken() throws Exception {
-        if (StringUtils.isNotBlank(accessToken) && System.currentTimeMillis() < expireAtMs - 60_000L) {
-            logger.info("OpenAPI token 缓存命中, leftMs=" + (expireAtMs - System.currentTimeMillis()));
-            return accessToken;
+    public String getAccessToken() throws Exception {
+        String key = cacheKey();
+        long now = System.currentTimeMillis();
+        if (key.equals(cachedKey) && StringUtils.isNotBlank(cachedToken) && now < cachedExpireAtMs - 60_000L) {
+            logger.info("OpenAPI token 缓存命中(JVM), leftMs=" + (cachedExpireAtMs - now));
+            return cachedToken;
         }
-        logger.info("OpenAPI token 开始刷新 host=" + config.getOpenapiHost()
-                + " authorizePath=" + config.getAuthorizePath()
-                + " tokenPath=" + config.getAccessTokenPath()
-                + " corpid=" + config.getCorpid()
-                + " app_key=" + config.getAppKey());
-        String code = fetchAuthCode();
-        logger.info("OpenAPI authorize 取得 code, len=" + (code == null ? 0 : code.length()));
-        JSONObject tokenBody = exchangeToken(code);
-        String token = firstNonBlank(tokenBody.getString("accessToken"), tokenBody.getString("acessToken"));
-        if (StringUtils.isBlank(token)) {
-            throw new IllegalStateException("获取 accessToken 失败: " + tokenBody);
+        synchronized (TOKEN_LOCK) {
+            now = System.currentTimeMillis();
+            if (key.equals(cachedKey) && StringUtils.isNotBlank(cachedToken) && now < cachedExpireAtMs - 60_000L) {
+                logger.info("OpenAPI token 缓存命中(JVM), leftMs=" + (cachedExpireAtMs - now));
+                return cachedToken;
+            }
+            logger.info("OpenAPI token 开始刷新 host=" + config.getOpenapiHost()
+                    + " authorizePath=" + config.getAuthorizePath()
+                    + " tokenPath=" + config.getAccessTokenPath()
+                    + " corpid=" + config.getCorpid()
+                    + " app_key=" + config.getAppKey());
+            String code = fetchAuthCodeWithRetry();
+            logger.info("OpenAPI authorize 取得 code, len=" + (code == null ? 0 : code.length()));
+            JSONObject tokenBody = exchangeToken(code);
+            String token = firstNonBlank(tokenBody.getString("accessToken"), tokenBody.getString("acessToken"));
+            if (StringUtils.isBlank(token)) {
+                throw new IllegalStateException("获取 accessToken 失败: " + tokenBody);
+            }
+            int expiresIn = tokenBody.getIntValue("expires_in");
+            if (expiresIn <= 0) {
+                expiresIn = 7200;
+            }
+            cachedKey = key;
+            cachedToken = token;
+            cachedExpireAtMs = System.currentTimeMillis() + expiresIn * 1000L;
+            logger.info("Weaver OpenAPI accessToken 刷新成功, expiresIn=" + expiresIn
+                    + " tokenPrefix=" + token.substring(0, Math.min(8, token.length())) + "...");
+            return cachedToken;
         }
-        int expiresIn = tokenBody.getIntValue("expires_in");
-        if (expiresIn <= 0) {
-            expiresIn = 7200;
+    }
+
+    private String cacheKey() {
+        return StringUtils.defaultString(config.getOpenapiHost())
+                + "|" + StringUtils.defaultString(config.getCorpid())
+                + "|" + StringUtils.defaultString(config.getAppKey());
+    }
+
+    private String fetchAuthCodeWithRetry() throws Exception {
+        Exception last = null;
+        for (int i = 1; i <= AUTH_RETRY_TIMES; i++) {
+            try {
+                return fetchAuthCode();
+            } catch (Exception e) {
+                last = e;
+                String msg = e.getMessage() == null ? "" : e.getMessage();
+                if (!isRetryableAuthFailure(msg) || i >= AUTH_RETRY_TIMES) {
+                    throw e;
+                }
+                long sleepMs = AUTH_RETRY_BASE_MS * i;
+                logger.warn("OpenAPI authorize 可重试失败 attempt=" + i + "/" + AUTH_RETRY_TIMES
+                        + " sleepMs=" + sleepMs + " err=" + msg);
+                try {
+                    Thread.sleep(sleepMs);
+                } catch (InterruptedException ie) {
+                    Thread.currentThread().interrupt();
+                    throw e;
+                }
+            }
         }
-        this.accessToken = token;
-        this.expireAtMs = System.currentTimeMillis() + expiresIn * 1000L;
-        logger.info("Weaver OpenAPI accessToken 刷新成功, expiresIn=" + expiresIn
-                + " tokenPrefix=" + token.substring(0, Math.min(8, token.length())) + "...");
-        return this.accessToken;
+        throw last != null ? last : new IllegalStateException("authorize 失败");
+    }
+
+    private static boolean isRetryableAuthFailure(String msg) {
+        return msg.contains("DegradeException")
+                || msg.contains("SentinelBlockException")
+                || msg.contains("\"errcode\":\"-10\"")
+                || msg.contains("\"errcode\":-10");
     }
 
     private String fetchAuthCode() throws Exception {
@@ -59,10 +113,18 @@ public class WeaverOpenApiTokenManager {
         String body = WeaverHttpHelper.get(url, 15000, 30000);
         assertJsonApiBody("authorize", body);
         JSONObject json = JSONObject.parseObject(body);
-        if (!"0".equals(String.valueOf(json.get("errcode")))) {
+        // 兼容 {errcode:0} 与 {message:{errcode:-10}}
+        Object err = json.get("errcode");
+        if (err == null && json.getJSONObject("message") != null) {
+            err = json.getJSONObject("message").get("errcode");
+        }
+        if (err != null && !"0".equals(String.valueOf(err))) {
             throw new IllegalStateException("authorize 业务失败: " + body);
         }
         String code = json.getString("code");
+        if (StringUtils.isBlank(code) && json.getJSONObject("message") != null) {
+            code = json.getJSONObject("message").getString("code");
+        }
         if (StringUtils.isBlank(code)) {
             throw new IllegalStateException("authorize 未返回 code: " + body);
         }

+ 69 - 0
src/com/kingdee/eas/custom/weaverTodo/client/WeaverSendMsgClient.java

@@ -0,0 +1,69 @@
+package com.kingdee.eas.custom.weaverTodo.client;
+
+import com.alibaba.fastjson.JSONObject;
+import com.kingdee.eas.custom.weaverTodo.config.WeaverTodoConfig;
+import org.apache.log4j.Logger;
+
+/**
+ * 调用 E10 消息中心 sendMsg,用于 BMC 通知(非统一待办)。
+ */
+public class WeaverSendMsgClient {
+    private static final Logger logger = Logger.getLogger(WeaverSendMsgClient.class);
+
+    private final WeaverTodoConfig config;
+    private final WeaverOpenApiTokenManager tokenManager;
+
+    public WeaverSendMsgClient(WeaverTodoConfig config, WeaverOpenApiTokenManager tokenManager) {
+        this.config = config;
+        this.tokenManager = tokenManager;
+    }
+
+    /**
+     * 调用 sendMsg,返回对方原始响应 JSON(写入 OAMsgCallLog.ResponseResult)。
+     */
+    public String push(JSONObject payload, String traceId) throws Exception {
+        String tid = traceId == null ? "-" : traceId;
+        logger.info("[WEAVER-TOREAD][" + tid + "] HTTP sendMsg BEGIN title="
+                + payload.getString("title")
+                + " eventId=" + payload.get("eventId")
+                + " moduleId=" + payload.get("moduleId"));
+        String token = tokenManager.getAccessToken();
+        String url = config.getOpenapiHost() + config.getSendMsgPath() + "?access_token=" + token;
+        String bodyStr = payload.toJSONString();
+        logger.info("[WEAVER-TOREAD][" + tid + "] HTTP sendMsg URL(host+path)="
+                + config.getOpenapiHost() + config.getSendMsgPath()
+                + " body=" + bodyStr);
+        String resp = WeaverHttpHelper.postJson(url, bodyStr, 15000, 45000);
+        boolean ok = isSuccess(resp);
+        logger.info("[WEAVER-TOREAD][" + tid + "] HTTP sendMsg END ok=" + ok + " resp=" + resp);
+        if (!ok) {
+            logger.warn("[WEAVER-TOREAD][" + tid + "] HTTP sendMsg FAILED");
+        }
+        return resp == null ? "" : resp;
+    }
+
+    public static boolean isSuccess(String resp) {
+        try {
+            JSONObject root = JSONObject.parseObject(resp);
+            if (root == null) {
+                return false;
+            }
+            Object code = root.get("code");
+            if (code == null || !"200".equals(String.valueOf(code))) {
+                return false;
+            }
+            Object status = root.get("status");
+            if (status == null) {
+                return true;
+            }
+            if (status instanceof Boolean) {
+                return ((Boolean) status).booleanValue();
+            }
+            String s = String.valueOf(status);
+            return "true".equalsIgnoreCase(s) || "1".equals(s) || "成功".equals(s);
+        } catch (Exception e) {
+            logger.error("[WEAVER-TOREAD] 解析 sendMsg 响应失败", e);
+            return false;
+        }
+    }
+}

+ 74 - 5
src/com/kingdee/eas/custom/weaverTodo/config/WeaverTodoConfig.java

@@ -11,9 +11,8 @@ import java.util.Properties;
 
 /**
  * 从 EAS_HOME/server/properties/baoye/weaverTodoConfig.properties 加载。
- * 与 SSO 的 oauth apps 分开;此处放 OpenAPI 的 corpid/app_key 等。
- * 走 E10 统一待办 OpenAPI(通常 /papi/openapi)。
- * getInstance 按文件 lastModified 热加载,改 properties 后无需重启。
+ * 待办走 receiveRequestInfo;通知走消息中心 sendMsg。二者共用 OpenAPI host/token。
+ * getInstance 按文件 lastModified 热加载。
  */
 public class WeaverTodoConfig {
     private static final Logger logger = Logger.getLogger(WeaverTodoConfig.class);
@@ -53,6 +52,7 @@ public class WeaverTodoConfig {
     private final String authorizePath;
     private final String accessTokenPath;
     private final String receivePath;
+    private final String sendMsgPath;
     private final String corpid;
     private final String appKey;
     private final String appSecret;
@@ -64,6 +64,12 @@ public class WeaverTodoConfig {
     private final String approvePcPath;
     private final boolean enabled;
     private final int wfDelayMs;
+    private final String notifyTenantKey;
+    private final int notifyModuleId;
+    private final int notifyEventId;
+    private final String notifySenderName;
+    private final String notifySenderWorkCode;
+    private final int[] notifyChannels;
 
     private WeaverTodoConfig(String path, long mtime) {
         Properties p = new Properties();
@@ -82,13 +88,14 @@ public class WeaverTodoConfig {
         }
         boolean wantEnable = loaded && !"false".equalsIgnoreCase(p.getProperty("weaver.todo.enabled", "true"));
         this.openapiHost = trimSlash(p.getProperty("weaver.openapi.host", ""));
-        // 须使用 /papi/openapi/...;勿配成 /oauth2/authorize 一类门户页,否则会拿到 HTML
         this.authorizePath = normalizePath(p.getProperty("weaver.openapi.authorizePath",
                 "/papi/openapi/oauth2/authorize"));
         this.accessTokenPath = normalizePath(p.getProperty("weaver.openapi.accessTokenPath",
                 "/papi/openapi/oauth2/access_token"));
         this.receivePath = normalizePath(p.getProperty("weaver.openapi.receivePath",
                 "/papi/openapi/api/open_intunifytodo/server_engine/receiveRequestInfo"));
+        this.sendMsgPath = normalizePath(p.getProperty("weaver.openapi.sendMsgPath",
+                "/papi/openapi/api/mc/msg/sendMsg"));
         this.corpid = p.getProperty("weaver.corpid", "").trim();
         this.appKey = p.getProperty("weaver.app_key", "").trim();
         this.appSecret = p.getProperty("weaver.app_secret", "").trim();
@@ -100,12 +107,22 @@ public class WeaverTodoConfig {
         this.approvePcPath = p.getProperty("weaver.path.pc.approve",
                 "/easweb/webviews/workflow/transferApprove.jsp").trim();
         this.wfDelayMs = parseInt(p.getProperty("weaver.wf.delayMs", "3000"), 3000);
+        // 通知固定参数以 properties 为准;缺省按生产。测试请配 moduleId=139141 eventId=287602
+        this.notifyTenantKey = p.getProperty("weaver.notify.tenantKey", "t6srq5l4vh").trim();
+        this.notifyModuleId = parseInt(p.getProperty("weaver.notify.moduleId", "100011"), 100011);
+        this.notifyEventId = parseInt(p.getProperty("weaver.notify.eventId", "241221"), 241221);
+        this.notifySenderName = p.getProperty("weaver.notify.senderName", "SHR").trim();
+        this.notifySenderWorkCode = p.getProperty("weaver.notify.senderWorkCode", "").trim();
+        this.notifyChannels = parseChannels(p.getProperty("weaver.notify.channels", "1"));
         logger.info("OpenAPI paths host=" + openapiHost
                 + " authorize=" + authorizePath
                 + " token=" + accessTokenPath
                 + " receive=" + receivePath
+                + " sendMsg=" + sendMsgPath
                 + " syscode=" + syscode
-                + " ssoEntryPath=" + ssoEntryPath);
+                + " notifyTenant=" + notifyTenantKey
+                + " moduleId=" + notifyModuleId
+                + " eventId=" + notifyEventId);
         if (wantEnable && StringUtils.isAnyBlank(openapiHost, corpid, appKey, appSecret, syscode, shrServerName, ssoAppId)) {
             logger.error("weaverTodoConfig 必填项缺失,推送已关闭");
             this.enabled = false;
@@ -114,6 +131,30 @@ public class WeaverTodoConfig {
         }
     }
 
+    private static int[] parseChannels(String s) {
+        if (s == null || s.trim().isEmpty()) {
+            return new int[]{1};
+        }
+        String[] parts = s.split(",");
+        int[] arr = new int[parts.length];
+        int n = 0;
+        for (int i = 0; i < parts.length; i++) {
+            try {
+                arr[n++] = Integer.parseInt(parts[i].trim());
+            } catch (Exception ignored) {
+            }
+        }
+        if (n == 0) {
+            return new int[]{1};
+        }
+        if (n == arr.length) {
+            return arr;
+        }
+        int[] out = new int[n];
+        System.arraycopy(arr, 0, out, 0, n);
+        return out;
+    }
+
     private static String trimSlash(String s) {
         if (s == null) {
             return "";
@@ -167,6 +208,10 @@ public class WeaverTodoConfig {
         return receivePath;
     }
 
+    public String getSendMsgPath() {
+        return sendMsgPath;
+    }
+
     public String getCorpid() {
         return corpid;
     }
@@ -202,4 +247,28 @@ public class WeaverTodoConfig {
     public int getWfDelayMs() {
         return wfDelayMs;
     }
+
+    public String getNotifyTenantKey() {
+        return notifyTenantKey;
+    }
+
+    public int getNotifyModuleId() {
+        return notifyModuleId;
+    }
+
+    public int getNotifyEventId() {
+        return notifyEventId;
+    }
+
+    public String getNotifySenderName() {
+        return notifySenderName;
+    }
+
+    public String getNotifySenderWorkCode() {
+        return notifySenderWorkCode;
+    }
+
+    public int[] getNotifyChannels() {
+        return notifyChannels == null ? new int[]{1} : notifyChannels.clone();
+    }
 }

+ 2 - 1
src/com/kingdee/eas/custom/weaverTodo/enums/WeaverCallOperTypeEnum.java

@@ -11,7 +11,8 @@ public enum WeaverCallOperTypeEnum {
     TODO_DELETE("待办删除"),
     TO_BE_READ_UNREAD("待阅未读"),
     TO_BE_READ_READ("待阅已读"),
-    TO_BE_READ("待阅");
+    TO_BE_READ("待阅"),
+    NOTIFY_SEND("通知发送");
 
     private final String label;
 

+ 112 - 37
src/com/kingdee/eas/custom/weaverTodo/server/WeaverToBeReadMessageServer.java

@@ -1,5 +1,6 @@
 package com.kingdee.eas.custom.weaverTodo.server;
 
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.kingdee.bos.Context;
 import com.kingdee.bos.dao.ormapping.ObjectUuidPK;
@@ -8,12 +9,10 @@ import com.kingdee.eas.base.message.BMCMessageFactory;
 import com.kingdee.eas.base.message.BMCMessageInfo;
 import com.kingdee.eas.base.message.IBMCMessage;
 import com.kingdee.eas.base.message.MsgStatus;
-import com.kingdee.eas.custom.weaverTodo.client.WeaverReceiveTodoClient;
+import com.kingdee.eas.base.permission.UserInfo;
+import com.kingdee.eas.custom.weaverTodo.client.WeaverSendMsgClient;
 import com.kingdee.eas.custom.weaverTodo.config.WeaverTodoConfig;
 import com.kingdee.eas.custom.weaverTodo.enums.WeaverCallOperTypeEnum;
-import com.kingdee.eas.custom.weaverTodo.enums.WeaverIsRemarkEnum;
-import com.kingdee.eas.custom.weaverTodo.enums.WeaverViewTypeEnum;
-import com.kingdee.eas.custom.weaverTodo.util.WeaverPushTrace;
 import com.kingdee.eas.custom.weaverTodo.util.WeaverTodoSupport;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.log4j.Logger;
@@ -22,7 +21,9 @@ import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 
 /**
- * 待阅/抄送:BMC 通知 bosType=41C9B39B,映射 isremark=8。
+ * BMC 通知:走消息中心 sendMsg,不再推统一待办 isremark=8。
+ * 只发送,不做已读回写;无 BMC url 时不传 entity.pcUrl。
+ * 发送人:getSenderId 查用户 number/name;正文 getBody;人员只传 workCode。
  */
 public class WeaverToBeReadMessageServer {
     private static final Logger logger = Logger.getLogger(WeaverToBeReadMessageServer.class);
@@ -30,63 +31,137 @@ public class WeaverToBeReadMessageServer {
 
     private final WeaverTodoConfig config;
     private final WeaverTodoSupport support;
-    private final WeaverReceiveTodoClient client;
+    private final WeaverSendMsgClient sendMsgClient;
 
     public WeaverToBeReadMessageServer(WeaverTodoConfig config,
                                        WeaverTodoSupport support,
-                                       WeaverReceiveTodoClient client) {
+                                       WeaverSendMsgClient sendMsgClient) {
         this.config = config;
         this.support = support;
-        this.client = client;
+        this.sendMsgClient = sendMsgClient;
     }
 
     public boolean handleToBeReadMessage(String id, MsgStatus msgStatus, Context ctx, String traceId) {
         String tid = StringUtils.isBlank(traceId) ? "-" : traceId;
-        logger.info("[WEAVER-TODO][" + tid + "] 业务=待阅 BEGIN id=" + id
-                + " status=" + (msgStatus == null ? null : msgStatus.getAlias())
-                + " targetIsremark=8");
+        logger.info("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg BEGIN id=" + id
+                + " status=" + (msgStatus == null ? null : msgStatus.getAlias()));
         try {
+            if (MsgStatus.READED.equals(msgStatus)) {
+                logger.info("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 跳过:已读不回写泛微");
+                return true;
+            }
             BOSUuid bosId = BOSUuid.read(id);
             String bosType = bosId.getType().toString();
             if (!BMC_BOS_TYPE.equalsIgnoreCase(bosType)) {
-                logger.info("[WEAVER-TODO][" + tid + "] 业务=待阅 跳过:非BMC bosType=" + bosType);
+                logger.info("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 跳过:非BMC bosType=" + bosType);
                 return false;
             }
             IBMCMessage msgIns = BMCMessageFactory.getLocalInstance(ctx);
             BMCMessageInfo msgInfo = msgIns.getBMCMessageInfo(new ObjectUuidPK(id));
             String receiver = support.resolveReceiverWorkcode(ctx, msgInfo.getReceiver());
             if (StringUtils.isBlank(receiver)) {
-                logger.error("[WEAVER-TODO][" + tid + "] 业务=待阅 放弃:接收人工号为空 id=" + id);
+                logger.error("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 放弃:接收人工号为空 id=" + id);
+                return false;
+            }
+            String title = support.sanitize(StringUtils.defaultIfBlank(msgInfo.getTitle(), "SHR通知"));
+            // 正文:BMCMessageInfo.getBody();空则回退标题
+            String text = support.sanitize(StringUtils.defaultIfBlank(msgInfo.getBody(), title));
+            String senderId = msgInfo.getSenderId();
+            UserInfo senderUser = trySenderKey(ctx, senderId);
+            String senderWorkCode = senderUser == null ? "" : StringUtils.trimToEmpty(senderUser.getNumber());
+            String senderName = senderUser == null ? "" : StringUtils.trimToEmpty(senderUser.getName());
+            if (StringUtils.isBlank(senderWorkCode)) {
+                logger.error("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 放弃:发送人工号为空 senderId="
+                        + senderId + " id=" + id);
                 return false;
             }
-            String title = StringUtils.defaultIfBlank(msgInfo.getTitle(), "SHR通知");
-            WeaverViewTypeEnum viewType = MsgStatus.READED.equals(msgStatus)
-                    ? WeaverViewTypeEnum.READ : WeaverViewTypeEnum.UNREAD;
-            WeaverCallOperTypeEnum operType = WeaverViewTypeEnum.READ.equals(viewType)
-                    ? WeaverCallOperTypeEnum.TO_BE_READ_READ
-                    : WeaverCallOperTypeEnum.TO_BE_READ_UNREAD;
-            String msgCenter = config.getShrServerName()
-                    + "/shr/dynamic.do?uipk=shr.workflow.view&inFrame=true&target=allMessageConfig";
-            String charset = StandardCharsets.UTF_8.name();
-            String pcurl = "/qy/auth/weaver/app.do?appId=" + URLEncoder.encode(config.getSsoAppId(), charset)
-                    + "&redirect=" + URLEncoder.encode(msgCenter, charset);
-            JSONObject payload = support.buildPayload(
-                    id, title, "SHR通知", "通知", receiver, "",
-                    pcurl, pcurl, WeaverIsRemarkEnum.CC, viewType, null);
-            logger.info("[WEAVER-TODO][" + tid + "] 业务=待阅 即将推送 "
-                    + WeaverPushTrace.summarize(payload));
-            boolean ok = client.push(payload, tid);
-            support.addCallLog(ctx, id, "", "", operType,
-                    payload.toJSONString(), String.valueOf(ok), ok ? 1 : 0,
-                    "traceId=" + tid + " isremark=8 " + (ok ? "成功" : "失败"));
-            logger.info("[WEAVER-TODO][" + tid + "] 业务=待阅 END ok=" + ok
-                    + " " + WeaverPushTrace.summarize(payload));
+            if (StringUtils.isBlank(senderName)) {
+                senderName = senderWorkCode;
+            }
+
+            JSONObject payload = buildSendMsgPayload(id, title, text, receiver, senderWorkCode, senderName, msgInfo.getUrl());
+            logger.info("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 即将推送 title=" + title
+                    + " receiver=" + receiver + " sender=" + senderWorkCode);
+            String resp = sendMsgClient.push(payload, tid);
+            boolean ok = WeaverSendMsgClient.isSuccess(resp);
+            support.addCallLog(ctx, id, "", "", WeaverCallOperTypeEnum.NOTIFY_SEND,
+                    support.buildSendMsgUrl(),
+                    payload.toJSONString(), resp, ok ? 1 : 0,
+                    "traceId=" + tid + " sendMsg " + (ok ? "成功" : "失败"));
+            logger.info("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg END ok=" + ok);
             return ok;
         } catch (Exception e) {
-            logger.error("[WEAVER-TODO][" + tid + "] 业务=待阅 异常 id=" + id, e);
-            support.addCallLog(ctx, id, "", "", WeaverCallOperTypeEnum.TO_BE_READ,
+            logger.error("[WEAVER-TOREAD][" + tid + "] 业务=通知sendMsg 异常 id=" + id, e);
+            support.addCallLog(ctx, id, "", "", WeaverCallOperTypeEnum.NOTIFY_SEND,
+                    support.buildSendMsgUrl(),
                     "", e.getMessage(), 0, "traceId=" + tid + " " + e.getMessage());
             return false;
         }
     }
+
+    /**
+     * 用 BMC 发送人 id 查用户,取 number / name。
+     */
+    private UserInfo trySenderKey(Context ctx, String senderId) {
+        return support.getUserById(ctx, senderId);
+    }
+
+    private JSONObject buildSendMsgPayload(String msgId,
+                                           String title,
+                                           String text,
+                                           String receiverWorkCode,
+                                           String senderWorkCode,
+                                           String senderName,
+                                           String bmcUrl) throws Exception {
+        JSONObject body = new JSONObject();
+        body.put("title", title);
+        body.put("text", StringUtils.defaultIfBlank(text, title));
+        body.put("eventId", config.getNotifyEventId());
+        body.put("moduleId", config.getNotifyModuleId());
+        body.put("todo", Boolean.FALSE);
+
+        JSONObject sender = new JSONObject();
+        sender.put("tenantKey", config.getNotifyTenantKey());
+        sender.put("workCode", senderWorkCode);
+        sender.put("name", senderName);
+        body.put("sender", sender);
+
+        JSONArray receivers = new JSONArray();
+        JSONObject receiver = new JSONObject();
+        receiver.put("tenantKey", config.getNotifyTenantKey());
+        receiver.put("workCode", receiverWorkCode);
+        receivers.add(receiver);
+        body.put("recivers", receivers);
+
+        JSONArray channels = new JSONArray();
+        int[] ch = config.getNotifyChannels();
+        for (int i = 0; i < ch.length; i++) {
+            channels.add(Integer.valueOf(ch[i]));
+        }
+        body.put("channels", channels);
+
+        JSONObject entity = new JSONObject();
+        entity.put("id", StringUtils.defaultString(msgId));
+        entity.put("name", title.length() > 100 ? title.substring(0, 100) : title);
+        if (StringUtils.isNotBlank(bmcUrl)) {
+            entity.put("pcUrl", buildSsoRelativeUrl(bmcUrl.trim()));
+        }
+        body.put("entity", entity);
+        return body;
+    }
+
+    private String buildSsoRelativeUrl(String bmcUrl) throws Exception {
+        String redirectTarget;
+        if (bmcUrl.startsWith("http://") || bmcUrl.startsWith("https://")) {
+            redirectTarget = bmcUrl;
+        } else if (bmcUrl.startsWith("/")) {
+            redirectTarget = config.getShrServerName() + bmcUrl;
+        } else {
+            redirectTarget = config.getShrServerName() + "/" + bmcUrl;
+        }
+        String entryPath = StringUtils.defaultIfBlank(config.getSsoEntryPath(), "/qy/auth/weaver/app.do");
+        String charset = StandardCharsets.UTF_8.name();
+        return entryPath + "?appId=" + URLEncoder.encode(config.getSsoAppId(), charset)
+                + "&redirect=" + URLEncoder.encode(redirectTarget, charset);
+    }
 }

+ 66 - 3
src/com/kingdee/eas/custom/weaverTodo/server/WeaverTodoMessageServer.java

@@ -49,8 +49,23 @@ public class WeaverTodoMessageServer {
                 + " procInstId=" + procInstId + " billId=" + billId
                 + " targetIsremark=0");
         try {
-            support.delayForWf();
-            assignId = support.getAssignIdByMsgId(ctx, msgId);
+            // 先拿 AssignmentId 再 sleep:sleep 期间若撤回,AssignRead 可能已没,后查会空(对齐 YH)
+            assignId = waitAssignIdByMsgId(ctx, msgId, tid);
+            if (StringUtils.isBlank(assignId)) {
+                logger.error("[WEAVER-TODO][" + tid + "] 业务=新增待办 放弃:AssignmentId 为空 msgId=" + msgId);
+                support.addCallLog(ctx, msgId, "", billId, WeaverCallOperTypeEnum.TODO_ADD,
+                        support.buildReceiveTodoUrl(),
+                        "", "assignId empty", 0, "traceId=" + tid + " assignId empty");
+                return false;
+            }
+            if (StringUtils.isBlank(procInstId)) {
+                logger.error("[WEAVER-TODO][" + tid + "] 业务=新增待办 放弃:procInstId 为空 msgId=" + msgId
+                        + " assignId=" + assignId);
+                support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.TODO_ADD,
+                        support.buildReceiveTodoUrl(),
+                        "", "procInstId empty", 0, "traceId=" + tid + " procInstId empty");
+                return false;
+            }
             billId = support.resolveBillId(ctx, billId, assignId);
             String workflowname = support.resolveWorkflowName(ctx, msgId, billId, assignId);
 
@@ -77,6 +92,12 @@ public class WeaverTodoMessageServer {
             String pcurl = support.buildRelativePcUrl(ctx, billId, assignId);
             String appurl = support.buildRelativeAppUrl(ctx, billId, assignId);
 
+            // 关键落本地关联,避免 sleep 期间撤回后本地也查不到
+            support.saveAssignRecord(ctx, msgId, assignId, billId, workflowname, procInstId, receiver);
+
+            // 再等一会,让「待办转已办」等并发先处理完再推,顺序与 YH 一致
+            support.delayForWf();
+
             JSONObject payload = support.buildPayload(
                     procInstId, title, workflowname, nodename, receiver, creator,
                     pcurl, appurl, WeaverIsRemarkEnum.TODO, WeaverViewTypeEnum.UNREAD,
@@ -87,8 +108,8 @@ public class WeaverTodoMessageServer {
                     + " pcurl=" + pcurl);
 
             boolean ok = client.push(payload, tid);
-            support.saveAssignRecord(ctx, msgId, assignId, billId, workflowname, procInstId, receiver);
             support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.TODO_ADD,
+                    support.buildReceiveTodoUrl(),
                     payload.toJSONString(), String.valueOf(ok), ok ? 1 : 0,
                     "traceId=" + tid + " isremark=0 " + (ok ? "成功" : "失败"));
             logger.info("[WEAVER-TODO][" + tid + "] 业务=新增待办 END ok=" + ok
@@ -98,6 +119,7 @@ public class WeaverTodoMessageServer {
             logger.error("[WEAVER-TODO][" + tid + "] 业务=新增待办 异常 msgId=" + msgId
                     + " assignId=" + assignId, e);
             support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.TODO_ADD,
+                    support.buildReceiveTodoUrl(),
                     "", e.getMessage(), 0, "traceId=" + tid + " " + e.getMessage());
             return false;
         }
@@ -171,6 +193,7 @@ public class WeaverTodoMessageServer {
                         + " ※原审批人应离开待办,不应进已办;新人靠 addMessage 推 isremark=0");
                 boolean ok = client.push(del, tid);
                 support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.TODO_DELETE,
+                        support.buildReceiveTodoUrl(),
                         del.toJSONString(), String.valueOf(ok), ok ? 1 : 0,
                         "traceId=" + tid + " isremark=-1 withdraw state=" + assignState
                                 + " " + (ok ? "成功" : "失败"));
@@ -190,6 +213,7 @@ public class WeaverTodoMessageServer {
 
             boolean taskOk = client.push(done, tid);
             support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.TODO_DONE,
+                    support.buildReceiveTodoUrl(),
                     done.toJSONString(), String.valueOf(taskOk), taskOk ? 1 : 0,
                     "traceId=" + tid + " isremark=2 " + (taskOk ? "成功" : "失败"));
             logger.info("[WEAVER-TODO][" + tid + "] 业务=已办 END ok=" + taskOk
@@ -212,6 +236,7 @@ public class WeaverTodoMessageServer {
                         + WeaverPushTrace.summarize(end));
                 boolean endOk = client.push(end, tid);
                 support.addCallLog(ctx, msgId, assignId, billId, WeaverCallOperTypeEnum.FLOW_END,
+                        support.buildReceiveTodoUrl(),
                         end.toJSONString(), String.valueOf(endOk), endOk ? 1 : 0,
                         "traceId=" + tid + " isremark=4 " + (endOk ? "成功" : "失败"));
                 logger.info("[WEAVER-TODO][" + tid + "] 业务=办结 END ok=" + endOk
@@ -222,6 +247,7 @@ public class WeaverTodoMessageServer {
         } catch (Exception e) {
             logger.error("[WEAVER-TODO][" + tid + "] 业务=已办/办结 异常 assignId=" + assignId, e);
             support.addCallLog(ctx, msgId, assignId, "", WeaverCallOperTypeEnum.TODO_DONE,
+                    support.buildReceiveTodoUrl(),
                     "", e.getMessage(), 0, "traceId=" + tid + " " + e.getMessage());
             return false;
         }
@@ -260,6 +286,7 @@ public class WeaverTodoMessageServer {
                     + WeaverPushTrace.summarize(payload));
             boolean ok = client.push(payload, tid);
             support.addCallLog(ctx, msgId, assignId, "", WeaverCallOperTypeEnum.TODO_DELETE,
+                    support.buildReceiveTodoUrl(),
                     payload.toJSONString(), String.valueOf(ok), ok ? 1 : 0,
                     "traceId=" + tid + " isremark=-1 " + (ok ? "成功" : "失败"));
             logger.info("[WEAVER-TODO][" + tid + "] 业务=删除待办 END ok=" + ok);
@@ -267,6 +294,7 @@ public class WeaverTodoMessageServer {
         } catch (Exception e) {
             logger.error("[WEAVER-TODO][" + tid + "] 业务=删除待办 异常 assignId=" + assignId, e);
             support.addCallLog(ctx, msgId, assignId, "", WeaverCallOperTypeEnum.TODO_DELETE,
+                    support.buildReceiveTodoUrl(),
                     "", e.getMessage(), 0, "traceId=" + tid + " " + e.getMessage());
             return false;
         }
@@ -309,6 +337,41 @@ public class WeaverTodoMessageServer {
         return WeaverRequestStatusEnum.ARCHIVED;
     }
 
+
+    /**
+     * 短轮询拿 AssignmentId:平台刚写 AssignRead 时可能尚未可见;
+     * 绝不先长 sleep 再查,否则 sleep 中撤回后就查空了。
+     */
+    private String waitAssignIdByMsgId(Context ctx, String msgId, String tid) throws Exception {
+        long budgetMs = Math.max(500L, config.getWfDelayMs());
+        long stepMs = 500L;
+        long deadline = System.currentTimeMillis() + budgetMs;
+        String assignId = "";
+        int attempt = 0;
+        while (true) {
+            attempt++;
+            assignId = support.getAssignIdByMsgId(ctx, msgId);
+            if (StringUtils.isNotBlank(assignId)) {
+                if (attempt > 1) {
+                    logger.info("[WEAVER-TODO][" + tid + "] 业务=新增待办 AssignmentId 第"
+                            + attempt + "次查到 assignId=" + assignId);
+                }
+                return assignId;
+            }
+            long left = deadline - System.currentTimeMillis();
+            if (left <= 0) {
+                break;
+            }
+            try {
+                Thread.sleep(Math.min(stepMs, left));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+        return "";
+    }
+
     private static String nvlTrace(String traceId) {
         return StringUtils.isBlank(traceId) ? "-" : traceId;
     }

+ 38 - 7
src/com/kingdee/eas/custom/weaverTodo/util/WeaverTodoSupport.java

@@ -152,7 +152,24 @@ public class WeaverTodoSupport {
         return "";
     }
 
+    
     /**
+     * 按用户 id 取 UserInfo(BMC 发送人等)。
+     */
+    public UserInfo getUserById(Context ctx, String userId) {
+        if (StringUtils.isBlank(userId)) {
+            return null;
+        }
+        try {
+            IUser iUser = UserFactory.getLocalInstance(ctx);
+            return iUser.getUserInfo(new ObjectUuidPK(userId.trim()));
+        } catch (Exception e) {
+            logger.warn("按 id 取用户失败 userId=" + userId + ": " + e.getMessage());
+            return null;
+        }
+    }
+
+/**
      * 泛微要求相对路径。落地走 SSO:entryPath?appId&redirect=审批页绝对地址(编码)。
      * entryPath 来自 weaver.sso.entryPath,默认 /qy/auth/weaver/app.do。
      */
@@ -247,22 +264,36 @@ public class WeaverTodoSupport {
         }
     }
 
+    /** 统一待办接口 URL(不含 access_token,便于落库)。 */
+    public String buildReceiveTodoUrl() {
+        return StringUtils.defaultString(config.getOpenapiHost())
+                + StringUtils.defaultString(config.getReceivePath());
+    }
+
+    /** 消息中心 sendMsg URL(不含 access_token)。 */
+    public String buildSendMsgUrl() {
+        return StringUtils.defaultString(config.getOpenapiHost())
+                + StringUtils.defaultString(config.getSendMsgPath());
+    }
+
     public void addCallLog(Context ctx, String msgId, String assignId, String billId,
-                           WeaverCallOperTypeEnum operType, String requestParams, String responseResult,
+                           WeaverCallOperTypeEnum operType, String requestUrl,
+                           String requestParams, String responseResult,
                            int statusCode, String message) {
         try {
             logger.info("[WEAVER-TODO] CallLog write OperType=" + (operType == null ? "" : operType.getLabel())
                     + " msgId=" + msgId + " assignId=" + assignId + " billId=" + billId
+                    + " requestUrl=" + requestUrl
                     + " statusCode=" + statusCode + " message=" + message);
 
-            String sql = "insert into OAMsgCallLog(MsgId,assignID,billId,OperType,RequestParams,ResponseResult,StatusCode,Message,CreateTime) "
-                    + "values(?,?,?,?,?,?,?,?,?)";
+            String sql = "insert into OAMsgCallLog(MsgId,assignID,billId,OperType,RequestUrl,RequestParams,ResponseResult,StatusCode,Message,CreateTime) "
+                    + "values(?,?,?,?,?,?,?,?,?,?)";
             String operLabel = operType == null ? "" : operType.getLabel();
-            // 按表字段长度截断,避免超长 JSON/异常信息撑爆 VARCHAR
             Object[] params = new Object[]{
-                     msgId,   assignId ,  billId,   operLabel,
-                    requestParams,responseResult,
-                    statusCode,  message , new Timestamp(System.currentTimeMillis())
+                    msgId, assignId, billId, operLabel,
+                    StringUtils.defaultString(requestUrl),
+                    requestParams, responseResult,
+                    statusCode, message, new Timestamp(System.currentTimeMillis())
             };
             DbUtil.execute(ctx, sql, params);
         } catch (Exception e) {