From c1bcbf2f884f4d8640a3423cf32df04a1c239808 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 16:43:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E4=BA=8B=E9=A1=B9=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=89=BF=E8=AF=BA=E5=AD=97=E6=AE=B5=20+=20=E5=8C=BF?= =?UTF-8?q?=E5=90=8D=E6=8C=87=E5=8D=97=E6=8E=A5=E5=8F=A3=20+=20hall=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=9D=A2=E5=BC=80=E5=8C=BF=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service_item 加 7 列(协办部门/经办人/办理时段/跑动次数/快递/收费标准+依据) - /api/hall/items 出参补 applyUrl 与承诺字段 - 新增 GET /api/hall/item(匿名,指南详情) - url-pattern 收窄至写入面,/api/hall/** 匿名只读 - url-pattern 另补 /api/base/*(契约 §1 表格未列,页头用户信息与左侧菜单必须 CAS 会话) --- sql/migrate_v2_item_promise.sql | 18 ++ .../controller/api/HallController.java | 41 ++-- .../yunhai/svchall/entity/ServiceItem.java | 29 +++ .../yunhai/svchall/model/hall/HallModels.java | 63 ++++++ .../yunhai/svchall/model/oapi/OapiModels.java | 12 + .../service/impl/ServiceItemService.java | 72 ++++++ src/main/resources/application.yml | 7 +- .../api/HallControllerSiteTest.java | 35 ++- .../service/impl/OapiItemGuideTest.java | 212 ++++++++++++++++++ 9 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 sql/migrate_v2_item_promise.sql create mode 100644 src/main/java/com/yunhai/svchall/model/hall/HallModels.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/OapiItemGuideTest.java diff --git a/sql/migrate_v2_item_promise.sql b/sql/migrate_v2_item_promise.sql new file mode 100644 index 0000000..b9166e3 --- /dev/null +++ b/sql/migrate_v2_item_promise.sql @@ -0,0 +1,18 @@ +-- 2026-10-20 svc-hall 一期 P1-1:事项"服务承诺"字段(6 个业务字段拆 7 列,收费标准拆 desc/basis) +-- 契约:docs/svc-hall一期接口契约-20261020.md §2.1(冻结,字段名全链路统一不得改名) +-- 口径:全部可空——存量事项不强制回填,空值语义=未维护(前端必须容忍 null) +-- 语法:MySQL 5.7(无 IF NOT EXISTS for ADD COLUMN,重复执行会报 Duplicate column name,按需在外部判重) + +ALTER TABLE service_item + ADD COLUMN coop_depts VARCHAR(500) NULL COMMENT '协办部门(顿号分隔文本快照)', + ADD COLUMN handler_name VARCHAR(64) NULL COMMENT '经办人员', + ADD COLUMN handle_time_desc VARCHAR(200) NULL COMMENT '办理时间(办公时段,区别于办理时限)', + ADD COLUMN run_count TINYINT NULL COMMENT '跑现场次数(0=零跑动)', + ADD COLUMN express_flag TINYINT NULL COMMENT '是否快递送达:0否/1是(到付)/2是(学校承担)', + ADD COLUMN fee_desc VARCHAR(200) NULL COMMENT '收费标准', + ADD COLUMN fee_basis VARCHAR(200) NULL COMMENT '收费依据'; + +-- 回滚(一条语句整体撤列,索引/视图未引用这些列,可直接删): +-- ALTER TABLE service_item +-- DROP COLUMN coop_depts, DROP COLUMN handler_name, DROP COLUMN handle_time_desc, +-- DROP COLUMN run_count, DROP COLUMN express_flag, DROP COLUMN fee_desc, DROP COLUMN fee_basis; diff --git a/src/main/java/com/yunhai/svchall/controller/api/HallController.java b/src/main/java/com/yunhai/svchall/controller/api/HallController.java index 97881dd..aeb603e 100644 --- a/src/main/java/com/yunhai/svchall/controller/api/HallController.java +++ b/src/main/java/com/yunhai/svchall/controller/api/HallController.java @@ -1,10 +1,10 @@ package com.yunhai.svchall.controller.api; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.hall.HallModels; import com.yunhai.svchall.model.oapi.OapiModels; import com.yunhai.svchall.model.svc.SvcModels; import com.yunhai.svchall.service.SiteDirectory; -import com.yunhai.svchall.service.SiteGuard; import com.yunhai.svchall.service.impl.ServiceItemService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -13,12 +13,14 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 公共办事大厅(用户面 /api/hall/**,CAS 登录):给学生/教师/访客申请用的"事项大厅"数据面, - * 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——出参直接复用 + * 公共办事大厅(用户面 /api/hall/**,D7 起匿名只读):给学生/教师/访客申请用的"事项大厅"数据面, + * 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——列表/筛选项出参直接复用 * {@link OapiModels.ListData} / {@link OapiModels.Filters},不另立 VO(形状单一事实源)。 *

- * 与 oapi 的区别只有两点:①鉴权从"应用签名"换成"CAS + 本校成员",调用者是浏览器; - * ②只暴露已发布事项的展示字段,附件/明细等仍走 oapi(门户用)。 + * 与 oapi 的区别只有两点:①鉴权:{@code /api/hall/**} 不在 {@code yh.sso.url-pattern} 列表内 + * (D7 匿名面,与 CMS 门户 {@code /portal/**} 同一策略),{@code BoaUser} 参数因此恒可为 null, + * 本控制器一律不据它判权限——可见范围只由 siteCode 决定;②只暴露已发布事项的展示字段, + * 附件/明细等仍走 oapi(门户用)。 *

* 新建独立前端 {@code svc-hall-public}(学生/教师/访客办理入口)时,这就是它的列表/筛选数据源; * 管理端(front)不使用本控制器。 @@ -30,9 +32,8 @@ public class HallController { private final ServiceItemService itemService; private final SiteDirectory siteDirectory; - private final SiteGuard siteGuard; - /** 已发布事项分页(关键词/分类/服务对象/部门筛选;本校成员可见) */ + /** 已发布事项分页(关键词/分类/服务对象/部门筛选;游客可见) */ @GetMapping("/items") public OapiModels.ListData items(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode, @@ -42,7 +43,7 @@ public class HallController { @RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "pageSize", required = false) Integer pageSize) { - SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode); + SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); return itemService.oapiItems(site.getSchId(), site.getSiteCode(), objectKey, categoryKey, deptGroupId, keyword, page, pageSize); } @@ -51,17 +52,28 @@ public class HallController { @GetMapping("/filters") public OapiModels.Filters filters(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) { - SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode); + SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); return itemService.oapiFilters(site.getSchId(), site.getSiteCode()); } + /** + * 事项指南详情(一期契约 §3.2,匿名):siteCode + itemCode 均必填、只返回已发布事项; + * 出参不含 formCode/flowGuid 与办件人/办件量信息(必填校验与状态判定在 service 侧收口)。 + */ + @GetMapping("/item") + public HallModels.ItemGuideVo item(BoaUser authUserWithDept, + @RequestParam(value = "siteCode", required = false) String siteCode, + @RequestParam(value = "itemCode", required = false) String itemCode) { + return itemService.hallItemGuide(siteCode, itemCode); + } + /** 站点信息(含 CMS 主题 raw 透传;用户面弱联动数据源,不保证字段非空) */ @GetMapping("/site") public SvcModels.SiteOptionVo site(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) { - SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode); + SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); SvcModels.SiteOptionVo option = siteDirectory.findOption(siteCode); - // requireMember 已校验存在性与成员身份,这里理论上非空;防御性兜底用 SiteInfo 补壳 + // requireSite 已校验存在性,这里理论上非空;防御性兜底用 SiteInfo 补壳 if (option == null) { option = new SvcModels.SiteOptionVo(); option.setSiteCode(site.getSiteCode()); @@ -70,11 +82,4 @@ public class HallController { } return option; } - - /** 本校成员 + 站点存在(与办件发起同一口径:只服务本校站点,不做跨校聚合) */ - private SiteDirectory.SiteInfo requireMember(BoaUser user, String siteCode) { - SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); - siteGuard.requireSchoolMember(user, site.getSchId()); - return site; - } } diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java index fcf440f..a457adc 100644 --- a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java +++ b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java @@ -145,6 +145,35 @@ public class ServiceItem extends BaseEntity { @Column(name = "apply_url", length = 500) private String applyUrl; + // 服务承诺字段(P1-1):政务服务化度量口径,空=未维护 + /** 收费标准 */ + @Column(name = "fee_desc", length = 200) + private String feeDesc; + + /** 收费依据 */ + @Column(name = "fee_basis", length = 200) + private String feeBasis; + + /** 协办部门(顿号分隔文本快照,不做部门树外键) */ + @Column(name = "coop_depts", length = 500) + private String coopDepts; + + /** 经办人员 */ + @Column(name = "handler_name", length = 64) + private String handlerName; + + /** 办理时间(办公时段,区别于 handle_time_limit 办理时限) */ + @Column(name = "handle_time_desc", length = 200) + private String handleTimeDesc; + + /** 跑现场次数(0=零跑动) */ + @Column(name = "run_count") + private Integer runCount; + + /** 是否快递送达:0 否 / 1 是(到付)/ 2 是(学校承担) */ + @Column(name = "express_flag") + private Integer expressFlag; + /** 管理端自定义排序(列表默认排序第一优先级,升序) */ @Column(name = "sort_no", nullable = false) private Integer sortNo = 0; diff --git a/src/main/java/com/yunhai/svchall/model/hall/HallModels.java b/src/main/java/com/yunhai/svchall/model/hall/HallModels.java new file mode 100644 index 0000000..e66651c --- /dev/null +++ b/src/main/java/com/yunhai/svchall/model/hall/HallModels.java @@ -0,0 +1,63 @@ +package com.yunhai.svchall.model.hall; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户面({@code /api/hall/**},D7 匿名只读)出参收口。 + * 与 {@link com.yunhai.svchall.model.oapi.OapiModels} 的分工:列表/筛选项形状与 CMS 供数同源, + * 继续复用 OapiModels;指南详情是**匿名面专属形状**——刻意不含 formCode/flowGuid + * (游客不需要,也不暴露流程配置)与任何办件人/办件量信息(一期契约 §1 匿名端点约束)。 + */ +public final class HallModels { + + private HallModels() { + } + + /** GET /api/hall/item 出参(一期契约 §3.2;服务承诺字段全部可空) */ + @Data + public static class ItemGuideVo { + private Integer id; + private String itemCode; + private String title; + private String summary; + private String objectKey; + private String categoryKey; + private String categoryName; + private String deptName; + private Integer handleMode; + private Integer onlineFlag; + /** 非空=外链事项(三态最高优先,前端新窗口打开;与内部表单互斥由前端判定) */ + private String applyUrl; + private String handlePlace; + private String handleTimeLimit; + private String promiseTimeLimit; + private String contactPhone; + private String supervisePhone; + /** 封面(出参已绝对化) */ + private String coverImage; + private Integer viewCount; + private String coopDepts; + private String handlerName; + private String handleTimeDesc; + private Integer runCount; + private Integer expressFlag; + private String feeDesc; + private String feeBasis; + /** 三段富文本:入库时已 jsoup 白名单清洗,此处只做 /upload/ 内链绝对化,不再二次清洗 */ + private String processHtml; + private String materialHtml; + private String noticeHtml; + private List attachments = new ArrayList<>(); + } + + /** 指南附件(fileUrl 为完整可达地址) */ + @Data + public static class Attachment { + private String fileName; + private String fileUrl; + private Long fileSize; + } +} diff --git a/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java index 54c5f9d..6382b1d 100644 --- a/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java +++ b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java @@ -29,6 +29,18 @@ public final class OapiModels { private Integer handleMode; private Integer onlineFlag; private LocalDateTime publishTime; + /** 三态最高优先:非空=外链事项,前端新窗口打开(一期契约 §5) */ + private String applyUrl; + private String promiseTimeLimit; + private String handleTimeLimit; + /** 服务承诺字段(P1-1,§3.1):全部可空,空=未维护,消费方必须容忍 null */ + private Integer runCount; + private Integer expressFlag; + private String coopDepts; + private String handlerName; + private String handleTimeDesc; + private String feeDesc; + private String feeBasis; } /** GET /oapi/svc/items 出参 */ diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java index a0244e8..5ff2cc6 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java @@ -8,6 +8,7 @@ import com.yunhai.svchall.entity.ServiceItem; import com.yunhai.svchall.enums.HandleModeEnum; import com.yunhai.svchall.enums.ServiceDictTypeEnum; import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.hall.HallModels; import com.yunhai.svchall.model.oapi.OapiModels; import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; import com.yunhai.svchall.model.svc.SvcModels.IdSortItem; @@ -389,11 +390,82 @@ public class ServiceItemService { row.setHandleMode(item.getHandleMode()); row.setOnlineFlag(item.getOnlineFlag()); row.setPublishTime(item.getPublishTime()); + // 一期契约 §3.1 追加字段:纯文本/数字,不做地址绝对化(coverImage 才是路径) + row.setApplyUrl(item.getApplyUrl()); + row.setPromiseTimeLimit(item.getPromiseTimeLimit()); + row.setHandleTimeLimit(item.getHandleTimeLimit()); + row.setRunCount(item.getRunCount()); + row.setExpressFlag(item.getExpressFlag()); + row.setCoopDepts(item.getCoopDepts()); + row.setHandlerName(item.getHandlerName()); + row.setHandleTimeDesc(item.getHandleTimeDesc()); + row.setFeeDesc(item.getFeeDesc()); + row.setFeeBasis(item.getFeeBasis()); data.getItems().add(row); } return data; } + // ===== 用户面事项指南(/api/hall/item,D7 匿名只读)===== + + /** + * 匿名事项指南(一期契约 §3.2):siteCode + itemCode 均必填,只返回已发布事项。 + * 租户由 siteCode 经站点目录解析(游客无 BoaUser,不做成员校验——匿名只读,权限面即 siteCode 本身); + * 出参刻意不含 formCode/flowGuid 与任何办件人/办件量信息(§1 匿名端点约束)。 + */ + public HallModels.ItemGuideVo hallItemGuide(String siteCode, String itemCode) { + if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 与 itemCode 均为必填"); + } + SiteInfo site = siteDirectory.requireSite(siteCode.trim()); + ServiceItem item = itemRepository + .findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), itemCode.trim()) + .orElse(null); + if (item == null || item.getStatus() == null + || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布"); + } + HallModels.ItemGuideVo vo = new HallModels.ItemGuideVo(); + vo.setId(item.getId()); + vo.setItemCode(item.getItemCode()); + vo.setTitle(item.getTitle()); + vo.setSummary(item.getSummary()); + vo.setObjectKey(item.getObjectKey()); + vo.setCategoryKey(item.getCategoryKey()); + vo.setCategoryName(item.getCategoryKey() == null ? null + : dictService.nameMap(site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY) + .get(item.getCategoryKey())); + vo.setDeptName(item.getDeptName()); + vo.setHandleMode(item.getHandleMode()); + vo.setOnlineFlag(item.getOnlineFlag()); + vo.setApplyUrl(item.getApplyUrl()); + vo.setHandlePlace(item.getHandlePlace()); + vo.setHandleTimeLimit(item.getHandleTimeLimit()); + vo.setPromiseTimeLimit(item.getPromiseTimeLimit()); + vo.setContactPhone(item.getContactPhone()); + vo.setSupervisePhone(item.getSupervisePhone()); + vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage())); + vo.setViewCount(item.getViewCount()); + vo.setCoopDepts(item.getCoopDepts()); + vo.setHandlerName(item.getHandlerName()); + vo.setHandleTimeDesc(item.getHandleTimeDesc()); + vo.setRunCount(item.getRunCount()); + vo.setExpressFlag(item.getExpressFlag()); + vo.setFeeDesc(item.getFeeDesc()); + vo.setFeeBasis(item.getFeeBasis()); + vo.setProcessHtml(FileUtil.absolutize(item.getProcessHtml())); + vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml())); + vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml())); + for (SvcItemAttachmentVo a : attachmentService.guideAttachments(site.getSchId(), item.getId())) { + HallModels.Attachment attachment = new HallModels.Attachment(); + attachment.setFileName(a.getFileName()); + attachment.setFileUrl(FileUtil.absolutizePlain(a.getFileUrl())); + attachment.setFileSize(a.getFileSize()); + vo.getAttachments().add(attachment); + } + return vo; + } + public OapiModels.Filters oapiFilters(Long schId, String siteCode) { OapiModels.Filters filters = new OapiModels.Filters(); Map objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode)); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9123b41..4148bd5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,9 +44,12 @@ yh: client-host-url: http://localhost:8087 allow-headers: all allow-origins: all - # 用户面(发起/我的办件)与管理面全在 /api/* 下;本应用无匿名面。 + # hall 用户面(/api/hall/**)匿名只读——与 CMS 门户 /portal/** 同一策略(D7,一期契约 §1): + # 游客可读事项列表/筛选项/事项指南,控制器不依赖 BoaUser 判权限,可见范围由 siteCode 决定。 + # 列表只拦写入面(发起、我的办件、撤回、重提)、应用内审批、上传、管理面,以及 + # /api/base/*(页头用户信息与左侧菜单,必须 CAS 会话——契约 §1 表格未列,2026-10-20 补); # 流程中心回调与业务供数都在 /oapi/*(不在 pattern 内,由平台应用签名强制验签保护) - url-pattern: /api/* + url-pattern: /api/apply/*, /api/apply/approval/*, /api/file/*, /api/base/*, /api/svc/* app-auth: enabled: true # 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换, diff --git a/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java b/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java index a9f1a61..0153da3 100644 --- a/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java +++ b/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java @@ -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()); } } diff --git a/src/test/java/com/yunhai/svchall/service/impl/OapiItemGuideTest.java b/src/test/java/com/yunhai/svchall/service/impl/OapiItemGuideTest.java new file mode 100644 index 0000000..d10259c --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/OapiItemGuideTest.java @@ -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 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("

流程

"); + item.setMaterialHtml("

材料

"); + item.setNoticeHtml("

注意

"); + 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("

流程

", vo.getProcessHtml()); + assertEquals("

材料

", vo.getMaterialHtml()); + assertEquals("

注意

", 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; + } +}