周末修订
build / build-push (push) Successful in 3m59s

This commit is contained in:
chenxing
2026-09-15 23:41:34 +08:00
parent e4b843cf00
commit 1cb95225a6
52 changed files with 1250 additions and 3788 deletions
@@ -1,57 +0,0 @@
package com.yunhai.cms.controller.api.svcitem;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.model.dto.SvcDictSaveRequest;
import com.yunhai.cms.svcitem.model.vo.SvcDeptVo;
import com.yunhai.cms.svcitem.model.vo.SvcDictVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.svcitem.service.impl.SvcDictService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/**
* 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree,事项库详细设计 §3.3)。
*/
@RestController
@RequestMapping("/api/svc")
@RequiredArgsConstructor
public class SvcDictController {
private final SvcDictService svcDictService;
private final SvcItemQuery svcItemQuery;
/** 字典列表(dictType 空=全部;referenced=被引用条数) */
@GetMapping("/dict/list")
public List<SvcDictVo> list(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "dictType", required = false) String dictType) {
return svcDictService.list(authUserWithDept, siteId, dictType);
}
/** 保存(新建/改名;稳定键被引用后不可改、被引用不可删——服务端校验) */
@PostMapping("/dict/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcDictSaveRequest request) {
return svcDictService.save(authUserWithDept, request);
}
@PostMapping("/dict/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
svcDictService.delete(authUserWithDept, request);
}
/** 服务部门选项(UserBase 组树代理;平台不可达降级空列表) */
@GetMapping("/dept/tree")
public List<SvcDeptVo> deptTree(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.deptTree(authUserWithDept, siteId);
}
}
@@ -1,105 +0,0 @@
package com.yunhai.cms.controller.api.svcitem;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.model.dto.IdSortItem;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemSimpleVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* 事项管理(/api/svc/item/**,事项库详细设计 §3.3)。
* 包必须落 controller.api 之下以获得 ApiParamExceptionHandler 的参数绑定异常翻译(§3.1 修正)。
* 消费方纪律:只依赖 {@link SvcItemQuery} 接缝,不注入实现类(拆分时替换实现即可整体切换)。
*/
@RestController
@RequestMapping("/api/svc/item")
@RequiredArgsConstructor
public class SvcItemController {
private final SvcItemQuery svcItemQuery;
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept, SvcItemPageQuery query) {
return svcItemQuery.adminPage(authUserWithDept, query);
}
/** 编辑回显(ext_json.apply_url 已提取) */
@GetMapping("/get")
public SvcItemEditVo get(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return svcItemQuery.adminGet(authUserWithDept, siteId, id);
}
/** 保存(新建草稿/编辑;富文本 jsoup 清洗,apply_url 写 ext_json) */
@PostMapping("/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcItemSaveRequest request) {
return svcItemQuery.adminSave(authUserWithDept, request);
}
@PostMapping("/publish/{id}")
public void publish(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
svcItemQuery.adminPublish(authUserWithDept, siteId, id);
}
@PostMapping("/offline/{id}")
public void offline(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
svcItemQuery.adminOffline(authUserWithDept, siteId, id);
}
/** 删除(附件 svc_item 引用同步解引用) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
svcItemQuery.adminDelete(authUserWithDept, request);
}
/** 自定义排序(按传入 (id, sortNo) 落库) */
@PostMapping("/sort")
public void sort(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestBody List<IdSortItem> items) {
svcItemQuery.adminSort(authUserWithDept, siteId, items);
}
/** 同步部门名快照(可选动作:UserBase 组树改名后刷新 dept_name) */
@PostMapping("/syncDeptName")
public Map<String, Object> syncDeptName(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.syncDeptName(authUserWithDept, siteId);
}
/** 选择器精简列表 */
@GetMapping("/selList")
public List<SvcItemSimpleVo> selList(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.adminSelList(authUserWithDept, siteId);
}
/** 指南附件回显(ref_type=svc_item, ref_id=id,编辑页列表) */
@GetMapping("/attList")
public List<SvcItemAttachmentVo> attList(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return svcItemQuery.itemAttachments(authUserWithDept, siteId, id);
}
}
@@ -0,0 +1,60 @@
package com.yunhai.cms.controller.oapi.site;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 站点目录开放接口(/oapi/site/list,appKey 签名访问;V2.0 Part C.3)。
* 消费方=办事大厅 svc-hall(CmsSiteDirectoryImpl:resolveSite 反解 siteCode→schId、
* 站点选择器按校拉取;本地缓存 5min)。schId 可选:不传=全部启用站点(跨校全量,
* 供 siteCode 反解);传了=按校过滤(P0-1 契约对齐,两侧注释口径一致)。
*/
@RestController
@RequestMapping("/oapi/site")
@RequiredArgsConstructor
public class SiteOapiController {
private final CmsSiteRepository siteRepository;
@GetMapping("/list")
public List<SiteOptionVo> list(@RequestParam(value = "schId", required = false) Integer schId) {
if (schId == null) {
return siteRepository.findByStatusOrderByIdAsc(1).stream()
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
if (schId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId 非法");
}
return siteRepository.findBySchId(schId).stream()
.filter(site -> site.getStatus() != null && site.getStatus() == 1)
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
private static SiteOptionVo toVo(CmsSite site) {
SiteOptionVo vo = new SiteOptionVo();
vo.setSiteCode(site.getCode());
vo.setName(site.getName());
vo.setSchId(site.getSchId() == null ? null : site.getSchId().longValue());
return vo;
}
/** 站点选项(siteCode 为办事大厅侧定位键;schId 用于权限与租户定界) */
@Data
public static class SiteOptionVo {
private String siteCode;
private String name;
private Long schId;
}
}
@@ -1,40 +0,0 @@
package com.yunhai.cms.controller.oapi.svcitem;
import com.yunhai.cms.svcitem.model.vo.SvcItemSnapshotVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 事项跨服务快照(/oapi/**,appKey 签名访问;事项库详细设计 §3.4 冻结契约)。
* 错误一律走 BizException+信封 code(平台口径 HTTP 恒 200),未签名由签名过滤器返回 401;
* 不做"仅按 itemCode 全局搜"的降级查询——跨服务必须 (siteCode,itemCode) 成对。
* 接口不缓存(调用频次=办件量级,直查 DB)。
*/
@RestController
@RequestMapping("/oapi/svc/item")
@RequiredArgsConstructor
public class SvcItemOapiController {
private final SvcItemQuery svcItemQuery;
/** 发布态要点快照:供 svhall 发起办件时落 flw_instance(跨库不 join,办件不受后续变更影响) */
@GetMapping("/snapshot")
public SvcItemSnapshotVo snapshot(@RequestParam(value = "siteCode", required = false) String siteCode,
@RequestParam(value = "itemCode", required = false) String itemCode) {
if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode/itemCode 不得为空");
}
SvcItemSnapshotVo vo = svcItemQuery.snapshot(siteCode.trim(), itemCode.trim());
if (vo == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
}
return vo;
}
}
@@ -26,6 +26,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@@ -350,7 +351,30 @@ public class PortalController {
query.setDeptGroupId(dept);
query.setKeyword(trimToNull(q));
query.setPage(page == null || page < 1 ? 1 : page);
PortalServiceListData serviceList = svcItemQuery.portalPage(query);
// P1-4:svc-hall 停机/重启窗口(连接类异常,含 DHHttpException code=0)降级空态渲染 200,
// 不再连坐访客 500;业务性缺失语义维持 §2.3 修复后的行为(portalPage 无 code=2 分支)
PortalServiceListData serviceList;
boolean svcUnavailable;
try {
serviceList = svcItemQuery.portalPage(query);
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(降级空态渲染)siteId={}:{}", site.getId(), e.toString());
serviceList = new PortalServiceListData();
serviceList.setSiteId(site.getId());
serviceList.setPage(1);
serviceList.setPageSize(10);
serviceList.setTotal(0L);
serviceList.setTotalPages(0);
svcUnavailable = true;
}
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
model.addAttribute("canonical", url.serviceList());
return "themes/" + effectiveTheme + "/service";
}
// 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1
Map<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
@@ -400,14 +424,34 @@ public class PortalController {
if (site == null) {
return null;
}
PortalServiceItem item = svcItemQuery.portalItem(itemId, site.getId());
if (item == null) {
// P1-4:svc-hall 连接类异常降级空态渲染 200;业务性缺失(code=2→null)维持 404 语义
PortalServiceItem item;
boolean svcUnavailable;
try {
item = svcItemQuery.portalItem(itemId, site.getId());
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(指南页降级空态渲染)siteId={} itemId={}:{}",
site.getId(), itemId, e.toString());
item = null;
svcUnavailable = true;
}
if (item == null && !svcUnavailable) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
svcItemQuery.countView(itemId);
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "guide.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
return "themes/" + effectiveTheme + "/guide";
}
try {
svcItemQuery.countView(itemId);
} catch (RuntimeException e) {
log.debug("[portal] 浏览量累加失败(忽略)itemId={}:{}", itemId, e.toString());
}
model.addAttribute("url", url);
model.addAttribute("site", site);
@@ -421,10 +465,9 @@ public class PortalController {
url.rewriteUploadUrls(item.getProcessHtml()),
url.rewriteUploadUrls(item.getMaterialHtml()),
url.rewriteUploadUrls(item.getNoticeHtml())));
model.addAttribute("attachments", svcItemQuery.attachments(itemId));
model.addAttribute("attachments", attachmentsQuietly(itemId));
List<Map<String, Object>> related = new ArrayList<>();
for (PortalServiceItem rel : svcItemQuery.relatedItems(site.getId(), itemId,
item.getCategoryKey(), RELATED_ITEMS_SIZE)) {
for (PortalServiceItem rel : relatedQuietly(site.getId(), itemId, item.getCategoryKey())) {
Map<String, Object> row = new HashMap<>();
row.put("id", rel.getId());
row.put("title", rel.getTitle());
@@ -444,9 +487,13 @@ public class PortalController {
model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId()));
}
// "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先)
// ② 未填且站点配了 svc_hall_base → 生成 svhall 发起页 URL ③ 都没有 → 不注入(模板不渲染按钮)
// ② 未填且站点配了 svc_hall_base 且事项开通在线办理(onlineFlag=1 且绑流程,§3.7——
// 与 svc-hall 侧 submit 校验"未开通在线办理"口径对齐,线下/纯指南事项不再渲染按钮)
// ③ 其余 → 不注入(模板不渲染按钮)
String applyUrl = item.getApplyUrl();
if ((applyUrl == null || applyUrl.trim().isEmpty())
boolean onlineHandlable = item.getOnlineFlag() != null && item.getOnlineFlag() == 1
&& StringUtils.hasText(item.getFlowGuid());
if ((applyUrl == null || applyUrl.trim().isEmpty()) && onlineHandlable
&& site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) {
String base = site.getSvcHallBase().trim();
while (base.endsWith("/")) {
@@ -468,6 +515,39 @@ public class PortalController {
return "themes/" + effectiveTheme + "/guide";
}
/** 门户页面公共骨架属性(P1-4 降级路径与正常路径共用;q/seoDescription 等页面特有属性由调用方补) */
private void addChromeAttributes(Model model, PortalSiteSnapshot site, HttpServletRequest request,
String effectiveTheme, PortalUrlHelper url, String pageTitle) {
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("pageTitle", pageTitle);
}
/** 附件查询静默降级(svc-hall 抖动不阻断已可渲染的指南页;业务性缺失已在查询层转空) */
private List<com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo> attachmentsQuietly(Integer itemId) {
try {
return svcItemQuery.attachments(itemId);
} catch (RuntimeException e) {
log.warn("[portal] 附件查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 相关事项查询静默降级(同上) */
private List<PortalServiceItem> relatedQuietly(Integer siteId, Integer itemId, String categoryKey) {
try {
return svcItemQuery.relatedItems(siteId, itemId, categoryKey, RELATED_ITEMS_SIZE);
} catch (RuntimeException e) {
log.warn("[portal] 相关事项查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */
private Map<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
Map<String, Object> filters = new LinkedHashMap<>();
@@ -4,7 +4,6 @@ import com.yunhai.cms.config.CacheNames;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.svcitem.event.ServiceItemChangedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
@@ -45,32 +44,8 @@ public class PortalCacheEvictListener {
clear(CacheNames.LIST);
}
/**
* 事项域变更(事项库 §3.6):精确失效 cms:svcItem——itemId 非空 evict 指南页模型,
* filters:{siteId} 始终失效;不动 cms:article/cms:list/cms:site(事项不影响文章与站点快照)。
*/
/**
* 事项域变更(事项库 §3.6):失效 cms:svcItem。
* scope=item(事项增改发删排序)走精确失效:itemId 非空 evict 指南页模型,另 evict filters:{siteId};
* scope=dict/dept(字典改名停用、部门名同步)影响所有已缓存指南页的展示名快照(categoryName/objectName/deptName),
* 而 itemId 缓存键不带站点维度、无法按站精确失效 → 整缓存 clear(与本监听器 cms:list/cms:site
* 同款规模论证:单实例 Caffeine、站点 ≤20,clear 成本可忽略)。不动 cms:article/cms:list/cms:site。
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onServiceItemChanged(ServiceItemChangedEvent event) {
Cache cache = cacheManager.getCache(CacheNames.SERVICE_ITEM);
if (cache == null) {
return;
}
if (!"item".equals(event.getScope())) {
cache.clear();
return;
}
if (event.getItemId() != null) {
cache.evict(event.getItemId());
}
cache.evict("filters:" + event.getSiteId());
}
// 事项域缓存(cms:svcItem):V2.0 拆分后无本地写事件,remote 模式靠 TTL 过期
// (办事大厅变更后最多滞后 5min);ServiceItemChangedEvent 已随事项管理能力迁出。
private void evictArticle(Integer articleId) {
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
@@ -68,12 +68,11 @@ public class AttachmentService {
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图 / svc_item=事项指南附件(列表可见) / svc_item_asset=事项封面与正文内嵌资产。
* 事项侧拆两个场景的原因:指南附件查询(refType=svc_item, refId=itemId)必须只返回"用户显式上传的附件",
* 封面与正文图片若同场景会被混进指南页附件列表。
* category=单页栏目贴图。svc_item/svc_item_asset 两个事项场景已随事项库迁出办事大厅
* (V2.0,见 docs/19 §九),CMS 侧下线。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category", "svc_item", "svc_item_asset");
Arrays.asList("banner", "link", "site", "category");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -85,13 +84,13 @@ public class AttachmentService {
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category/svc_item/svc_item_asset,B2 初步标记) */
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category) */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"scene 非法:" + scene + "(仅 banner/link/site/category/svc_item/svc_item_asset)");
"scene 非法:" + scene + "(仅 banner/link/site/category)");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
@@ -37,6 +37,9 @@ public class CategoryService {
private static final java.util.Set<String> HOME_STYLE_VALUES =
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
/** 祖先链上溯上限(防脏数据自环导致死循环;正常栏目树远低于此深度) */
private static final int MAX_ANCESTOR_WALK = 64;
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
@@ -119,9 +122,29 @@ public class CategoryService {
if (!parent.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
}
if (category.getId() != null && parent.getParentId() != null
&& parent.getParentId().equals(category.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持把子栏目设为父栏目(成环)");
requireNoCycle(category, parent);
}
/**
* 防环:从目标父栏目沿<b>祖先链</b>上溯,遇到"正在保存的栏目自身"即为环。
*
* <p>原实现只挡一级父子互换(parent.parentId == 自己),三级及以上的环写得进去;
* 环会让整枝栏目在门户导航与栏目树上消失,故补成任意层级(前端下拉同步放开置灰,
* 改为保存前同口径校验 + 明确提示,前后端口径一致)。
*/
private void requireNoCycle(CmsCategory category, CmsCategory parent) {
if (category.getId() == null) {
return;
}
int guard = 0;
CmsCategory cursor = parent;
while (cursor != null && guard++ <= MAX_ANCESTOR_WALK) {
if (category.getId().equals(cursor.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"不支持把栏目挂到自身或自己的子栏目下(会成环)");
}
Integer next = cursor.getParentId();
cursor = (next == null || next == 0) ? null : categoryRepository.findById(next).orElse(null);
}
}
@@ -1,52 +0,0 @@
package com.yunhai.cms.svcitem.entity;
import com.yunhai.cms.entity.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 事项字典表(事项库详细设计 §2.2;不复用 cms_category——栏目树是内容目录,语义不同)。
* dict_key 为稳定键:已被事项引用后不可改,只允许改名 dict_name。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_service_dict",
uniqueConstraints = @UniqueConstraint(name = "uk_svc_dict", columnNames = {"site_id", "dict_type", "dict_key"}),
indexes = @Index(name = "idx_svc_dict_list", columnList = "site_id, dict_type, status, sort_no"))
public class CmsServiceDict extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** object=服务对象 / category=事项类别(ServiceDictTypeEnum 收口) */
@Column(name = "dict_type", nullable = false, length = 32)
private String dictType;
/** 稳定键(站内同类型唯一;被事项引用后不可改) */
@Column(name = "dict_key", nullable = false, length = 64)
private String dictKey;
/** 展示名 */
@Column(name = "dict_name", nullable = false, length = 128)
private String dictName;
/** 排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 0 停用 / 1 启用(停用后不出现在筛选与选择器,存量引用仍可渲染) */
@Column(name = "status", nullable = false)
private Integer status = 1;
/** 预留扩展(如颜色/图标) */
@Column(name = "ext_json", columnDefinition = "JSON")
private String extJson;
}
@@ -1,147 +0,0 @@
package com.yunhai.cms.svcitem.entity;
import com.yunhai.cms.entity.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 事项主表(事项库详细设计 §2.1)。事项按站点隔离;关联一律逻辑外键(不写 @ManyToOne,
* 同 CmsArticle 先例)。dept 为 UserBase 组树节点 id/名字的保存时快照(改名不自动同步)。
* ext_json 一期仅一个键 apply_url("我要办理"外链;空则按站点 svc_hall_base 生成)。
*
* <p>索引声明口径:带排序方向的门户列表索引 `idx_svc_item_portal`
* (site_id, status, sort_no ASC, publish_time DESC, id DESC) 含 DESC 列,Hibernate
* {@code @Index} 无法表达,故只声明在 cms_init.sql / upgrade_svcitem_r2.sql;
* 纯靠 ddl-auto=update 建立的开发库需跑一次 upgrade_svcitem_r2.sql 补建(脚本内为幂等守卫块)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_service_item",
uniqueConstraints = @UniqueConstraint(name = "uk_svc_item_code", columnNames = {"site_id", "item_code"}),
indexes = {
@Index(name = "idx_svc_item_list", columnList = "site_id, status, online_flag, sort_no"),
@Index(name = "idx_svc_item_obj", columnList = "site_id, status, object_key"),
@Index(name = "idx_svc_item_cat", columnList = "site_id, status, category_key"),
@Index(name = "idx_svc_item_dept", columnList = "site_id, status, dept_group_id")})
public class CmsServiceItem extends BaseEntity {
/** 所属站点(隔离/查询维度) */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 事项编码,站内唯一;跨服务键的一半(另一半是 siteCode) */
@Column(name = "item_code", nullable = false, length = 64)
private String itemCode;
/** 事项名称 */
@Column(name = "title", nullable = false, length = 200)
private String title;
/** 一句话简介(列表卡片副标题、快照摘要) */
@Column(name = "summary", length = 500)
private String summary;
/** 服务对象,指向 cms_service_dict(dict_type=object) 的 dict_key */
@Column(name = "object_key", length = 64)
private String objectKey;
/** 事项类别,指向 cms_service_dict(dict_type=category) 的 dict_key */
@Column(name = "category_key", length = 64)
private String categoryKey;
/** 服务部门 = UserBase 组树节点 id 快照(不建字典表) */
@Column(name = "dept_group_id")
private Integer deptGroupId;
/** 部门名快照(列表/指南免跨服务查名;改名不自动同步) */
@Column(name = "dept_name", length = 128)
private String deptName;
/** 办理方式:1 线上 / 2 线下 / 3 线上线下(HandleModeEnum) */
@Column(name = "handle_mode", nullable = false)
private Integer handleMode = 1;
/** 是否可在线办理:1 是 / 0 否("在线办理"筛选维度,与 handle_mode 允许不一致) */
@Column(name = "online_flag", nullable = false)
private Integer onlineFlag = 1;
/** 办理地点 */
@Column(name = "handle_place", length = 200)
private String handlePlace;
/** 办理时限(文本,如"3 个工作日";一期不做工作日历) */
@Column(name = "handle_time_limit", length = 64)
private String handleTimeLimit;
/** 承诺时限(文本) */
@Column(name = "promise_time_limit", length = 64)
private String promiseTimeLimit;
/** 咨询电话 */
@Column(name = "contact_phone", length = 64)
private String contactPhone;
/** 监督投诉电话 */
@Column(name = "supervise_phone", length = 64)
private String supervisePhone;
/** 办理流程(保存时 jsoup 白名单清洗,同 article.content 口径) */
@Column(name = "process_html", columnDefinition = "LONGTEXT")
private String processHtml;
/** 所需材料(同上) */
@Column(name = "material_html", columnDefinition = "LONGTEXT")
private String materialHtml;
/** 注意事项(同上) */
@Column(name = "notice_html", columnDefinition = "LONGTEXT")
private String noticeHtml;
/** 关联 CMS 栏目:常见问题(逻辑外键 → cms_category.id) */
@Column(name = "faq_category_id")
private Integer faqCategoryId;
/** 关联 CMS 栏目:公告信息 */
@Column(name = "notice_category_id")
private Integer noticeCategoryId;
/** 关联 CMS 栏目:文件规定 */
@Column(name = "rule_category_id")
private Integer ruleCategoryId;
/** 事项图标/封面(存上传相对路径,渲染走 url.upload(...)) */
@Column(name = "cover_image", length = 500)
private String coverImage;
/** 管理端自定义排序(列表默认排序第一优先级,升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 状态:0 草稿 / 1 已发布 / 2 已下架(ServiceItemStatusEnum) */
@Column(name = "status", nullable = false)
private Integer status = 0;
/** 发布时间 */
@Column(name = "publish_time")
private LocalDateTime publishTime;
/** 下架时间 */
@Column(name = "offline_time")
private LocalDateTime offlineTime;
/** 指南页浏览量(异步累加,不承诺精确) */
@Column(name = "view_count", nullable = false)
private Integer viewCount = 0;
/** 扩展字段 JSON(一期仅 apply_url:http(s) 外链,空则不渲染"我要办理") */
@Column(name = "ext_json", columnDefinition = "JSON")
private String extJson;
}
@@ -1,37 +0,0 @@
package com.yunhai.cms.svcitem.enums;
/**
* 办理方式(事项库详细设计 §2.4)。
* 与 online_flag 语义不同、允许不一致:如「线上线下」但本期未开通在线入口时 online_flag=0。
*/
public enum HandleModeEnum {
/** 线上办理 */
ONLINE(1),
/** 线下办理 */
OFFLINE(2),
/** 线上线下均可 */
BOTH(3);
private final int code;
HandleModeEnum(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static boolean isValid(Integer code) {
if (code == null) {
return false;
}
for (HandleModeEnum e : values()) {
if (e.code == code) {
return true;
}
}
return false;
}
}
@@ -1,35 +0,0 @@
package com.yunhai.cms.svcitem.enums;
/**
* 事项字典类型(一期两类,值域由本枚举收口,事项库详细设计 §2.2):
* object=服务对象(个人/法人…)、category=事项类别(教务/后勤…)。
*/
public enum ServiceDictTypeEnum {
/** 服务对象 */
OBJECT("object"),
/** 事项类别 */
CATEGORY("category");
private final String key;
ServiceDictTypeEnum(String key) {
this.key = key;
}
public String getKey() {
return key;
}
/** 非法值(含 null)返回 null,由调用方决定报错口径 */
public static ServiceDictTypeEnum byKey(String key) {
if (key != null) {
for (ServiceDictTypeEnum e : values()) {
if (e.key.equals(key)) {
return e;
}
}
}
return null;
}
}
@@ -1,37 +0,0 @@
package com.yunhai.cms.svcitem.enums;
/**
* 事项状态(对齐 ArticleStatusEnum 三态,事项库详细设计 §2.4):
* 草稿→发布→下架,重新编辑可从已发布/已下架回到草稿语义(保存即修改,状态不自动迁移)。
* 门户可见性:仅已发布。
*/
public enum ServiceItemStatusEnum {
/** 草稿 */
DRAFT(0),
/** 已发布 */
PUBLISHED(1),
/** 已下架 */
OFFLINE(2);
private final int code;
ServiceItemStatusEnum(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static ServiceItemStatusEnum byCode(Integer code) {
if (code != null) {
for (ServiceItemStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
}
return DRAFT;
}
}
@@ -1,36 +0,0 @@
package com.yunhai.cms.svcitem.event;
/**
* 事项域变更事件(事项库详细设计 §3.6,照 SiteContentChangedEvent 形态)。
* 事务提交后由 PortalCacheEvictListener 失效 cms:svcItem 缓存:
* scope=item(事项自身增改发删排序)→ itemId 非空 evict 指南页模型 + evict filters:{siteId};
* scope=dict/dept(字典改名停用、部门名同步)→ 影响所有指南页的展示名快照 → 整缓存 clear。
* 业务代码禁止直接 evict 缓存,一律发事件。
*/
public class ServiceItemChangedEvent {
private final Integer siteId;
private final Integer itemId;
/** 变更来源说明:item=事项自身(精确失效)/ dict=字典 / dept=部门名同步(后两者整缓存失效) */
private final String scope;
public ServiceItemChangedEvent(Integer siteId, Integer itemId, String scope) {
this.siteId = siteId;
this.itemId = itemId;
this.scope = scope;
}
public Integer getSiteId() {
return siteId;
}
public Integer getItemId() {
return itemId;
}
public String getScope() {
return scope;
}
}
@@ -1,18 +0,0 @@
package com.yunhai.cms.svcitem.model.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 事项排序项(POST /api/svc/item/sort:按传入顺序落库 sortNo)。
*/
@Data
public class IdSortItem {
@NotNull(message = "id 不得为空")
private Integer id;
@NotNull(message = "sortNo 不得为空")
private Integer sortNo;
}
@@ -1,41 +0,0 @@
package com.yunhai.cms.svcitem.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/**
* 事项字典保存入参(事项库详细设计 §2.2/§3.3)。
* dict_key 为稳定键:编辑时若已被事项引用则不可修改(服务端校验),只能改名 dict_name。
*/
@Data
public class SvcDictSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
/** object=服务对象 / category=事项类别(ServiceDictTypeEnum 收口) */
@NotBlank(message = "字典类型不得为空")
private String dictType;
@NotBlank(message = "字典键不得为空")
@Pattern(regexp = "^[A-Za-z0-9_-]{1,64}$",
message = "字典键仅限字母/数字/下划线/中划线")
@Size(max = 64)
private String dictKey;
@NotBlank(message = "字典名称不得为空")
@Size(max = 128, message = "字典名称过长")
private String dictName;
private Integer sortNo;
/** 0 停用 / 1 启用(缺省 1;停用后不出现在筛选与选择器,存量引用仍可渲染) */
private Integer status;
}
@@ -1,98 +0,0 @@
package com.yunhai.cms.svcitem.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.util.List;
/**
* 事项保存入参(事项库详细设计 §3.3)。富文本三字段服务端 jsoup 清洗;
* applyUrl 进 ext_json(渲染优先级见 PortalController.doGuide:手填 > svc_hall_base 生成 > 不渲染)。
*/
@Data
public class SvcItemSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
/** 事项编码:站内唯一,跨服务键的一半(URL 安全字符,进 svhall 发起页 URL) */
@NotBlank(message = "事项编码不得为空")
@Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$",
message = "事项编码仅限字母/数字/下划线/中划线,2~64 位")
private String itemCode;
@NotBlank(message = "事项名称不得为空")
@Size(max = 200, message = "事项名称过长")
private String title;
@Size(max = 500, message = "简介过长")
private String summary;
/** 服务对象(cms_service_dict dict_type=object 的 dict_key,须为启用字典) */
@Size(max = 64)
private String objectKey;
/** 事项类别(dict_type=category 的 dict_key,须为启用字典) */
@Size(max = 64)
private String categoryKey;
/** 服务部门(UserBase 组树节点 id;服务端按组树校验并刷新部门名快照) */
private Integer deptGroupId;
/** 办理方式:1 线上 / 2 线下 / 3 线上线下(缺省 1;与 onlineFlag 允许不一致) */
private Integer handleMode;
/** 是否可在线办理:1/0(缺省 1;"在线办理"筛选维度) */
private Integer onlineFlag;
@Size(max = 200)
private String handlePlace;
@Size(max = 64)
private String handleTimeLimit;
@Size(max = 64)
private String promiseTimeLimit;
@Size(max = 64)
private String contactPhone;
@Size(max = 64)
private String supervisePhone;
/** 办理流程 HTML(服务端 jsoup 白名单清洗) */
private String processHtml;
/** 所需材料 HTML(同上) */
private String materialHtml;
/** 注意事项 HTML(同上) */
private String noticeHtml;
/** 关联栏目:常见问题(须属于本站点) */
private Integer faqCategoryId;
/** 关联栏目:公告信息 */
private Integer noticeCategoryId;
/** 关联栏目:文件规定 */
private Integer ruleCategoryId;
@Size(max = 500)
private String coverImage;
private Integer sortNo;
/** "我要办理"外链(写入 ext_json.apply_url;须 http(s)://,空=按站点 svc_hall_base 生成) */
@Size(max = 500)
private String applyUrl;
/** 指南页附件 id 列表(保存时按此列表精确重绑 ref_type=svc_item, ref_id=事项id;不在列表中的旧引用被解除) */
private List<Integer> attachmentIds;
}
@@ -1,30 +0,0 @@
package com.yunhai.cms.svcitem.model.query;
import lombok.Data;
/**
* 事项管理端分页查询(事项库详细设计 §3.3)。
*/
@Data
public class SvcItemPageQuery {
private Integer siteId;
/** 状态:空=全部,0=草稿 1=已发布 2=已下架 */
private Integer status;
/** 名称/编码关键字 */
private String keyword;
private String objectKey;
private String categoryKey;
private Integer deptGroupId;
/** 页码,1 起 */
private Integer page = 1;
/** 页长(服务端上限 100) */
private Integer pageSize = 10;
}
@@ -1,7 +1,5 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import com.yunhai.cms.utils.ExtJsonUtils;
import lombok.Data;
import java.time.LocalDateTime;
@@ -73,39 +71,8 @@ public class PortalServiceItem {
/** ext_json.apply_url 原始值(可空) */
private String applyUrl;
public static PortalServiceItem of(CmsServiceItem item, String objectName, String categoryName) {
PortalServiceItem vo = new PortalServiceItem();
vo.setId(item.getId());
vo.setSiteId(item.getSiteId());
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setSummary(item.getSummary());
vo.setObjectKey(item.getObjectKey());
vo.setObjectName(objectName);
vo.setCategoryKey(item.getCategoryKey());
vo.setCategoryName(categoryName);
vo.setDeptGroupId(item.getDeptGroupId());
vo.setDeptName(item.getDeptName());
vo.setHandleMode(item.getHandleMode());
vo.setOnlineFlag(item.getOnlineFlag());
vo.setHandlePlace(item.getHandlePlace());
vo.setHandleTimeLimit(item.getHandleTimeLimit());
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
vo.setContactPhone(item.getContactPhone());
vo.setSupervisePhone(item.getSupervisePhone());
vo.setProcessHtml(item.getProcessHtml());
vo.setMaterialHtml(item.getMaterialHtml());
vo.setNoticeHtml(item.getNoticeHtml());
vo.setFaqCategoryId(item.getFaqCategoryId());
vo.setNoticeCategoryId(item.getNoticeCategoryId());
vo.setRuleCategoryId(item.getRuleCategoryId());
vo.setCoverImage(item.getCoverImage());
vo.setPublishTime(item.getPublishTime());
vo.setViewCount(item.getViewCount());
Map<String, Object> ext = ExtJsonUtils.parse(item.getExtJson());
vo.setApplyUrl(ExtJsonUtils.getString(ext, "apply_url"));
return vo;
}
/** 绑定流程 GUID(可空;§3.7"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空) */
private String flowGuid;
/** copy-on-write:渲染期补基准(同 doArticle 的 detail.withContent 先例,避免污染缓存对象) */
public PortalServiceItem withRewrittenHtml(String processHtml, String materialHtml, String noticeHtml) {
@@ -138,6 +105,7 @@ public class PortalServiceItem {
copy.setPublishTime(publishTime);
copy.setViewCount(viewCount);
copy.setApplyUrl(applyUrl);
copy.setFlowGuid(flowGuid);
return copy;
}
}
@@ -1,6 +1,5 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import lombok.Data;
import java.time.LocalDateTime;
@@ -53,20 +52,6 @@ public class PortalServiceListData {
private LocalDateTime publishTime;
public static Item of(CmsServiceItem item, String categoryName) {
Item vo = new Item();
vo.setId(item.getId());
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setSummary(item.getSummary());
vo.setCoverImage(item.getCoverImage());
vo.setDeptName(item.getDeptName());
vo.setCategoryName(categoryName);
vo.setHandleMode(item.getHandleMode());
vo.setOnlineFlag(item.getOnlineFlag());
vo.setPublishTime(item.getPublishTime());
return vo;
}
}
@Data
@@ -1,28 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 服务部门选项(UserBase 组树按类型过滤后的**嵌套**形态,/api/svc/dept/tree 返回体)。
* children 为空表示叶子;层级来自组树本身(非部门祖先节点不下发自身,但会被下钻穿透)。
*/
@Data
public class SvcDeptVo {
private Integer id;
private String name;
private List<SvcDeptVo> children = new ArrayList<>();
public SvcDeptVo() {
}
public SvcDeptVo(Integer id, String name) {
this.id = id;
this.name = name;
}
}
@@ -1,43 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
import lombok.Data;
/**
* 事项字典 VO(管理端列表/选择器共用)。
* referenced:被事项引用的条数(全状态口径)——改键拦截与停用提示的展示依据。
*/
@Data
public class SvcDictVo {
private Integer id;
private Integer siteId;
private String dictType;
private String dictKey;
private String dictName;
private Integer sortNo;
/** 0 停用 / 1 启用 */
private Integer status;
/** 被事项引用条数(服务端装配) */
private Long referenced;
public static SvcDictVo of(CmsServiceDict dict, Long referenced) {
SvcDictVo vo = new SvcDictVo();
vo.setId(dict.getId());
vo.setSiteId(dict.getSiteId());
vo.setDictType(dict.getDictType());
vo.setDictKey(dict.getDictKey());
vo.setDictName(dict.getDictName());
vo.setSortNo(dict.getSortNo());
vo.setStatus(dict.getStatus());
vo.setReferenced(referenced == null ? 0L : referenced);
return vo;
}
}
@@ -1,110 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import com.yunhai.cms.utils.ExtJsonUtils;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 事项编辑回显 VO(含 ext_json 提取的 applyUrl 与状态/时间,供表单回显与按钮态)。
*/
@Data
public class SvcItemEditVo {
private Integer id;
private Integer siteId;
private String itemCode;
private String title;
private String summary;
private String objectKey;
private String categoryKey;
private Integer deptGroupId;
private String deptName;
private Integer handleMode;
private Integer onlineFlag;
private String handlePlace;
private String handleTimeLimit;
private String promiseTimeLimit;
private String contactPhone;
private String supervisePhone;
/** 已清洗入库内容原样回显 */
private String processHtml;
private String materialHtml;
private String noticeHtml;
private Integer faqCategoryId;
private Integer noticeCategoryId;
private Integer ruleCategoryId;
private String coverImage;
private Integer sortNo;
private Integer status;
private LocalDateTime publishTime;
private LocalDateTime offlineTime;
private Integer viewCount;
/** ext_json.apply_url("我要办理"手填外链,可空) */
private String applyUrl;
public static SvcItemEditVo of(CmsServiceItem item) {
SvcItemEditVo vo = new SvcItemEditVo();
vo.setId(item.getId());
vo.setSiteId(item.getSiteId());
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setSummary(item.getSummary());
vo.setObjectKey(item.getObjectKey());
vo.setCategoryKey(item.getCategoryKey());
vo.setDeptGroupId(item.getDeptGroupId());
vo.setDeptName(item.getDeptName());
vo.setHandleMode(item.getHandleMode());
vo.setOnlineFlag(item.getOnlineFlag());
vo.setHandlePlace(item.getHandlePlace());
vo.setHandleTimeLimit(item.getHandleTimeLimit());
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
vo.setContactPhone(item.getContactPhone());
vo.setSupervisePhone(item.getSupervisePhone());
vo.setProcessHtml(item.getProcessHtml());
vo.setMaterialHtml(item.getMaterialHtml());
vo.setNoticeHtml(item.getNoticeHtml());
vo.setFaqCategoryId(item.getFaqCategoryId());
vo.setNoticeCategoryId(item.getNoticeCategoryId());
vo.setRuleCategoryId(item.getRuleCategoryId());
vo.setCoverImage(item.getCoverImage());
vo.setSortNo(item.getSortNo());
vo.setStatus(item.getStatus());
vo.setPublishTime(item.getPublishTime());
vo.setOfflineTime(item.getOfflineTime());
vo.setViewCount(item.getViewCount());
Map<String, Object> ext = ExtJsonUtils.parse(item.getExtJson());
vo.setApplyUrl(ExtJsonUtils.getString(ext, "apply_url"));
return vo;
}
}
@@ -1,79 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 事项管理端分页行(事项库详细设计 §3.3)。
* objectName/categoryName 由服务端按字典表回填(含停用字典——管理端需看到历史归属)。
*/
@Data
public class SvcItemPageItem {
private Integer id;
private Integer siteId;
private String itemCode;
private String title;
private String summary;
private String objectKey;
private String objectName;
private String categoryKey;
private String categoryName;
private Integer deptGroupId;
private String deptName;
/** 1 线上 / 2 线下 / 3 线上线下 */
private Integer handleMode;
/** 1/0 */
private Integer onlineFlag;
/** 0 草稿 / 1 已发布 / 2 已下架 */
private Integer status;
private Integer sortNo;
private LocalDateTime publishTime;
private Integer viewCount;
private LocalDateTime createTime;
private String createBy;
public static SvcItemPageItem of(CmsServiceItem item, String objectName, String categoryName) {
SvcItemPageItem vo = new SvcItemPageItem();
vo.setId(item.getId());
vo.setSiteId(item.getSiteId());
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setSummary(item.getSummary());
vo.setObjectKey(item.getObjectKey());
vo.setObjectName(objectName);
vo.setCategoryKey(item.getCategoryKey());
vo.setCategoryName(categoryName);
vo.setDeptGroupId(item.getDeptGroupId());
vo.setDeptName(item.getDeptName());
vo.setHandleMode(item.getHandleMode());
vo.setOnlineFlag(item.getOnlineFlag());
vo.setStatus(item.getStatus());
vo.setSortNo(item.getSortNo());
vo.setPublishTime(item.getPublishTime());
vo.setViewCount(item.getViewCount());
vo.setCreateTime(item.getCreateTime());
vo.setCreateBy(item.getCreateBy());
return vo;
}
}
@@ -1,28 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import lombok.Data;
/**
* 事项选择器精简行(id/itemCode/title/status)。
*/
@Data
public class SvcItemSimpleVo {
private Integer id;
private String itemCode;
private String title;
private Integer status;
public static SvcItemSimpleVo of(CmsServiceItem item) {
SvcItemSimpleVo vo = new SvcItemSimpleVo();
vo.setId(item.getId());
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setStatus(item.getStatus());
return vo;
}
}
@@ -1,37 +0,0 @@
package com.yunhai.cms.svcitem.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 跨服务要点快照(oapi /oapi/svc/item/snapshot 返回体,事项库详细设计 §3.4 冻结契约)。
* 供 svhall 发起办件时落 flw_instance;跨库不 join,历史办件不受事项后续改名/下架影响。
*/
@Data
public class SvcItemSnapshotVo {
private String siteCode;
private String itemCode;
private String title;
private String summary;
private String deptName;
/** 1 线上 / 2 线下 / 3 线上线下 */
private Integer handleMode;
private Integer onlineFlag;
private String handleTimeLimit;
private String contactPhone;
/** 可空:手填外链;svhall 原生办理模式可忽略 */
private String applyUrl;
private LocalDateTime publishTime;
}
@@ -1,23 +0,0 @@
package com.yunhai.cms.svcitem.repository;
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CmsServiceDictRepository extends JpaRepository<CmsServiceDict, Integer> {
List<CmsServiceDict> findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(Integer siteId, String dictType);
List<CmsServiceDict> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
Optional<CmsServiceDict> findByIdAndSiteId(Integer id, Integer siteId);
Optional<CmsServiceDict> findBySiteIdAndDictTypeAndDictKey(Integer siteId, String dictType, String dictKey);
boolean existsBySiteIdAndDictTypeAndDictKey(Integer siteId, String dictType, String dictKey);
boolean existsBySiteIdAndDictTypeAndDictKeyAndIdNot(Integer siteId, String dictType, String dictKey,
Integer id);
}
@@ -1,71 +0,0 @@
package com.yunhai.cms.svcitem.repository;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface CmsServiceItemRepository extends JpaRepository<CmsServiceItem, Integer>,
JpaSpecificationExecutor<CmsServiceItem> {
Optional<CmsServiceItem> findByIdAndSiteId(Integer id, Integer siteId);
/** 跨服务键查询:uk(site_id, item_code) 命中即唯一 */
Optional<CmsServiceItem> findBySiteIdAndItemCode(Integer siteId, String itemCode);
boolean existsBySiteIdAndItemCode(Integer siteId, String itemCode);
boolean existsBySiteIdAndItemCodeAndIdNot(Integer siteId, String itemCode, Integer id);
/** 字典键被事项引用计数(字典改键/停用守护用) */
long countBySiteIdAndObjectKey(Integer siteId, String objectKey);
long countBySiteIdAndCategoryKey(Integer siteId, String categoryKey);
/** 关联事项:同类别已发布,排除自身(Pageable 控制条数与排序) */
List<CmsServiceItem> findBySiteIdAndStatusAndCategoryKeyAndIdNot(Integer siteId, Integer status,
String categoryKey, Integer id,
Pageable pageable);
/** 管理端选择器:全状态按排序取(条数由 Pageable 控制) */
List<CmsServiceItem> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId, Pageable pageable);
// ===== 门户筛选项计数(已发布口径,group by 一次往返/维度,事项库 §5.2/R4)=====
@Query("select i.objectKey, count(i) from CmsServiceItem i"
+ " where i.siteId = :siteId and i.status = 1 and i.objectKey is not null"
+ " group by i.objectKey")
List<Object[]> countPublishedGroupByObjectKey(@Param("siteId") Integer siteId);
@Query("select i.categoryKey, count(i) from CmsServiceItem i"
+ " where i.siteId = :siteId and i.status = 1 and i.categoryKey is not null"
+ " group by i.categoryKey")
List<Object[]> countPublishedGroupByCategoryKey(@Param("siteId") Integer siteId);
/** 部门名取组内最新快照(组树改名后新保存的事项名覆盖旧值) */
@Query("select i.deptGroupId, max(i.deptName), count(i) from CmsServiceItem i"
+ " where i.siteId = :siteId and i.status = 1 and i.deptGroupId is not null"
+ " group by i.deptGroupId")
List<Object[]> countPublishedGroupByDept(@Param("siteId") Integer siteId);
/** 指南页浏览量异步累加(fire-and-forget,失败忽略,比照 ViewCounterService 口径) */
@Modifying
@Query("update CmsServiceItem i set i.viewCount = i.viewCount + 1 where i.id = :id")
int incrementViewCount(@Param("id") Integer id);
// ===== 字典引用计数(全状态口径,字典改键/停用守护与列表"被引用"展示用)=====
@Query("select i.objectKey, count(i) from CmsServiceItem i"
+ " where i.siteId = :siteId and i.objectKey is not null group by i.objectKey")
List<Object[]> countGroupByObjectKey(@Param("siteId") Integer siteId);
@Query("select i.categoryKey, count(i) from CmsServiceItem i"
+ " where i.siteId = :siteId and i.categoryKey is not null group by i.categoryKey")
List<Object[]> countGroupByCategoryKey(@Param("siteId") Integer siteId);
}
@@ -1,21 +0,0 @@
package com.yunhai.cms.svcitem.service;
import com.yunhai.platform.utils.model.BoaGroup;
/**
* 部门组树来源接缝(事项库 R2 追加):默认实现代理 UserBase oapi 的静态调用
* {@code YschBaseOpenApi.getGroupTree()}。
*
* <p>包一层 bean 的理由:静态方法在测试中无法替身,导致"部门可达"这条主路径长期零覆盖
* (SchoolService 同款调用只能用"不可达"降级路径绕测)。抽出接缝后单测可注入固定组树,
* 断言"类型过滤 / 层级构建 / 快照取名 / id 不在树上拒绝"全链路;拆分独立应用时也只需换实现。
*/
public interface SvcDeptSource {
/**
* 拉取组织机构组树根节点。
*
* @return 组树根;平台不可达或异常时返回 {@code null}(不抛出,降级口径由消费方决定)
*/
BoaGroup getGroupTree();
}
@@ -1,34 +1,26 @@
package com.yunhai.cms.svcitem.service;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.model.dto.IdSortItem;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcDeptVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemSimpleVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemSnapshotVo;
import com.yunhai.platform.utils.model.BoaUser;
import java.util.List;
import java.util.Map;
/**
* 事项域进程内接缝(V1.1 §4.2 / 详细设计 §3.2):门户/管理端/跨服务统一经本接口消费事项数据,
* 不直接依赖 Repository。拆分触发(V1.1 §2.6 单校>5000 条等)命中时,只需替换为
* {@code @ConditionalOnProperty} 的 oapi 实现类,三个消费方零改动。
* 事项域进程内接缝(V2.0 拆分后收敛为**门户消费面**):
* 事项数据与管理能力已迁至独立"办事大厅"应用(svc-hall),CMS 只保留门户主题化展示壳,
* 本接口由 {@code SvcItemRemoteQuery}(经签名 oapi 调办事大厅 /oapi/svc/** 开放接口)实现,
* PortalController 等消费方零改动(原接口注释"拆分时换 @ConditionalOnProperty 实现"的兑现)。
* 原管理端方法与 snapshot 随管理功能/快照口一并迁出,见 docs/19 §九。
*/
public interface SvcItemQuery {
/** 门户:服务中心列表分页(三维筛选 + 关键词;实时查询不缓存) */
PortalServiceListData portalPage(SvcItemPortalQuery query);
/** 门户:三组筛选项 + 计数(已发布口径;缓存 filters:{siteId}) */
/** 门户:三组筛选项 + 计数(已发布口径;缓存 filters:{siteId},remote 模式 TTL 过期) */
PortalServiceFilters portalFilters(Integer siteId);
/** 门户:指南页模型(不存在/未发布返回 null,缓存 null 哨兵 30s 防穿透) */
@@ -37,47 +29,9 @@ public interface SvcItemQuery {
/** 门户:关联事项(同类别已发布 Top N,排除自身) */
List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit);
/** 跨服务:按 (siteCode,itemCode) 取发布态要点快照;查不到/未发布返回 null(不做全局降级查询) */
SvcItemSnapshotVo snapshot(String siteCode, String itemCode);
/** 门户:指南页附件(ref_type=svc_item, ref_id=itemId) */
/** 门户:指南页附件(办事大厅 svc_item_guide 绑定,fileUrl 已是完整地址) */
List<SvcItemAttachmentVo> attachments(Integer itemId);
/** 门户:指南页浏览量异步累加(不承诺精确,失败忽略) */
/** 门户:指南页浏览量异步累加(转发办事大厅 /oapi/svc/item/view,不承诺精确) */
void countView(Integer itemId);
// ===== 管理端 =====
/** 分页({total, list:[SvcItemPageItem]},pageSize 上限 100) */
Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query);
/** 编辑回显 */
SvcItemEditVo adminGet(BoaUser user, Integer siteId, Integer id);
/** 保存(新建草稿/编辑;返回 id) */
Integer adminSave(BoaUser user, SvcItemSaveRequest request);
/** 发布(草稿/已下架 → 已发布) */
void adminPublish(BoaUser user, Integer siteId, Integer id);
/** 下架(已发布 → 已下架) */
void adminOffline(BoaUser user, Integer siteId, Integer id);
/** 删除(物理删除 + 附件解引用) */
void adminDelete(BoaUser user, DeleteRequest request);
/** 选择器精简列表(上限 SEL_LIST_MAX,超出记 warn) */
List<SvcItemSimpleVo> adminSelList(BoaUser user, Integer siteId);
/** 自定义排序(按传入 (id, sortNo) 落库;列表默认排序第一优先级) */
void adminSort(BoaUser user, Integer siteId, List<IdSortItem> items);
/** 同步部门名快照(按组树刷新全站 dept_name,返回 {updated:n}) */
Map<String, Object> syncDeptName(BoaUser user, Integer siteId);
/** 服务部门选项(UserBase 组树按配置类型过滤,嵌套下发) */
List<SvcDeptVo> deptTree(BoaUser user, Integer siteId);
/** 事项当前绑定的指南附件(编辑回显;ref_type=svc_item, ref_id=itemId) */
List<SvcItemAttachmentVo> itemAttachments(BoaUser user, Integer siteId, Integer itemId);
}
@@ -1,169 +0,0 @@
package com.yunhai.cms.svcitem.service.impl;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
import com.yunhai.cms.svcitem.enums.ServiceDictTypeEnum;
import com.yunhai.cms.svcitem.event.ServiceItemChangedEvent;
import com.yunhai.cms.svcitem.model.dto.SvcDictSaveRequest;
import com.yunhai.cms.svcitem.model.vo.SvcDictVo;
import com.yunhai.cms.svcitem.repository.CmsServiceDictRepository;
import com.yunhai.cms.svcitem.repository.CmsServiceItemRepository;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 事项字典服务(事项库详细设计 §2.2/§3.3):object=服务对象 / category=事项类别 两类。
* 纪律:dict_key 稳定键——被事项引用后不可改(只能改名 dict_name)、被引用不可删除(只能停用);
* 停用后不出现在门户筛选与管理端选择器,存量引用仍可渲染。
* 字典变更发 ServiceItemChangedEvent(itemId=null) 失效 filters:{siteId}(指南页 categoryName
* 残留由 5min TTL 自愈,与文章列表缓存口径一致)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SvcDictService {
private final CmsServiceDictRepository dictRepository;
private final CmsServiceItemRepository itemRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
/** 字典列表(管理端;referenced=被事项引用条数,全状态口径) */
public List<SvcDictVo> list(BoaUser user, Integer siteId, String dictType) {
siteService.requireManageable(user, siteId);
if (dictType != null && !dictType.trim().isEmpty() && ServiceDictTypeEnum.byKey(dictType) == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + dictType + "(仅 object/category)");
}
List<CmsServiceDict> dicts = dictType == null || dictType.trim().isEmpty()
? dictRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId)
: dictRepository.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, dictType);
Map<String, Long> objectRefs = countMap(itemRepository.countGroupByObjectKey(siteId));
Map<String, Long> categoryRefs = countMap(itemRepository.countGroupByCategoryKey(siteId));
List<SvcDictVo> result = new ArrayList<>();
for (CmsServiceDict dict : dicts) {
Map<String, Long> refs = ServiceDictTypeEnum.CATEGORY.getKey().equals(dict.getDictType())
? categoryRefs : objectRefs;
result.add(SvcDictVo.of(dict, refs.get(dict.getDictKey())));
}
return result;
}
/** 保存:新建校验 uk;编辑改键需未被引用;停用不受限(存量引用仍可渲染) */
@Transactional
public Integer save(BoaUser user, SvcDictSaveRequest req) {
siteService.requireManageable(user, req.getSiteId());
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(req.getDictType());
if (type == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + req.getDictType() + "(仅 object/category)");
}
CmsServiceDict dict;
if (req.getId() == null) {
if (dictRepository.existsBySiteIdAndDictTypeAndDictKey(
req.getSiteId(), type.getKey(), req.getDictKey())) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
}
dict = new CmsServiceDict();
dict.setSiteId(req.getSiteId());
dict.setDictType(type.getKey());
dict.setDictKey(req.getDictKey());
} else {
dict = dictRepository.findByIdAndSiteId(req.getId(), req.getSiteId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
if (!dict.getDictKey().equals(req.getDictKey())) {
long referenced = referencedCount(req.getSiteId(), type, dict.getDictKey());
if (referenced > 0) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字典键已被 " + referenced + " 个事项引用,不可修改;只能修改名称");
}
if (dictRepository.existsBySiteIdAndDictTypeAndDictKeyAndIdNot(
req.getSiteId(), type.getKey(), req.getDictKey(), req.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
}
dict.setDictKey(req.getDictKey());
}
}
dict.setDictName(req.getDictName());
dict.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
dict.setStatus(req.getStatus() == null ? 1 : req.getStatus());
dict = dictRepository.save(dict);
eventPublisher.publishEvent(new ServiceItemChangedEvent(req.getSiteId(), null, "dict"));
return dict.getId();
}
/** 删除:被引用即拦(与栏目删除一致的引用完整性规则);未被引用才可删 */
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
siteService.requireManageable(user, req.getSiteId());
CmsServiceDict dict = dictRepository.findByIdAndSiteId(req.getId(), req.getSiteId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(dict.getDictType());
long referenced = referencedCount(req.getSiteId(), type, dict.getDictKey());
if (referenced > 0) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字典已被 " + referenced + " 个事项引用,不能删除;可改为停用(存量引用仍可渲染)");
}
dictRepository.delete(dict);
eventPublisher.publishEvent(new ServiceItemChangedEvent(req.getSiteId(), null, "dict"));
}
/** 键 → 展示名(全状态口径:停用字典的存量引用仍可渲染) */
public Map<String, String> nameMap(Integer siteId, ServiceDictTypeEnum type) {
Map<String, String> names = new HashMap<>();
for (CmsServiceDict dict : dictRepository
.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, type.getKey())) {
names.put(dict.getDictKey(), dict.getDictName());
}
return names;
}
/** 启用字典(门户筛选/管理端选择器口径:停用不出现在筛选与选择器) */
public List<CmsServiceDict> enabledDicts(Integer siteId, ServiceDictTypeEnum type) {
List<CmsServiceDict> result = new ArrayList<>();
for (CmsServiceDict dict : dictRepository
.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, type.getKey())) {
if (dict.getStatus() != null && dict.getStatus() == 1) {
result.add(dict);
}
}
return result;
}
/** 键必须存在于启用字典(保存事项时的归属校验) */
public void requireEnabledKey(Integer siteId, ServiceDictTypeEnum type, String dictKey) {
CmsServiceDict dict = dictRepository
.findBySiteIdAndDictTypeAndDictKey(siteId, type.getKey(), dictKey)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别") + "不存在:" + dictKey));
if (dict.getStatus() == null || dict.getStatus() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别")
+ "已停用,请先启用或更换:" + dictKey);
}
}
private long referencedCount(Integer siteId, ServiceDictTypeEnum type, String dictKey) {
return type == ServiceDictTypeEnum.CATEGORY
? itemRepository.countBySiteIdAndCategoryKey(siteId, dictKey)
: itemRepository.countBySiteIdAndObjectKey(siteId, dictKey);
}
private static Map<String, Long> countMap(List<Object[]> rows) {
Map<String, Long> map = new HashMap<>();
for (Object[] row : rows) {
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
}
return map;
}
}
@@ -0,0 +1,116 @@
package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.utils.http.IDHttp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 办事大厅开放接口客户端(V2.0 Part C.3):经共享 IDHttp(出站自动签名,yh.app-auth 身份
* appKey=100005)调 svc-hall 的 /oapi/svc/**;办事大厅侧 RequireAppSignFilter 验签,
* 其 yh.app-auth.trusted-apps 须包含 CMS 的 appKey(运维项)。
* 信封 {code,msg,data} 由 IDHttp 解包(code!=0 抛业务异常,即办事大厅 code=2 语义在此转异常)。
*/
@Component
public class SvcHallClient {
private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class);
private final IDHttp http;
private final String baseUrl;
public SvcHallClient(IDHttp http,
@Value("${yh.svchall.base-url:http://localhost:8086}") String baseUrl) {
this.http = http;
this.baseUrl = baseUrl == null ? "" : trimTrailing(baseUrl);
}
/** GET /oapi/svc/items → data(分页 + 三维筛选 + 关键词) */
public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId,
String keyword, Integer page, Integer pageSize) {
StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/items?siteCode=" + encode(siteCode));
append(url, "objectKey", objectKey);
append(url, "categoryKey", categoryKey);
append(url, "keyword", keyword);
if (deptGroupId != null) {
append(url, "deptGroupId", String.valueOf(deptGroupId));
}
if (page != null) {
append(url, "page", String.valueOf(page));
}
if (pageSize != null) {
append(url, "pageSize", String.valueOf(pageSize));
}
return http.get(url.toString(), JsonNode.class);
}
/** GET /oapi/svc/filters → data */
public JsonNode filters(String siteCode) {
return http.get(baseUrl + "/oapi/svc/filters?siteCode=" + encode(siteCode), JsonNode.class);
}
/** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */
public JsonNode detail(String siteCode, Integer itemId, String itemCode) {
StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/item/detail?siteCode=" + encode(siteCode));
if (itemId != null) {
append(url, "itemId", String.valueOf(itemId));
}
if (itemCode != null) {
append(url, "itemCode", itemCode);
}
return http.get(url.toString(), JsonNode.class);
}
/** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */
public void view(String siteCode, String itemCode) {
try {
http.post(baseUrl + "/oapi/svc/item/view?siteCode=" + encode(siteCode)
+ "&itemCode=" + encode(itemCode), null, JsonNode.class);
} catch (Exception e) {
log.warn("[cms] 办事大厅浏览量累加失败 siteCode={} itemCode={}:{}", siteCode, itemCode, e.getMessage());
}
}
/** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */
public void viewByItemId(Integer itemId) {
if (itemId == null) {
return;
}
try {
http.post(baseUrl + "/oapi/svc/item/view?itemId=" + itemId, null, JsonNode.class);
} catch (Exception e) {
log.warn("[cms] 办事大厅浏览量累加失败 itemId={}:{}", itemId, e.getMessage());
}
}
/** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */
public JsonNode attachments(Integer itemId) {
return http.get(baseUrl + "/oapi/svc/item/attachments?itemId=" + itemId, JsonNode.class);
}
/** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */
public JsonNode related(Integer itemId, Integer limit) {
return http.get(baseUrl + "/oapi/svc/item/related?itemId=" + itemId
+ (limit != null ? "&limit=" + limit : ""), JsonNode.class);
}
private static void append(StringBuilder url, String key, String value) {
if (value != null && !value.isEmpty()) {
url.append("&").append(key).append("=").append(encode(value));
}
}
private static String encode(String value) {
try {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8.name());
} catch (Exception e) {
return value;
}
}
private static String trimTrailing(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
}
@@ -0,0 +1,249 @@
package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.http.DHttpException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* SvcItemQuery 的远程实现(V2.0 拆分后的唯一实现):经 {@link SvcHallClient} 调办事大厅
* /oapi/svc/** 开放接口,把出参映射回原门户 VO——模板契约(docs/10 §3.4)零变化的保证。
* 缓存纪律:portalFilters/portalItem 维持原 @Cacheable 键(TTL 过期,无跨应用事件失效——
* 办事大厅变更后 CMS 最多滞后 5min);portalPage 实时调用。
*/
@Slf4j
@Service
public class SvcItemRemoteQuery implements SvcItemQuery {
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SvcHallClient client;
private final CmsSiteRepository siteRepository;
public SvcItemRemoteQuery(SvcHallClient client, CmsSiteRepository siteRepository) {
this.client = client;
this.siteRepository = siteRepository;
}
@Override
public PortalServiceListData portalPage(SvcItemPortalQuery query) {
String siteCode = siteCodeOf(query.getSiteId());
JsonNode data = client.items(siteCode, query.getObjectKey(), query.getCategoryKey(),
query.getDeptGroupId(), query.getKeyword(), query.getPage(), query.getPageSize());
PortalServiceListData out = new PortalServiceListData();
out.setSiteId(query.getSiteId());
out.setTotal(longVal(data, "total"));
out.setPage(intVal(data, "page"));
out.setPageSize(intVal(data, "pageSize"));
out.setTotalPages(intVal(data, "totalPages"));
JsonNode items = data.get("items");
if (items != null && items.isArray()) {
for (JsonNode row : items) {
PortalServiceListData.Item item = new PortalServiceListData.Item();
item.setId(intVal(row, "id"));
item.setItemCode(text(row, "itemCode"));
item.setTitle(text(row, "title"));
item.setSummary(text(row, "summary"));
item.setCoverImage(text(row, "coverImage"));
item.setDeptName(text(row, "deptName"));
item.setCategoryName(text(row, "categoryName"));
item.setHandleMode(intVal(row, "handleMode"));
item.setOnlineFlag(intVal(row, "onlineFlag"));
item.setPublishTime(time(row, "publishTime"));
out.getItems().add(item);
}
}
return out;
}
@Override
public PortalServiceFilters portalFilters(Integer siteId) {
String siteCode = siteCodeOf(siteId);
JsonNode data = client.filters(siteCode);
PortalServiceFilters out = new PortalServiceFilters();
for (JsonNode row : nodes(data, "objects")) {
out.getObjects().add(new PortalServiceFilters.Option(
text(row, "key"), text(row, "name"), longVal(row, "count")));
}
for (JsonNode row : nodes(data, "categories")) {
out.getCategories().add(new PortalServiceFilters.Option(
text(row, "key"), text(row, "name"), longVal(row, "count")));
}
for (JsonNode row : nodes(data, "depts")) {
out.getDepts().add(new PortalServiceFilters.DeptOption(
intVal(row, "id"), text(row, "name"), longVal(row, "count")));
}
return out;
}
@Override
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
String siteCode = siteCodeOf(siteId);
JsonNode row;
try {
row = client.detail(siteCode, itemId, null);
} catch (BizException e) {
// 兼容分支:服务内直抛 BizException 的旧口径(本链路实际走 DHttpException)
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
return null;
} catch (DHttpException e) {
// BP-R1-2:IDHttp 对传输失败与业务码非 0 一律抛 DHttpException(code=0 为传输类,
// 携带业务码为业务类)——原 catch(BizException) 是死代码。业务 code=2
// (不存在/未发布)→ 门户 404 语义(null 哨兵);传输/鉴权类失败上抛
// (P1-4 停机降级由 PortalController 统一承接,不混入 404)。
if (e.getCode() == 2) {
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
return null;
}
throw e;
}
return toPortalItem(row);
}
@Override
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
List<PortalServiceItem> result = new ArrayList<>();
if (categoryKey == null || categoryKey.isEmpty()) {
return result;
}
JsonNode row;
try {
row = client.related(itemId, limit);
} catch (BizException e) {
return result;
} catch (DHttpException e) {
// 业务性缺失(code=2)→ 相关事项空;传输类失败上抛(停机降级口径见 portalItem 注释)
if (e.getCode() == 2) {
return result;
}
throw e;
}
for (JsonNode rel : row.isArray() ? row : new ArrayList<JsonNode>()) {
PortalServiceItem vo = new PortalServiceItem();
vo.setId(intVal(rel, "id"));
vo.setItemCode(text(rel, "itemCode"));
vo.setTitle(text(rel, "title"));
vo.setDeptName(text(rel, "deptName"));
vo.setHandleMode(intVal(rel, "handleMode"));
vo.setCategoryKey(categoryKey);
result.add(vo);
}
return result;
}
@Override
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
List<SvcItemAttachmentVo> result = new ArrayList<>();
JsonNode row;
try {
row = client.attachments(itemId);
} catch (BizException e) {
return result;
} catch (DHttpException e) {
// 业务性缺失(code=2)→ 附件空;传输类失败上抛(停机降级口径见 portalItem 注释)
if (e.getCode() == 2) {
return result;
}
throw e;
}
for (JsonNode att : row.isArray() ? row : new ArrayList<JsonNode>()) {
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
vo.setId(intVal(att, "id"));
vo.setFileName(text(att, "fileName"));
vo.setWebUrl(text(att, "fileUrl"));
vo.setFileSize(longVal(att, "fileSize"));
result.add(vo);
}
return result;
}
@Override
public void countView(Integer itemId) {
// fire-and-forget:itemId 直取口径(办事大厅侧自行定位),客户端内部失败仅 warn
client.viewByItemId(itemId);
}
// ===== 装配 =====
private PortalServiceItem toPortalItem(JsonNode row) {
PortalServiceItem vo = new PortalServiceItem();
vo.setId(intVal(row, "id"));
vo.setItemCode(text(row, "itemCode"));
vo.setTitle(text(row, "title"));
vo.setSummary(text(row, "summary"));
vo.setObjectKey(text(row, "objectKey"));
vo.setObjectName(text(row, "objectName"));
vo.setCategoryKey(text(row, "categoryKey"));
vo.setCategoryName(text(row, "categoryName"));
vo.setDeptGroupId(intVal(row, "deptGroupId"));
vo.setDeptName(text(row, "deptName"));
vo.setHandleMode(intVal(row, "handleMode"));
vo.setOnlineFlag(intVal(row, "onlineFlag"));
vo.setHandlePlace(text(row, "handlePlace"));
vo.setHandleTimeLimit(text(row, "handleTimeLimit"));
vo.setPromiseTimeLimit(text(row, "promiseTimeLimit"));
vo.setContactPhone(text(row, "contactPhone"));
vo.setSupervisePhone(text(row, "supervisePhone"));
vo.setProcessHtml(text(row, "processHtml"));
vo.setMaterialHtml(text(row, "materialHtml"));
vo.setNoticeHtml(text(row, "noticeHtml"));
vo.setFaqCategoryId(intVal(row, "faqCategoryId"));
vo.setNoticeCategoryId(intVal(row, "noticeCategoryId"));
vo.setRuleCategoryId(intVal(row, "ruleCategoryId"));
vo.setCoverImage(text(row, "coverImage"));
vo.setPublishTime(time(row, "publishTime"));
vo.setViewCount(intVal(row, "viewCount"));
vo.setApplyUrl(text(row, "applyUrl"));
vo.setFlowGuid(text(row, "flowGuid"));
return vo;
}
private String siteCodeOf(Integer siteId) {
return siteRepository.findById(siteId)
.map(CmsSite::getCode)
.orElseThrow(() -> new IllegalArgumentException("站点不存在:" + siteId));
}
private static Iterable<JsonNode> nodes(JsonNode data, String field) {
JsonNode arr = data == null ? null : data.get(field);
return arr != null && arr.isArray() ? arr : new ArrayList<JsonNode>();
}
private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
}
private static Integer intVal(JsonNode node, String field) {
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
}
private static Long longVal(JsonNode node, String field) {
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null;
}
private static LocalDateTime time(JsonNode node, String field) {
String v = text(node, field);
if (v == null || v.isEmpty()) {
return null;
}
try {
return LocalDateTime.parse(v, TIME);
} catch (Exception e) {
return null;
}
}
}
@@ -1,760 +0,0 @@
package com.yunhai.cms.svcitem.service.impl;
import com.yunhai.cms.config.CacheNames;
import com.yunhai.cms.entity.CmsAttachment;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.repository.CmsAttachmentRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.AttachmentService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import com.yunhai.cms.svcitem.enums.HandleModeEnum;
import com.yunhai.cms.svcitem.enums.ServiceDictTypeEnum;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import com.yunhai.cms.svcitem.event.ServiceItemChangedEvent;
import com.yunhai.cms.svcitem.model.dto.IdSortItem;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcDeptVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemPageItem;
import com.yunhai.cms.svcitem.model.vo.SvcItemSimpleVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemSnapshotVo;
import com.yunhai.cms.svcitem.repository.CmsServiceItemRepository;
import com.yunhai.cms.svcitem.service.SvcDeptSource;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.utils.ExtJsonUtils;
import com.yunhai.cms.utils.HtmlSanitizer;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import javax.annotation.PreDestroy;
import javax.persistence.criteria.Predicate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
* 事项服务(SvcItemQuery 接缝的唯一实现,事项库详细设计 §3.2/§3.3):
* 管理端 CRUD/发布/下架/删除/排序 + 门户查询 + 跨服务快照。
* 纪律:写方法 @Transactional、读不加;分页返回 Map{total,list};缓存失效只经事件;
* 富文本三字段保存时 jsoup 清洗;部门为 UserBase 组树快照(保存时校验+刷新名)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SvcItemService implements SvcItemQuery {
/** ext_json.apply_url 形态(渲染优先级:手填 > svc_hall_base 生成 > 不渲染,见 doGuide) */
private static final Pattern APPLY_URL_PATTERN = Pattern.compile("^https?://\\S+$");
/** 指南附件场景键(编辑页「指南附件」显式列表;attachmentIds 绑定口径) */
private static final String REF_TYPE_SVC_ITEM = "svc_item";
/**
* 事项内容资产场景键(封面 + 正文内嵌图;由内容派生归属,不进指南附件列表)。
* 与 {@link #REF_TYPE_SVC_ITEM} 分开,是因为 {@code attachments(itemId)} 只按 svc_item 查,
* 若内容资产同场景会混进指南页附件列表。
*/
private static final String REF_TYPE_SVC_ASSET = "svc_item_asset";
/** 选择器/同步部门名的全量扫描上限(防御异常库容) */
private static final int SEL_LIST_MAX = 500;
/** 浏览量异步队列容量(满则丢弃计数并告警,不反压请求线程) */
private static final int VIEW_QUEUE_CAPACITY = 1000;
/**
* 部门树节点类型(默认与 SchoolService 同口径 type=2 = 学校组)。业务若要"校内科室",
* 改配置 {@code cms.svc.item.dept-group-type} 一处即可(UserBase GroupTypeEnum 常见取值 2=学校组、3=科室),
* 接口形态与前端选择器不变(docs/18 §遗留 待业务确认)。
*/
@Value("${cms.svc.item.dept-group-type:2}")
private int deptGroupType = 2;
private final CmsServiceItemRepository itemRepository;
private final CmsCategoryRepository categoryRepository;
private final CmsSiteRepository siteRepository;
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
private final AttachmentService attachmentService;
private final SvcDictService dictService;
private final SvcDeptSource deptSource;
private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transactionTemplate;
// ===== 门户查询 =====
/** 服务中心列表(实时查询不缓存:筛选组合多、收益低,比照 search(...) 先例) */
@Override
public PortalServiceListData portalPage(SvcItemPortalQuery query) {
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100),
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("publishTime"), Sort.Order.desc("id")));
Page<CmsServiceItem> page = itemRepository.findAll(publishedSpec(query), pageable);
Map<String, String> categoryNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.CATEGORY);
PortalServiceListData data = new PortalServiceListData();
data.setSiteId(query.getSiteId());
data.setTotal(page.getTotalElements());
data.setPage(pageIndex + 1);
data.setPageSize(pageable.getPageSize());
data.setTotalPages(page.getTotalPages());
for (CmsServiceItem item : page.getContent()) {
data.getItems().add(PortalServiceListData.Item.of(item,
categoryNames.get(item.getCategoryKey())));
}
return data;
}
/** 三组筛选项 + 计数(已发布口径;选项=启用字典且有已发布引用的键) */
@Override
@Cacheable(cacheNames = CacheNames.SERVICE_ITEM, key = "'filters:' + #siteId")
public PortalServiceFilters portalFilters(Integer siteId) {
PortalServiceFilters filters = new PortalServiceFilters();
Map<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(siteId));
Map<String, Long> categoryCounts = countMap(itemRepository.countPublishedGroupByCategoryKey(siteId));
for (com.yunhai.cms.svcitem.entity.CmsServiceDict dict : dictService
.enabledDicts(siteId, ServiceDictTypeEnum.OBJECT)) {
Long count = objectCounts.get(dict.getDictKey());
if (count != null && count > 0) {
filters.getObjects().add(new PortalServiceFilters.Option(
dict.getDictKey(), dict.getDictName(), count));
}
}
for (com.yunhai.cms.svcitem.entity.CmsServiceDict dict : dictService
.enabledDicts(siteId, ServiceDictTypeEnum.CATEGORY)) {
Long count = categoryCounts.get(dict.getDictKey());
if (count != null && count > 0) {
filters.getCategories().add(new PortalServiceFilters.Option(
dict.getDictKey(), dict.getDictName(), count));
}
}
for (Object[] row : itemRepository.countPublishedGroupByDept(siteId)) {
filters.getDepts().add(new PortalServiceFilters.DeptOption(
((Number) row[0]).intValue(), String.valueOf(row[1]), ((Number) row[2]).longValue()));
}
return filters;
}
/** 指南页模型(不存在/未发布/跨站 → null 缓存哨兵 30s 防穿透) */
@Override
@Cacheable(cacheNames = CacheNames.SERVICE_ITEM, key = "#itemId")
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
CmsServiceItem item = itemRepository.findById(itemId).orElse(null);
if (item == null || item.getStatus() == null
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()
|| !item.getSiteId().equals(siteId)) {
return null;
}
return toPortalItem(item);
}
/** 关联事项:同类别已发布 Top N(排除自身;无类别返回空表) */
@Override
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
if (!StringUtils.hasText(categoryKey) || limit <= 0) {
return new ArrayList<>();
}
List<CmsServiceItem> items = itemRepository.findBySiteIdAndStatusAndCategoryKeyAndIdNot(
siteId, ServiceItemStatusEnum.PUBLISHED.getCode(), categoryKey, itemId,
PageRequest.of(0, limit, Sort.by(Sort.Order.asc("sortNo"),
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
List<PortalServiceItem> result = new ArrayList<>();
for (CmsServiceItem item : items) {
result.add(toPortalItem(item));
}
return result;
}
/** 跨服务要点快照(不缓存:调用频次=办件量级,直查 DB;查不到/未发布返回 null) */
@Override
public SvcItemSnapshotVo snapshot(String siteCode, String itemCode) {
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
if (site == null) {
return null;
}
CmsServiceItem item = itemRepository.findBySiteIdAndItemCode(site.getId(), itemCode).orElse(null);
if (item == null || item.getStatus() == null
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
return null;
}
SvcItemSnapshotVo vo = new SvcItemSnapshotVo();
vo.setSiteCode(siteCode);
vo.setItemCode(item.getItemCode());
vo.setTitle(item.getTitle());
vo.setSummary(item.getSummary());
vo.setDeptName(item.getDeptName());
vo.setHandleMode(item.getHandleMode());
vo.setOnlineFlag(item.getOnlineFlag());
vo.setHandleTimeLimit(item.getHandleTimeLimit());
vo.setContactPhone(item.getContactPhone());
vo.setApplyUrl(ExtJsonUtils.getString(ExtJsonUtils.parse(item.getExtJson()), "apply_url"));
vo.setPublishTime(item.getPublishTime());
return vo;
}
/** 指南页附件(ref_type=svc_item, ref_id=itemId) */
@Override
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
List<SvcItemAttachmentVo> result = new ArrayList<>();
for (CmsAttachment attachment : attachmentRepository.findByRefTypeAndRefId(REF_TYPE_SVC_ITEM, itemId)) {
result.add(SvcItemAttachmentVo.of(attachment));
}
return result;
}
// ===== 管理端 =====
@Override
public Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query) {
siteService.requireManageable(user, query.getSiteId());
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100),
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("createTime"), Sort.Order.desc("id")));
Specification<CmsServiceItem> spec = (root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
if (StringUtils.hasText(query.getKeyword())) {
String like = "%" + query.getKeyword().trim() + "%";
predicates.add(cb.or(cb.like(root.get("title"), like),
cb.like(root.get("itemCode"), like)));
}
if (StringUtils.hasText(query.getObjectKey())) {
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
}
if (StringUtils.hasText(query.getCategoryKey())) {
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
}
if (query.getDeptGroupId() != null) {
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Map<String, String> objectNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.OBJECT);
Map<String, String> categoryNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.CATEGORY);
Page<CmsServiceItem> page = itemRepository.findAll(spec, pageable);
List<SvcItemPageItem> items = new ArrayList<>();
for (CmsServiceItem item : page.getContent()) {
items.add(SvcItemPageItem.of(item,
objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey())));
}
Map<String, Object> result = new HashMap<>();
result.put("total", page.getTotalElements());
result.put("list", items);
return result;
}
@Override
public SvcItemEditVo adminGet(BoaUser user, Integer siteId, Integer id) {
siteService.requireManageable(user, siteId);
return SvcItemEditVo.of(requireItem(siteId, id));
}
/** 保存:全部校验规则见设计 §3.3;附件全量重绑(release + 按新列表标记,幂等) */
@Override
@Transactional
public Integer adminSave(BoaUser user, SvcItemSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
validateItemCode(req);
validateDictKeys(req);
validateCategoryRefs(site.getId(), req);
String deptName = resolveDeptName(req.getSiteId(), req.getDeptGroupId());
String applyUrl = validateApplyUrl(req.getApplyUrl());
CmsServiceItem item;
if (req.getId() == null) {
item = new CmsServiceItem();
item.setSiteId(site.getId());
item.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
item.setViewCount(0);
} else {
item = requireItem(site.getId(), req.getId());
}
item.setItemCode(req.getItemCode());
item.setTitle(req.getTitle());
item.setSummary(req.getSummary());
item.setObjectKey(emptyToNull(req.getObjectKey()));
item.setCategoryKey(emptyToNull(req.getCategoryKey()));
item.setDeptGroupId(req.getDeptGroupId());
item.setDeptName(deptName);
item.setHandleMode(req.getHandleMode() == null ? HandleModeEnum.ONLINE.getCode() : req.getHandleMode());
if (!HandleModeEnum.isValid(item.getHandleMode())) {
throw new BizException(ErrorCode.PARAM_ERROR, "办理方式非法:" + item.getHandleMode());
}
item.setOnlineFlag(req.getOnlineFlag() == null || req.getOnlineFlag() != 0 ? 1 : 0);
item.setHandlePlace(req.getHandlePlace());
item.setHandleTimeLimit(req.getHandleTimeLimit());
item.setPromiseTimeLimit(req.getPromiseTimeLimit());
item.setContactPhone(req.getContactPhone());
item.setSupervisePhone(req.getSupervisePhone());
item.setProcessHtml(HtmlSanitizer.clean(req.getProcessHtml()));
item.setMaterialHtml(HtmlSanitizer.clean(req.getMaterialHtml()));
item.setNoticeHtml(HtmlSanitizer.clean(req.getNoticeHtml()));
item.setFaqCategoryId(req.getFaqCategoryId());
item.setNoticeCategoryId(req.getNoticeCategoryId());
item.setRuleCategoryId(req.getRuleCategoryId());
item.setCoverImage(req.getCoverImage());
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
item.setExtJson(applyUrl == null ? null : "{\"apply_url\":" + quoteJson(applyUrl) + "}");
item = itemRepository.save(item);
bindAttachments(site.getId(), item, req.getAttachmentIds());
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
return item.getId();
}
/** 发布:草稿/已下架 → 已发布(记录发布时间;事件失效指南页缓存与筛选项计数) */
@Override
@Transactional
public void adminPublish(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsServiceItem item = requireItem(site.getId(), id);
if (!StringUtils.hasText(item.getTitle())) {
throw new BizException(ErrorCode.PARAM_ERROR, "事项名称缺失,不可发布");
}
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
item.setPublishTime(LocalDateTime.now());
itemRepository.save(item);
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
}
/** 下架:已发布 → 已下架(门户即刻 404;svhall 已发办件不受影响——快照隔离) */
@Override
@Transactional
public void adminOffline(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsServiceItem item = requireItem(site.getId(), id);
item.setStatus(ServiceItemStatusEnum.OFFLINE.getCode());
item.setOfflineTime(LocalDateTime.now());
itemRepository.save(item);
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
}
/** 删除:物理删除 + 附件解引用(指南附件 + 封面/正文资产两个场景都释放,转孤儿可清理);办件侧靠快照隔离不受影响 */
@Override
@Transactional
public void adminDelete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsServiceItem item = requireItem(site.getId(), req.getId());
attachmentService.release(REF_TYPE_SVC_ITEM, item.getId());
attachmentService.release(REF_TYPE_SVC_ASSET, item.getId());
itemRepository.delete(item);
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
}
@Override
public List<SvcItemSimpleVo> adminSelList(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
List<CmsServiceItem> items = itemRepository.findBySiteIdOrderBySortNoAscIdAsc(
siteId, PageRequest.of(0, SEL_LIST_MAX));
if (items.size() >= SEL_LIST_MAX) {
log.warn("[cms] 事项选择器命中上限 {} 条(siteId={}),结果已截断", SEL_LIST_MAX, siteId);
}
List<SvcItemSimpleVo> result = new ArrayList<>();
for (CmsServiceItem item : items) {
result.add(SvcItemSimpleVo.of(item));
}
return result;
}
/** 排序:按传入 (id, sortNo) 落库(列表默认排序第一优先级) */
@Override
@Transactional
public void adminSort(BoaUser user, Integer siteId, List<IdSortItem> items) {
CmsSite site = siteService.requireManageable(user, siteId);
for (IdSortItem pair : items == null ? new ArrayList<IdSortItem>() : items) {
CmsServiceItem item = requireItem(site.getId(), pair.getId());
item.setSortNo(pair.getSortNo());
itemRepository.save(item);
}
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), null, "item"));
}
/** 同步部门名(可选动作,§2.3/R5):按组树刷新全站事项 dept_name 快照,返回更新条数 */
@Override
@Transactional
public Map<String, Object> syncDeptName(BoaUser user, Integer siteId) {
CmsSite site = siteService.requireManageable(user, siteId);
Map<Integer, String> deptNames = new LinkedHashMap<>();
collectDeptNodes(fetchGroupTree(), deptNames);
if (deptNames.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名");
}
int updated = 0;
List<CmsServiceItem> candidates = itemRepository.findBySiteIdOrderBySortNoAscIdAsc(
site.getId(), PageRequest.of(0, SEL_LIST_MAX));
if (candidates.size() >= SEL_LIST_MAX) {
log.warn("[cms] 部门名同步命中上限 {} 条(siteId={}),超出部分未同步", SEL_LIST_MAX, site.getId());
}
for (CmsServiceItem item : candidates) {
if (item.getDeptGroupId() == null) {
continue;
}
String fresh = deptNames.get(item.getDeptGroupId());
if (fresh != null && !fresh.equals(item.getDeptName())) {
item.setDeptName(fresh);
itemRepository.save(item);
updated++;
}
}
if (updated > 0) {
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), null, "dept"));
}
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
return result;
}
/** 服务部门选项(UserBase 组树按 deptGroupType 过滤并保留层级;平台不可达降级空列表,SchoolService 同款) */
@Override
public List<SvcDeptVo> deptTree(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
return buildDeptTree(fetchGroupTree());
}
/** 组树 → 只含部门节点的嵌套树(非部门祖先不下发自身,但继续下钻,避免层级被压平) */
private List<SvcDeptVo> buildDeptTree(BoaGroup root) {
List<SvcDeptVo> top = new ArrayList<>();
if (root == null) {
return top;
}
if (isDeptNode(root)) {
SvcDeptVo vo = new SvcDeptVo(root.getId(), root.getName());
vo.setChildren(buildDeptChildren(root));
top.add(vo);
return top;
}
return buildDeptChildren(root);
}
private List<SvcDeptVo> buildDeptChildren(BoaGroup parent) {
List<SvcDeptVo> out = new ArrayList<>();
if (parent.getChildren() == null) {
return out;
}
for (BoaGroup child : parent.getChildren()) {
if (isDeptNode(child)) {
SvcDeptVo vo = new SvcDeptVo(child.getId(), child.getName());
vo.setChildren(buildDeptChildren(child));
out.add(vo);
} else {
out.addAll(buildDeptChildren(child));
}
}
return out;
}
private boolean isDeptNode(BoaGroup node) {
return node.getType() != null && node.getType() == deptGroupType;
}
/** 管理端:事项当前绑定的指南附件(编辑回显) */
@Override
public List<SvcItemAttachmentVo> itemAttachments(BoaUser user, Integer siteId, Integer itemId) {
siteService.requireManageable(user, siteId);
requireItem(siteId, itemId);
return attachments(itemId);
}
/** 指南页浏览量异步累加(fire-and-forget,失败忽略;比照 ViewCounterService 口径) */
@Override
public void countView(Integer itemId) {
incrementView(itemId);
}
private void incrementView(Integer itemId) {
if (itemId == null) {
return;
}
try {
executor.execute(() -> {
try {
transactionTemplate.executeWithoutResult(
status -> itemRepository.incrementViewCount(itemId));
} catch (Exception e) {
log.warn("[cms] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
}
});
} catch (RejectedExecutionException e) {
log.warn("[cms] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
}
}
/**
* 浏览量累加单线程执行器:有界队列 + 拒绝即丢弃并告警——浏览量是 best-effort 指标,
* 队列满时宁可丢计数也不能反压页面渲染(故不用 CallerRunsPolicy)。
*/
private final ExecutorService executor = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(VIEW_QUEUE_CAPACITY),
runnable -> {
Thread thread = new Thread(runnable, "cms-svc-view-counter");
thread.setDaemon(true);
return thread;
},
(runnable, pool) -> log.warn("[cms] 事项浏览量队列已满(上限 {}),丢弃一次计数", VIEW_QUEUE_CAPACITY));
@PreDestroy
public void shutdown() {
executor.shutdown();
try {
executor.awaitTermination(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// ===== 校验与装配 =====
private void validateItemCode(SvcItemSaveRequest req) {
boolean duplicated = req.getId() == null
? itemRepository.existsBySiteIdAndItemCode(req.getSiteId(), req.getItemCode())
: itemRepository.existsBySiteIdAndItemCodeAndIdNot(req.getSiteId(), req.getItemCode(), req.getId());
if (duplicated) {
throw new BizException(ErrorCode.PARAM_ERROR, "事项编码已存在:" + req.getItemCode() + "(站内唯一)");
}
}
private void validateDictKeys(SvcItemSaveRequest req) {
if (StringUtils.hasText(req.getObjectKey())) {
dictService.requireEnabledKey(req.getSiteId(), ServiceDictTypeEnum.OBJECT, req.getObjectKey());
}
if (StringUtils.hasText(req.getCategoryKey())) {
dictService.requireEnabledKey(req.getSiteId(), ServiceDictTypeEnum.CATEGORY, req.getCategoryKey());
}
}
/** 三个关联栏目必须属于本站点(防跨站注入,比照 home_layout 的 treeIds 校验) */
private void validateCategoryRefs(Integer siteId, SvcItemSaveRequest req) {
requireSiteCategory(siteId, req.getFaqCategoryId(), "常见问题栏目");
requireSiteCategory(siteId, req.getNoticeCategoryId(), "公告信息栏目");
requireSiteCategory(siteId, req.getRuleCategoryId(), "文件规定栏目");
}
private void requireSiteCategory(Integer siteId, Integer categoryId, String label) {
if (categoryId == null) {
return;
}
CmsCategory category = categoryRepository.findById(categoryId).orElse(null);
if (category == null || !siteId.equals(category.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, label + "不存在或不属于本站点:" + categoryId);
}
}
/** 部门校验+取权威名:组树不可达时拒绝(防止绕过选择器写脏快照),id 不在树上拒绝 */
private String resolveDeptName(Integer siteId, Integer deptGroupId) {
if (deptGroupId == null) {
return null;
}
Map<Integer, String> deptNames = new LinkedHashMap<>();
collectDeptNodes(fetchGroupTree(), deptNames);
String name = deptNames.get(deptGroupId);
if (name == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"服务部门不存在或部门服务不可达(id=" + deptGroupId + "),请重新选择");
}
return name;
}
/** 组树拉取(不可达返回 null,由调用方决定降级口径);type 过滤口径见 deptGroupType */
private BoaGroup fetchGroupTree() {
return deptSource.getGroupTree();
}
private void collectDeptNodes(BoaGroup node, Map<Integer, String> out) {
if (node == null) {
return;
}
if (isDeptNode(node)) {
out.putIfAbsent(node.getId(), node.getName());
}
if (node.getChildren() != null) {
for (BoaGroup child : node.getChildren()) {
collectDeptNodes(child, out);
}
}
}
private String validateApplyUrl(String applyUrl) {
if (!StringUtils.hasText(applyUrl)) {
return null;
}
String url = applyUrl.trim();
if (!APPLY_URL_PATTERN.matcher(url).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR, "我要办理外链必须是完整的 http(s):// 地址");
}
return url;
}
/**
* 附件引用重绑:以「内容派生」为权威口径(修复审计 D1——上传时 scene 只写 ref_type 不写 ref_id,
* 若仅按本次会话上传的 id 列表重绑,正文内嵌图与封面永远停在 ref_id=null 的无主态,
* 且编辑已有事项二次保存会清掉上一轮建立的引用)。三步:
* <ol>
* <li>解除本事项已绑定、但本次列表里已不存在的行("移除即解除引用"语义,只解本事项,不误伤他人);</li>
* <li>按列表绑定(本站 + 可认领校验,命中他事项引用或跨站的行跳过并告警,不再静默改写/丢弃);</li>
* <li>内容派生:封面 URL 与三段富文本里的本站上传资产按内容全量同步(syncAssets)——
* 内容里有的标记归属,已不在内容中的解除归属;既覆盖"编辑保存时正文既有内嵌图不丢引用",
* 也覆盖封面换图/清空与正文删图(旧资产转回未使用,不留悬挂引用)。</li>
* </ol>
*/
private void bindAttachments(Integer siteId, CmsServiceItem item, List<Integer> attachmentIds) {
Integer itemId = item.getId();
Set<Integer> keep = attachmentIds == null ? Collections.emptySet() : new HashSet<>(attachmentIds);
List<CmsAttachment> released = new ArrayList<>();
for (CmsAttachment attachment : attachmentRepository.findByRefTypeAndRefId(REF_TYPE_SVC_ITEM, itemId)) {
if (!keep.contains(attachment.getId())) {
attachment.setRefType(null);
attachment.setRefId(null);
released.add(attachment);
}
}
if (!released.isEmpty()) {
attachmentRepository.saveAll(released);
}
if (!keep.isEmpty()) {
List<CmsAttachment> bound = new ArrayList<>();
for (CmsAttachment attachment : attachmentRepository.findAllById(keep)) {
if (!siteId.equals(attachment.getSiteId())) {
log.warn("[cms] 事项附件跳过:非本站点附件 itemId={} attachmentId={}", itemId, attachment.getId());
continue;
}
if (!claimable(attachment, itemId)) {
log.warn("[cms] 事项附件跳过:已被其他引用占用 itemId={} attachmentId={} refType={} refId={}",
itemId, attachment.getId(), attachment.getRefType(), attachment.getRefId());
continue;
}
attachment.setRefType(REF_TYPE_SVC_ITEM);
attachment.setRefId(itemId);
bound.add(attachment);
}
if (!bound.isEmpty()) {
attachmentRepository.saveAll(bound);
}
}
// 3) 内容资产(封面 + 三段正文内嵌图):按内容全量同步——内容里有的标记归属,已不在内容中的解除归属
Set<String> contentAssets = new LinkedHashSet<>(attachmentService.localAssetUrls(item.getProcessHtml()));
contentAssets.addAll(attachmentService.localAssetUrls(item.getMaterialHtml()));
contentAssets.addAll(attachmentService.localAssetUrls(item.getNoticeHtml()));
attachmentService.localWebUrl(item.getCoverImage()).ifPresent(contentAssets::add);
attachmentService.syncAssets(siteId, REF_TYPE_SVC_ASSET, itemId, contentAssets);
}
/**
* 可认领判定:无引用(refType 空)、上传时的半绑定态(事项两场景且 refId 空——AttachmentService.upload
* 只写 refType 不写 refId 的产物)、或已属本事项,三者可绑;已被他事项/他场景占用则拒绝,
* 避免绑定动作把别人的引用偷走。
*/
private static boolean claimable(CmsAttachment attachment, Integer itemId) {
if (attachment.getRefType() == null) {
return true;
}
boolean svcScene = REF_TYPE_SVC_ITEM.equals(attachment.getRefType())
|| REF_TYPE_SVC_ASSET.equals(attachment.getRefType());
return svcScene && (attachment.getRefId() == null || itemId.equals(attachment.getRefId()));
}
private CmsServiceItem requireItem(Integer siteId, Integer id) {
return itemRepository.findByIdAndSiteId(id, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
}
private PortalServiceItem toPortalItem(CmsServiceItem item) {
String objectName = null;
String categoryName = null;
if (StringUtils.hasText(item.getObjectKey())) {
objectName = dictService.nameMap(item.getSiteId(), ServiceDictTypeEnum.OBJECT)
.get(item.getObjectKey());
}
if (StringUtils.hasText(item.getCategoryKey())) {
categoryName = dictService.nameMap(item.getSiteId(), ServiceDictTypeEnum.CATEGORY)
.get(item.getCategoryKey());
}
return PortalServiceItem.of(item, objectName, categoryName);
}
private Specification<CmsServiceItem> publishedSpec(SvcItemPortalQuery query) {
return (root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
predicates.add(cb.equal(root.get("status"), ServiceItemStatusEnum.PUBLISHED.getCode()));
if (StringUtils.hasText(query.getObjectKey())) {
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
}
if (StringUtils.hasText(query.getCategoryKey())) {
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
}
if (query.getDeptGroupId() != null) {
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
}
if (StringUtils.hasText(query.getKeyword())) {
String like = "%" + query.getKeyword().trim() + "%";
predicates.add(cb.or(cb.like(root.get("title"), like),
cb.like(root.get("summary"), like)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
private static String emptyToNull(String value) {
return StringUtils.hasText(value) ? value : null;
}
/** JSON 字符串字面量(apply_url 已过 http(s) 白名单,无引号/控制字符风险,仍做转义兜底) */
private static String quoteJson(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private static Map<String, Long> countMap(List<Object[]> rows) {
Map<String, Long> map = new HashMap<>();
for (Object[] row : rows) {
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
}
return map;
}
}
@@ -1,25 +0,0 @@
package com.yunhai.cms.svcitem.service.impl;
import com.yunhai.cms.svcitem.service.SvcDeptSource;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaGroup;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 默认部门组树来源:代理 UserBase oapi 静态调用,异常降级为 null(与 SchoolService 同口径)。
*/
@Slf4j
@Component
public class YschBaseSvcDeptSource implements SvcDeptSource {
@Override
public BoaGroup getGroupTree() {
try {
return YschBaseOpenApi.getGroupTree();
} catch (Exception e) {
log.warn("[cms] 获取 UserBase 组树失败:{}", e.getMessage());
return null;
}
}
}
+17
View File
@@ -60,12 +60,22 @@ yh:
# UserBase 注册后分配(进白名单),与 yh.open 保持一致;生产由 projman 注入
app-key: 100005
app-secret: 2c7885de594742cc840b8762910b2d2e
# 入站验签白名单:办事大厅 svc-hall(站点目录 /oapi/site/list 消费方,appKey=100018;
# P0-1 修复)。secret 与 svc-hall application.yml yh.app-auth.app-secret 逐字一致(联调态),
# 生产两侧由 projman/Nacos 注入同一密钥对
trusted-apps:
"100018": "svc-hall-dev-secret-100018"
open:
enabled: true
base-host: http://base.yunhaixingtong.com
base-path: /userbase
app-key: 100005
app-secret: 2c7885de594742cc840b8762910b2d2e
# 办事大厅(svc-hall)对接(V2.0 Part C.3):门户 service/guide 数据源。
# 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧 trusted-apps 须含 100005);
# 入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。
svchall:
base-url: http://localhost:8086
# env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN
@@ -87,6 +97,13 @@ cms:
# 含 context-path,如 https://base.yunhaixingtong.com/cms;生产由 projman 注入。
# 为空(本地开发默认)时按当前请求派生(反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
base-origin: ""
svc:
item:
# 【V2.0 拆分遗留说明】事项库已迁独立"办事大厅"应用(svc-hall),CMS 侧 SvcItemQuery
# 由 SvcItemRemoteQuery 经 oapi 调办事大厅;本配置已废弃(部门口径归办事大厅
# svchall.item.dept-group-type)。保留注释防误解。
dept-group-type: 7
logging:
level:
@@ -6,7 +6,8 @@
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext -->
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
<main class="detail-main container" id="main-content">
<div class="page-card svc-guide">
<nav class="breadcrumb" aria-label="面包屑">
@@ -14,10 +15,16 @@
<span class="crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current" th:text="${item.title}">事项名称</span>
<span class="current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<article class="svc-guide-body">
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="empty-state svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="article-title" th:text="${item.title}">事项名称</h1>
<div class="article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
@@ -102,7 +109,7 @@
</nav>
</article>
<aside class="svc-related" th:if="${!#lists.isEmpty(relatedItems)}">
<aside class="svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="section-title">相关服务</h2>
<ul class="svc-related-list">
<li th:each="rel : ${relatedItems}">
@@ -16,8 +16,14 @@
<h1 class="page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="empty-state svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="svc-filters"
th:if="${!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts)}">
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="svc-filter-label">服务对象</span>
<div class="svc-filter-options">
@@ -47,7 +53,7 @@
</div>
</div>
<div class="svc-toolbar">
<div class="svc-toolbar" th:if="${!svcUnavailable}">
<form class="svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
@@ -55,7 +61,7 @@
<a class="svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="svc-list">
<ul class="svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="svc-item" th:href="${url.serviceItem(it.id)}">
<span class="svc-item-cover" th:if="${it.coverImage != null}">
@@ -80,15 +86,15 @@
</a>
</li>
</ul>
<div class="empty-state" th:if="${#lists.isEmpty(serviceList.items)}">
<div class="empty-state" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页) -->
<nav class="pagination" th:if="${serviceList.totalPages > 1}" aria-label="分页">
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
@@ -0,0 +1,121 @@
package com.yunhai.cms.controller.oapi;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 站点目录开放接口 IT(P0-1 解堵的 CMS 半边):
* 咬合契约=svc-hall CmsSiteDirectoryImpl(appKey=100018 签名调用;resolveSite 不传 schId 拉全量、
* listSites(schId) 按校过滤)。本类冻结三件事:
* <ul>
* <li>鉴权:/oapi/** 无签名 401(RequireAppSignFilter)、错密钥 401;</li>
* <li>契约:schId 可选——不传=全部启用站点(含跨校),传了=按校过滤;</li>
* <li>出参形态:data[] 元素键=siteCode/name/schId(消费方 mapToVos 按这三键解析)。</li>
* </ul>
* 签名 canonical 与平台 AppSignInterceptor 一致:METHOD + "\n" + path[?sortedQuery] + "\n" + ts,
* HmacSHA256 输出 Base64(见 SignUtil.signRequest / AppAuthFilter.buildSignedTarget)。
*/
@DisplayName("SiteOapi /oapi/site/list - P0-1 解堵:签名鉴权 + schId 可选契约")
class SiteOapiIT extends CmsIntegrationTestBase {
/** 与 svc-hall application.yml yh.app-auth.app-secret 逐字一致(联调态密钥) */
private static final String SVC_HALL_APP_KEY = "100018";
private static final String SVC_HALL_APP_SECRET = "svc-hall-dev-secret-100018";
@Autowired
private MockMvc mockMvc;
@BeforeEach
void seed() {
purgeTables();
persistSite("demo1", 1, "演示站点一");
persistSite("schx", 1, "主站X子站");
persistSite("demo2", 2, "演示站点二");
// 停用站点:任何形态(全量/按校)都不得下发
CmsSite disabled = persistSite("off1", 1, "停用站点");
disabled.setStatus(0);
siteRepository.saveAndFlush(disabled);
}
@AfterEach
void clean() {
purgeTables();
}
@Test
@DisplayName("无签名 → 401(RequireAppSignFilter 拦截)")
void noSignature_yields401() throws Exception {
mockMvc.perform(get("/oapi/site/list")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/oapi/site/list?schId=1")).andExpect(status().isUnauthorized());
}
@Test
@DisplayName("错误密钥签名 → 401")
void wrongSecret_yields401() throws Exception {
mockMvc.perform(get("/oapi/site/list")
.header("X-App-Key", SVC_HALL_APP_KEY)
.header("X-Timestamp", String.valueOf(System.currentTimeMillis()))
.header("X-Signature", "bad-signature"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("带签名不传 schId → 全部启用站点(跨校全量,供 resolveSite 反解 siteCode)")
void signed_withoutSchId_returnsAllEnabledSites() throws Exception {
MvcResult result = mockMvc.perform(get("/oapi/site/list").headers(signHeaders("/oapi/site/list")))
.andExpect(status().isOk())
.andReturn();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(body).contains("\"siteCode\":\"demo1\"").contains("\"siteCode\":\"demo2\"");
assertThat(body).doesNotContain("off1").doesNotContain("停用站点");
}
@Test
@DisplayName("带签名传 schId → 仅该校启用站点")
void signed_withSchId_filtersBySchool() throws Exception {
MvcResult result = mockMvc.perform(get("/oapi/site/list?schId=1")
.headers(signHeaders("/oapi/site/list?schId=1")))
.andExpect(status().isOk())
.andReturn();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(body).contains("\"siteCode\":\"demo1\"").contains("\"siteCode\":\"schx\"");
assertThat(body).doesNotContain("demo2");
}
@Test
@DisplayName("出参形态冻结:{code,msg,data[]}, 元素键=siteCode/name/schId(schId 为数字)")
void responseShape_frozenForSvcHallConsumer() throws Exception {
MvcResult result = mockMvc.perform(get("/oapi/site/list").headers(signHeaders("/oapi/site/list")))
.andExpect(status().isOk())
.andReturn();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
// 信封(IDHttp 按code/data 解包)+ 消费方三键(CmsSiteDirectoryImpl.mapToVos)
assertThat(body).startsWith("{\"code\":0").contains("\"data\":[");
assertThat(body).contains("\"name\":\"演示站点一\"").contains("\"schId\":1");
}
/** 按平台 AppSignInterceptor 规则生成签名头(path + 排序 query,与 AppAuthFilter 服务端验签对齐) */
private static org.springframework.http.HttpHeaders signHeaders(String target) {
String ts = String.valueOf(System.currentTimeMillis());
String sign = com.yunhai.platform.utils.crypto.SignUtil.signRequest(
"GET", target, ts, SVC_HALL_APP_SECRET);
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
headers.add("X-App-Key", SVC_HALL_APP_KEY);
headers.add("X-Timestamp", ts);
headers.add("X-Signature", sign);
return headers;
}
}
@@ -0,0 +1,102 @@
package com.yunhai.cms.portal;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.StubSvcItemQuery;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 门户停机空态降级 IT(P1-4 拍板口径:空态页替代 500):
* svc-hall 连接类异常(stub.down=true 模拟 DHttpException code=0)时——
* 列表页/指南页渲染 200 + "事项服务暂时不可用"空态;指南页不渲染"我要办理"按钮;
* 业务性缺失(code=2→null 哨兵,§2.3 修复后)维持 404 语义不受降级影响。
* (单类独跑:与其他 IT 合跑会共库假红)
*/
@DisplayName("PortalSvcUnavailable - 事项服务停机空态降级(P1-4)")
class PortalSvcUnavailableIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private StubSvcItemQuery stub;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
stub.missingIds.clear();
stub.defaultItem = true;
stub.down = false;
stub.items.clear();
stub.pageItems.clear();
}
@AfterEach
void clean() {
stub.down = false;
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
@Test
@DisplayName("svc-hall 停机:列表页 200 + 空态文案(非 500 错误页)")
void serviceList_rendersEmptyStateWhenDown() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
stub.down = true;
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk())
.andReturn());
assertThat(html).contains("事项服务暂时不可用,请稍后再试");
assertThat(html).doesNotContain("转学办理").doesNotContain("服务暂时不可用页");
}
@Test
@DisplayName("svc-hall 停机:指南页 200 + 空态文案,且不渲染我要办理按钮")
void guide_rendersEmptyStateAndHidesApplyWhenDown() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
stub.down = true;
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", 1001))
.andExpect(status().isOk())
.andReturn());
assertThat(html).contains("事项服务暂时不可用,请稍后再试");
assertThat(html).doesNotContain("svc-apply-btn").doesNotContain("转学办理");
}
@Test
@DisplayName("恢复后自愈:down=false 时正常渲染(回归哨兵)")
void recoversAfterRestore() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk())
.andReturn());
assertThat(html).contains("转学办理");
assertThat(html).doesNotContain("事项服务暂时不可用");
}
@Test
@DisplayName("业务性缺失仍 404(降级不吞掉 §2.3 的 null 哨兵语义)")
void businessMissing_still404() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
stub.missingIds.add(999999);
mockMvc.perform(get("/portal/demo1/sv/{id}", 999999))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,166 @@
package com.yunhai.cms.portal;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.StubSvcItemQuery;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 门户服务中心/事项指南集成测试(V2.0 拆分后):CMS 只保留展示壳,事项数据经
* {@link StubSvcItemQuery} 替身提供(生产=办事大厅 oapi)。覆盖:路由双变体、301 兼容、
* 404 语义、Cache-Control、applyUrl 三态、M0 可选模板整页回退。
*/
@DisplayName("ServicePortal - 门户展示壳(V2.0 remote 口径)")
class ServicePortalIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private StubSvcItemQuery stub;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
stub.missingIds.clear();
stub.defaultItem = true;
stub.items.clear();
stub.pageItems.clear();
}
@AfterEach
void clean() {
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
@Test
@DisplayName("路由双变体:/portal/{code}/service 与 /portal/{p}/{s}/service 均 200")
void routes_singleAndNested() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
assertThat(body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心").contains("转学办理");
Integer parent = persistSite("schx", 1, "主站").getId();
persistSite("child", 1, "子站", parent);
assertThat(body(mockMvc.perform(get("/portal/schx/child/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心");
}
@Test
@DisplayName("旧单段 code 访问子站服务页 301 到嵌套形态(保留子路径与 query)")
void routes_legacySingleSegmentRedirects301() throws Exception {
Integer parent = persistSite("schy", 1, "主站Y").getId();
persistSite("childy", 1, "子站Y", parent);
mockMvc.perform(get("/portal/childy/service?obj=person"))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", "/portal/schy/childy/service?obj=person"));
}
@Test
@DisplayName("指南页:命中 200 带 Cache-Control;missingIds 模拟办事大厅 code=2 → 404;defaultItem=false 同样 404")
void guideItem_statusSemantics() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "jw", null);
mockMvc.perform(get("/portal/demo1/sv/{id}", 1001))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=300"));
// 不存在/未发布(办事大厅 code=2)
stub.missingIds.add(999999);
mockMvc.perform(get("/portal/demo1/sv/{id}", 999999))
.andExpect(status().isNotFound());
stub.defaultItem = false;
mockMvc.perform(get("/portal/demo1/sv/{id}", 555555))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("applyUrl 三态:手填外链优先;未手填未配基地址 → 不渲染按钮")
void guide_applyUrlThreeStates() throws Exception {
// ① 手填外链(stub 种子注入)
stub.seed(1001, siteId, "S1", "转学办理", "jw", "https://hall.example.com/x");
assertThat(body(mockMvc.perform(get("/portal/demo1/sv/{id}", 1001))
.andExpect(status().isOk()).andReturn()))
.contains("href=\"https://hall.example.com/x\"");
// ②③ 站点未配 svc_hall_base 且未手填 → 无"我要办理"按钮
stub.seed(1002, siteId, "S2", "线下事项", "jw", null);
assertThat(body(mockMvc.perform(get("/portal/demo1/sv/{id}", 1002))
.andExpect(status().isOk()).andReturn()))
.doesNotContain("svc-apply-btn");
}
@Test
@DisplayName("§3.7 自动生成分支加在线条件:未开通在线办理不出按钮;手填外链分支维持不变")
void guide_autoApplyUrlRequiresOnlineHandlable() throws Exception {
// 站点配置 svc_hall_base(自动生成分支前提)
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId)
.orElseThrow(IllegalStateException::new);
site.setConfigJson("{\"svc_hall_base\":\"http://localhost:8087\"}");
siteRepository.saveAndFlush(site);
clearCaches();
// 在线办理事项(onlineFlag=1 且绑流程):自动生成发起页 URL
stub.seed(2001, siteId, "ON-1", "在线事项", "jw", null);
assertThat(body(mockMvc.perform(get("/portal/demo1/sv/{id}", 2001))
.andExpect(status().isOk()).andReturn()))
.contains("svc-apply-btn")
.contains("http://localhost:8087/#/apply?siteCode=demo1&amp;itemCode=ON-1");
// 线下事项(onlineFlag=0 未绑流程):即使站点配了基地址也不出按钮(§3.7 收紧)
stub.seed(2002, siteId, "OFF-1", "线下事项", "jw", null);
stub.items.get(2002).setOnlineFlag(0);
stub.items.get(2002).setFlowGuid(null);
assertThat(body(mockMvc.perform(get("/portal/demo1/sv/{id}", 2002))
.andExpect(status().isOk()).andReturn()))
.doesNotContain("svc-apply-btn");
// 线下事项但手填了外链:手填分支维持不变(最高优先,不受在线条件影响)
stub.seed(2003, siteId, "EXT-1", "外链事项", "jw", "https://ext.example.com/apply");
stub.items.get(2003).setOnlineFlag(0);
stub.items.get(2003).setFlowGuid(null);
assertThat(body(mockMvc.perform(get("/portal/demo1/sv/{id}", 2003))
.andExpect(status().isOk()).andReturn()))
.contains("https://ext.example.com/apply");
}
@Test
@DisplayName("M0: 无 service.html 的主题访问服务中心 → 200 且资产 URL 主题段为 default")
void fallback_themeWithoutServiceTemplateFallsBackWholePage() throws Exception {
stub.seed(1001, siteId, "S1", "转学办理", "person", null);
// t-demo-red 为测试夹具包:注册成功(必备四页齐全)但无 service.html/guide.html
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId)
.orElseThrow(IllegalStateException::new);
site.setTheme("t-demo-red");
siteRepository.saveAndFlush(site);
clearCaches();
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("服务中心").contains("转学办理");
assertThat(html).contains("/assets/default/css/main.css");
assertThat(html).doesNotContain("/assets/t-demo-red/");
}
}
@@ -68,9 +68,9 @@ class ThemeContractIT extends CmsIntegrationTestBase {
});
articleId = persistArticle(site.getId(), listCatId, "契约测试文章",
ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)).getId();
// 事项库 R2:服务中心/指南页路由种子(已发布事项;缺可选模板的主题走 M0 整页回退)
serviceItemId = persistSvcItem(site.getId(), "CT-001", "契约测试事项",
com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum.PUBLISHED).getId();
// 事项库 R2:服务中心/指南页路由由 StubSvcItemQuery 提供默认事项(V2.0 拆分后
// 事项数据在办事大厅侧,门户展示壳测试用 stub;缺可选模板的主题走 M0 整页回退)
serviceItemId = 900001;
}
private String body(MvcResult result, String theme, String route)
@@ -287,24 +287,16 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
}
@Test
@DisplayName("事项库 R2: scene=svc_item 放行且错误文案含 svc_item;ref_type/ref_id 查询命中")
void upload_svcItemSceneAllowedAndReferenced() {
// 场景入白名单:svc_item 上传落 refType 初步标记
AttachmentVo vo = attachmentService.upload(
TestUsers.superAdmin(), siteId, null, "svc_item", imageFile("jpg"));
assertThat(vo.getRefType()).isEqualTo("svc_item");
// 错误文案同步(仅 banner/link/site/category/svc_item)
@DisplayName("V2.0 拆分: svc_item/svc_item_asset 场景已迁办事大厅,CMS 上传被拒")
void upload_svcScenesRejectedAfterSplit() {
assertThatThrownBy(() -> attachmentService.upload(
TestUsers.superAdmin(), siteId, null, "svc_item2", imageFile("jpg")))
TestUsers.superAdmin(), siteId, null, "svc_item", imageFile("jpg")))
.isInstanceOf(BizException.class)
.hasMessageContaining("svc_item");
// 指南附件查询通道:markUsed(refType=svc_item, refId) 后 findByRefTypeAndRefId 命中
attachmentService.markUsed(siteId, vo.getUrl(), "svc_item", 1001);
assertThat(attachmentRepository.findByRefTypeAndRefId("svc_item", 1001))
.hasSize(1)
.first().extracting(CmsAttachment::getRefId).isEqualTo(1001);
.hasMessageContaining("scene 非法");
assertThatThrownBy(() -> attachmentService.upload(
TestUsers.superAdmin(), siteId, null, "svc_item_asset", imageFile("jpg")))
.isInstanceOf(BizException.class)
.hasMessageContaining("scene 非法");
}
@Test
@@ -14,11 +14,6 @@ import com.yunhai.cms.repository.CmsBannerRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsLinkRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import com.yunhai.cms.svcitem.repository.CmsServiceDictRepository;
import com.yunhai.cms.svcitem.repository.CmsServiceItemRepository;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -40,6 +35,7 @@ import java.time.LocalDateTime;
*/
@SpringBootTest(properties = "cms.schedule.auto-enabled=false")
@AutoConfigureMockMvc
@org.springframework.context.annotation.Import(StubSvcItemQuery.StubConfig.class)
public abstract class CmsIntegrationTestBase {
@Autowired
@@ -56,10 +52,6 @@ public abstract class CmsIntegrationTestBase {
protected CmsLinkRepository linkRepository;
@Autowired
protected CmsAttachmentRepository attachmentRepository;
@Autowired
protected CmsServiceItemRepository serviceItemRepository;
@Autowired
protected CmsServiceDictRepository serviceDictRepository;
@BeforeEach
void clearCachesBeforeEachTest() {
@@ -80,8 +72,6 @@ public abstract class CmsIntegrationTestBase {
categoryRepository.deleteAllInBatch();
bannerRepository.deleteAllInBatch();
linkRepository.deleteAllInBatch();
serviceItemRepository.deleteAllInBatch();
serviceDictRepository.deleteAllInBatch();
siteRepository.deleteAllInBatch();
}
@@ -175,37 +165,6 @@ public abstract class CmsIntegrationTestBase {
return attachmentRepository.saveAndFlush(attachment);
}
/** 事项字典种子(status 默认启用) */
protected CmsServiceDict persistSvcDict(Integer siteId, String dictType, String dictKey,
String dictName, Integer status) {
CmsServiceDict dict = new CmsServiceDict();
dict.setSiteId(siteId);
dict.setDictType(dictType);
dict.setDictKey(dictKey);
dict.setDictName(dictName);
dict.setSortNo(0);
dict.setStatus(status == null ? 1 : status);
return serviceDictRepository.saveAndFlush(dict);
}
/** 事项种子(必需字段齐备,其余可后续补设后 saveAndFlush) */
protected CmsServiceItem persistSvcItem(Integer siteId, String itemCode, String title,
ServiceItemStatusEnum status) {
CmsServiceItem item = new CmsServiceItem();
item.setSiteId(siteId);
item.setItemCode(itemCode);
item.setTitle(title);
item.setHandleMode(1);
item.setOnlineFlag(1);
item.setSortNo(0);
item.setStatus(status.getCode());
item.setViewCount(0);
if (status == ServiceItemStatusEnum.PUBLISHED) {
item.setPublishTime(nowMinusSeconds(60));
}
return serviceItemRepository.saveAndFlush(item);
}
/** 简易轮询等待(无 awaitility 依赖):最长 waitMillis 内每 50ms 检查一次期望 */
protected static void awaitTrue(long waitMillis, java.util.function.BooleanSupplier condition)
throws InterruptedException {
@@ -0,0 +1,149 @@
package com.yunhai.cms.support;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 事项域测试替身(V2.0 拆分后):SvcItemQuery 的生产实现是 SvcItemRemoteQuery(调办事大厅 oapi),
* 集成测试环境用本 stub 以 @Primary 替换——门户展示壳(路由/模板/装配/回退)的测试不再依赖外部应用。
* 远程映射(JSON → VO)的正确性由办事大厅侧契约测试与真机联调覆盖(docs/19 §九)。
*
* <p>种子方式:子类 @Autowired 注入本 bean(具体类)后直接操作公开容器;
* {@link #missingIds} 用于模拟 404(办事大厅 code=2 语义);{@link #defaultItem}=true 时
* 任意未登记 id 都返回一份默认已发布事项(ThemeContractIT 全主题渲染用)。
*/
public class StubSvcItemQuery implements SvcItemQuery {
/** itemId → 指南页模型(portalItem 精确命中) */
public final Map<Integer, PortalServiceItem> items = new HashMap<>();
/** itemId → 指南附件 */
public final Map<Integer, List<SvcItemAttachmentVo>> attachments = new HashMap<>();
/** itemId → 关联事项 */
public final Map<Integer, List<PortalServiceItem>> related = new HashMap<>();
/** siteId → 筛选项 */
public final Map<Integer, PortalServiceFilters> filters = new HashMap<>();
/** 模拟不存在/未发布(404)的 id */
public final List<Integer> missingIds = new ArrayList<>();
/** 任意未登记 id 返回默认事项(默认 true;404 语义测试置 false) */
public boolean defaultItem = true;
/** 模拟 svc-hall 停机(P1-4):置 true 后 portalPage/portalItem 抛连接类异常(DHttpException code=0) */
public boolean down = false;
/** portalPage 返回的列表行(Item 形态) */
public final List<PortalServiceListData.Item> pageItems = new ArrayList<>();
@Override
public PortalServiceListData portalPage(SvcItemPortalQuery query) {
if (down) {
throw new com.yunhai.platform.http.DHttpException("DHttp request failed: GET ... (connect timeout)", 0);
}
PortalServiceListData data = new PortalServiceListData();
data.setSiteId(query.getSiteId());
data.setTotal((long) pageItems.size());
data.setPage(query.getPage() == null ? 1 : query.getPage());
data.setPageSize(10);
data.setTotalPages(1);
data.getItems().addAll(pageItems);
return data;
}
@Override
public PortalServiceFilters portalFilters(Integer siteId) {
return filters.getOrDefault(siteId, new PortalServiceFilters());
}
@Override
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
if (down) {
throw new com.yunhai.platform.http.DHttpException("DHttp request failed: GET ... (connect refused)", 0);
}
if (missingIds.contains(itemId)) {
return null;
}
PortalServiceItem item = items.get(itemId);
if (item != null) {
return item;
}
return defaultItem ? defaultItem(itemId, siteId) : null;
}
@Override
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
return related.getOrDefault(itemId, new ArrayList<>());
}
@Override
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
return attachments.getOrDefault(itemId, new ArrayList<>());
}
@Override
public void countView(Integer itemId) {
// no-op
}
/** 便捷种子:登记一个已发布事项(siteCode/itemCode 取占位,其余字段按需改) */
public PortalServiceItem seed(Integer itemId, Integer siteId, String itemCode, String title,
String categoryKey, String applyUrl) {
PortalServiceItem item = defaultItem(itemId, siteId);
item.setItemCode(itemCode);
item.setTitle(title);
item.setCategoryKey(categoryKey);
item.setCategoryName("教务服务");
item.setApplyUrl(applyUrl);
items.put(itemId, item);
PortalServiceListData.Item row = new PortalServiceListData.Item();
row.setId(itemId);
row.setItemCode(itemCode);
row.setTitle(title);
row.setCategoryName("教务服务");
row.setHandleMode(item.getHandleMode());
row.setOnlineFlag(item.getOnlineFlag());
row.setPublishTime(item.getPublishTime());
pageItems.add(row);
return item;
}
private static PortalServiceItem defaultItem(Integer itemId, Integer siteId) {
PortalServiceItem item = new PortalServiceItem();
item.setId(itemId);
item.setSiteId(siteId);
item.setItemCode("CT-001");
item.setTitle("契约测试事项");
item.setHandleMode(1);
item.setOnlineFlag(1);
item.setFlowGuid("fg-stub"); // §3.7:自动生成分支在线条件=onlineFlag=1 且绑流程
item.setPublishTime(LocalDateTime.now().minusMinutes(1));
item.setViewCount(0);
return item;
}
/** 测试配置:以 @Primary 替换生产远程实现 */
@TestConfiguration
public static class StubConfig {
@Bean
@Primary
public StubSvcItemQuery stubSvcItemQuery() {
return new StubSvcItemQuery();
}
}
}
@@ -1,193 +0,0 @@
package com.yunhai.cms.svcitem;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.vo.SvcDeptVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.service.SvcDeptSource;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaGroup;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 服务部门链集成测试(审计批次 4 补口:此前 dept_group_id 整条链零覆盖)。
*
* <p>覆盖:组树类型过滤 + 层级构建、保存时按权威名落快照、id 不在树上拒绝、组树不可达降级、
* syncDeptName 刷新快照并失效指南页缓存。组树经 {@link SvcDeptSource} 接缝注入固定树,
* 使"部门可达"这条主路径可断言(生产实现代理 UserBase 静态调用)。
*/
@DisplayName("SvcItemService - 服务部门链(组树接缝)")
class SvcDeptChainIT extends CmsIntegrationTestBase {
/** 可变组树桩:用例按需替换;null 模拟平台不可达 */
static BoaGroup stubTree;
private static final Integer DEPT_ID = 11;
private static final Integer SCHOOL_ID = 10;
private static final Integer NOT_DEPT_ID = 12;
private static final Integer UNKNOWN_DEPT_ID = 999;
@TestConfiguration
static class StubDeptSourceConfig {
@Bean
@Primary
SvcDeptSource stubSvcDeptSource() {
return () -> stubTree;
}
}
@Autowired
private SvcItemQuery svcItemQuery;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
persistSvcDict(siteId, "object", "person", "个人", 1);
persistSvcDict(siteId, "category", "jw", "教务服务", 1);
stubTree = tree("第一中学", "教务处");
}
@AfterEach
void clean() {
stubTree = null;
purgeTables();
}
/** 根(type=1) → 学校(type=2) → [教务处(type=2), 非部门节点(type=9)];另加一所兄弟学校 */
private static BoaGroup tree(String schoolName, String deptName) {
BoaGroup root = group(1, "云海集团", 1);
BoaGroup school = group(SCHOOL_ID, schoolName, 2);
BoaGroup dept = group(DEPT_ID, deptName, 2);
BoaGroup notDept = group(NOT_DEPT_ID, "非部门节点", 9);
BoaGroup sibling = group(20, "第二中学", 2);
school.setChildren(Arrays.asList(dept, notDept));
root.setChildren(Arrays.asList(school, sibling));
return root;
}
private static BoaGroup group(Integer id, String name, Integer type) {
BoaGroup g = new BoaGroup(id, name);
g.setType(type);
return g;
}
private SvcItemSaveRequest request() {
SvcItemSaveRequest req = new SvcItemSaveRequest();
req.setSiteId(siteId);
req.setItemCode("BG-ZX-001");
req.setTitle("转学办理");
return req;
}
private static List<String> names(List<SvcDeptVo> nodes) {
List<String> out = new ArrayList<>();
for (SvcDeptVo node : nodes) {
out.add(node.getName());
}
return out;
}
@Test
@DisplayName("deptTree:按配置类型过滤(非部门节点不下发)且保留层级")
void deptTree_nestedAndTypeFiltered() {
List<SvcDeptVo> top = svcItemQuery.deptTree(TestUsers.superAdmin(), siteId);
assertThat(names(top)).containsExactly("第一中学", "第二中学");
SvcDeptVo school = top.get(0);
assertThat(school.getId()).isEqualTo(SCHOOL_ID);
assertThat(names(school.getChildren())).as("type=9 的节点被过滤,部门子节点保留").containsExactly("教务处");
assertThat(school.getChildren().get(0).getId()).isEqualTo(DEPT_ID);
assertThat(school.getChildren().get(0).getChildren()).isEmpty();
assertThat(top.get(1).getChildren()).isEmpty();
}
@Test
@DisplayName("deptTree:组树不可达降级为空列表(不抛异常)")
void deptTree_unreachableReturnsEmpty() {
stubTree = null;
assertThat(svcItemQuery.deptTree(TestUsers.superAdmin(), siteId)).isEmpty();
}
@Test
@DisplayName("保存:合法部门 id 落权威名快照")
void save_validDeptIdSnapshotsName() {
SvcItemSaveRequest req = request();
req.setDeptGroupId(DEPT_ID);
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id);
assertThat(vo.getDeptName()).isEqualTo("教务处");
assertThat(vo.getDeptGroupId()).isEqualTo(DEPT_ID);
}
@Test
@DisplayName("保存:组树不可达时带部门保存被拒(防绕过选择器写脏快照)")
void save_unreachableDeptRejected() {
stubTree = null;
SvcItemSaveRequest req = request();
req.setDeptGroupId(DEPT_ID);
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class)
.hasMessageContaining("部门服务不可达");
}
@Test
@DisplayName("保存:id 不在组树上被拒;留空可保存")
void save_unknownDeptRejectedAndBlankAllowed() {
SvcItemSaveRequest bad = request();
bad.setDeptGroupId(UNKNOWN_DEPT_ID);
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), bad))
.isInstanceOf(BizException.class)
.hasMessageContaining("服务部门不存在");
SvcItemSaveRequest blank = request();
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), blank);
assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isNull();
}
@Test
@DisplayName("syncDeptName:组树改名后刷新快照条数,并失效指南页缓存")
void syncDeptName_refreshesSnapshotAndCache() {
SvcItemSaveRequest req = request();
req.setDeptGroupId(DEPT_ID);
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
assertThat(svcItemQuery.portalItem(id, siteId).getDeptName()).isEqualTo("教务处");
// 组树改名后:未同步前指南页仍读缓存中的旧名
stubTree = tree("第一中学", "教务处(改)");
Map<String, Object> result = svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId);
assertThat(result.get("updated")).isEqualTo(1);
PortalServiceItem refreshed = svcItemQuery.portalItem(id, siteId);
assertThat(refreshed.getDeptName()).as("syncDeptName 事件已整表失效指南页缓存").isEqualTo("教务处(改)");
assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isEqualTo("教务处(改)");
}
@Test
@DisplayName("syncDeptName:组树不可达时报错且不改数据")
void syncDeptName_unreachableRejected() {
stubTree = null;
assertThatThrownBy(() -> svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId))
.isInstanceOf(BizException.class)
.hasMessageContaining("不可达或为空");
}
}
@@ -1,219 +0,0 @@
package com.yunhai.cms.svcitem;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.cms.svcitem.enums.ServiceDictTypeEnum;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import com.yunhai.cms.svcitem.model.dto.SvcDictSaveRequest;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.SvcDictVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.svcitem.service.impl.SvcDictService;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 事项字典集成测试(事项库详细设计 §2.2/§3.3):键唯一、稳定键引用守护、
* 停用后不出现在筛选与选择器、存量引用仍可渲染。
*/
@DisplayName("SvcDictService - 事项字典")
class SvcDictServiceIT extends CmsIntegrationTestBase {
@Autowired
private SvcDictService svcDictService;
@Autowired
private SvcItemQuery svcItemQuery;
private Integer siteId;
private Integer otherSiteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
otherSiteId = persistSite("demo2", 2, "演示站点二").getId();
}
@AfterEach
void clean() {
purgeTables();
}
/** 事项种子(带字典键)——落库直插,绕过 adminSave 的字典启用校验便于构造引用态 */
private com.yunhai.cms.svcitem.entity.CmsServiceItem persistItemWithKeys(
String code, String title, String objectKey, String categoryKey) {
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistSvcItem(siteId, code, title, ServiceItemStatusEnum.DRAFT);
item.setObjectKey(objectKey);
item.setCategoryKey(categoryKey);
return serviceItemRepository.saveAndFlush(item);
}
private SvcDictSaveRequest request(String dictType, String dictKey, String dictName) {
SvcDictSaveRequest req = new SvcDictSaveRequest();
req.setSiteId(siteId);
req.setDictType(dictType);
req.setDictKey(dictKey);
req.setDictName(dictName);
return req;
}
@Test
@DisplayName("键唯一:站内同类型撞键拒绝;同键异类型/异站互不冲突(uk_svc_dict 语义)")
void save_uniqueKeyPerSiteAndType() {
svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人"));
assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(),
request("object", "person", "个人重复")))
.isInstanceOf(BizException.class).hasMessageContaining("字典键已存在");
// 同键异类型放行
assertThat(svcDictService.save(TestUsers.superAdmin(),
request("category", "person", "个人类别"))).isNotNull();
// 异站同键放行
SvcDictSaveRequest other = request("object", "person", "他站个人");
other.setSiteId(otherSiteId);
assertThat(svcDictService.save(TestUsers.superAdmin(), other)).isNotNull();
}
@Test
@DisplayName("类型收口:非 object/category 拒绝(保存与列表两条入口)")
void save_dictTypeWhitelist() {
assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(),
request("hack", "x", "非法类型")))
.isInstanceOf(BizException.class).hasMessageContaining("字典类型非法");
assertThatThrownBy(() -> svcDictService.list(TestUsers.superAdmin(), siteId, "hack"))
.isInstanceOf(BizException.class).hasMessageContaining("字典类型非法");
}
@Test
@DisplayName("稳定键守护:被事项引用后改键拒绝(只能改名);未引用可改键")
void save_referencedKeyImmutable() {
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务"));
// 未引用:改键放行
SvcDictSaveRequest rename = request("category", "jwc", "教务处服务");
rename.setId(dictId);
svcDictService.save(TestUsers.superAdmin(), rename);
// 被引用后改键拒绝
persistItemWithKeys("S1", "事项一", null, "jwc");
SvcDictSaveRequest changeKey = request("category", "jw2", "教务服务");
changeKey.setId(dictId);
assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(), changeKey))
.isInstanceOf(BizException.class).hasMessageContaining("不可修改");
// 被引用后改名放行(dict_name 可改)
SvcDictSaveRequest renameOnly = request("category", "jwc", "教务处综合服务");
renameOnly.setId(dictId);
svcDictService.save(TestUsers.superAdmin(), renameOnly);
assertThat(svcDictService.list(TestUsers.superAdmin(), siteId, "category"))
.extracting("dictName").containsExactly("教务处综合服务");
}
@Test
@DisplayName("删除守护:被引用的字典不能删(可停用);未引用可删")
void delete_referencedRejected() {
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人"));
DeleteRequest del = new DeleteRequest();
del.setSiteId(siteId);
del.setId(dictId);
svcDictService.delete(TestUsers.superAdmin(), del);
assertThat(serviceDictRepository.findById(dictId)).isEmpty();
Integer dictId2 = svcDictService.save(TestUsers.superAdmin(), request("object", "legal", "法人"));
persistItemWithKeys("S1", "事项一", "legal", null);
DeleteRequest del2 = new DeleteRequest();
del2.setSiteId(siteId);
del2.setId(dictId2);
assertThatThrownBy(() -> svcDictService.delete(TestUsers.superAdmin(), del2))
.isInstanceOf(BizException.class).hasMessageContaining("不能删除");
}
@Test
@DisplayName("停用语义:不出现在门户筛选(有已发布引用也不出);存量引用仍可渲染名称")
void disable_excludedFromFiltersButStillRenders() {
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人"));
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistItemWithKeys("S1", "事项一", "person", null);
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
serviceItemRepository.saveAndFlush(item);
PortalServiceFilters before = svcItemQuery.portalFilters(siteId);
assertThat(before.getObjects()).extracting("key").containsExactly("person");
// 停用 → 事件失效 filters 缓存 → 选项消失
SvcDictSaveRequest disable = request("object", "person", "个人");
disable.setId(dictId);
disable.setStatus(0);
svcDictService.save(TestUsers.superAdmin(), disable);
assertThat(svcItemQuery.portalFilters(siteId).getObjects()).isEmpty();
// 存量引用仍可渲染:指南页模型 objectName 回填(全状态字典口径)
com.yunhai.cms.svcitem.model.vo.PortalServiceItem portal =
svcItemQuery.portalItem(item.getId(), siteId);
assertThat(portal).isNotNull();
assertThat(portal.getObjectName()).isEqualTo("个人");
}
@Test
@DisplayName("列表 referenced 计数:被引用条数回填(全状态口径),未引用为 0")
void list_referencedCountBackfilled() {
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务"));
svcDictService.save(TestUsers.superAdmin(), request("category", "log", "后勤服务"));
for (int i = 0; i < 2; i++) {
persistItemWithKeys("S" + i, "事项" + i, null, "jw");
}
List<SvcDictVo> list = svcDictService.list(TestUsers.superAdmin(), siteId, "category");
assertThat(list).extracting("dictKey").containsExactly("jw", "log");
assertThat(list).extracting("referenced").containsExactly(2L, 0L);
assertThat(dictId).isNotNull();
}
@Test
@DisplayName("nameMap/enabledDicts:全状态 vs 仅启用的口径差")
void nameMapAndEnabledDicts_semantics() {
svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人"));
Integer stopId = svcDictService.save(TestUsers.superAdmin(), request("object", "legal", "法人"));
SvcDictSaveRequest disable = request("object", "legal", "法人");
disable.setId(stopId);
disable.setStatus(0);
svcDictService.save(TestUsers.superAdmin(), disable);
assertThat(svcDictService.nameMap(siteId, ServiceDictTypeEnum.OBJECT).keySet())
.as("全状态:渲染口径含停用键").containsExactlyInAnyOrder("person", "legal");
assertThat(svcDictService.enabledDicts(siteId, ServiceDictTypeEnum.OBJECT))
.extracting("dictKey").containsExactly("person");
}
@Test
@DisplayName("字典改名:指南页已缓存模型立即刷新 categoryName(审计 D4 修复锚点)")
void rename_refreshesCachedGuideModel() {
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务"));
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistItemWithKeys("S1", "转学办理", null, "jw");
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
serviceItemRepository.saveAndFlush(item);
assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName()).isEqualTo("教务服务");
// 改名:scope=dict 事件整表清 cms:svcItem → 已缓存指南页不再滞留旧名(修复前最多滞后 5min)
SvcDictSaveRequest rename = request("category", "jw", "教务与学籍服务");
rename.setId(dictId);
svcDictService.save(TestUsers.superAdmin(), rename);
assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName())
.as("改名后指南页模型立即可见新名").isEqualTo("教务与学籍服务");
assertThat(svcItemQuery.portalFilters(siteId).getCategories())
.extracting("name").containsExactly("教务与学籍服务");
}
}
@@ -1,241 +0,0 @@
package com.yunhai.cms.svcitem;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 跨服务快照 oapi 集成测试(事项库详细设计 §3.4,C-3/C-4 校正):
* 未签名 401;缺参/不存在/未发布走信封 code=2(HTTP 恒 200 平台口径);
* 正常返回发布态要点快照;真实签名端到端(test 专属 trusted-apps)。
*/
@DisplayName("SvcItemOapi - 跨服务快照契约(M2)")
@SpringBootTest(properties = "yh.app-auth.trusted-apps.t-svhall=test-secret-1234567890")
class SvcItemOapiIT extends CmsIntegrationTestBase {
private static final String APP_KEY = "t-svhall";
private static final String APP_SECRET = "test-secret-1234567890";
private static final ObjectMapper MAPPER = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
private Integer siteId;
private Integer itemId;
@BeforeEach
void seed() throws Exception {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistSvcItem(siteId, "BG-ZX-001", "转学办理", ServiceItemStatusEnum.PUBLISHED);
item.setDeptName("教务处");
item.setContactPhone("0571-88888888");
item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}");
itemId = serviceItemRepository.saveAndFlush(item).getId();
}
@AfterEach
void clean() {
purgeTables();
}
/** 手工建站(指定 path/parent):基类 persistSite 会先按 code 插一次、无法构造同名 code 的两站 */
private com.yunhai.cms.entity.CmsSite manualSite(String code, String path, Integer parentSiteId,
Integer schId, String name) {
com.yunhai.cms.entity.CmsSite site = new com.yunhai.cms.entity.CmsSite();
site.setCode(code);
site.setPath(path);
site.setParentSiteId(parentSiteId);
site.setSchId(schId);
site.setSchName("学校-" + schId);
site.setName(name);
site.setTheme("default");
site.setStatus(1);
return siteRepository.saveAndFlush(site);
}
private JsonNode envelope(MvcResult result) throws Exception {
return MAPPER.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8));
}
/** 模拟 AppAuthFilter 已验签(直接打请求属性),聚焦业务断言 */
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signed(
String path, String query) {
return get(path + (query == null || query.isEmpty() ? "" : "?" + query))
.requestAttr(com.yunhai.platform.appauth.AppAuthFilter.APP_AUTH_ATTR, APP_KEY);
}
@Test
@DisplayName("未签名访问 /oapi/** → HTTP 401(签名强制过滤器,非信封)")
void unsignedRequest_unauthorized401() throws Exception {
mockMvc.perform(get("/oapi/svc/item/snapshot")
.param("siteCode", "demo1").param("itemCode", "BG-ZX-001"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("缺 siteCode/itemCode → 信封 code=2(HTTP 200),不做全局 itemCode 降级查询")
void missingParams_envelopeParamError() throws Exception {
JsonNode noSite = envelope(mockMvc.perform(signed("/oapi/svc/item/snapshot", "itemCode=BG-ZX-001"))
.andExpect(status().isOk()).andReturn());
assertThat(noSite.get("code").asInt()).isEqualTo(2);
assertThat(noSite.get("msg").asText()).contains("不得为空");
JsonNode noItem = envelope(mockMvc.perform(signed("/oapi/svc/item/snapshot", "siteCode=demo1"))
.andExpect(status().isOk()).andReturn());
assertThat(noItem.get("code").asInt()).isEqualTo(2);
}
@Test
@DisplayName("站点不存在/事项不存在/未发布 → 信封 code=2「事项不存在或未发布」")
void notFoundOrUnpublished_envelopeParamError() throws Exception {
JsonNode noSite = envelope(mockMvc.perform(
signed("/oapi/svc/item/snapshot", "siteCode=ghost&itemCode=BG-ZX-001"))
.andExpect(status().isOk()).andReturn());
assertThat(noSite.get("code").asInt()).isEqualTo(2);
assertThat(noSite.get("msg").asText()).contains("事项不存在或未发布");
JsonNode noItem = envelope(mockMvc.perform(
signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=NOT-EXIST"))
.andExpect(status().isOk()).andReturn());
assertThat(noItem.get("code").asInt()).isEqualTo(2);
// 未发布:下架后快照同样 code=2(办件隔离由 svhall 侧快照承担)
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
serviceItemRepository.findById(itemId).orElseThrow(IllegalStateException::new);
item.setStatus(com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum.OFFLINE.getCode());
serviceItemRepository.saveAndFlush(item);
JsonNode offline = envelope(mockMvc.perform(
signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=BG-ZX-001"))
.andExpect(status().isOk()).andReturn());
assertThat(offline.get("code").asInt()).isEqualTo(2);
}
@Test
@DisplayName("正常快照:code=0 + 发布态字段(双键 siteCode/itemCode 成对返回)")
void snapshot_happyPath() throws Exception {
JsonNode data = envelope(mockMvc.perform(
signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=BG-ZX-001"))
.andExpect(status().isOk()).andReturn()).get("data");
assertThat(data.get("siteCode").asText()).isEqualTo("demo1");
assertThat(data.get("itemCode").asText()).isEqualTo("BG-ZX-001");
assertThat(data.get("title").asText()).isEqualTo("转学办理");
assertThat(data.get("deptName").asText()).isEqualTo("教务处");
assertThat(data.get("handleMode").asInt()).isEqualTo(1);
assertThat(data.get("onlineFlag").asInt()).isEqualTo(1);
assertThat(data.get("contactPhone").asText()).isEqualTo("0571-88888888");
assertThat(data.get("applyUrl").asText()).isEqualTo("https://apply.example.com/zx");
assertThat(data.hasNonNull("publishTime")).isTrue();
}
@Test
@DisplayName("真实签名端到端:HmacSHA256(Base64) 三件套通过 AppAuthFilter 验签")
void realSignature_endToEnd() throws Exception {
// canonical target = path + 排序后 query(SignCanonical 口径:itemCode < siteCode)
String ts = String.valueOf(System.currentTimeMillis());
String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1";
String signature = SignUtil.signRequest("GET", target, ts, APP_SECRET);
// query 经 URI 模板直传(MockMvc param() 不进 getQueryString(),而验签 target 含排序 query)
MvcResult ok = mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", APP_KEY)
.header("X-Timestamp", ts)
.header("X-Signature", signature))
.andExpect(status().isOk()).andReturn();
JsonNode data = envelope(ok).get("data");
assertThat(data.get("title").asText()).isEqualTo("转学办理");
}
@Test
@DisplayName("真实签名失败(错 secret)→ 401(AppAuthFilter 拦截,未到业务层)")
void realSignature_wrongSecretUnauthorized() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1";
String signature = SignUtil.signRequest("GET", target, ts, "wrong-secret-1234567890");
mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", APP_KEY)
.header("X-Timestamp", ts)
.header("X-Signature", signature))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("签名失效分支:时间戳超窗(>5min)→ 401")
void realSignature_expiredTimestampUnauthorized() throws Exception {
// 时间窗默认 5 分钟(AppAuthProperties.expireMs=300000):签名算法本身正确,仅时间戳过期
String ts = String.valueOf(System.currentTimeMillis() - 10 * 60 * 1000L);
String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1";
String signature = SignUtil.signRequest("GET", target, ts, APP_SECRET);
mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", APP_KEY)
.header("X-Timestamp", ts)
.header("X-Signature", signature))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("签名失效分支:未登记 appKey / 签名头缺失或畸形 → 401")
void realSignature_unknownAppKeyAndMalformedHeadersUnauthorized() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1";
// 未登记 appKey(签名用某个 secret 算过,但白名单里没有该 key)
mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", "ghost-app")
.header("X-Timestamp", ts)
.header("X-Signature", SignUtil.signRequest("GET", target, ts, APP_SECRET)))
.andExpect(status().isUnauthorized());
// 缺签名头
mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", APP_KEY)
.header("X-Timestamp", ts))
.andExpect(status().isUnauthorized());
// 时间戳非数字
mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001")
.header("X-App-Key", APP_KEY)
.header("X-Timestamp", "not-a-number")
.header("X-Signature", "whatever"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("siteCode 同名歧义(uk 为 parent+code):确定性取最早站点,不 500(设计 §1.3 行为固化)")
void siteCodeAmbiguity_resolvesDeterministically() throws Exception {
// 两个不同父站下的同名子站 code(uk_site_path 由 path 区分:pa/dupcode vs pb/dupcode)——
// 这正是 uk_site_parent_code 允许、而 findFirstByCodeOrderByIdAsc 需要兜住的场景
Integer parentA = manualSite("pa", "pa", 0, 1, "学校一").getId();
Integer parentB = manualSite("pb", "pb", 0, 2, "学校二").getId();
Integer childA = manualSite("dupcode", "pa/dupcode", parentA, 1, "学校一子站").getId();
Integer childB = manualSite("dupcode", "pb/dupcode", parentB, 2, "学校二子站").getId();
assertThat(childB).isGreaterThan(childA);
serviceItemRepository.saveAndFlush(
persistSvcItem(childA, "DUP-1", "先建子站事项", ServiceItemStatusEnum.PUBLISHED));
serviceItemRepository.saveAndFlush(
persistSvcItem(childB, "DUP-1", "后建子站事项", ServiceItemStatusEnum.PUBLISHED));
// 修复前:findByCode 命中两条 → IncorrectResultSizeDataAccessException(500)
JsonNode data = envelope(mockMvc.perform(
signed("/oapi/svc/item/snapshot", "siteCode=dupcode&itemCode=DUP-1"))
.andExpect(status().isOk()).andReturn()).get("data");
assertThat(data.get("title").asText()).as("取 id 最小的站点(最早创建)").isEqualTo("先建子站事项");
}
}
@@ -1,299 +0,0 @@
package com.yunhai.cms.svcitem;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 门户服务中心/事项指南集成测试(事项库详细设计 §5/§6 + M0 回退 IT 0.3):
* 路由双变体、301 兼容、404 语义、筛选与分页 URL 预生成(C-2)、富文本补基准、
* applyUrl 三态、可选模板整页回退(缺模板主题的资产段退 default)。
*/
@DisplayName("SvcItemPortal - 门户公开层(L2)")
class SvcItemPortalIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private com.yunhai.cms.svcitem.service.SvcItemQuery svcItemQuery;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
persistSvcDict(siteId, "object", "person", "个人", 1);
persistSvcDict(siteId, "object", "legal", "法人", 1);
persistSvcDict(siteId, "category", "jw", "教务服务", 1);
}
@AfterEach
void clean() {
purgeTables();
}
private Integer publishItem(String code, String title, String objectKey, String categoryKey) {
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
persistSvcItem(siteId, code, title, ServiceItemStatusEnum.PUBLISHED);
item.setObjectKey(objectKey);
item.setCategoryKey(categoryKey);
item.setDeptName("教务处");
return serviceItemRepository.saveAndFlush(item).getId();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
// ===== 路由(§5.1:单段 + 嵌套双变体、301、404)=====
@Test
@DisplayName("路由双变体:/portal/{code}/service 与 /portal/{p}/{s}/service 均 200")
void routes_singleAndNested() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
assertThat(body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心").contains("转学办理");
Integer parent = persistSite("schx", 1, "主站").getId();
Integer childId = persistSite("child", 1, "子站", parent).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem child =
persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED);
serviceItemRepository.saveAndFlush(child);
assertThat(body(mockMvc.perform(get("/portal/schx/child/service"))
.andExpect(status().isOk()).andReturn()))
.contains("服务中心").contains("子站事项");
}
@Test
@DisplayName("旧单段 code 访问子站服务页 301 到嵌套形态(保留子路径与 query)")
void routes_legacySingleSegmentRedirects301() throws Exception {
Integer parent = persistSite("schy", 1, "主站Y").getId();
persistSite("childy", 1, "子站Y", parent);
// query 串需走 URI 模板直传(MockMvc param() 不进 getQueryString(),真实请求无此差异)
mockMvc.perform(get("/portal/childy/service?obj=person"))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", "/portal/schy/childy/service?obj=person"));
}
@Test
@DisplayName("指南页路由:存在且已发布 200;不存在/草稿 404;响应头带 Cache-Control")
void guideItem_statusSemantics() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "max-age=300"));
mockMvc.perform(get("/portal/demo1/sv/{id}", 999999))
.andExpect(status().isNotFound());
Integer draft = persistSvcItem(siteId, "S2", "草稿事项", ServiceItemStatusEnum.DRAFT).getId();
mockMvc.perform(get("/portal/demo1/sv/{id}", draft))
.andExpect(status().isNotFound());
}
// ===== M0 整页回退(§4.2/§4.3:缺可选模板的主题整页退 default,资产段同步退)=====
@Test
@DisplayName("M0: 无 service.html 的主题访问服务中心 → 200 且资产 URL 主题段为 default(防 default HTML 配他家 CSS)")
void fallback_themeWithoutServiceTemplateFallsBackWholePage() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
// t-demo-red 为测试夹具包:注册成功(必备四页齐全)但无 service.html/guide.html
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
site.setTheme("t-demo-red");
siteRepository.saveAndFlush(site);
clearCaches();
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("服务中心").contains("转学办理");
assertThat(html).contains("/assets/default/css/main.css");
assertThat(html).doesNotContain("/assets/t-demo-red/");
Integer id = serviceItemRepository.findBySiteIdAndItemCode(siteId, "S1")
.orElseThrow(IllegalStateException::new).getId();
String guideHtml = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk()).andReturn());
assertThat(guideHtml).contains("/assets/default/css/main.css");
assertThat(guideHtml).doesNotContain("/assets/t-demo-red/");
}
// ===== 列表筛选与分页 URL 预生成(C-2,§5.4)=====
@Test
@DisplayName("三维筛选:计数正确、facet URL 后端预生成(勾选带参/取消去参),模板零拼 query")
void serviceList_filtersAndFacetUrls() throws Exception {
publishItem("S1", "转学办理", "person", "jw");
publishItem("S2", "休学办理", "person", "jw");
publishItem("S3", "法人年检", "legal", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("个人").contains("法人").contains("教务服务");
// 未勾选态 facet URL:带本维参数(page 归 1,无 page 参数)
assertThat(html).contains("/portal/demo1/service?obj=person\">");
assertThat(html).contains("/portal/demo1/service?obj=legal\">");
// 勾选 obj=person 后:仅个人事项;取消链接回到无参基址;cat facet URL 保留 obj 维
String filtered = body(mockMvc.perform(get("/portal/demo1/service")
.param("obj", "person"))
.andExpect(status().isOk()).andReturn());
assertThat(filtered).contains("转学办理").contains("休学办理");
assertThat(filtered).doesNotContain("法人年检");
assertThat(filtered).contains("/portal/demo1/service\">");
assertThat(filtered).contains("/portal/demo1/service?obj=person&amp;cat=jw\">");
assertThat(filtered).contains("清除筛选");
}
@Test
@DisplayName("分页 URL 预生成:携带当前筛选与关键词(编码),第 2 页起带 page 参数")
void serviceList_paginationUrlsCarryFilters() throws Exception {
for (int i = 1; i <= 12; i++) {
publishItem("S" + i, "事项" + String.format("%02d", i), "person", "jw");
}
String html = body(mockMvc.perform(get("/portal/demo1/service")
.param("obj", "person").param("q", "事项"))
.andExpect(status().isOk()).andReturn());
// 第 2 页链接保留 obj + 编码后的 q,并追加 page=2(链接组装顺序 = obj, q, page)
assertThat(html).contains("obj=person&amp;q=%E4%BA%8B%E9%A1%B9&amp;page=2");
// 第 1 页(active)不带 page 参数
assertThat(html).doesNotContain("page=1\"");
}
@Test
@DisplayName("空结果与空站点:优雅空态不 500")
void serviceList_emptyState() throws Exception {
String html = body(mockMvc.perform(get("/portal/demo1/service"))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("暂未上架");
}
@Test
@DisplayName("关键词命中标题与摘要;canonical 预生成;嵌套指南页路由可用")
void serviceList_keywordCanonicalAndNestedGuide() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new);
item.setSummary("适用于在校学生转学申请");
serviceItemRepository.saveAndFlush(item);
publishItem("S2", "休学办理", "person", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/service").param("q", "在校学生"))
.andExpect(status().isOk()).andReturn());
assertThat(html).as("关键词命中摘要").contains("转学办理").doesNotContain("休学办理");
assertThat(html).contains("rel=\"canonical\"").contains("/portal/demo1/service");
// 嵌套指南页路由(设计 §5.1 成对路由:此前只测了嵌套列表页)
Integer parent = persistSite("schg", 1, "主站G").getId();
Integer childId = persistSite("childg", 1, "子站G", parent).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem childItem =
persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED);
childItem.setProcessHtml("<p>子站流程</p>");
serviceItemRepository.saveAndFlush(childItem);
assertThat(body(mockMvc.perform(get("/portal/schg/childg/sv/{id}", childItem.getId()))
.andExpect(status().isOk()).andReturn()))
.contains("子站事项").contains("子站流程").contains("rel=\"canonical\"");
}
@Test
@DisplayName("指南页浏览量异步累加:HTTP 访问触发 + 计数器原子自增(不承诺即时,轮询等待)")
void guideItem_viewCountIncrementsAsync() throws Exception {
Integer id = publishItem("S1", "转学办理", "person", "jw");
// ① HTTP 路径:指南页渲染成功并触发一次计数(异步)
mockMvc.perform(get("/portal/demo1/sv/{id}", id)).andExpect(status().isOk());
// ② 再补 4 次显式计数:把"异步累加链路是否工作"与"MockMvc 渲染耗时"解耦
for (int i = 0; i < 4; i++) {
svcItemQuery.countView(id);
}
int expected = 5;
int viewCount = 0;
// 轮询预算 30s:异步任务是 fire-and-forget(设计不承诺即时),且满载环境下取连接可能等待
// Hikari 默认 connectionTimeout(30s),故预算需覆盖该量级,避免把连接竞争误判为功能缺陷
for (int i = 0; i < 300; i++) {
viewCount = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new)
.getViewCount();
if (viewCount >= expected) {
break;
}
java.lang.Thread.sleep(100);
}
assertThat(viewCount).as("1 次指南页访问 + 4 次显式计数应累加为 %s", expected)
.isGreaterThanOrEqualTo(expected);
}
// ===== 指南页装配(§5.3)=====
@Test
@DisplayName("指南页:富文本补上传基准、附件下载、关联栏目 url.category、关联事项、手填外链按钮")
void guide_modelAssembly() throws Exception {
Integer faqCat = persistCategory(siteId, 0, "FAQ", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
serviceItemRepository.findById(publishItem("S1", "转学办理", "person", "jw")).orElseThrow(IllegalStateException::new);
item.setProcessHtml("<p><img src=\"/cms/upload/cms/demo1/202609/a.jpg\"></p>");
item.setFaqCategoryId(faqCat);
item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}");
serviceItemRepository.saveAndFlush(item);
com.yunhai.cms.entity.CmsAttachment att = persistAttachment(siteId, "svc_item", "申请表.pdf");
att.setRefId(item.getId());
attachmentRepository.saveAndFlush(att);
publishItem("S2", "休学办理", "person", "jw"); // 同类别关联事项
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", item.getId()))
.andExpect(status().isOk()).andReturn());
// 上传地址渲染期补基准(B-1 口径:MockMvc 请求派生基准 = http://localhost)
assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/a.jpg\"");
// 附件与下载链接(webUrl 相对路径经 url.upload 补基准)
assertThat(html).contains("申请表.pdf");
assertThat(html).contains("http://localhost/cms/upload/cms/" + siteId + "/");
// 关联栏目链接经 url.category 生成
assertThat(html).contains("/portal/demo1/c/" + faqCat);
// 关联事项(同类别已发布,排除自身)
assertThat(html).contains("休学办理");
// 我要办理三态①:手填外链优先
assertThat(html).contains("https://apply.example.com/zx");
assertThat(html).contains("我要办理");
}
@Test
@DisplayName("我要办理三态:② svc_hall_base 生成 svhall 发起页 URL;③ 都没有 → 不渲染按钮")
void applyUrl_generatedAndAbsentStates() throws Exception {
// ② 未手填且站点配 svc_hall_base → 生成(尾斜杠归一)
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
site.setConfigJson("{\"svc_hall_base\":\"https://svhall.example.com/\"}");
siteRepository.saveAndFlush(site);
clearCaches();
Integer id = publishItem("S1", "转学办理", "person", "jw");
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id))
.andExpect(status().isOk()).andReturn());
assertThat(html).contains("我要办理");
assertThat(html).contains("https://svhall.example.com/#/apply?siteCode=demo1&amp;itemCode=S1");
// ③ 都没有 → 按钮不渲染
com.yunhai.cms.entity.CmsSite bare = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
bare.setConfigJson(null);
siteRepository.saveAndFlush(bare);
clearCaches();
Integer id2 = publishItem("S2", "休学办理", "person", "jw");
String html2 = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id2))
.andExpect(status().isOk()).andReturn());
// 以按钮类名断言(模板源码注释含"我要办理"字样会随注释输出)
assertThat(html2).doesNotContain("svc-apply-btn");
assertThat(html2).doesNotContain("svhall.example.com");
}
}
@@ -1,489 +0,0 @@
package com.yunhai.cms.svcitem;
import com.yunhai.cms.entity.CmsAttachment;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum;
import com.yunhai.cms.svcitem.model.dto.IdSortItem;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemPageItem;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.svcitem.service.impl.SvcItemService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 事项管理集成测试(事项库详细设计 §3.3 保存校验清单 / 状态机 / 权限 / 附件回收 / 缓存失效链)。
*/
@DisplayName("SvcItemService - 事项管理(L1 事项库)")
class SvcItemServiceIT extends CmsIntegrationTestBase {
@Autowired
private SvcItemQuery svcItemQuery;
@Autowired
private SvcItemService svcItemService;
private Integer siteId;
private Integer otherSiteId;
@BeforeEach
void seed() {
purgeTables();
siteId = persistSite("demo1", 1, "演示站点一").getId();
otherSiteId = persistSite("demo2", 2, "演示站点二").getId();
persistSvcDict(siteId, "object", "person", "个人", 1);
persistSvcDict(siteId, "object", "legal", "法人", 1);
persistSvcDict(siteId, "category", "jw", "教务服务", 1);
persistSvcDict(siteId, "category", "logistics", "后勤服务", 1);
}
@AfterEach
void clean() {
purgeTables();
}
private SvcItemSaveRequest request() {
SvcItemSaveRequest req = new SvcItemSaveRequest();
req.setSiteId(siteId);
req.setItemCode("BG-ZX-001");
req.setTitle("转学办理");
req.setSummary("适用于在校学生转学申请");
return req;
}
// ===== 保存与校验规则(§3.3 清单)=====
@Test
@DisplayName("保存草稿:富文本 jsoup 清洗(script 剥除)、apply_url 落 ext_json、回显一致")
void save_cleansHtmlAndStoresApplyUrl() {
SvcItemSaveRequest req = request();
req.setObjectKey("person");
req.setCategoryKey("jw");
req.setProcessHtml("<p>流程一</p><script>alert(1)</script><p onclick=\"x()\">流程二</p>");
req.setApplyUrl("https://apply.example.com/zx");
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id);
assertThat(vo.getStatus()).isEqualTo(ServiceItemStatusEnum.DRAFT.getCode());
assertThat(vo.getProcessHtml()).contains("流程一").contains("流程二")
.doesNotContain("<script").doesNotContain("onclick");
assertThat(vo.getApplyUrl()).isEqualTo("https://apply.example.com/zx");
Map<String, Object> page = svcItemQuery.adminPage(TestUsers.superAdmin(),
queryOf(null, null));
assertThat(page.get("total")).isEqualTo(1L);
@SuppressWarnings("unchecked")
List<SvcItemPageItem> list = (List<SvcItemPageItem>) page.get("list");
assertThat(list.get(0).getObjectName()).isEqualTo("个人");
assertThat(list.get(0).getCategoryName()).isEqualTo("教务服务");
}
@Test
@DisplayName("事项编码站内唯一:新建撞码/编辑与他项撞码均拒绝(uk_svc_item_code 语义)")
void save_duplicateItemCodeRejected() {
Integer first = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
// 新建撞码
SvcItemSaveRequest dup = request();
dup.setTitle("另一个转学");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), dup))
.isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在");
// 编辑改为他项编码
SvcItemSaveRequest edit = request();
edit.setId(first);
edit.setItemCode("BG-ZX-002");
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
SvcItemSaveRequest clash = request();
clash.setId(first);
clash.setItemCode("BG-ZX-001");
// 站内已有 BG-ZX-001?——已改名,此处验证编辑撞“现存他码”:先再建一条
SvcItemSaveRequest second = request();
second.setItemCode("BG-ZX-003");
second.setTitle("第二条");
Integer secondId = svcItemQuery.adminSave(TestUsers.superAdmin(), second);
SvcItemSaveRequest clash2 = request();
clash2.setId(secondId);
clash2.setItemCode("BG-ZX-002");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), clash2))
.isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在");
// 同码不同站互不冲突(site 隔离)
SvcItemSaveRequest otherSite = request();
otherSite.setSiteId(otherSiteId);
otherSite.setItemCode("BG-ZX-001");
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), otherSite)).isNotNull();
}
@Test
@DisplayName("字典键校验:不存在的键拒绝;已停用字典拒绝;空串视同未填")
void save_dictKeyMustBeEnabled() {
SvcItemSaveRequest req = request();
req.setObjectKey("ghost");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class).hasMessageContaining("服务对象不存在");
req.setObjectKey("person");
persistSvcDict(siteId, "category", "stopped", "已停用类别", 0);
req.setCategoryKey("stopped");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class).hasMessageContaining("已停用");
req.setCategoryKey("");
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull();
}
@Test
@DisplayName("apply_url 格式校验:非 http(s) 完整地址拒绝;空值不落 ext_json")
void save_applyUrlMustBeHttp() {
SvcItemSaveRequest req = request();
req.setApplyUrl("javascript:alert(1)");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class).hasMessageContaining("http");
req.setApplyUrl("www.example.com");
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class).hasMessageContaining("http");
req.setApplyUrl(null);
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
assertThat(serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new)
.getExtJson()).isNull();
}
@Test
@DisplayName("关联栏目校验:跨站栏目 id 拒绝(防跨站注入)")
void save_crossSiteCategoryRejected() {
Integer otherCat = persistCategory(otherSiteId, 0, "他站栏目", CategoryTypeEnum.LIST).getId();
SvcItemSaveRequest req = request();
req.setFaqCategoryId(otherCat);
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class).hasMessageContaining("不属于本站点");
Integer ownCat = persistCategory(siteId, 0, "本站栏目", CategoryTypeEnum.LIST).getId();
req.setFaqCategoryId(ownCat);
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull();
}
@Test
@DisplayName("权限:子站/他校管理员 FORBIDDEN(requireManageable),本站通过")
void permission_schoolScoped() {
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.schoolUser(2), request()))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
SvcItemSaveRequest own = request();
own.setSiteId(otherSiteId);
assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull();
}
// ===== 状态机(§2.4)=====
@Test
@DisplayName("状态机:草稿→发布(门户可见)→下架(门户 404)→再发布(又可见)")
void stateMachine_publishOfflineRepublish() {
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
assertThat(svcItemQuery.portalItem(id, siteId)).as("草稿不可见(null 哨兵)").isNull();
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
PortalServiceItem published = svcItemQuery.portalItem(id, siteId);
assertThat(published).as("发布后事件已失效 null 哨兵缓存").isNotNull();
assertThat(published.getPublishTime()).isNotNull();
svcItemQuery.adminOffline(TestUsers.superAdmin(), siteId, id);
assertThat(svcItemQuery.portalItem(id, siteId)).as("下架后缓存已失效且返回 null").isNull();
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
assertThat(svcItemQuery.portalItem(id, siteId)).isNotNull();
}
@Test
@DisplayName("编辑已发布事项:事件失效指南页缓存,新内容立即可见")
void editPublishedItem_evictsCache() {
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
assertThat(svcItemQuery.portalItem(id, siteId).getTitle()).isEqualTo("转学办理");
SvcItemSaveRequest edit = request();
edit.setId(id);
edit.setTitle("转学办理(2026 版)");
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
assertThat(svcItemQuery.portalItem(id, siteId).getTitle())
.as("保存后 AFTER_COMMIT 事件已 evict itemId").isEqualTo("转学办理(2026 版)");
}
// ===== 附件回收(§3.5)=====
@Test
@DisplayName("附件绑定与回收:attachmentIds 全量重绑;删除事项释放 svc_item 引用")
void attachments_bindAndRelease() {
CmsAttachment att1 = persistAttachment(siteId, "svc_item", "材料一.pdf");
CmsAttachment att2 = persistAttachment(siteId, "svc_item", "材料二.pdf");
SvcItemSaveRequest req = request();
req.setAttachmentIds(Arrays.asList(att1.getId(), att2.getId()));
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
assertThat(svcItemQuery.attachments(id)).hasSize(2);
// 重绑:仅保留 att1 → att2 释放为未使用
SvcItemSaveRequest edit = request();
edit.setId(id);
edit.setAttachmentIds(Collections.singletonList(att1.getId()));
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
assertThat(svcItemQuery.attachments(id)).extracting("fileName")
.containsExactly("材料一.pdf");
assertThat(attachmentRepository.findById(att2.getId()).orElseThrow(IllegalStateException::new)
.getRefId()).as("未在列表中的附件回到未使用").isNull();
// 删除事项:svc_item 引用整体释放
DeleteRequest del = new DeleteRequest();
del.setSiteId(siteId);
del.setId(id);
svcItemQuery.adminDelete(TestUsers.superAdmin(), del);
assertThat(svcItemQuery.attachments(id)).isEmpty();
assertThat(attachmentRepository.findById(att1.getId()).orElseThrow(IllegalStateException::new)
.getRefType()).isNull();
assertThat(serviceItemRepository.findById(id)).isEmpty();
}
// ===== 附件引用:内容派生重绑(审计 D1/D5 修复锚点)=====
/** 造附件:webUrl 走 FILE_WEB_PATH(/cms/upload/) 前缀,与线上上传产物同形 */
private CmsAttachment attachment(String fileName, Integer ownerItemId) {
CmsAttachment att = new CmsAttachment();
att.setSiteId(siteId);
att.setFileName(fileName);
att.setFilePath("cms/" + siteId + "/202609/" + fileName.hashCode() + ".png");
att.setWebUrl("/cms/upload/" + att.getFilePath());
att.setFileSize(2048L);
att.setMimeType("image/png");
if (ownerItemId != null) {
att.setRefType("svc_item");
att.setRefId(ownerItemId);
}
return attachmentRepository.saveAndFlush(att);
}
private CmsAttachment reload(CmsAttachment att) {
return attachmentRepository.findById(att.getId()).orElseThrow(IllegalStateException::new);
}
@Test
@DisplayName("封面与正文内嵌图按内容归属;编辑二次保存不丢引用;清空即解绑(原 D1 缺陷)")
void attachments_contentDerivedCoverAndHtml() {
CmsAttachment cover = attachment("封面.png", null);
CmsAttachment inline = attachment("流程图.png", null);
SvcItemSaveRequest req = request();
req.setCoverImage(cover.getWebUrl());
req.setProcessHtml("<p>流程</p><img src=\"" + inline.getWebUrl() + "\" alt=\"流程图\">");
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
assertThat(reload(cover).getRefType()).as("封面归属事项内容资产场景").isEqualTo("svc_item_asset");
assertThat(reload(cover).getRefId()).isEqualTo(id);
assertThat(reload(inline).getRefType()).isEqualTo("svc_item_asset");
assertThat(reload(inline).getRefId()).isEqualTo(id);
assertThat(svcItemQuery.attachments(id)).as("内容资产不进指南附件列表").isEmpty();
// 二次保存(内容不变、未带 attachmentIds):引用必须存活——修复前会被 release 清掉且永不恢复
SvcItemSaveRequest edit = request();
edit.setId(id);
edit.setCoverImage(cover.getWebUrl());
edit.setProcessHtml(req.getProcessHtml());
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
assertThat(reload(cover).getRefId()).as("二次保存后封面仍归属本事项").isEqualTo(id);
assertThat(reload(inline).getRefId()).as("二次保存后内嵌图仍归属本事项").isEqualTo(id);
// 清空封面并移除正文图:两者回到未使用(可被附件管理页清理)
SvcItemSaveRequest cleared = request();
cleared.setId(id);
cleared.setProcessHtml("<p>流程</p>");
svcItemQuery.adminSave(TestUsers.superAdmin(), cleared);
assertThat(reload(cover).getRefType()).isNull();
assertThat(reload(cover).getRefId()).isNull();
assertThat(reload(inline).getRefType()).isNull();
}
@Test
@DisplayName("附件归属校验:已被他事项占用的不抢走、跨站附件跳过、空列表幂等")
void attachments_ownershipGuards() {
Integer otherItem = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
CmsAttachment stolen = attachment("他项材料.pdf", otherItem);
CmsAttachment crossSite = new CmsAttachment();
crossSite.setSiteId(otherSiteId);
crossSite.setFileName("他站材料.pdf");
crossSite.setFilePath("cms/" + otherSiteId + "/202609/x.pdf");
crossSite.setWebUrl("/cms/upload/cms/" + otherSiteId + "/202609/x.pdf");
crossSite = attachmentRepository.saveAndFlush(crossSite);
SvcItemSaveRequest req = request();
req.setItemCode("BG-ZX-002");
req.setTitle("补办学生证");
req.setAttachmentIds(Arrays.asList(stolen.getId(), crossSite.getId()));
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
assertThat(reload(stolen).getRefId()).as("他事项占用的引用不被改写").isEqualTo(otherItem);
assertThat(reload(crossSite).getRefType()).as("跨站附件不绑定").isNull();
assertThat(svcItemQuery.attachments(id)).isEmpty();
// 空列表幂等:以本事项自身编码(BG-ZX-002)重复保存两次均不抛异常,附件仍为空
SvcItemSaveRequest again = request();
again.setId(id);
again.setItemCode("BG-ZX-002");
again.setTitle("补办学生证");
again.setAttachmentIds(null);
svcItemQuery.adminSave(TestUsers.superAdmin(), again);
svcItemQuery.adminSave(TestUsers.superAdmin(), again);
assertThat(svcItemQuery.attachments(id)).isEmpty();
}
@Test
@DisplayName("权限:管理端全部方法对非本站管理员均 FORBIDDEN")
void permission_allAdminMethodsScoped() {
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
BoaUser intruder = TestUsers.schoolUser(999);
assertThatThrownBy(() -> svcItemQuery.adminSave(intruder, request()))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminPage(intruder, queryOf(null, null)))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminGet(intruder, siteId, id))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminPublish(intruder, siteId, id))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminOffline(intruder, siteId, id))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminDelete(intruder, deleteRequestOf(id)))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminSort(intruder, siteId,
Collections.singletonList(sortItemOf(id, 1))))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.adminSelList(intruder, siteId))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.itemAttachments(intruder, siteId, id))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.deptTree(intruder, siteId))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
assertThatThrownBy(() -> svcItemQuery.syncDeptName(intruder, siteId))
.isInstanceOf(BizException.class).hasMessageContaining("无权");
// 本校(otherSiteId 的 schId=2)通过:确认拦截来自站点归属而非"用户无角色"
SvcItemSaveRequest own = request();
own.setSiteId(otherSiteId);
assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull();
}
private DeleteRequest deleteRequestOf(Integer id) {
DeleteRequest del = new DeleteRequest();
del.setSiteId(siteId);
del.setId(id);
return del;
}
private IdSortItem sortItemOf(Integer id, Integer sortNo) {
IdSortItem item = new IdSortItem();
item.setId(id);
item.setSortNo(sortNo);
return item;
}
// ===== 门户筛选计数与缓存(§3.6/§5.2)=====
@Test
@DisplayName("portalFilters:仅统计已发布事项;发布后 filters 缓存被事件失效并更新计数")
void portalFilters_countsAndCacheInvalidation() {
persistSvcItem(siteId, "S1", "事项一", ServiceItemStatusEnum.PUBLISHED);
persistSvcItem(siteId, "S2", "事项二", ServiceItemStatusEnum.PUBLISHED);
persistSvcItem(siteId, "S3", "事项草稿", ServiceItemStatusEnum.DRAFT);
// 直接落库种子无字典键 → 先补:用 adminSave 造带键事项更贴近真实
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
assertThat(filters.getObjects()).as("种子无字典键,对象组为空").isEmpty();
SvcItemSaveRequest req = request();
req.setObjectKey("person");
req.setCategoryKey("jw");
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
// 草稿不进筛选计数(filters 缓存此时是旧值;草稿保存也已发事件失效,重查仍应无此键)
assertThat(svcItemQuery.portalFilters(siteId).getObjects()).isEmpty();
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
PortalServiceFilters after = svcItemQuery.portalFilters(siteId);
assertThat(after.getObjects()).extracting("name").containsExactly("个人");
assertThat(after.getObjects()).extracting("count").containsExactly(1L);
}
// ===== 查询面(§3.3 page / §5.2 portalPage)=====
private SvcItemPageQuery queryOf(Integer status, String keyword) {
SvcItemPageQuery query = new SvcItemPageQuery();
query.setSiteId(siteId);
query.setStatus(status);
query.setKeyword(keyword);
return query;
}
@Test
@DisplayName("adminPage:关键字命中标题/编码,状态过滤,pageSize 上限 100")
void adminPage_filters() {
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
SvcItemSaveRequest req2 = request();
req2.setItemCode("BG-HQ-100");
req2.setTitle("宿舍报修");
svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "报修")).get("total"))
.isEqualTo(1L);
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "BG-ZX")).get("total"))
.as("关键字也命中编码").isEqualTo(1L);
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a);
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(1, null)).get("total"))
.isEqualTo(1L);
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(0, null)).get("total"))
.isEqualTo(1L);
}
@Test
@DisplayName("排序:sort_no 升序为列表第一优先级;adminSort 落库")
void sort_sortNoFirstPriority() {
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
SvcItemSaveRequest req2 = request();
req2.setItemCode("BG-HQ-100");
req2.setTitle("宿舍报修");
Integer b = svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
com.yunhai.cms.svcitem.model.dto.IdSortItem s1 = new com.yunhai.cms.svcitem.model.dto.IdSortItem();
s1.setId(b);
s1.setSortNo(1);
com.yunhai.cms.svcitem.model.dto.IdSortItem s2 = new com.yunhai.cms.svcitem.model.dto.IdSortItem();
s2.setId(a);
s2.setSortNo(9);
svcItemService.adminSort(TestUsers.superAdmin(), siteId, Arrays.asList(s1, s2));
Map<String, Object> page = svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, null));
@SuppressWarnings("unchecked")
List<SvcItemPageItem> list = (List<SvcItemPageItem>) page.get("list");
assertThat(list).extracting("title").containsExactly("宿舍报修", "转学办理");
}
@Test
@DisplayName("选择器 selList:全状态返回精简行")
void selList_allStatuses() {
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a);
SvcItemSaveRequest req2 = request();
req2.setItemCode("BG-HQ-100");
req2.setTitle("宿舍报修");
svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
assertThat(svcItemQuery.adminSelList(TestUsers.superAdmin(), siteId)).hasSize(2);
}
}
@@ -0,0 +1,99 @@
package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.platform.http.DHttpException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 远程事项查询降级语义单测(BP-R1-2 / P1-4 组成):IDHttp 对传输失败与业务码非 0 一律抛
* {@link DHttpException}(code=0 传输类 / code=N 业务类),原三处 catch(BizException) 是死代码。
* 冻结语义:业务 code=2(不存在/未发布)→ detail null(门户 404)/ related、attachments 空;
* 传输类失败(code=0)→ 上抛(停机降级由 PortalController 承接,不得混入 404)。
*/
@DisplayName("SvcItemRemoteQuery - 业务性缺失降级 vs 传输失败上抛")
class SvcItemRemoteQueryTest {
private SvcHallClient client;
private CmsSiteRepository siteRepository;
private SvcItemRemoteQuery query;
@BeforeEach
void setUp() {
client = mock(SvcHallClient.class);
siteRepository = mock(CmsSiteRepository.class);
query = new SvcItemRemoteQuery(client, siteRepository);
CmsSite site = new CmsSite();
site.setCode("demo1");
when(siteRepository.findById(1)).thenReturn(Optional.of(site));
}
@Test
@DisplayName("portalItem:业务 code=2(不存在/未发布)→ null(门户 404 语义)")
void portalItem_businessMissing_returnsNull() {
when(client.detail(anyString(), anyInt(), isNull()))
.thenThrow(new DHttpException("事项不存在或未发布", 2));
assertNull(query.portalItem(1001, 1));
}
@Test
@DisplayName("portalItem:传输类失败(code=0)→ 上抛(停机降级不混入 404)")
void portalItem_transportFailure_propagates() {
when(client.detail(anyString(), anyInt(), isNull()))
.thenThrow(new DHttpException("DHttp request failed: GET ...", 0));
assertThrows(DHttpException.class, () -> query.portalItem(1001, 1));
}
@Test
@DisplayName("relatedItems:业务 code=2 → 空列表;传输类失败上抛")
void relatedItems_semantics() {
when(client.related(anyInt(), anyInt())).thenThrow(new DHttpException("关联事项不存在", 2));
assertEquals(0, query.relatedItems(1, 1001, "cat", 5).size());
// doThrow 风格重打桩:旧桩会抛异常,when(...) 内联调用会被旧桩打断
org.mockito.Mockito.doThrow(new DHttpException("connect timeout", 0))
.when(client).related(anyInt(), anyInt());
assertThrows(DHttpException.class, () -> query.relatedItems(1, 1001, "cat", 5));
}
@Test
@DisplayName("attachments:业务 code=2 → 空列表;传输类失败上抛")
void attachments_semantics() {
when(client.attachments(anyInt())).thenThrow(new DHttpException("事项不存在或未发布", 2));
assertEquals(0, query.attachments(1001).size());
org.mockito.Mockito.doThrow(new DHttpException("connect timeout", 0))
.when(client).attachments(anyInt());
assertThrows(DHttpException.class, () -> query.attachments(1001));
}
@Test
@DisplayName("portalItem:正常出参映射回门户 VO(title/onlineFlag/applyUrl 键)")
void portalItem_mapsResponse() throws Exception {
JsonNode row = new ObjectMapper().readTree(
"{\"id\":1001,\"itemCode\":\"S1\",\"title\":\"转学办理\",\"onlineFlag\":1,"
+ "\"applyUrl\":\"https://x.example/apply\",\"publishTime\":\"2026-09-01 10:00:00\"}");
when(client.detail(anyString(), anyInt(), isNull())).thenReturn(row);
PortalServiceItem vo = query.portalItem(1001, 1);
assertEquals("转学办理", vo.getTitle());
assertEquals(Integer.valueOf(1), vo.getOnlineFlag());
assertEquals("https://x.example/apply", vo.getApplyUrl());
}
}
+3
View File
@@ -57,6 +57,9 @@ yh:
enabled: true
app-key: 100XXX
app-secret: xxxxxxxx
# 入站验签白名单:svc-hall 站点目录调用(与主配置同款联调密钥,SiteOapiIT 咬合用)
trusted-apps:
"100018": "svc-hall-dev-secret-100018"
open:
enabled: true
base-host: http://base.yunhaixingtong.com