feat(server): 事项服务承诺字段 + 匿名指南接口 + hall 用户面开匿名
- service_item 加 7 列(协办部门/经办人/办理时段/跑动次数/快递/收费标准+依据) - /api/hall/items 出参补 applyUrl 与承诺字段 - 新增 GET /api/hall/item(匿名,指南详情) - url-pattern 收窄至写入面,/api/hall/** 匿名只读 - url-pattern 另补 /api/base/*(契约 §1 表格未列,页头用户信息与左侧菜单必须 CAS 会话)
This commit is contained in:
@@ -4,7 +4,6 @@ import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -13,36 +12,32 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户面站点信息端点测试(T2 主题联动,2026-10-03 执行方案)。
|
||||
* GET /api/hall/site?siteCode=:出参=SiteOptionVo 全量(siteCode/name/schId/theme/themeParams)。
|
||||
* 未登录拦截由平台 CAS 过滤器在 /api/hall/** 统一承担,与 items/filters 同 URL 前缀同口径,
|
||||
* 控制器层无差异可断言;此处冻结控制器级三态。
|
||||
* D7(2026-10-20 一期契约 §1)后 {@code /api/hall/**} 已退出 {@code yh.sso.url-pattern},
|
||||
* 未登录拦截不复存在:BoaUser 在控制器里恒可为 null 且不参与判权,可见范围只由 siteCode 决定;
|
||||
* 因此此处冻结的是"匿名可用 + 站点解析 fail-closed"两态,原先的本校成员拦截用例随守卫下线一并撤除。
|
||||
*/
|
||||
@DisplayName("HallController /api/hall/site - 用户面站点信息(T2)")
|
||||
@DisplayName("HallController /api/hall/site - 用户面站点信息(T2 / D7 匿名)")
|
||||
class HallControllerSiteTest {
|
||||
|
||||
private SiteDirectory siteDirectory;
|
||||
private SiteGuard siteGuard;
|
||||
private HallController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteDirectory = mock(SiteDirectory.class);
|
||||
siteGuard = mock(SiteGuard.class);
|
||||
controller = new HallController(mock(com.yunhai.svchall.service.impl.ServiceItemService.class),
|
||||
siteDirectory, siteGuard);
|
||||
siteDirectory);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("本校成员 + siteCode 命中:出参含 siteCode/name/schId/theme/themeParams")
|
||||
void site_memberAndHit_returnsFullOption() {
|
||||
@DisplayName("游客(无 BoaUser)+ siteCode 命中:出参含 siteCode/name/schId/theme/themeParams")
|
||||
void site_anonymousAndHit_returnsFullOption() {
|
||||
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
|
||||
SvcModels.SiteOptionVo option = new SvcModels.SiteOptionVo();
|
||||
option.setSiteCode("S1");
|
||||
@@ -76,8 +71,6 @@ class HallControllerSiteTest {
|
||||
@Test
|
||||
@DisplayName("siteCode 不存在/服务不可达:requireSite 的业务异常原样上抛(与 items 同口径)")
|
||||
void site_unknownSiteCode_propagatesBizException() {
|
||||
doThrow(new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:NOPE"))
|
||||
.when(siteGuard).requireSchoolMember(isNull(), isNull());
|
||||
when(siteDirectory.requireSite("NOPE")).thenThrow(
|
||||
new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:NOPE"));
|
||||
|
||||
@@ -86,12 +79,16 @@ class HallControllerSiteTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非本校成员:siteGuard 拦截异常上抛(requireMember 口径不变)")
|
||||
void site_notSchoolMember_propagatesGuardException() {
|
||||
@DisplayName("站点 schId 与请求方无归属关系:匿名面不做成员校验,照常返回(D7 口径)")
|
||||
void site_foreignSchId_stillReturns() {
|
||||
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 99L));
|
||||
doThrow(new BizException(ErrorCode.FORBIDDEN, "仅本校用户可办理本校事项"))
|
||||
.when(siteGuard).requireSchoolMember(isNull(), eq(99L));
|
||||
SvcModels.SiteOptionVo option = new SvcModels.SiteOptionVo();
|
||||
option.setSiteCode("S1");
|
||||
option.setName("站点一");
|
||||
option.setSchId(99L);
|
||||
when(siteDirectory.findOption("S1")).thenReturn(option);
|
||||
|
||||
assertThrows(BizException.class, () -> controller.site(null, "S1"));
|
||||
SvcModels.SiteOptionVo result = controller.site(null, "S1");
|
||||
assertEquals(99L, result.getSchId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.hall.HallModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
import com.yunhai.svchall.repository.ServiceFormRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.DeptSource;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 匿名事项指南出参单测(一期契约 §3.2,D7)。GET /api/hall/item 的控制器只做参数转发,
|
||||
* 必填校验/发布态判定/字段装配全部收口在 {@link ServiceItemService#hallItemGuide},故直测该方法。
|
||||
* 三态:未发布(含不存在)报错、已发布出参齐全(三段富文本 + 6 个服务承诺字段)、缺参报错。
|
||||
*/
|
||||
@DisplayName("ServiceItemService.hallItemGuide - 匿名事项指南(P1-1 / D7)")
|
||||
class OapiItemGuideTest {
|
||||
|
||||
private static final String SITE = "S1";
|
||||
private static final String ITEM_CODE = "teacher-onboard";
|
||||
private static final Long SCH_ID = 1L;
|
||||
private static final Integer ITEM_ID = 12;
|
||||
|
||||
private ServiceItemRepository itemRepository;
|
||||
private AttachmentService attachmentService;
|
||||
private ServiceItemService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
itemRepository = mock(ServiceItemRepository.class);
|
||||
attachmentService = mock(AttachmentService.class);
|
||||
ServiceDictService dictService = mock(ServiceDictService.class);
|
||||
Map<String, String> categoryNames = new HashMap<>();
|
||||
categoryNames.put("org", "组织人事");
|
||||
when(dictService.nameMap(anyLong(), anyString(), any(ServiceDictTypeEnum.class))).thenReturn(categoryNames);
|
||||
SiteDirectory siteDirectory = mock(SiteDirectory.class);
|
||||
when(siteDirectory.requireSite(SITE)).thenReturn(new SiteDirectory.SiteInfo(SITE, "站点一", SCH_ID));
|
||||
service = new ServiceItemService(itemRepository, mock(ServiceFormRepository.class), siteDirectory,
|
||||
dictService, mock(ServiceFormService.class), attachmentService, mock(SiteGuard.class),
|
||||
mock(DeptSource.class), mock(FlowClient.class),
|
||||
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
|
||||
mock(TransactionTemplate.class));
|
||||
FileUtil.init("target/test-hall-guide", "http://localhost:8086/upload/");
|
||||
}
|
||||
|
||||
/** 已发布事项:服务承诺 7 列已维护(feeBasis 故意留空,验证 null 可透传) */
|
||||
private ServiceItem publishedItem() {
|
||||
ServiceItem item = new ServiceItem();
|
||||
item.setId(ITEM_ID);
|
||||
item.setSchId(SCH_ID);
|
||||
item.setSiteCode(SITE);
|
||||
item.setItemCode(ITEM_CODE);
|
||||
item.setTitle("教职工入职手续办理");
|
||||
item.setSummary("面向新入职教职工");
|
||||
item.setObjectKey("teacher");
|
||||
item.setCategoryKey("org");
|
||||
item.setDeptName("人事处");
|
||||
item.setHandleMode(3);
|
||||
item.setOnlineFlag(1);
|
||||
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||
item.setHandlePlace("行政楼 406 办公室");
|
||||
item.setHandleTimeLimit("3");
|
||||
item.setPromiseTimeLimit("1-3个工作日");
|
||||
item.setContactPhone("86843815");
|
||||
item.setSupervisePhone("86843000");
|
||||
item.setCoverImage("/upload/image/cover.png");
|
||||
item.setViewCount(102);
|
||||
item.setCoopDepts("组织部、教务处");
|
||||
item.setHandlerName("田老师");
|
||||
item.setHandleTimeDesc("工作日 08:00-11:45,13:30-16:15");
|
||||
item.setRunCount(1);
|
||||
item.setExpressFlag(0);
|
||||
item.setFeeDesc("无");
|
||||
item.setProcessHtml("<p>流程</p>");
|
||||
item.setMaterialHtml("<p>材料</p>");
|
||||
item.setNoticeHtml("<p>注意</p>");
|
||||
return item;
|
||||
}
|
||||
|
||||
private void stubFound(ServiceItem item) {
|
||||
when(itemRepository.findBySchIdAndSiteCodeAndItemCode(SCH_ID, SITE, ITEM_CODE))
|
||||
.thenReturn(Optional.of(item));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未发布(草稿)事项:BizException,文案「事项不存在或未发布」")
|
||||
void draftItem_rejected() {
|
||||
ServiceItem draft = publishedItem();
|
||||
draft.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
|
||||
stubFound(draft);
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("事项不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("事项不存在:与未发布同一文案(不区分两态,免暴露库容)")
|
||||
void missingItem_rejected() {
|
||||
when(itemRepository.findBySchIdAndSiteCodeAndItemCode(SCH_ID, SITE, ITEM_CODE))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("事项不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已发布:三段富文本 + 6 个服务承诺字段齐全,附件 fileUrl 绝对化")
|
||||
void publishedItem_returnsGuideWithPromiseFields() {
|
||||
stubFound(publishedItem());
|
||||
SvcItemAttachmentVo attachment = new SvcItemAttachmentVo();
|
||||
attachment.setFileName("入会登记表.pdf");
|
||||
attachment.setFileUrl("/upload/file/form.pdf");
|
||||
attachment.setFileSize(214016L);
|
||||
when(attachmentService.guideAttachments(SCH_ID, ITEM_ID))
|
||||
.thenReturn(Collections.singletonList(attachment));
|
||||
|
||||
HallModels.ItemGuideVo vo = service.hallItemGuide(SITE, ITEM_CODE);
|
||||
|
||||
assertEquals(ITEM_ID, vo.getId());
|
||||
assertEquals(ITEM_CODE, vo.getItemCode());
|
||||
assertEquals("<p>流程</p>", vo.getProcessHtml());
|
||||
assertEquals("<p>材料</p>", vo.getMaterialHtml());
|
||||
assertEquals("<p>注意</p>", vo.getNoticeHtml());
|
||||
assertEquals("组织人事", vo.getCategoryName());
|
||||
// P1-1 服务承诺字段(§2.2 出参名,不得自创)
|
||||
assertEquals("组织部、教务处", vo.getCoopDepts());
|
||||
assertEquals("田老师", vo.getHandlerName());
|
||||
assertEquals("工作日 08:00-11:45,13:30-16:15", vo.getHandleTimeDesc());
|
||||
assertEquals(1, vo.getRunCount().intValue());
|
||||
assertEquals(0, vo.getExpressFlag().intValue());
|
||||
assertEquals("无", vo.getFeeDesc());
|
||||
assertNull(vo.getFeeBasis(), "未维护的收费依据透传 null");
|
||||
// 承诺时限/办理时限/外链三态字段(§3.1 同源)
|
||||
assertEquals("1-3个工作日", vo.getPromiseTimeLimit());
|
||||
assertEquals("3", vo.getHandleTimeLimit());
|
||||
assertNull(vo.getApplyUrl(), "内部办理事项无外链");
|
||||
assertEquals("http://localhost:8086/upload/image/cover.png", vo.getCoverImage());
|
||||
assertEquals(1, vo.getAttachments().size());
|
||||
assertEquals("http://localhost:8086/upload/file/form.pdf", vo.getAttachments().get(0).getFileUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("外链事项:applyUrl 原样透传(三态最高优先,前端据此新窗口打开)")
|
||||
void externalApplyUrl_passesThrough() {
|
||||
ServiceItem item = publishedItem();
|
||||
item.setApplyUrl("https://rs.example.edu/onboard");
|
||||
stubFound(item);
|
||||
|
||||
assertEquals("https://rs.example.edu/onboard", service.hallItemGuide(SITE, ITEM_CODE).getApplyUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("itemCode 缺失(null/空串/空白):BizException PARAM_ERROR,不触库")
|
||||
void blankItemCode_rejectedBeforeQuery() {
|
||||
for (String blank : new String[]{null, "", " "}) {
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, blank));
|
||||
assertTrue(e.getMessage().contains("必填"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("siteCode 缺失:同样 BizException PARAM_ERROR(游客不会拿到跨校列表)")
|
||||
void blankSiteCode_rejected() {
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(null, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("必填"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("脱敏:ItemGuideVo 无 formCode/flowGuid 属性,也不携带办件人与办件记录字段")
|
||||
void guideVo_exposesNoBindingOrApplyInfo() {
|
||||
for (String forbidden : new String[]{"formCode", "flowGuid", "submitterName", "submitterNo",
|
||||
"applyId", "applyCount", "deptGroupId"}) {
|
||||
assertFalse(hasField(HallModels.ItemGuideVo.class, forbidden), "匿名指南出参不应含 " + forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasField(Class<?> type, String name) {
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
if (field.getName().equals(name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user