feat(sdk+msgcenter): 对齐 SDK 重构包名(openapi.client→base)+ 消息中心代理后端(09-16/09-19 遗漏提交)
build / build-push (push) Successful in 1m34s
build / build-push (push) Successful in 1m34s
- import 全面切换 com.yunhai.platform.openapi.base.*(对齐 Nexus -57 SDK)
- MsgCenter 代理三件(Properties/Controller/ProxyService)+ 测试 11/11(09-19 消息中心二期本地完成态)
- Dockerfile 已在 563edb5 去掉写死 profile;本提交补齐源码侧遗漏
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -26,10 +26,10 @@ import java.util.List;
|
||||
* 导致 OpenAPI 客户端反序列化 BoaApp.createTime 时抛 DateTimeParseException,
|
||||
* 消息中心(getMessageByUserId)等接口因此报"内部服务错误"。
|
||||
*
|
||||
* <p>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 格式)。
|
||||
* <p>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 {
|
||||
|
||||
@@ -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<String, String> appNames = new LinkedHashMap<>();
|
||||
|
||||
/** bizType → 应用 front 基址(jumpUrl = 基址 + "/#" + jumpPath);生产同主域用相对段如 /flow-center */
|
||||
private Map<String, String> jumpBases = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> summary(BoaUser authUserWithDept) {
|
||||
return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 实时推送握手地址(UserMsg 一次性票据 + 浏览器可达端点)。
|
||||
* 未配置 WS / UserMsg 不可达 → url=null,前端维持"手动刷新"兜底表现。
|
||||
*/
|
||||
@GetMapping("msgCenter/wsTicket")
|
||||
public Map<String, Object> wsTicket(BoaUser authUserWithDept) {
|
||||
Map<String, Object> 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,9 +19,9 @@ import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] 出站 UserBase oapi 调用计数(direction=out,方案 §3.2)。<br>
|
||||
* 切在 yh-openapi-client 的 {@code OpenApiHttpClient}(所有出站开放接口调用的唯一公共入口,
|
||||
* Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient 注入式调用
|
||||
* 全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。<br>
|
||||
* 切在 yh-openapi-client 的 {@code YschOpenCall}(SDK 重构后所有出站开放接口调用的唯一
|
||||
* 公共入口,Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient
|
||||
* 注入式调用全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。<br>
|
||||
* 入站请求数(direction=in)与出站调用数(direction=out)相除即「放大系数」,
|
||||
* 是缓存层次决策的直接依据(方案 §5)。
|
||||
*/
|
||||
@@ -39,10 +39,10 @@ public class OutboundOapiMetricsAspect {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截 OpenApiHttpClient 全部公开执行方法(getJson/postJson/get/post/postMultipart)。<br>
|
||||
* 各方法签名第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。
|
||||
* 拦截 YschOpenCall 全部公开调用方法(get/getList/post/postMultipart/download)。<br>
|
||||
* 按名重载第 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());
|
||||
|
||||
@@ -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。
|
||||
* <ul>
|
||||
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
|
||||
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
|
||||
* <li>jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
|
||||
* (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</li>
|
||||
* </ul>
|
||||
* 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<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
|
||||
Map<String, Object> 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<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (data != null && data.path("content").isArray()) {
|
||||
for (JsonNode row : data.get("content")) {
|
||||
rows.add(rewrite(row));
|
||||
}
|
||||
}
|
||||
Map<String, Object> 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<String, Object> summary(Integer userId, Integer schId) {
|
||||
Map<String, Object> 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<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (todos != null && todos.isArray()) {
|
||||
for (JsonNode t : todos) {
|
||||
Map<String, Object> 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<Long> msgIds) {
|
||||
if (!enabled() || msgIds == null || msgIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> rewrite(JsonNode row) {
|
||||
Map<String, Object> 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<String, Object> emptyPage() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 细节,仅返回总体状态。
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = service.summary(201, 1);
|
||||
assertEquals(0L, out.get("unreadCount"));
|
||||
assertEquals(0, ((java.util.List<?>) out.get("todos")).size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user