From 5f5ac3db71c84e82cd94d6d8f41862ec9ad1914a Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 19 Sep 2026 13:08:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk+msgcenter):=20=E5=AF=B9=E9=BD=90=20SDK?= =?UTF-8?q?=20=E9=87=8D=E6=9E=84=E5=8C=85=E5=90=8D=EF=BC=88openapi.client?= =?UTF-8?q?=E2=86=92base=EF=BC=89+=20=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=90=8E=E7=AB=AF=EF=BC=8809-16/09-19=20?= =?UTF-8?q?=E9=81=97=E6=BC=8F=E6=8F=90=E4=BA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - import 全面切换 com.yunhai.platform.openapi.base.*(对齐 Nexus -57 SDK) - MsgCenter 代理三件(Properties/Controller/ProxyService)+ 测试 11/11(09-19 消息中心二期本地完成态) - Dockerfile 已在 563edb5 去掉写死 profile;本提交补齐源码侧遗漏 --- docker/application-prod.yml | 4 +- .../yunhai/userhome/config/JacksonConfig.java | 8 +- .../userhome/config/MsgCenterProperties.java | 35 +++ .../api/MessageCenterController.java | 76 +++++++ .../controller/api/MiscController.java | 2 +- .../controller/api/UserController.java | 4 +- .../api/UserSettingsController.java | 4 +- .../loadtestmetrics/ApiMetricsController.java | 19 +- .../OutboundOapiMetricsAspect.java | 12 +- .../service/MsgCenterProxyService.java | 213 ++++++++++++++++++ .../impl/OpenApiAppRemoteServiceImpl.java | 2 +- .../impl/UserCurrentRoleServiceImpl.java | 2 +- src/main/resources/application.yml | 46 +++- .../service/MsgCenterProxyServiceTest.java | 169 ++++++++++++++ 14 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java create mode 100644 src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java create mode 100644 src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java create mode 100644 src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java 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。 + *

+ * 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> rows = new ArrayList<>(); + if (data != null && data.path("content").isArray()) { + for (JsonNode row : data.get("content")) { + rows.add(rewrite(row)); + } + } + Map out = new HashMap<>(); + out.put("total", data == null || !data.has("total") ? 0 : data.get("total").asInt()); + out.put("list", rows); + return out; + } catch (Exception e) { + log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString()); + return empty; + } + } + + /** 铃铛摘要:未读数 + 各应用待办角标(停机各自降级 0/空) */ + public Map summary(Integer userId, Integer schId) { + Map out = new HashMap<>(); + out.put("unreadCount", 0L); + out.put("todos", new ArrayList<>()); + if (!enabled()) { + return out; + } + try { + JsonNode unread = http.get(url("/oapi/msg/unreadCount?userId=" + userId), JsonNode.class); + out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong()); + } catch (Exception e) { + log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); + } + try { + JsonNode todos = http.get(url("/oapi/msg/todo/list?userId=" + userId + + (schId == null ? "" : "&schId=" + schId)), JsonNode.class); + List> rows = new ArrayList<>(); + if (todos != null && todos.isArray()) { + for (JsonNode t : todos) { + Map row = new LinkedHashMap<>(); + row.put("appKey", text(t, "appKey")); + row.put("count", t.has("count") ? t.get("count").asInt() : 0); + rows.add(row); + } + } + out.put("todos", rows); + } catch (Exception e) { + log.warn("[msgcenter] UserMsg 待办角标不可达(降级空)userId={}:{}", userId, e.toString()); + } + return out; + } + + /** 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪) */ + public void read(Integer userId, List msgIds) { + if (!enabled() || msgIds == null || msgIds.isEmpty()) { + return; + } + Map body = new HashMap<>(); + body.put("msgIds", msgIds); + body.put("userId", userId); + http.post(url("/oapi/msg/read"), body, JsonNode.class); + } + + /** 全部已读 */ + public void readAll(Integer userId) { + if (!enabled()) { + return; + } + Map body = new HashMap<>(); + body.put("userId", userId); + http.post(url("/oapi/msg/readAll"), body, JsonNode.class); + } + + /** + * WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket), + * 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。 + */ + public String createWsTicket(Integer userId) { + if (!enabled() || !StringUtils.hasText(properties.getUsermsgWsUrl())) { + return null; + } + try { + Map body = new HashMap<>(); + body.put("userId", userId); + JsonNode data = http.post(url("/oapi/msg/ws/ticket"), body, JsonNode.class); + String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText(); + if (!StringUtils.hasText(ticket)) { + return null; + } + String wsUrl = properties.getUsermsgWsUrl().trim(); + return wsUrl + (wsUrl.contains("?") ? "&" : "?") + "ticket=" + ticket; + } catch (Exception e) { + log.warn("[msgcenter] UserMsg WS 票据不可达(降级 null,前端不建立长连接)userId={}:{}", + userId, e.toString()); + return null; + } + } + + // ===== 出参改写 ===== + + /** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且配置了 bizType 基址时)+ 应用名补全 */ + private Map rewrite(JsonNode row) { + Map out = new LinkedHashMap<>(); + out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); + out.put("title", text(row, "title")); + out.put("content", text(row, "content")); + out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null); + out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null); + out.put("jumpPath", text(row, "jumpPath")); + out.put("appKey", text(row, "appKey")); + String appKey = text(row, "appKey"); + out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey)); + out.put("bizType", text(row, "bizType")); + out.put("bizId", text(row, "bizId")); + out.put("sendTime", text(row, "sendTime")); + out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0); + out.put("jumpUrl", jumpUrl(row)); + return out; + } + + /** 基址 + "/#" + jumpPath;未配置基址/非应用内跳转/无路径 → null(前端不渲染跳转) */ + private String jumpUrl(JsonNode row) { + Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; + String jumpPath = text(row, "jumpPath"); + String bizType = text(row, "bizType"); + if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath) + || !StringUtils.hasText(bizType)) { + return null; + } + String base = properties.getJumpBases().get(bizType); + if (!StringUtils.hasText(base)) { + return null; + } + String b = base.trim(); + while (b.endsWith("/")) { + b = b.substring(0, b.length() - 1); + } + String path = jumpPath.startsWith("/") ? jumpPath : "/" + jumpPath; + return b + "/#" + path; + } + + private static Map emptyPage() { + Map out = new HashMap<>(); + out.put("total", 0); + out.put("list", new ArrayList<>()); + 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; + } +} diff --git a/src/main/java/com/yunhai/userhome/service/impl/OpenApiAppRemoteServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/OpenApiAppRemoteServiceImpl.java index 9ea21c4..3817fb2 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/OpenApiAppRemoteServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/OpenApiAppRemoteServiceImpl.java @@ -1,6 +1,6 @@ package com.yunhai.userhome.service.impl; -import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.platform.utils.model.BoaApp; import com.yunhai.platform.utils.model.BoaAppType; diff --git a/src/main/java/com/yunhai/userhome/service/impl/UserCurrentRoleServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/UserCurrentRoleServiceImpl.java index a8d3128..6cb384e 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/UserCurrentRoleServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/UserCurrentRoleServiceImpl.java @@ -1,6 +1,6 @@ package com.yunhai.userhome.service.impl; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.userhome.entity.UserCurrentRole; import com.yunhai.userhome.service.IUserCurrentRoleService; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 30d33fb..f20f090 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,8 @@ spring: host: localhost port: 6379 database: 0 + # 本地 Redis 开启了 requirepass(联调环境约定);健康检查据此转绿 + password: 123456 # ===== 基础平台 yh 配置(遵循 yh-* spring-boot-starter 约定,具体 key 以对应 starter 版本为准)===== yh: @@ -46,7 +48,9 @@ yh: enabled: true sso: # CAS 单点登录(浏览器 SSO 重定向 / ticket 校验) - server-url-prefix: http://sso.yunhaixingtong.com/cas/ + # 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),票据校验=根路径 + # /serviceValidate(160 实测;/cas/ 为存量本地默认值,160 网关已无此路径) + server-url-prefix: http://sso.yunhaixingtong.com/ # 本地 context-path=/、端口 8070;prod 由 Nacos 的 yh.sso.client-host-url 覆盖 client-host-url: http://localhost:8070 allow-headers: all @@ -60,18 +64,48 @@ yh: app-key: 100001 app-secret: ec4e860206374c80938719fff8e14a86 open: - # yh-openapi 开放接口客户端(前缀必须是 yh.open,OpenApiProperties 绑定;base-host + base-path 拼出 - # 完整跨仓基址,openapi 客户端再拼 /oapi/** 端点;app-key/app-secret 同时驱动应用间签名) - enabled: true + # yh-openapi 服务目录:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx; + # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删 base-host: http://base.yunhaixingtong.com base-path: /userbase - app-key: 100001 - app-secret: ec4e860206374c80938719fff8e14a86 + # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 + # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 + # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT + # 或 Nacos userhome.yaml 同名键 yh.env-config.source.front 注入(projman 配置管理维护), + # 未配置时端点返回 {},前端走同域推导兜底。如需本地联调可打开: + # env-config: + # source: + # front: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"http://localhost:8070","fileHost":"http://localhost:8070/upload/","statsRealyConf":[]}' logging: level: com.yunhai.userhome: debug +# ===== 消息中心代理(§3.5 最小消息接入 + WS 实时推送)===== +userhome: + msgcenter: + # UserMsg 服务基址(IDHttp 出站自动签名,身份=yh.app-auth appKey=100001, + # UserMsg trusted-apps 已含)。空 = 未接入(代理降级空,铃铛维持原表现) + usermsg-base-url: http://localhost:8080 + # 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底); + # 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg + usermsg-ws-url: ws://localhost:8080/ws/msg + # appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案) + app-names: + 100009: 通知公告 + 100617: 通知公告 + 100019: 流程中心 + 100001: 用户主页 + # bizType → 应用 front 基址:jumpPath 是裸相对路径(如 /task/123)且落地页是 hash 路由, + # 代理拼 jumpUrl = 基址 + "/#" + jumpPath。生产(Nacos userhome.yaml 覆盖)同主域相对段: + # jump-bases: { flow: /flow-center, flow.task: /flow-center, flow.result: /flow-center, flow.urge: /flow-center } + jump-bases: + flow: http://localhost:8088 + flow.task: http://localhost:8088 + flow.result: http://localhost:8088 + flow.urge: http://localhost:8088 + notice: http://localhost:8077 + # [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。 # Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患; # show-details=never 不外泄 DB/Redis 细节,仅返回总体状态。 diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java new file mode 100644 index 0000000..a79e0d8 --- /dev/null +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -0,0 +1,169 @@ +package com.yunhai.userhome.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.http.DHttpException; +import com.yunhai.platform.utils.http.IDHttp; +import com.yunhai.userhome.config.MsgCenterProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约 + * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 + * jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。 + * UserMsg 不可达降级空结果(铃铛不 500)。 + */ +@DisplayName("MsgCenterProxyService - UserMsg 代理与 jumpUrl 改写") +class MsgCenterProxyServiceTest { + + private IDHttp http; + private MsgCenterProperties properties; + private MsgCenterProxyService service; + + @BeforeEach + void setUp() { + http = mock(IDHttp.class); + properties = new MsgCenterProperties(); + properties.setUsermsgBaseUrl("http://localhost:8080"); + properties.getJumpBases().put("flow.task", "http://localhost:8088/"); + service = new MsgCenterProxyService(http, properties, new ObjectMapper()); + } + + private static com.fasterxml.jackson.databind.JsonNode json(String s) throws Exception { + return new ObjectMapper().readTree(s); + } + + @Test + void list_rewritesJumpUrlForFlowTask() throws Exception { + properties.getAppNames().put("100617", "通知公告"); + when(http.get(contains("/oapi/msg/list?userId=201"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("{\"total\":2,\"content\":[" + + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," + + "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0}," + + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + + "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); + + Map out = service.list(201, 1, 10, null); + assertEquals(2, out.get("total")); + java.util.List rows = (java.util.List) out.get("list"); + Map first = (Map) rows.get(0); + assertEquals("http://localhost:8088/#/task/123", first.get("jumpUrl"), "基址+/#+裸路径"); + assertEquals("flow.task", first.get("bizType")); + assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名"); + assertNull(((Map) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); + } + + @Test + void list_appNameMappedFromAppKey() throws Exception { + properties.getAppNames().put("100617", "通知公告"); + when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," + + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); + } + + @Test + void wsTicket_returnsUrlWithTicket() throws Exception { + properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); + when(http.post(contains("/oapi/msg/ws/ticket"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("{\"ticket\":\"abc123ticket\",\"expiresIn\":60}")); + String url = service.createWsTicket(201); + assertEquals("ws://localhost:8080/ws/msg?ticket=abc123ticket", url); + } + + @Test + void wsTicket_wsUrlNotConfigured_nullWithoutOutbound() { + properties.setUsermsgWsUrl(""); + assertNull(service.createWsTicket(201)); + verify(http, never()).post(anyString(), any(), any()); + } + + @Test + void wsTicket_userMsgDown_degradesNull() { + properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); + when(http.post(contains("/oapi/msg/ws/ticket"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenThrow(new DHttpException("down", 0)); + assertNull(service.createWsTicket(201), "票据失败降级 null,前端不建立长连接"); + } + + @Test + void list_noBaseConfigured_jumpUrlNull() throws Exception { + properties.getJumpBases().clear(); + when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)"); + } + + @Test + void list_userMsgDown_degradesToEmpty() { + when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenThrow(new DHttpException("DHttp request failed", 0)); + Map out = service.list(201, 1, 10, null); + assertEquals(0, out.get("total")); + assertEquals(0, ((java.util.List) out.get("list")).size()); + } + + @Test + void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { + when(http.get(contains("/oapi/msg/unreadCount"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("3")); + when(http.get(contains("/oapi/msg/todo/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]")); + + Map out = service.summary(201, 1); + assertEquals(3L, out.get("unreadCount")); + assertEquals(1, ((java.util.List) out.get("todos")).size()); + + // 未读数不可达单独降级为 0,待办仍可用 + when(http.get(contains("/oapi/msg/unreadCount"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + .thenThrow(new DHttpException("down", 0)); + Map out2 = service.summary(201, 1); + assertEquals(0L, out2.get("unreadCount")); + assertEquals(1, ((java.util.List) out2.get("todos")).size()); + } + + @Test + void read_sendsOwnerScopedPayload() { + service.read(201, java.util.Collections.singletonList(7L)); + verify(http).post(contains("/oapi/msg/read"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class)); + } + + @Test + void disabled_degradesWithoutOutbound() { + properties.setUsermsgBaseUrl(""); + assertEquals(0, service.list(201, 1, 10, null).get("total")); + service.read(201, java.util.Collections.singletonList(7L)); + verify(http, never()).get(anyString(), any()); + verify(http, never()).post(anyString(), any(), any()); + } + + @Test + void summary_disabled_degradesToZero() { + properties.setUsermsgBaseUrl(""); + Map out = service.summary(201, 1); + assertEquals(0L, out.get("unreadCount")); + assertEquals(0, ((java.util.List) out.get("todos")).size()); + } +}