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