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:
@@ -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;
|
||||
@@ -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/**,<b>D7 起匿名只读</b>):给学生/教师/访客申请用的"事项大厅"数据面,
|
||||
* 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——列表/筛选项出参直接复用
|
||||
* {@link OapiModels.ListData} / {@link OapiModels.Filters},不另立 VO(形状单一事实源)。
|
||||
* <p>
|
||||
* 与 oapi 的区别只有两点:①鉴权从"应用签名"换成"CAS + 本校成员",调用者是浏览器;
|
||||
* ②只暴露已发布事项的展示字段,附件/明细等仍走 oapi(门户用)。
|
||||
* 与 oapi 的区别只有两点:①鉴权:{@code /api/hall/**} 不在 {@code yh.sso.url-pattern} 列表内
|
||||
* (D7 匿名面,与 CMS 门户 {@code /portal/**} 同一策略),{@code BoaUser} 参数因此恒可为 null,
|
||||
* 本控制器一律不据它判权限——可见范围只由 siteCode 决定;②只暴露已发布事项的展示字段,
|
||||
* 附件/明细等仍走 oapi(门户用)。
|
||||
* <p>
|
||||
* 新建独立前端 {@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Attachment> attachments = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 指南附件(fileUrl 为完整可达地址) */
|
||||
@Data
|
||||
public static class Attachment {
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private Long fileSize;
|
||||
}
|
||||
}
|
||||
@@ -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 出参 */
|
||||
|
||||
@@ -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<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode));
|
||||
|
||||
@@ -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 注册后分配(进白名单),占位值上线前必须替换,
|
||||
|
||||
@@ -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