From 48cd8884d83cf492649034b7cf03cf2cb4677feb Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 10 Oct 2026 15:17:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(notice):=20=E5=B7=B2=E8=AF=BB=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E7=A9=BA=E6=80=81=20receivers=20=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E5=8F=98=E5=88=97=E8=A1=A8=E4=BF=AE=E5=A4=8D=20+=20SDK=20?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E9=9D=A2=E6=B5=8B=E8=AF=95=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoticeService.getReadDetailGrouped:receivers 空态(全员通知)返回不可变 emptyList,追加已读者前先拷贝为 ArrayList,避免 UnsupportedOperationException - 新增 NoticeReadDetailGroupedAllTypeTest、NoticeSdkAdviceExposureTest --- .../notice/service/impl/NoticeService.java | 2 + .../sdk/NoticeSdkAdviceExposureTest.java | 112 ++++++++++++++++++ .../NoticeReadDetailGroupedAllTypeTest.java | 83 +++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 src/test/java/com/yunhai/notice/sdk/NoticeSdkAdviceExposureTest.java create mode 100644 src/test/java/com/yunhai/notice/service/impl/NoticeReadDetailGroupedAllTypeTest.java diff --git a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java index c47b569..9e4261f 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -707,6 +707,8 @@ public class NoticeService implements INoticeService { if (receivers.isEmpty()) { receivers = parseReceiveAccounts(notice.getReceiveList()); } + // 空态(ALL 全员通知)返回的是不可变 emptyList,先拷贝再追加 + receivers = new ArrayList<>(receivers); // 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整 for (String account : readAccounts) { if (!receivers.contains(account)) { diff --git a/src/test/java/com/yunhai/notice/sdk/NoticeSdkAdviceExposureTest.java b/src/test/java/com/yunhai/notice/sdk/NoticeSdkAdviceExposureTest.java new file mode 100644 index 0000000..7d550b0 --- /dev/null +++ b/src/test/java/com/yunhai/notice/sdk/NoticeSdkAdviceExposureTest.java @@ -0,0 +1,112 @@ +package com.yunhai.notice.sdk; + +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.apistd.spring.ApiStdProperties; +import com.yunhai.platform.apistd.spring.GlobalExceptionHandler; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.openapi.spring.OpenApiAutoConfiguration; +import com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * D4 应用级证据(SDK 健壮性自审 2026-09-27):Notice 依赖树上 advice 真的可用的三重证明—— + *
    + *
  1. 类可达:Notice classpath(聚合 starter → yh-openapi-spring-boot-starter + yh-api-std) + * 上 OpenApiExceptionAdvice 可加载;
  2. + *
  3. 装配可达:servlet web 应用上下文里仅注册 OpenApiAutoConfiguration(无 YschOpenCall 等 + * 其它 bean),嵌套条件装配即产出 advice bean——证明自动注册不依赖应用额外声明;
  4. + *
  5. 行为可达:与 GlobalExceptionHandler 同场,SDK 出向失败落 code=5+档位文案 + * (A 档原文 / B 档目标+状态 / C 档通用),非 SDK 异常仍 code=6,401 仍 code=4。
  6. + *
+ * 本测试在 Notice 工程内运行(对 .m2 中新装 SDK 编译),即"应用上下文里 advice 生效"的 + * 发布前等价证明;线上运行时证据另由部署后分档验证补齐。 + */ +class NoticeSdkAdviceExposureTest { + + @RestController + static class SdkFailureController { + + @GetMapping("/sdk/a") + public String addressing() { + // 真实 A 档形态:未配置的调用目标(ServiceDirectory.resolve 的原文) + throw new OpenApiException("unknown call target 'ghost': configure yh.open.services.ghost" + + " (or yh.call.targets.ghost.base-host) first", + OpenApiException.FailureKind.ADDRESSING, "ghost"); + } + + @GetMapping("/sdk/b") + public String downstream() { + throw new OpenApiException("OpenAPI request failed: httpStatus=500", 500, "SECRET-DOWN", + OpenApiException.FailureKind.DOWNSTREAM, "usermsg"); + } + + @GetMapping("/sdk/other") + public String other() { + throw new IllegalStateException("boom"); + } + + @GetMapping("/sdk/unauthorized") + public String unauthorized() { + throw new DException(ErrorCode.UNAUTHORIZED, "验签失败", null, null, 401); + } + } + + @Test + void adviceClass_resolvable_onNoticeClasspath() throws Exception { + assertSame(OpenApiExceptionAdvice.class, + Class.forName("com.yunhai.platform.openapi.spring.OpenApiExceptionAdvice")); + assertSame(OpenApiException.FailureKind.class, + Class.forName("com.yunhai.platform.openapi.core.OpenApiException$FailureKind")); + } + + @Test + void adviceBean_autoRegistered_inServletWebContext_withoutAppDeclaration() { + try (AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext()) { + ctx.register(OpenApiAutoConfiguration.class); + ctx.refresh(); + assertNotNull(ctx.getBean(OpenApiExceptionAdvice.class), + "仅注册自动装配类(应用零声明)也应产出 advice bean"); + } + } + + @Test + void sdkFailures_tieredExposure_code5_fallbackIntact() throws Exception { + MockMvc mvc = MockMvcBuilders.standaloneSetup(new SdkFailureController()) + .setControllerAdvice(new OpenApiExceptionAdvice(), + new GlobalExceptionHandler(new ApiStdProperties())) + .build(); + // A 档:原文透出(可行动配置指引),code≠6 + mvc.perform(get("/sdk/a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(5)) + .andExpect(jsonPath("$.msg").value( + "unknown call target 'ghost': configure yh.open.services.ghost" + + " (or yh.call.targets.ghost.base-host) first")); + // B 档:目标名+HTTP 状态,下游响应体不外露 + String body = mvc.perform(get("/sdk/b")).andReturn().getResponse().getContentAsString(); + org.junit.jupiter.api.Assertions.assertTrue(body.contains("target=usermsg") && body.contains("HTTP 500"), + body); + org.junit.jupiter.api.Assertions.assertFalse(body.contains("SECRET-DOWN"), body); + // 非 SDK 异常:兜底未被打穿 + mvc.perform(get("/sdk/other")) + .andExpect(jsonPath("$.code").value(6)) + .andExpect(jsonPath("$.msg").value("内部服务错误")); + // 401 既有语义:code=4 + mvc.perform(get("/sdk/unauthorized")) + .andExpect(jsonPath("$.code").value(4)) + .andExpect(jsonPath("$.msg").value("验签失败")); + } +} diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeReadDetailGroupedAllTypeTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeReadDetailGroupedAllTypeTest.java new file mode 100644 index 0000000..cd38e2e --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeReadDetailGroupedAllTypeTest.java @@ -0,0 +1,83 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.entity.NoticeRead; +import com.yunhai.notice.repository.NoticeReadRepository; +import com.yunhai.notice.repository.NoticeReceiverRepository; +import com.yunhai.notice.repository.NoticeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 回归锁(批次3 T5):ALL 型全员通知的阅读情况分组不得再抛 + * {@link UnsupportedOperationException}。
+ * 根因:receive_list=ALL 时 parseReceiveAccounts 返回不可变空表, + * 历史实现直接在其上追加已读人 → 抛异常(PC api 侧同样命中)。 + * 修复 = 追加前 new ArrayList 拷贝;本测试锁死该行为,防回归。 + */ +@DisplayName("getReadDetailGrouped - ALL 型通知回归锁") +class NoticeReadDetailGroupedAllTypeTest { + + private NoticeRepository noticeRepository; + private NoticeReadRepository noticeReadRepository; + private NoticeReceiverRepository noticeReceiverRepository; + private NoticeService service; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + noticeReadRepository = mock(NoticeReadRepository.class); + noticeReceiverRepository = mock(NoticeReceiverRepository.class); + service = new NoticeService(); + ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); + ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository); + ReflectionTestUtils.setField(service, "noticeReceiverRepository", noticeReceiverRepository); + } + + private Notice allTypeNotice() { + Notice n = new Notice(); + ReflectionTestUtils.setField(n, "id", 77); + n.setTitle("E2E直发"); + n.setReceiveList("ALL"); + n.setSchId(990001); + return n; + } + + @Test + @DisplayName("ALL 型 + 已读一人:不再抛 UnsupportedOperationException,已读人入组") + void allTypeNotice_appendsReadersWithoutException() { + when(noticeRepository.findById(77)).thenReturn(Optional.of(allTypeNotice())); + NoticeRead read = new NoticeRead(); + read.setReaderAccount("flow_liu"); + read.setReaderName("刘老师(提交人)"); + read.setReadTime(LocalDateTime.of(2026, 9, 27, 15, 14, 25)); + when(noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(77)) + .thenReturn(Collections.singletonList(read)); + // ALL 型不落接收明细 + when(noticeReceiverRepository.findByNoticeId(77)).thenReturn(Collections.emptyList()); + + List> groups = assertDoesNotThrow(() -> service.getReadDetailGrouped(77)); + + // 用户中心未装配(单测环境)→ 归「其他」组;已读人必须在列且状态 read + assertEquals(1, groups.size()); + assertEquals("其他", groups.get(0).get("GroupName")); + @SuppressWarnings("unchecked") + List> teas = (List>) groups.get(0).get("teaslist"); + assertEquals(1, teas.size()); + assertEquals("flow_liu", teas.get(0).get("account")); + assertEquals("read", teas.get(0).get("states")); + } +}