From 1cb95225a64a5d90038d2017c6f2ad85f8b193bc Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 23:41:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=91=A8=E6=9C=AB=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/svcitem/SvcDictController.java | 57 -- .../api/svcitem/SvcItemController.java | 105 --- .../oapi/site/SiteOapiController.java | 60 ++ .../oapi/svcitem/SvcItemOapiController.java | 40 - .../controller/portal/PortalController.java | 98 ++- .../listener/PortalCacheEvictListener.java | 29 +- .../cms/service/impl/AttachmentService.java | 11 +- .../cms/service/impl/CategoryService.java | 29 +- .../cms/svcitem/entity/CmsServiceDict.java | 52 -- .../cms/svcitem/entity/CmsServiceItem.java | 147 ---- .../cms/svcitem/enums/HandleModeEnum.java | 37 - .../svcitem/enums/ServiceDictTypeEnum.java | 35 - .../svcitem/enums/ServiceItemStatusEnum.java | 37 - .../event/ServiceItemChangedEvent.java | 36 - .../cms/svcitem/model/dto/IdSortItem.java | 18 - .../svcitem/model/dto/SvcDictSaveRequest.java | 41 - .../svcitem/model/dto/SvcItemSaveRequest.java | 98 --- .../svcitem/model/query/SvcItemPageQuery.java | 30 - .../svcitem/model/vo/PortalServiceItem.java | 38 +- .../model/vo/PortalServiceListData.java | 15 - .../cms/svcitem/model/vo/SvcDeptVo.java | 28 - .../cms/svcitem/model/vo/SvcDictVo.java | 43 - .../cms/svcitem/model/vo/SvcItemEditVo.java | 110 --- .../cms/svcitem/model/vo/SvcItemPageItem.java | 79 -- .../cms/svcitem/model/vo/SvcItemSimpleVo.java | 28 - .../svcitem/model/vo/SvcItemSnapshotVo.java | 37 - .../repository/CmsServiceDictRepository.java | 23 - .../repository/CmsServiceItemRepository.java | 71 -- .../cms/svcitem/service/SvcDeptSource.java | 21 - .../cms/svcitem/service/SvcItemQuery.java | 62 +- .../svcitem/service/impl/SvcDictService.java | 169 ---- .../svcitem/service/impl/SvcHallClient.java | 116 +++ .../service/impl/SvcItemRemoteQuery.java | 249 ++++++ .../svcitem/service/impl/SvcItemService.java | 760 ------------------ .../service/impl/YschBaseSvcDeptSource.java | 25 - src/main/resources/application.yml | 17 + .../templates/themes/default/guide.html | 15 +- .../templates/themes/default/service.html | 18 +- .../cms/controller/oapi/SiteOapiIT.java | 121 +++ .../cms/portal/PortalSvcUnavailableIT.java | 102 +++ .../yunhai/cms/portal/ServicePortalIT.java | 166 ++++ .../yunhai/cms/portal/ThemeContractIT.java | 6 +- .../cms/service/impl/AttachmentServiceIT.java | 24 +- .../cms/support/CmsIntegrationTestBase.java | 43 +- .../yunhai/cms/support/StubSvcItemQuery.java | 149 ++++ .../yunhai/cms/svcitem/SvcDeptChainIT.java | 193 ----- .../yunhai/cms/svcitem/SvcDictServiceIT.java | 219 ----- .../com/yunhai/cms/svcitem/SvcItemOapiIT.java | 241 ------ .../yunhai/cms/svcitem/SvcItemPortalIT.java | 299 ------- .../yunhai/cms/svcitem/SvcItemServiceIT.java | 489 ----------- .../service/impl/SvcItemRemoteQueryTest.java | 99 +++ src/test/resources/application.yml | 3 + 52 files changed, 1250 insertions(+), 3788 deletions(-) delete mode 100644 src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java delete mode 100644 src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java create mode 100644 src/main/java/com/yunhai/cms/controller/oapi/site/SiteOapiController.java delete mode 100644 src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDeptVo.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDictVo.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemEditVo.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemPageItem.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSimpleVo.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSnapshotVo.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceDictRepository.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceItemRepository.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/service/SvcDeptSource.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/SvcDictService.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java delete mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java create mode 100644 src/test/java/com/yunhai/cms/controller/oapi/SiteOapiIT.java create mode 100644 src/test/java/com/yunhai/cms/portal/PortalSvcUnavailableIT.java create mode 100644 src/test/java/com/yunhai/cms/portal/ServicePortalIT.java create mode 100644 src/test/java/com/yunhai/cms/support/StubSvcItemQuery.java delete mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcDeptChainIT.java delete mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcDictServiceIT.java delete mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemOapiIT.java delete mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java delete mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemServiceIT.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQueryTest.java diff --git a/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java b/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java deleted file mode 100644 index 29f8673..0000000 --- a/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java +++ /dev/null @@ -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 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 deptTree(BoaUser authUserWithDept, - @RequestParam("siteId") Integer siteId) { - return svcItemQuery.deptTree(authUserWithDept, siteId); - } -} diff --git a/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java b/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java deleted file mode 100644 index b78bd4e..0000000 --- a/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java +++ /dev/null @@ -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 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 items) { - svcItemQuery.adminSort(authUserWithDept, siteId, items); - } - - /** 同步部门名快照(可选动作:UserBase 组树改名后刷新 dept_name) */ - @PostMapping("/syncDeptName") - public Map syncDeptName(BoaUser authUserWithDept, - @RequestParam("siteId") Integer siteId) { - return svcItemQuery.syncDeptName(authUserWithDept, siteId); - } - - /** 选择器精简列表 */ - @GetMapping("/selList") - public List selList(BoaUser authUserWithDept, - @RequestParam("siteId") Integer siteId) { - return svcItemQuery.adminSelList(authUserWithDept, siteId); - } - - /** 指南附件回显(ref_type=svc_item, ref_id=id,编辑页列表) */ - @GetMapping("/attList") - public List attList(BoaUser authUserWithDept, - @RequestParam("siteId") Integer siteId, - @RequestParam("id") Integer id) { - return svcItemQuery.itemAttachments(authUserWithDept, siteId, id); - } -} diff --git a/src/main/java/com/yunhai/cms/controller/oapi/site/SiteOapiController.java b/src/main/java/com/yunhai/cms/controller/oapi/site/SiteOapiController.java new file mode 100644 index 0000000..b3372f8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/oapi/site/SiteOapiController.java @@ -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 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; + } +} diff --git a/src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java b/src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java deleted file mode 100644 index ef9c9dc..0000000 --- a/src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java index 3592e15..eda9eb7 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java @@ -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 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> 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 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 attachmentsQuietly(Integer itemId) { + try { + return svcItemQuery.attachments(itemId); + } catch (RuntimeException e) { + log.warn("[portal] 附件查询失败(降级为空)itemId={}:{}", itemId, e.toString()); + return new ArrayList<>(); + } + } + + /** 相关事项查询静默降级(同上) */ + private List 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 currentServiceFilters(String obj, String cat, Integer dept, String q) { Map filters = new LinkedHashMap<>(); diff --git a/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java b/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java index 877d6a1..3dca47d 100644 --- a/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java +++ b/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java @@ -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); diff --git a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java index 48760c7..ddf5687 100644 --- a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java +++ b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java @@ -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 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, "上传文件不能为空"); diff --git a/src/main/java/com/yunhai/cms/service/impl/CategoryService.java b/src/main/java/com/yunhai/cms/service/impl/CategoryService.java index 61432bd..fa8bfba 100644 --- a/src/main/java/com/yunhai/cms/service/impl/CategoryService.java +++ b/src/main/java/com/yunhai/cms/service/impl/CategoryService.java @@ -37,6 +37,9 @@ public class CategoryService { private static final java.util.Set 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); + } + + /** + * 防环:从目标父栏目沿祖先链上溯,遇到"正在保存的栏目自身"即为环。 + * + *

原实现只挡一级父子互换(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); } } diff --git a/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java b/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java deleted file mode 100644 index a82da51..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java +++ /dev/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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java b/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java deleted file mode 100644 index f066725..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java +++ /dev/null @@ -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 生成)。 - * - *

索引声明口径:带排序方向的门户列表索引 `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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java b/src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java deleted file mode 100644 index ee14545..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java b/src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java deleted file mode 100644 index 639c4b5..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java b/src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java deleted file mode 100644 index a23ed82..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java +++ /dev/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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java b/src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java deleted file mode 100644 index c4a119e..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java b/src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java deleted file mode 100644 index 9767ca1..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java b/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java deleted file mode 100644 index 1294fb6..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java b/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java deleted file mode 100644 index 36fd0a8..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java +++ /dev/null @@ -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 attachmentIds; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java b/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java deleted file mode 100644 index b928ad3..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceItem.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceItem.java index 2101d1b..60bbc7e 100644 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceItem.java +++ b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceItem.java @@ -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 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; } } diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceListData.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceListData.java index 9b105c4..6753c90 100644 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceListData.java +++ b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceListData.java @@ -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 diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDeptVo.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDeptVo.java deleted file mode 100644 index 2e0d1db..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDeptVo.java +++ /dev/null @@ -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 children = new ArrayList<>(); - - public SvcDeptVo() { - } - - public SvcDeptVo(Integer id, String name) { - this.id = id; - this.name = name; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDictVo.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDictVo.java deleted file mode 100644 index 1158870..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDictVo.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemEditVo.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemEditVo.java deleted file mode 100644 index 9de2284..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemEditVo.java +++ /dev/null @@ -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 ext = ExtJsonUtils.parse(item.getExtJson()); - vo.setApplyUrl(ExtJsonUtils.getString(ext, "apply_url")); - return vo; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemPageItem.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemPageItem.java deleted file mode 100644 index 9f2ab8b..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemPageItem.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSimpleVo.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSimpleVo.java deleted file mode 100644 index 84f2aa4..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSimpleVo.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSnapshotVo.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSnapshotVo.java deleted file mode 100644 index 34c18ab..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSnapshotVo.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceDictRepository.java b/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceDictRepository.java deleted file mode 100644 index 932f852..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceDictRepository.java +++ /dev/null @@ -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 { - - List findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(Integer siteId, String dictType); - - List findBySiteIdOrderBySortNoAscIdAsc(Integer siteId); - - Optional findByIdAndSiteId(Integer id, Integer siteId); - - Optional 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); -} diff --git a/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceItemRepository.java b/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceItemRepository.java deleted file mode 100644 index 2a2c560..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceItemRepository.java +++ /dev/null @@ -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, - JpaSpecificationExecutor { - - Optional findByIdAndSiteId(Integer id, Integer siteId); - - /** 跨服务键查询:uk(site_id, item_code) 命中即唯一 */ - Optional 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 findBySiteIdAndStatusAndCategoryKeyAndIdNot(Integer siteId, Integer status, - String categoryKey, Integer id, - Pageable pageable); - - /** 管理端选择器:全状态按排序取(条数由 Pageable 控制) */ - List 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 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 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 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 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 countGroupByCategoryKey(@Param("siteId") Integer siteId); -} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/SvcDeptSource.java b/src/main/java/com/yunhai/cms/svcitem/service/SvcDeptSource.java deleted file mode 100644 index f6bd45d..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/service/SvcDeptSource.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.yunhai.cms.svcitem.service; - -import com.yunhai.platform.utils.model.BoaGroup; - -/** - * 部门组树来源接缝(事项库 R2 追加):默认实现代理 UserBase oapi 的静态调用 - * {@code YschBaseOpenApi.getGroupTree()}。 - * - *

包一层 bean 的理由:静态方法在测试中无法替身,导致"部门可达"这条主路径长期零覆盖 - * (SchoolService 同款调用只能用"不可达"降级路径绕测)。抽出接缝后单测可注入固定组树, - * 断言"类型过滤 / 层级构建 / 快照取名 / id 不在树上拒绝"全链路;拆分独立应用时也只需换实现。 - */ -public interface SvcDeptSource { - - /** - * 拉取组织机构组树根节点。 - * - * @return 组树根;平台不可达或异常时返回 {@code null}(不抛出,降级口径由消费方决定) - */ - BoaGroup getGroupTree(); -} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java b/src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java index 1000d66..2053bd0 100644 --- a/src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java +++ b/src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java @@ -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 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 attachments(Integer itemId); - /** 门户:指南页浏览量异步累加(不承诺精确,失败忽略) */ + /** 门户:指南页浏览量异步累加(转发办事大厅 /oapi/svc/item/view,不承诺精确) */ void countView(Integer itemId); - - // ===== 管理端 ===== - - /** 分页({total, list:[SvcItemPageItem]},pageSize 上限 100) */ - Map 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 adminSelList(BoaUser user, Integer siteId); - - /** 自定义排序(按传入 (id, sortNo) 落库;列表默认排序第一优先级) */ - void adminSort(BoaUser user, Integer siteId, List items); - - /** 同步部门名快照(按组树刷新全站 dept_name,返回 {updated:n}) */ - Map syncDeptName(BoaUser user, Integer siteId); - - /** 服务部门选项(UserBase 组树按配置类型过滤,嵌套下发) */ - List deptTree(BoaUser user, Integer siteId); - - /** 事项当前绑定的指南附件(编辑回显;ref_type=svc_item, ref_id=itemId) */ - List itemAttachments(BoaUser user, Integer siteId, Integer itemId); } diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcDictService.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcDictService.java deleted file mode 100644 index f88bd85..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcDictService.java +++ /dev/null @@ -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 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 dicts = dictType == null || dictType.trim().isEmpty() - ? dictRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId) - : dictRepository.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, dictType); - Map objectRefs = countMap(itemRepository.countGroupByObjectKey(siteId)); - Map categoryRefs = countMap(itemRepository.countGroupByCategoryKey(siteId)); - List result = new ArrayList<>(); - for (CmsServiceDict dict : dicts) { - Map 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 nameMap(Integer siteId, ServiceDictTypeEnum type) { - Map names = new HashMap<>(); - for (CmsServiceDict dict : dictRepository - .findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, type.getKey())) { - names.put(dict.getDictKey(), dict.getDictName()); - } - return names; - } - - /** 启用字典(门户筛选/管理端选择器口径:停用不出现在筛选与选择器) */ - public List enabledDicts(Integer siteId, ServiceDictTypeEnum type) { - List 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 countMap(List rows) { - Map map = new HashMap<>(); - for (Object[] row : rows) { - map.put(String.valueOf(row[0]), ((Number) row[1]).longValue()); - } - return map; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java new file mode 100644 index 0000000..4551431 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java new file mode 100644 index 0000000..f71a8e1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java @@ -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 relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) { + List 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()) { + 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 attachments(Integer itemId) { + List 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()) { + 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 nodes(JsonNode data, String field) { + JsonNode arr = data == null ? null : data.get(field); + return arr != null && arr.isArray() ? arr : new ArrayList(); + } + + 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; + } + } +} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java deleted file mode 100644 index 8105a62..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java +++ /dev/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 page = itemRepository.findAll(publishedSpec(query), pageable); - Map 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 objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(siteId)); - Map 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 relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) { - if (!StringUtils.hasText(categoryKey) || limit <= 0) { - return new ArrayList<>(); - } - List 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 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 attachments(Integer itemId) { - List result = new ArrayList<>(); - for (CmsAttachment attachment : attachmentRepository.findByRefTypeAndRefId(REF_TYPE_SVC_ITEM, itemId)) { - result.add(SvcItemAttachmentVo.of(attachment)); - } - return result; - } - - // ===== 管理端 ===== - - @Override - public Map 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 spec = (root, cq, cb) -> { - List 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 objectNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.OBJECT); - Map categoryNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.CATEGORY); - Page page = itemRepository.findAll(spec, pageable); - List items = new ArrayList<>(); - for (CmsServiceItem item : page.getContent()) { - items.add(SvcItemPageItem.of(item, - objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey()))); - } - Map 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 adminSelList(BoaUser user, Integer siteId) { - siteService.requireManageable(user, siteId); - List 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 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 items) { - CmsSite site = siteService.requireManageable(user, siteId); - for (IdSortItem pair : items == null ? new ArrayList() : 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 syncDeptName(BoaUser user, Integer siteId) { - CmsSite site = siteService.requireManageable(user, siteId); - Map deptNames = new LinkedHashMap<>(); - collectDeptNodes(fetchGroupTree(), deptNames); - if (deptNames.isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名"); - } - int updated = 0; - List 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 result = new HashMap<>(); - result.put("updated", updated); - return result; - } - - /** 服务部门选项(UserBase 组树按 deptGroupType 过滤并保留层级;平台不可达降级空列表,SchoolService 同款) */ - @Override - public List deptTree(BoaUser user, Integer siteId) { - siteService.requireManageable(user, siteId); - return buildDeptTree(fetchGroupTree()); - } - - /** 组树 → 只含部门节点的嵌套树(非部门祖先不下发自身,但继续下钻,避免层级被压平) */ - private List buildDeptTree(BoaGroup root) { - List 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 buildDeptChildren(BoaGroup parent) { - List 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 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 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 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 的无主态, - * 且编辑已有事项二次保存会清掉上一轮建立的引用)。三步: - *

    - *
  1. 解除本事项已绑定、但本次列表里已不存在的行("移除即解除引用"语义,只解本事项,不误伤他人);
  2. - *
  3. 按列表绑定(本站 + 可认领校验,命中他事项引用或跨站的行跳过并告警,不再静默改写/丢弃);
  4. - *
  5. 内容派生:封面 URL 与三段富文本里的本站上传资产按内容全量同步(syncAssets)—— - * 内容里有的标记归属,已不在内容中的解除归属;既覆盖"编辑保存时正文既有内嵌图不丢引用", - * 也覆盖封面换图/清空与正文删图(旧资产转回未使用,不留悬挂引用)。
  6. - *
- */ - private void bindAttachments(Integer siteId, CmsServiceItem item, List attachmentIds) { - Integer itemId = item.getId(); - Set keep = attachmentIds == null ? Collections.emptySet() : new HashSet<>(attachmentIds); - - List 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 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 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 publishedSpec(SvcItemPortalQuery query) { - return (root, cq, cb) -> { - List 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 countMap(List rows) { - Map map = new HashMap<>(); - for (Object[] row : rows) { - map.put(String.valueOf(row[0]), ((Number) row[1]).longValue()); - } - return map; - } -} diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java deleted file mode 100644 index c9c3bc6..0000000 --- a/src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java +++ /dev/null @@ -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; - } - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bebc784..4e6a9f1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/themes/default/guide.html b/src/main/resources/templates/themes/default/guide.html index 3f7c9c1..b887af3 100644 --- a/src/main/resources/templates/themes/default/guide.html +++ b/src/main/resources/templates/themes/default/guide.html @@ -6,7 +6,8 @@ + 三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。 + svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
-
+ +
+

事项服务暂时不可用,请稍后再试

+

事项数据服务暂不可达,稍后刷新即可恢复

+
+ +

事项名称

-