diff --git a/docker/application-prod.yml b/docker/application-prod.yml
index 1717aaa..b58a0d8 100644
--- a/docker/application-prod.yml
+++ b/docker/application-prod.yml
@@ -42,11 +42,9 @@ yh:
app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86
open:
- enabled: true
+ # 四域服务目录零配置;身份密钥唯一来源 = yh.app-auth
base-host: http://base.yunhaixingtong.com
base-path: /userbase
- app-key: 100001
- app-secret: ec4e860206374c80938719fff8e14a86
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
diff --git a/src/main/java/com/yunhai/userhome/config/JacksonConfig.java b/src/main/java/com/yunhai/userhome/config/JacksonConfig.java
index cae79ec..603d59b 100644
--- a/src/main/java/com/yunhai/userhome/config/JacksonConfig.java
+++ b/src/main/java/com/yunhai/userhome/config/JacksonConfig.java
@@ -26,10 +26,10 @@ import java.util.List;
* 导致 OpenAPI 客户端反序列化 BoaApp.createTime 时抛 DateTimeParseException,
* 消息中心(getMessageByUserId)等接口因此报"内部服务错误"。
*
- *
yh-openapi 的 OpenApiAutoConfiguration.openApiObjectMapper() 带 @ConditionalOnMissingBean,
- * 本工程定义该 ObjectMapper bean 后即被 OpenAPI 客户端(OpenApiHttpClient / AbstractOpenApiClient)采用;
- * 同时它也是 Spring MVC 使用的全局 mapper,因此同样保留 spring.jackson.* 配置(GMT+8 时区、
- * java.util.Date 的 yyyy-MM-dd HH:mm:ss 格式)。
+ *
yh-openapi 的装配(YschCallAutoConfiguration.yschOpenCall)经 ObjectProvider 取容器内
+ * ObjectMapper,本工程定义该 bean 后即被 OpenAPI 客户端(core.YschOpenCall,2026-09-16 SDK
+ * 重构后的唯一出站公共入口)采用;同时它也是 Spring MVC 使用的全局 mapper,因此同样保留
+ * spring.jackson.* 配置(GMT+8 时区、java.util.Date 的 yyyy-MM-dd HH:mm:ss 格式)。
*/
@Configuration
public class JacksonConfig {
diff --git a/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java
new file mode 100644
index 0000000..56851fb
--- /dev/null
+++ b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java
@@ -0,0 +1,35 @@
+package com.yunhai.userhome.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
+ * UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
+ * jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按 bizType 查基址表拼绝对 jumpUrl。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "userhome.msgcenter")
+public class MsgCenterProperties {
+
+ /** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
+ private String usermsgBaseUrl = "";
+
+ /**
+ * 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
+ * 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
+ * 生产经网关暴露,如 wss://msg.yunhaixingtong.com/usermsg/ws/msg。
+ */
+ private String usermsgWsUrl = "";
+
+ /** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
+ private Map appNames = new LinkedHashMap<>();
+
+ /** bizType → 应用 front 基址(jumpUrl = 基址 + "/#" + jumpPath);生产同主域用相对段如 /flow-center */
+ private Map jumpBases = new LinkedHashMap<>();
+}
diff --git a/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java b/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java
new file mode 100644
index 0000000..110d21c
--- /dev/null
+++ b/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java
@@ -0,0 +1,76 @@
+package com.yunhai.userhome.controller.api;
+
+import com.yunhai.platform.utils.model.BoaUser;
+import com.yunhai.userhome.config.Config;
+import com.yunhai.userhome.service.MsgCenterProxyService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * 消息中心代理接口(§3.5 最小消息接入,BP-R3-1):前端铃铛/消息列表改经本控制器
+ * 签名代理 UserMsg /oapi/msg/**(前端不直连 UserMsg)。旧 {@code /getMessageByUserId}
+ * 等本地 user_message 表链路保留不动(后续消息中心专项清理)。
+ */
+@RestController
+@RequestMapping(value = Config.API_PATH)
+@RequiredArgsConstructor
+public class MessageCenterController {
+
+ private final MsgCenterProxyService proxy;
+
+ /** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */
+ private Integer resolveSchId(BoaUser user) {
+ if (user.getSchool() != null) {
+ return user.getSchool().getId();
+ }
+ if (user.getBureau() != null) {
+ return user.getBureau().getId();
+ }
+ return 0;
+ }
+
+ /** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空) */
+ @GetMapping("msgCenter/list")
+ public Map list(BoaUser authUserWithDept,
+ @RequestParam(value = "page", required = false) Integer page,
+ @RequestParam(value = "count", required = false) Integer count,
+ @RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread) {
+ return proxy.list(authUserWithDept.getId(), page, count, onlyUnread);
+ }
+
+ /** 铃铛摘要:未读数 + 各应用待办角标 */
+ @GetMapping("msgCenter/summary")
+ public Map summary(BoaUser authUserWithDept) {
+ return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept));
+ }
+
+ /**
+ * WS 实时推送握手地址(UserMsg 一次性票据 + 浏览器可达端点)。
+ * 未配置 WS / UserMsg 不可达 → url=null,前端维持"手动刷新"兜底表现。
+ */
+ @GetMapping("msgCenter/wsTicket")
+ public Map wsTicket(BoaUser authUserWithDept) {
+ Map out = new java.util.HashMap<>();
+ out.put("url", proxy.createWsTicket(authUserWithDept.getId()));
+ return out;
+ }
+
+ /** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪) */
+ @PostMapping("msgCenter/read")
+ public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
+ proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId));
+ }
+
+ /** 全部已读 */
+ @PostMapping("msgCenter/readAll")
+ public void readAll(BoaUser authUserWithDept) {
+ proxy.readAll(authUserWithDept.getId());
+ }
+}
diff --git a/src/main/java/com/yunhai/userhome/controller/api/MiscController.java b/src/main/java/com/yunhai/userhome/controller/api/MiscController.java
index dadfeae..bb27c45 100644
--- a/src/main/java/com/yunhai/userhome/controller/api/MiscController.java
+++ b/src/main/java/com/yunhai/userhome/controller/api/MiscController.java
@@ -1,7 +1,7 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.openapi.client.OpenApiUserClient;
+import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
diff --git a/src/main/java/com/yunhai/userhome/controller/api/UserController.java b/src/main/java/com/yunhai/userhome/controller/api/UserController.java
index 482b33d..90bf57f 100644
--- a/src/main/java/com/yunhai/userhome/controller/api/UserController.java
+++ b/src/main/java/com/yunhai/userhome/controller/api/UserController.java
@@ -1,8 +1,8 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.openapi.client.OpenApiUserClient;
-import com.yunhai.platform.openapi.client.YschBaseOpenApi;
+import com.yunhai.platform.openapi.base.OpenApiUserClient;
+import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
diff --git a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
index caea91a..f80b331 100644
--- a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
+++ b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
@@ -1,8 +1,8 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.openapi.client.OpenApiUserClient;
-import com.yunhai.platform.openapi.client.YschBaseOpenApi;
+import com.yunhai.platform.openapi.base.OpenApiUserClient;
+import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaApp;
diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java
index c1671d9..a4e79cf 100644
--- a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java
+++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java
@@ -47,12 +47,12 @@ public class ApiMetricsController {
vo.setDays(d);
vo.setTop(n);
- Object[] in = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 0);
+ Object[] in = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 0));
vo.setInCnt(asLong(in, 0));
vo.setInAvgCostMs(avg(asLong(in, 0), asLong(in, 1)));
vo.setInErrCnt(asLong(in, 2));
- Object[] out = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 0);
+ Object[] out = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 0));
vo.setOutCnt(asLong(out, 0));
vo.setOutAvgCostMs(avg(asLong(out, 0), asLong(out, 1)));
vo.setOutErrCnt(asLong(out, 2));
@@ -65,8 +65,8 @@ public class ApiMetricsController {
vo.setTopOutUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_OUT, 0), n));
// 压测流量合计(单列口径,不与线上热度混算)
- Object[] ltIn = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 1);
- Object[] ltOut = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 1);
+ Object[] ltIn = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 1));
+ Object[] ltOut = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 1));
vo.setLoadTestInCnt(asLong(ltIn, 0));
vo.setLoadTestOutCnt(asLong(ltOut, 0));
return vo;
@@ -89,6 +89,17 @@ public class ApiMetricsController {
return rows;
}
+ /**
+ * Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
+ * (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
+ */
+ private Object[] firstRow(Object[] arr) {
+ if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
+ return (Object[]) arr[0];
+ }
+ return arr;
+ }
+
private long asLong(Object[] arr, int i) {
if (arr == null || i >= arr.length || arr[i] == null) {
return 0L;
diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java
index d418fc5..f68230c 100644
--- a/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java
+++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java
@@ -19,9 +19,9 @@ import javax.servlet.http.HttpServletRequest;
/**
* [LOADTEST-METRICS] 出站 UserBase oapi 调用计数(direction=out,方案 §3.2)。
- * 切在 yh-openapi-client 的 {@code OpenApiHttpClient}(所有出站开放接口调用的唯一公共入口,
- * Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient 注入式调用
- * 全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。
+ * 切在 yh-openapi-client 的 {@code YschOpenCall}(SDK 重构后所有出站开放接口调用的唯一
+ * 公共入口,Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient
+ * 注入式调用全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。
* 入站请求数(direction=in)与出站调用数(direction=out)相除即「放大系数」,
* 是缓存层次决策的直接依据(方案 §5)。
*/
@@ -39,10 +39,10 @@ public class OutboundOapiMetricsAspect {
}
/**
- * 拦截 OpenApiHttpClient 全部公开执行方法(getJson/postJson/get/post/postMultipart)。
- * 各方法签名第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。
+ * 拦截 YschOpenCall 全部公开调用方法(get/getList/post/postMultipart/download)。
+ * 按名重载第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。
*/
- @Around("execution(* com.yunhai.platform.openapi.OpenApiHttpClient.*(..))")
+ @Around("execution(* com.yunhai.platform.openapi.core.YschOpenCall.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
String uri = resolveUri(point);
String method = resolveHttpMethod(point.getSignature().getName());
diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
new file mode 100644
index 0000000..9374a34
--- /dev/null
+++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
@@ -0,0 +1,213 @@
+package com.yunhai.userhome.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.platform.utils.http.IDHttp;
+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;
+
+/**
+ * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):IDHttp 出站自动签名(yh.app-auth 身份
+ * appKey=100001,UserMsg trusted-apps 已含),UserHome 前端不直连 UserMsg。
+ *
+ * - 列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;
+ * - 已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);
+ * - jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
+ * (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。
+ *
+ * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
+ */
+@Slf4j
+@Service
+public class MsgCenterProxyService {
+
+ private final IDHttp http;
+ private final MsgCenterProperties properties;
+ private final ObjectMapper objectMapper;
+
+ public MsgCenterProxyService(IDHttp http, MsgCenterProperties properties, ObjectMapper objectMapper) {
+ this.http = http;
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ }
+
+ private boolean enabled() {
+ return StringUtils.hasText(properties.getUsermsgBaseUrl());
+ }
+
+ private String url(String pathAndQuery) {
+ String base = properties.getUsermsgBaseUrl().trim();
+ while (base.endsWith("/")) {
+ base = base.substring(0, base.length() - 1);
+ }
+ return base + pathAndQuery;
+ }
+
+ /** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */
+ public Map list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
+ Map empty = emptyPage();
+ if (!enabled()) {
+ return empty;
+ }
+ try {
+ JsonNode data = http.get(url("/oapi/msg/list?userId=" + userId
+ + "&pageIndex=" + (pageIndex == null || pageIndex < 1 ? 1 : pageIndex)
+ + "&pageSize=" + (pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))
+ + "&onlyUnread=" + Boolean.TRUE.equals(onlyUnread)), JsonNode.class);
+ List