fix(notice): 已读明细空态 receivers 不可变列表修复 + SDK 暴露面测试补齐
build / build-push (push) Successful in 1m2s

- NoticeService.getReadDetailGrouped:receivers 空态(全员通知)返回不可变 emptyList,追加已读者前先拷贝为 ArrayList,避免 UnsupportedOperationException
- 新增 NoticeReadDetailGroupedAllTypeTest、NoticeSdkAdviceExposureTest
This commit is contained in:
chenxing
2026-10-10 15:17:08 +08:00
parent b85f5e16fb
commit 48cd8884d8
3 changed files with 197 additions and 0 deletions
@@ -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)) {
@@ -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 真的可用的三重证明——
* <ol>
* <li>类可达:Notice classpath(聚合 starter → yh-openapi-spring-boot-starter + yh-api-std)
* 上 OpenApiExceptionAdvice 可加载;</li>
* <li>装配可达:servlet web 应用上下文里仅注册 OpenApiAutoConfiguration(无 YschOpenCall 等
* 其它 bean),嵌套条件装配即产出 advice bean——证明自动注册不依赖应用额外声明;</li>
* <li>行为可达:与 GlobalExceptionHandler 同场,SDK 出向失败落 code=5+档位文案
* (A 档原文 / B 档目标+状态 / C 档通用),非 SDK 异常仍 code=6,401 仍 code=4。</li>
* </ol>
* 本测试在 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("验签失败"));
}
}
@@ -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}。<br>
* 根因: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<Map<String, Object>> groups = assertDoesNotThrow(() -> service.getReadDetailGrouped(77));
// 用户中心未装配(单测环境)→ 归「其他」组;已读人必须在列且状态 read
assertEquals(1, groups.size());
assertEquals("其他", groups.get(0).get("GroupName"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> teas = (List<Map<String, Object>>) groups.get(0).get("teaslist");
assertEquals(1, teas.size());
assertEquals("flow_liu", teas.get(0).get("account"));
assertEquals("read", teas.get(0).get("states"));
}
}