From b85f5e16fb960670dd108e07dd184a2a9e8844e2 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 10 Oct 2026 15:17:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(notice):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=20A?= =?UTF-8?q?PI=20=E6=8E=A5=E5=8F=A3=E9=9D=A2=EF=BC=88/mobiapi=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=B7=AF=E5=BE=84=E5=B8=B8=E9=87=8F=E9=97=AD?= =?UTF-8?q?=E9=9B=86=E3=80=81=E8=AE=BF=E9=97=AE=E6=8B=A6=E6=88=AA=E5=99=A8?= =?UTF-8?q?=E4=B8=8E=E6=8E=A7=E5=88=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config:API_PATH 由通配 /{platform} 收敛为 /api(PC 管理端,CAS 会话),新增 MOBI_API_PATH=/mobiapi(移动端,平台 Bearer token) - 新增 MobiAccessInterceptor:移动端访问拦截(token 校验) - 新增 MobiWebMvcConfig:挂载 /mobiapi 前缀与拦截器 - 新增 BaseMobiController / NoticeMobiController:移动端通知接口 - 随附测试:MobiAccessInterceptorTest / MobiWebMvcConfigTest / NoticeMobiControllerTest --- .../interceptor/MobiAccessInterceptor.java | 34 ++++ .../java/com/yunhai/notice/config/Config.java | 7 +- .../notice/config/MobiWebMvcConfig.java | 27 ++++ .../mobiapi/BaseMobiController.java | 85 ++++++++++ .../mobiapi/NoticeMobiController.java | 86 ++++++++++ .../MobiAccessInterceptorTest.java | 76 +++++++++ .../notice/config/MobiWebMvcConfigTest.java | 86 ++++++++++ .../mobiapi/NoticeMobiControllerTest.java | 152 ++++++++++++++++++ 8 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptor.java create mode 100644 src/main/java/com/yunhai/notice/config/MobiWebMvcConfig.java create mode 100644 src/main/java/com/yunhai/notice/controller/mobiapi/BaseMobiController.java create mode 100644 src/main/java/com/yunhai/notice/controller/mobiapi/NoticeMobiController.java create mode 100644 src/test/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptorTest.java create mode 100644 src/test/java/com/yunhai/notice/config/MobiWebMvcConfigTest.java create mode 100644 src/test/java/com/yunhai/notice/controller/mobiapi/NoticeMobiControllerTest.java diff --git a/src/main/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptor.java b/src/main/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptor.java new file mode 100644 index 0000000..62c3e70 --- /dev/null +++ b/src/main/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptor.java @@ -0,0 +1,34 @@ +package com.yunhai.notice.common.interceptor; + +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 移动端 /mobiapi 访问拦截(口径 = 登录即可访问)。 + *

不做访问级别判级——移动端主体含学生(旧 SUPER/BUREAU/SCHOOL/NONE 判级照抄会全 403)。 + * token 解析、401+40101 信封与 pre-token 豁免由平台 SDK {@code LoginUserInterceptor} 承担 + * (tokenSurfaces 缺省 /mobiapi);本拦截器是业务侧兜底:SDK 之后的任何 /mobiapi 业务请求, + * 身份上下文为空即 401,防止依赖链路变化时出现"匿名可达的业务端点"。

+ */ +public class MobiAccessInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (UserContextHolder.get() == null && BoaUserContext.get() == null) { + try { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}"); + } catch (Exception ignored) { + // 响应已提交等场景:状态码已设置,写体失败不掩盖原始语义 + } + return false; + } + return true; + } +} diff --git a/src/main/java/com/yunhai/notice/config/Config.java b/src/main/java/com/yunhai/notice/config/Config.java index 6375822..d76204d 100644 --- a/src/main/java/com/yunhai/notice/config/Config.java +++ b/src/main/java/com/yunhai/notice/config/Config.java @@ -1,11 +1,14 @@ package com.yunhai.notice.config; /** - * 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 + * 全局路径常量(三层接口面,闭集):/api PC 管理端(CAS)、/mobiapi 移动端(token)、/oapi 开放接口(应用签名)。 + * 其余前缀(如旧 /manage、/{platform} 通配)一律 404(方案 §4.1 路径闭集)。 */ public enum Config { Inst; - public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔 + // 末尾不带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会拼出字面双斜杠而 404 + public static final String API_PATH = "/api"; // PC 管理端(CAS 会话) + public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 Bearer token) public static final String OPEN_API_PATH = "/oapi"; } diff --git a/src/main/java/com/yunhai/notice/config/MobiWebMvcConfig.java b/src/main/java/com/yunhai/notice/config/MobiWebMvcConfig.java new file mode 100644 index 0000000..7db0935 --- /dev/null +++ b/src/main/java/com/yunhai/notice/config/MobiWebMvcConfig.java @@ -0,0 +1,27 @@ +package com.yunhai.notice.config; + +import com.yunhai.notice.common.interceptor.MobiAccessInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 移动端 /mobiapi 拦截装配。 + *

{@code LOWEST_PRECEDENCE} 保证本配置器在 SDK 的 {@code YhAuthWebMvcConfigurer} + * (无 @Order)之后注册,登录态解析先于本兜底拦截执行。

+ *

排除 {@code /mobiapi/auth/**}:其中 login/bind/exchange 是 SDK 定义的 pre-token 端点 + * ({@code LoginUserInterceptor.PRE_TOKEN_ENDPOINTS}),设计上匿名可达——它们是登录链本身, + * 不是"业务免登录端点";logout/jsapiSignature 仍由 SDK 按 token 强制。业务端点无任何白名单。

+ */ +@Configuration +public class MobiWebMvcConfig implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new MobiAccessInterceptor()) + .addPathPatterns("/mobiapi/**") + .excludePathPatterns("/mobiapi/auth/**") + .order(Ordered.LOWEST_PRECEDENCE); + } +} diff --git a/src/main/java/com/yunhai/notice/controller/mobiapi/BaseMobiController.java b/src/main/java/com/yunhai/notice/controller/mobiapi/BaseMobiController.java new file mode 100644 index 0000000..1bd4f90 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/mobiapi/BaseMobiController.java @@ -0,0 +1,85 @@ +package com.yunhai.notice.controller.mobiapi; + +import com.yunhai.notice.config.Config; +import com.yunhai.notice.model.notice.UpFileResultVo; +import com.yunhai.notice.service.IFileService; +import com.yunhai.notice.service.IUserCenterService; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域移动端接口(/mobiapi/base/,token 身份)。 + *

端点从 {@code controller.api.BaseApiController} 按移动端需要迁出,业务逻辑仍在 + * {@link IUserCenterService} / {@link IFileService},本控制器仅"取参 → 调 Service → 返回"; + * 管理端专用端点留在 api/ 面,本期不删。

+ *

出入参口径与 api 侧一致(路径名沿用旧移动端 base/FilePreview|FileDown 调用形状; + * 文件预览/下载在 api 侧实际位于 FileController 的 file/Down|Preview,此处对齐其 + * IFileService 签名)。登出不在此暴露——SDK 已提供 {@code POST /mobiapi/auth/logout}。

+ */ +@RestController +@RequestMapping(value = Config.MOBI_API_PATH + "/base/") +public class BaseMobiController { + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + /** 获取当前登录用户信息(token 链注入)。 */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** 获取当前用户已授权的模块列表(移动端仅用于发布权限判定)。 */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + /** 获取当前用户学校组及用户(树形结构)。 */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getSchoolAllTree(schId); + } + + /** 获取指定组内用户(教师 + 管理员)。 */ + @GetMapping("GetGroupUser") + public List> getGroupUser( + @RequestParam("gid") Integer gid, + @RequestParam(value = "role", defaultValue = "2") Integer role) { + return userCenterService.getGroupUser(gid, role); + } + + /** 附件上传(单文件)。返回 { fileid, host, fileurl, name, type, size }。 */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "notice"); + } + + /** 附件在线预览(inline 输出,前端取 Blob 渲染;type 兼容旧调用参数)。 */ + @GetMapping("FilePreview") + public void filePreview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** 附件下载。 */ + @GetMapping("FileDown") + public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } +} diff --git a/src/main/java/com/yunhai/notice/controller/mobiapi/NoticeMobiController.java b/src/main/java/com/yunhai/notice/controller/mobiapi/NoticeMobiController.java new file mode 100644 index 0000000..d4b068a --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/mobiapi/NoticeMobiController.java @@ -0,0 +1,86 @@ +package com.yunhai.notice.controller.mobiapi; + +import com.yunhai.notice.config.Config; +import com.yunhai.notice.model.BasePageResult; +import com.yunhai.notice.model.notice.*; +import com.yunhai.notice.service.INoticeService; +import com.yunhai.notice.utils.PageUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * 通知公告移动端接口(/mobiapi 面,token 身份)。 + *

路径前缀:{@code Config.MOBI_API_PATH + "/notice/"};方法名/参数与 + * {@code controller.api.NoticeController} 保持一致,注入同一 {@link INoticeService}, + * 不改 Service 实现;返回裸对象,由 ApiResponseAdvice 统一包装 {code, msg, data}。

+ *

口径:登录即可访问(移动端主体含学生),不按管理端访问级别判级; + * 拦截与 401 语义见 {@code config.MobiWebMvcConfig} / {@code common.interceptor.MobiAccessInterceptor}。

+ */ +@RestController +@RequestMapping(value = Config.MOBI_API_PATH + "/notice/") +public class NoticeMobiController { + + @Autowired + private INoticeService noticeService; + + /** 已收通知列表。 */ + @GetMapping("List") + public NoticePageResult list(NoticeQuery query, + @SortDefault(sort = {"topmost", "createTime"}, + direction = Sort.Direction.DESC) Sort sort) { + return noticeService.userList(query, PageUtils.toPageable(query, sort)); + } + + /** 已发/全部通知列表(type=user 只看自己)。 */ + @GetMapping("SendList") + public BasePageResult sendList(NoticeQuery query, + @SortDefault(sort = "createTime", + direction = Sort.Direction.DESC) Sort sort) { + return noticeService.adminList(query, PageUtils.toPageable(query, sort)); + } + + /** 查看通知详情(type=1 标记已读,type=0 不标记)。 */ + @GetMapping("Detail") + public NoticeDetailVo detail(@RequestParam("serid") Integer serid, + @RequestParam(value = "type", defaultValue = "1") Integer type) { + return noticeService.detailMap(serid, type); + } + + /** 阅读情况(按学校组分组)。 */ + @GetMapping("ReadDetail") + public List> readDetail(@RequestParam("serid") Integer serid) { + return noticeService.getReadDetailGrouped(serid); + } + + /** 催收未读。 */ + @PostMapping("Expedite") + public void expedite(@RequestParam("serid") Integer serid) { + noticeService.expedite(serid); + } + + /** 一键全部已读。 */ + @PostMapping("InitializeRed") + public InitializeRedVo initializeRed() { + return noticeService.initializeRedResult(); + } + + /** 上一篇/下一篇通知ID({prevId, nextId},0=无)。 */ + @GetMapping("GetNextPrev") + public Map getNextPrev(@RequestParam("serid") Integer serid, + @RequestParam("type") String type) { + return noticeService.getNextPrev(serid, type); + } + + /** 批量下载通知全部附件(打包 zip)。 */ + @GetMapping("AllFileDown") + public void allFileDown(@RequestParam("dataid") Integer dataid, + HttpServletResponse response) { + noticeService.downloadAllFiles(dataid, response); + } +} diff --git a/src/test/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptorTest.java b/src/test/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptorTest.java new file mode 100644 index 0000000..17f6d16 --- /dev/null +++ b/src/test/java/com/yunhai/notice/common/interceptor/MobiAccessInterceptorTest.java @@ -0,0 +1,76 @@ +package com.yunhai.notice.common.interceptor; + +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 移动端兜底拦截契约(批次3 T5):
+ * ① 上下文为空 → 401 + 40101 信封;② 非空 → 放行;
+ * ③ 断言不存在访问级别判级:最低权限制用户(学生 NONE:无角色/无学校语境)同样放行—— + * 移动端主体含学生,照抄管理端 SUPER/BUREAU/SCHOOL/NONE 判级会全 403。 + */ +@DisplayName("MobiAccessInterceptor - 登录即可,不判级") +class MobiAccessInterceptorTest { + + private final MobiAccessInterceptor interceptor = new MobiAccessInterceptor(); + + @BeforeEach + void setUp() { + UserContextHolder.clear(); + BoaUserContext.clear(); + } + + @AfterEach + void tearDown() { + UserContextHolder.clear(); + BoaUserContext.clear(); + } + + @Test + @DisplayName("上下文为空 → 401 + 40101 信封") + void anonymousRejected401() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean pass = interceptor.preHandle(req, res, new Object()); + + assertFalse(pass); + assertEquals(401, res.getStatus()); + assertEquals("{\"code\":40101,\"msg\":\"未登录或登录已过期\",\"data\":null}", + res.getContentAsString()); + } + + @Test + @DisplayName("LoginUser 在位 → 放行") + void loginContextPasses() { + UserContextHolder.set(new LoginUser()); + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + assertTrue(interceptor.preHandle(req, res, new Object())); + assertEquals(200, res.getStatus()); + } + + @Test + @DisplayName("BoaUser 在位(无角色/学校语境的学生形态)→ 放行,无判级") + void studentShapedContextPassesWithoutLevelCheck() { + // 故意不设任何角色/权限字段:若实现引入访问级别判级,本用例将开始失败(回归哨) + BoaUserContext.set(new com.yunhai.platform.utils.model.BoaUser()); + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mobiapi/notice/List"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + assertTrue(interceptor.preHandle(req, res, new Object())); + assertEquals(200, res.getStatus()); + } +} diff --git a/src/test/java/com/yunhai/notice/config/MobiWebMvcConfigTest.java b/src/test/java/com/yunhai/notice/config/MobiWebMvcConfigTest.java new file mode 100644 index 0000000..ec8736b --- /dev/null +++ b/src/test/java/com/yunhai/notice/config/MobiWebMvcConfigTest.java @@ -0,0 +1,86 @@ +package com.yunhai.notice.config; + +import com.yunhai.notice.common.interceptor.MobiAccessInterceptor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 移动端拦截装配契约(批次3 T5):/mobiapi/** 已注册兜底拦截、/mobiapi/auth/** 豁免 + * (SDK pre-token 端点 login/bind/exchange 是登录链本身,设计上匿名可达)。 + */ +@DisplayName("MobiWebMvcConfig - 拦截装配") +class MobiWebMvcConfigTest { + + @Test + @DisplayName("/mobiapi/** 注册 MobiAccessInterceptor 且豁免 /mobiapi/auth/**") + void registersMobiInterceptorWithAuthExclusion() throws Exception { + MobiWebMvcConfig config = new MobiWebMvcConfig(); + InterceptorRegistry registry = new InterceptorRegistry(); + config.addInterceptors(registry); + + List registrations = registrationsOf(registry); + assertEquals(1, registrations.size(), "应恰好注册一个拦截器"); + + InterceptorRegistration reg = registrations.get(0); + assertTrue(interceptorOf(reg) instanceof MobiAccessInterceptor, + "拦截器类型应为 MobiAccessInterceptor"); + assertEquals(Arrays.asList("/mobiapi/**"), pathPatternsOf(reg), "拦截面应为 /mobiapi/**"); + assertEquals(Arrays.asList("/mobiapi/auth/**"), excludePatternsOf(reg), + "豁免面应为 /mobiapi/auth/**(pre-token 端点是登录链,非业务白名单)"); + } + + // ---- InterceptorRegistration 无公开读取器,用反射断言(结构契约测试口径) ---- + + private static Object interceptorOf(InterceptorRegistration reg) throws Exception { + Field f = InterceptorRegistration.class.getDeclaredField("interceptor"); + f.setAccessible(true); + return f.get(reg); + } + + private static List registrationsOf(InterceptorRegistry registry) throws Exception { + Field f = InterceptorRegistry.class.getDeclaredField("registrations"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + List list = (List) f.get(registry); + return list; + } + + private static List pathPatternsOf(InterceptorRegistration reg) throws Exception { + Object include = patternsField(reg, "includePatterns"); + return asStringList(include, "/mobiapi/**"); + } + + private static List excludePatternsOf(InterceptorRegistration reg) throws Exception { + Object exclude = patternsField(reg, "excludePatterns"); + return asStringList(exclude, "/mobiapi/auth/**"); + } + + private static Object patternsField(InterceptorRegistration reg, String name) throws Exception { + Field f = InterceptorRegistration.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(reg); + } + + private static List asStringList(Object patterns, String expectedSingle) { + // PatternsRequestCondition 内部持有 List(PathMatcher 语义,Ant 风格) + assertNotNull(patterns, "注册时必须显式声明路径模式(缺 " + expectedSingle + ")"); + List out = new ArrayList<>(); + if (patterns instanceof Iterable) { + for (Object o : (Iterable) patterns) { + out.add(String.valueOf(o)); + } + } + return out; + } +} diff --git a/src/test/java/com/yunhai/notice/controller/mobiapi/NoticeMobiControllerTest.java b/src/test/java/com/yunhai/notice/controller/mobiapi/NoticeMobiControllerTest.java new file mode 100644 index 0000000..6416262 --- /dev/null +++ b/src/test/java/com/yunhai/notice/controller/mobiapi/NoticeMobiControllerTest.java @@ -0,0 +1,152 @@ +package com.yunhai.notice.controller.mobiapi; + +import com.yunhai.notice.model.notice.NoticeDetailVo; +import com.yunhai.notice.model.notice.NoticePageResult; +import com.yunhai.notice.model.notice.NoticeQuery; +import com.yunhai.notice.model.notice.NoticeVo; +import com.yunhai.notice.service.INoticeService; +import com.yunhai.platform.apistd.spring.ApiResponseAdvice; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Sort; +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.PostMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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; + +/** + * 移动端通知契约测试(批次3 T5):
+ * ① 端点齐全性:List/SendList/Detail/ReadDetail/Expedite/InitializeRed/GetNextPrev/AllFileDown 八个映射在位;
+ * ② 薄透传等价:与 {@code api.NoticeController} 注入同一 INoticeService、同参调用(Mock 入参捕获比对);
+ * ③ 响应信封 {code,msg,data}(code:0,经 ApiResponseAdvice 包装,standalone MockMvc 注册同款 Advice);
+ * ④ 401/200:拦截语义属 {@code MobiAccessInterceptorTest},此处验登录上下文在位时 200。 + */ +@DisplayName("NoticeMobiController - 契约(端点齐全 / 薄透传 / 信封)") +class NoticeMobiControllerTest { + + private INoticeService noticeService; + private NoticeMobiController controller; + + @BeforeEach + void setUp() { + noticeService = mock(INoticeService.class); + controller = new NoticeMobiController(); + org.springframework.test.util.ReflectionTestUtils.setField(controller, "noticeService", noticeService); + } + + /** ① 端点齐全性:八个移动端路径都存在且方法级映射非空 */ + @Test + @DisplayName("端点齐全:八路径映射在位") + void allEightEndpointsMapped() { + Set paths = new HashSet<>(); + for (Method m : NoticeMobiController.class.getDeclaredMethods()) { + GetMapping gm = m.getAnnotation(GetMapping.class); + PostMapping pm = m.getAnnotation(PostMapping.class); + if (gm != null) { + paths.addAll(Arrays.asList(gm.value())); + } + if (pm != null) { + paths.addAll(Arrays.asList(pm.value())); + } + } + Set expected = new HashSet<>(Arrays.asList( + "List", "SendList", "Detail", "ReadDetail", + "Expedite", "InitializeRed", "GetNextPrev", "AllFileDown")); + Set missing = new HashSet<>(expected); + missing.removeAll(paths); + assertTrue(missing.isEmpty(), "缺少端点: " + missing + ",实际=" + paths); + assertEquals(expected.size(), paths.size(), "出现未预期端点: " + paths); + } + + /** ② 薄透传:List 与 api 侧同参(同一 INoticeService.userList(query, pageable)) */ + @Test + @DisplayName("薄透传:List 同参透传 INoticeService.userList") + void listDelegatesSameAsApiController() { + NoticeVo vo = new NoticeVo(); + vo.setId(82); + vo.setTitle("契约测试"); + NoticePageResultStub stub = new NoticePageResultStub( + Collections.singletonList(vo), 1, 1, 10, 1, false, 3); + when(noticeService.userList(any(), any())).thenReturn(stub); + + NoticeQuery query = new NoticeQuery(); + query.setPageIndex(1); + query.setPageSize(10); + Sort sort = Sort.by(Sort.Direction.DESC, "topmost", "createTime"); + NoticePageResult out = controller.list(query, sort); + + ArgumentCaptor qCap = ArgumentCaptor.forClass(NoticeQuery.class); + ArgumentCaptor pCap = + ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class); + verify(noticeService).userList(qCap.capture(), pCap.capture()); + assertEquals(1, qCap.getValue().getPageIndex()); + assertEquals(10, qCap.getValue().getPageSize()); + assertEquals(10, pCap.getValue().getPageSize()); + assertNotNull(pCap.getValue().getSort().getOrderFor("topmost")); + assertEquals(82, out.getContent().get(0).getId()); + assertEquals(3, out.getUnreadCount()); + } + + /** ③ 信封 + ④ 登录态 200:standalone MockMvc + 同款 ApiResponseAdvice(走无 Sort 参数的 Detail) */ + @Test + @DisplayName("信封 {code,msg,data} code:0 且登录态 200") + void envelopeShapeAndAuthorized200() throws Exception { + NoticeDetailVo vo = new NoticeDetailVo(); + when(noticeService.detailMap(77, 1)).thenReturn(vo); + + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new ApiResponseAdvice()) + .build(); + // 模拟登录上下文(真实链路由 SDK LoginUserInterceptor 写入) + com.yunhai.platform.auth.UserContextHolder.set(new com.yunhai.platform.auth.LoginUser()); + try { + mvc.perform(get("/mobiapi/notice/Detail").param("serid", "77").param("type", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").exists()) + .andExpect(jsonPath("$.data").exists()); + } finally { + com.yunhai.platform.auth.UserContextHolder.clear(); + } + } + + /** Detail 同参透传(type 默认 1 标读) */ + @Test + @DisplayName("薄透传:Detail 同参透传 detailMap(serid,type)") + void detailDelegates() { + NoticeDetailVo vo = new NoticeDetailVo(); + when(noticeService.detailMap(77, 1)).thenReturn(vo); + NoticeDetailVo out = controller.detail(77, 1); + assertNotNull(out); + verify(noticeService).detailMap(77, 1); + } + + /** 测试桩:避免依赖具体实现类构造(行为等价于 NoticeServiceImpl 返回形状) */ + private static class NoticePageResultStub extends com.yunhai.notice.model.notice.NoticePageResult { + NoticePageResultStub(List content, int total, int pageIndex, int pageSize, + int totalPages, boolean hasNext, long unreadCount) { + super(content, total, pageIndex, pageSize, totalPages, hasNext, unreadCount); + } + } +}