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:
chenxing
2026-10-20 16:43:17 +08:00
parent 6f855e57d1
commit c1bcbf2f88
9 changed files with 450 additions and 39 deletions
@@ -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;
}
}