> readIds = ArgumentCaptor.forClass(List.class);
- verify(msgProxy).read(eq(201), readIds.capture());
+ verify(msgProxy).read(eq(201), readIds.capture(), any());
assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读");
}
@@ -180,6 +180,6 @@ class MsgApprovalProxyServiceTest {
assertFalse((Boolean) out.get("ok"));
assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)");
- verify(msgProxy, never()).read(any(), any());
+ verify(msgProxy, never()).read(any(), any(), any());
}
}
diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
index c191739..1619619 100644
--- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
+++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
@@ -5,13 +5,19 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
+import com.yunhai.platform.openapi.msg.model.AppMsgCount;
+import com.yunhai.platform.openapi.msg.model.MsgListPage;
+import com.yunhai.platform.openapi.msg.model.ReadAllMsgRequest;
+import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties;
+import com.yunhai.userhome.entity.UserCurrentRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
+import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -45,27 +51,56 @@ import static org.mockito.Mockito.when;
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
+ *
+ * 按学校隔离(2026-10-09 批次3):消息读取(list/summary/unreadCount/countByApp/readAll)改走
+ * SDK msg 域类型化读方法(Tier 1,仍委托同一个 {@link YschOpenCall},故断言靶心从 JsonNode
+ * 换成 DTO class:{@code MsgListPage/UserMsgSummary/Long/AppMsgCount/Void}),并带
+ * 当前激活身份学校口径({@link MsgCenterProxyService#resolveReadSchId},R2);
+ * 待办角标 todo/list 仍按主学校口径(§8 不动角标链路),两者在 summary 上是两个独立入参。
*/
-@DisplayName("MsgCenterProxyService - YschOpenCall 代理、jumpUrl 改写与 showJump 判权")
+@DisplayName("MsgCenterProxyService - SDK 类型化读取、学校口径下行与 jumpUrl/showJump 改写")
class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
private YschOpenCall openCall;
+ private IUserCurrentRoleService userCurrentRoleService;
private MsgCenterProperties properties;
private MsgCenterProxyService service;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
+ userCurrentRoleService = mock(IUserCurrentRoleService.class);
properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL);
- service = new MsgCenterProxyService(openCall, properties);
+ service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
}
private static JsonNode json(String s) throws Exception {
- return new ObjectMapper().readTree(s);
+ return MAPPER.readTree(s);
+ }
+
+ /** SDK 类型化读方法的响应靶心:同一份 JSON 夹具直接绑成 SDK DTO(契约字段与 UserMsg 出参对齐) */
+ private static T dto(String s, Class type) throws Exception {
+ return MAPPER.readValue(s, type);
+ }
+
+ private static java.util.List listDto(String s, Class elementType) throws Exception {
+ return MAPPER.readValue(s, MAPPER.getTypeFactory()
+ .constructCollectionType(java.util.List.class, elementType));
+ }
+
+ /** 当前身份 stub(R2 数据源):findById(userId) 返回给定 schId 的激活身份 */
+ private void stubCurrentIdentity(Integer schId) {
+ UserCurrentRole role = new UserCurrentRole();
+ role.setUserId(201);
+ role.setRoleId(9);
+ role.setSchId(schId);
+ when(userCurrentRoleService.findById(201)).thenReturn(Optional.of(role));
}
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
@@ -89,16 +124,118 @@ class MsgCenterProxyServiceTest {
+ "\",\"name\":\"" + name + "\"}]}]"));
}
+ // ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
+
+ @Test
+ void resolveReadSchId_usesCurrentIdentitySchool() {
+ stubCurrentIdentity(7);
+ assertEquals(Integer.valueOf(7), service.resolveReadSchId(201), "当前身份 schId>0 → 原值下行(本校+平台可见)");
+ }
+
+ @Test
+ void resolveReadSchId_platformIdentityZeroMeansSeeAll() {
+ stubCurrentIdentity(0);
+ assertNull(service.resolveReadSchId(201), "schId=0(平台/超管)→ null=看全部(D5)");
+ stubCurrentIdentity(null);
+ assertNull(service.resolveReadSchId(201), "身份无 schId 同样按看全部处理");
+ }
+
+ @Test
+ void resolveReadSchId_identityChainDown_seesAllWithoutThrowing() {
+ when(userCurrentRoleService.findById(201)).thenThrow(new OpenApiException("userbase down"));
+ assertNull(service.resolveReadSchId(201), "身份链不可达降级 null=改造前全量口径,铃铛不 500");
+ assertNull(service.resolveReadSchId(null), "无登录人不出站");
+ verify(userCurrentRoleService, never()).findById(null);
+ }
+
+ // ===== 批次3:学校口径随读端点下行(R1 的下游半程) =====
+
+ @Test
+ void list_andSummary_passSchIdDownstream() throws Exception {
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
+ .thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
+ .thenReturn(dto("{\"unreadCount\":1}", UserMsgSummary.class));
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
+ .thenReturn(json("[]"));
+
+ service.list(201, 1, 10, null, null, null, null, null, 7);
+ service.summary(201, 7, 3);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor