- NoticeService.getReadDetailGrouped:receivers 空态(全员通知)返回不可变 emptyList,追加已读者前先拷贝为 ArrayList,避免 UnsupportedOperationException - 新增 NoticeReadDetailGroupedAllTypeTest、NoticeSdkAdviceExposureTest
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user