From 9e28092a8bd6d7bb6c786a5c319214846d57cbaa Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 11 Sep 2026 14:12:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(svcitem):=20=E4=BA=8B=E9=A1=B9=E5=BA=93+?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E5=A4=A7=E5=8E=85=20R2=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=8C=E5=B9=B6=E5=AE=8C=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E5=90=8E=E7=8B=AC=E7=AB=8B=E5=AE=A1=E8=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地(依据《事项库与办事大厅详细设计 v1》) - 事项域垂直包 com.yunhai.cms.svcitem.*(entity/enums/repository/service+impl/event/config/model dto+query+vo) 与 controller/{api,oapi}/svcitem;两表 DDL 进 cms_init.sql(表 7/8)+ upgrade_svcitem_r2.sql - SvcItemQuery 接缝:门户 8 方法 + 管理端 11 方法统一收口,控制器不再注入实现类 - 门户公开层:doServiceList/doGuide 双路由变体 + M0 可选模板整页回退 (视图名/themeBase/buildUrl/资产主题段四处同源 effectiveTheme,缺模板整包退 default) - oapi 跨服务快照:与平台 AppAuthFilter 同口径签名(canonical=method\npath?sortedQuery\ntimestamp, HmacSHA256/Base64, 5min 窗口)+ /oapi/* 强制验签过滤器 - default 皮肤 service.html/guide.html + main.css 服务中心/指南页样式段 审计修复(发现与处置全表见 docs/18-事项库审计与修复记录.md) - D1 附件引用链:内容资产(封面+正文内嵌图)独立场景 svc_item_asset,保存时按内容派生全量同步归属 (AttachmentService.localAssetUrls/localWebUrl/syncAssets:集合内标记、集合外解除)。 修复根因:upload 带 scene 时只写 ref_type 不写 ref_id,而保存仅按前端提交 id 重绑, 正文图与封面停在半绑定态 → 既不在孤儿口径内也不受 release 管辖,成永久无主行 - D5 归属校验:绑定前做可认领判定(无引用/半绑定/已属本事项方可绑),他事项引用与跨站附件跳过并告警 - D2 部门:组类型改配置 cms.svc.item.dept-group-type(默认 2=学校组,校内科室改 3); deptTree 返回嵌套组树;新增 SvcDeptSource 接缝使静态 getGroupTree 可替身(此前部门链零覆盖) - D4 缓存:事件 scope 语义化,dict/dept 变更整体失效 cms:svcItem(展示名快照不再滞后 5min) - 站点解析:findByCode 在 uk(parent_site_id,code) 下命中多条会抛异常变 500, 4 处生产调用点改 findFirstByCodeOrderByIdAsc(确定性取最早站点) - 加固:新增 idx_svc_item_portal(含 DESC 列)对症门户排序 filesort;升级脚本改 information_schema +PREPARE 幂等索引守卫块(表在缺索引可重跑修复);clear_cms_data.sql 补事项两表; view_count 改有界队列+拒绝丢弃告警;SEL_LIST_MAX 截断告警 测试 - 新增 SvcDeptChainIT 7、SvcItemServiceIT 16(含 D1/D5 回归锚点与全方法权限)、 SvcItemOapiIT 9(时间戳超窗/未知 appKey/畸形头/siteCode 歧义)、SvcItemPortalIT 11 (嵌套指南路由/关键词/canonical/浏览量异步)、SvcDictServiceIT 8(改名后缓存即时刷新) - ThemeContractIT 补"缺可选模板主题资产段必退 default"断言并订正七路由表述 - 全量 mvn -o test:388 测试 0 失败;升级脚本 bsch_cms_test 两跑幂等(含缺索引自动补建) --- sql/clear_cms_data.sql | 22 +- sql/upgrade_svcitem_r2.sql | 138 ++++ .../com/yunhai/cms/config/CacheConfig.java | 3 +- .../com/yunhai/cms/config/CacheNames.java | 6 + .../api/svcitem/SvcDictController.java | 57 ++ .../api/svcitem/SvcItemController.java | 105 +++ .../oapi/svcitem/SvcItemOapiController.java | 40 + .../controller/portal/PortalController.java | 291 ++++++- .../controller/portal/PortalPageRenderer.java | 21 + .../listener/PortalCacheEvictListener.java | 28 + .../cms/model/portal/PortalSiteSnapshot.java | 8 + .../cms/repository/CmsSiteRepository.java | 16 + .../cms/service/impl/AttachmentService.java | 83 +- .../cms/service/impl/PortalQueryService.java | 2 +- .../yunhai/cms/service/impl/SiteService.java | 3 +- .../OpenApiAuthEnforceConfiguration.java | 49 ++ .../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 + .../model/query/SvcItemPortalQuery.java | 31 + .../model/vo/PortalServiceFilters.java | 81 ++ .../svcitem/model/vo/PortalServiceItem.java | 143 ++++ .../model/vo/PortalServiceListData.java | 87 ++ .../cms/svcitem/model/vo/SvcDeptVo.java | 28 + .../cms/svcitem/model/vo/SvcDictVo.java | 43 + .../svcitem/model/vo/SvcItemAttachmentVo.java | 29 + .../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 | 83 ++ .../svcitem/service/impl/SvcDictService.java | 169 ++++ .../svcitem/service/impl/SvcItemService.java | 760 ++++++++++++++++++ .../service/impl/YschBaseSvcDeptSource.java | 25 + .../com/yunhai/cms/utils/PortalUrlHelper.java | 35 + src/main/resources/sql/cms_init.sql | 63 ++ .../themes/default/assets/css/main.css | 250 ++++++ .../templates/themes/default/guide.html | 126 +++ .../templates/themes/default/service.html | 105 +++ .../portal/PortalPageRendererTest.java | 64 ++ .../yunhai/cms/portal/ThemeContractIT.java | 27 +- .../cms/service/impl/AttachmentServiceIT.java | 21 + .../cms/service/impl/SiteServiceIT.java | 19 + .../cms/support/CmsIntegrationTestBase.java | 58 +- .../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 +++++++++++ .../yunhai/cms/utils/PortalUrlHelperTest.java | 37 + src/test/resources/schema-cms-test.sql | 63 ++ 61 files changed, 5537 insertions(+), 23 deletions(-) create mode 100644 sql/upgrade_svcitem_r2.sql create mode 100644 src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java create mode 100644 src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java create mode 100644 src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/config/OpenApiAuthEnforceConfiguration.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPortalQuery.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceFilters.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceItem.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceListData.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDeptVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcDictVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemAttachmentVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemEditVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemPageItem.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSimpleVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/model/vo/SvcItemSnapshotVo.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceDictRepository.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/repository/CmsServiceItemRepository.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/service/SvcDeptSource.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java create 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/SvcItemService.java create mode 100644 src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java create mode 100644 src/main/resources/templates/themes/default/guide.html create mode 100644 src/main/resources/templates/themes/default/service.html create mode 100644 src/test/java/com/yunhai/cms/controller/portal/PortalPageRendererTest.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcDeptChainIT.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcDictServiceIT.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemOapiIT.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java create mode 100644 src/test/java/com/yunhai/cms/svcitem/SvcItemServiceIT.java diff --git a/sql/clear_cms_data.sql b/sql/clear_cms_data.sql index f38350f..a4be86a 100644 --- a/sql/clear_cms_data.sql +++ b/sql/clear_cms_data.sql @@ -1,13 +1,13 @@ -- ============================================================ -- bsch_cms 全量清库脚本(清测试数据,全流程重走) -- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root) --- 范围:cms_* 六表全空(站点/栏目/文章/轮播/友链/附件),AUTO_INCREMENT 归位 +-- 范围:cms_* 八表全空(站点/栏目/文章/轮播/友链/附件/事项/事项字典),AUTO_INCREMENT 归位 -- 不动的东西: -- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库) -- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录) -- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明) -- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。 --- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"六表为空",本脚本正好满足)。 +-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"各表为空",本脚本正好满足)。 -- ============================================================ SET NAMES utf8mb4; @@ -18,17 +18,21 @@ TRUNCATE TABLE cms_banner; TRUNCATE TABLE cms_link; TRUNCATE TABLE cms_article; TRUNCATE TABLE cms_category; +TRUNCATE TABLE cms_service_item; +TRUNCATE TABLE cms_service_dict; TRUNCATE TABLE cms_site; SET FOREIGN_KEY_CHECKS = 1; --- 验证:六表应全为 0 -SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site -UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category -UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article -UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner -UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link -UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment; +-- 验证:八表应全为 0 +SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site +UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category +UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article +UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner +UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link +UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment +UNION ALL SELECT 'cms_service_item', COUNT(*) FROM cms_service_item +UNION ALL SELECT 'cms_service_dict', COUNT(*) FROM cms_service_dict; -- ------------------------------------------------------------ -- 可选(不在 SQL 内):清掉测试期上传的物理文件,只留种子静态资产。 diff --git a/sql/upgrade_svcitem_r2.sql b/sql/upgrade_svcitem_r2.sql new file mode 100644 index 0000000..cc7c5cd --- /dev/null +++ b/sql/upgrade_svcitem_r2.sql @@ -0,0 +1,138 @@ +-- ============================================================ +-- R2 升级脚本:事项库两表(事项库与办事大厅详细设计 §2.5,幂等) +-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本 +-- 顺序:加表(CREATE IF NOT EXISTS 幂等)→ 索引守卫补建(幂等)→ 校验 +-- 说明:MySQL 无 ADD INDEX IF NOT EXISTS,建表内联索引;存量库若只建了表缺索引 +-- (含 r2 首版之后新增的 idx_svc_item_portal),由第 3 段守卫块自动补建,可反复重跑。 +-- ============================================================ + +-- 1) 事项主表 +CREATE TABLE IF NOT EXISTS `cms_service_item` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)', + `title` VARCHAR(200) NOT NULL COMMENT '事项名称', + `summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)', + `object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)', + `category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)', + `dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)', + `dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)', + `handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下', + `online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)', + `handle_place` VARCHAR(200) NULL COMMENT '办理地点', + `handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")', + `promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)', + `contact_phone` VARCHAR(64) NULL COMMENT '咨询电话', + `supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话', + `process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)', + `material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)', + `notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)', + `faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)', + `notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)', + `rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)', + `cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架', + `publish_time` DATETIME NULL COMMENT '发布时间', + `offline_time` DATETIME NULL COMMENT '下架时间', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)', + `ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`), + KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`), + KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`), + KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`), + KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`), + KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)'; + +-- 2) 事项字典表 +CREATE TABLE IF NOT EXISTS `cms_service_dict` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别', + `dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)', + `dict_name` VARCHAR(128) NOT NULL COMMENT '展示名', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)', + `ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`), + KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)'; + +-- 3) 索引守卫补建(仅当表已存在但缺索引时生效;幂等,可反复重跑) +-- idx_svc_item_portal:门户服务中心列表 ORDER BY sort_no ASC, publish_time DESC, id DESC 的对症索引 +-- (原 idx_svc_item_list 前导列含门户未过滤的 online_flag,无法用于该排序 → 必然 filesort) +SET @db := DATABASE(); + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'uk_svc_item_code') = 0, + 'ALTER TABLE cms_service_item ADD UNIQUE KEY uk_svc_item_code (site_id, item_code)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'idx_svc_item_list') = 0, + 'ALTER TABLE cms_service_item ADD KEY idx_svc_item_list (site_id, status, online_flag, sort_no)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'idx_svc_item_obj') = 0, + 'ALTER TABLE cms_service_item ADD KEY idx_svc_item_obj (site_id, status, object_key)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'idx_svc_item_cat') = 0, + 'ALTER TABLE cms_service_item ADD KEY idx_svc_item_cat (site_id, status, category_key)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'idx_svc_item_dept') = 0, + 'ALTER TABLE cms_service_item ADD KEY idx_svc_item_dept (site_id, status, dept_group_id)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_item' + AND index_name = 'idx_svc_item_portal') = 0, + 'ALTER TABLE cms_service_item ADD KEY idx_svc_item_portal (site_id, status, sort_no ASC, publish_time DESC, id DESC)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_dict' + AND index_name = 'uk_svc_dict') = 0, + 'ALTER TABLE cms_service_dict ADD UNIQUE KEY uk_svc_dict (site_id, dict_type, dict_key)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @db AND table_name = 'cms_service_dict' + AND index_name = 'idx_svc_dict_list') = 0, + 'ALTER TABLE cms_service_dict ADD KEY idx_svc_dict_list (site_id, dict_type, status, sort_no)', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 4) 校验 +-- 4.1) 应返回 0 行(两表存在且注释非空) +SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('cms_service_item', 'cms_service_dict') + AND TABLE_COMMENT = ''; + +-- 4.2) 应返回 10 行(两表全部索引就位:主键 2 + 唯一键 2 + 普通索引 6) +SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name IN ('cms_service_item', 'cms_service_dict') + GROUP BY table_name, index_name + ORDER BY table_name, index_name; diff --git a/src/main/java/com/yunhai/cms/config/CacheConfig.java b/src/main/java/com/yunhai/cms/config/CacheConfig.java index 1066beb..61c1813 100644 --- a/src/main/java/com/yunhai/cms/config/CacheConfig.java +++ b/src/main/java/com/yunhai/cms/config/CacheConfig.java @@ -51,7 +51,8 @@ public class CacheConfig implements CachingConfigurer { manager.setCaches(Arrays.asList( new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()), new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()), - new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()))); + new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()), + new CaffeineCache(CacheNames.SERVICE_ITEM, caffeine(Duration.ofMinutes(5)).build()))); return manager; } diff --git a/src/main/java/com/yunhai/cms/config/CacheNames.java b/src/main/java/com/yunhai/cms/config/CacheNames.java index b35948a..aaaa007 100644 --- a/src/main/java/com/yunhai/cms/config/CacheNames.java +++ b/src/main/java/com/yunhai/cms/config/CacheNames.java @@ -10,6 +10,10 @@ package com.yunhai.cms.config; * 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent) *
  • {@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上); * 失效事件 = 该站点文章发布/下架/修改
  • + *
  • {@link #SERVICE_ITEM}:itemId → 事项指南页模型;filters:{siteId} → 三组筛选项+计数, + * TTL 5min±60s(itemId 不存在时 null 哨兵 30s 防穿透); + * 失效事件 = 事项发布/修改/下架/删除、字典变更(ServiceItemChangedEvent,仅事项域, + * 不触碰 cms:article/cms:list/cms:site)
  • * */ public final class CacheNames { @@ -20,6 +24,8 @@ public final class CacheNames { public static final String ARTICLE = "cms:article"; /** 门户栏目列表页 */ public static final String LIST = "cms:list"; + /** 事项指南页模型 + 筛选项(key=itemId / filters:{siteId},事项库 §3.6) */ + public static final String SERVICE_ITEM = "cms:svcItem"; private CacheNames() { } 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 new file mode 100644 index 0000000..29f8673 --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcDictController.java @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..b78bd4e --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/svcitem/SvcItemController.java @@ -0,0 +1,105 @@ +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/svcitem/SvcItemOapiController.java b/src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java new file mode 100644 index 0000000..ef9c9dc --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/oapi/svcitem/SvcItemOapiController.java @@ -0,0 +1,40 @@ +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 fe4e506..3592e15 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java @@ -13,6 +13,11 @@ import com.yunhai.cms.repository.CmsArticleRepository; import com.yunhai.cms.repository.CmsSiteRepository; import com.yunhai.cms.service.impl.PortalQueryService; import com.yunhai.cms.service.impl.ViewCounterService; +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.service.SvcItemQuery; import com.yunhai.cms.utils.PortalUrlHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,6 +35,7 @@ import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -47,12 +53,15 @@ public class PortalController { private static final int HOME_BLOCK_SIZE = 8; /** 首页置顶区取 N 条 */ private static final int HOME_TOP_SIZE = 6; + /** 指南页"相关事项"取 N 条(事项库 §5.3) */ + private static final int RELATED_ITEMS_SIZE = 6; private final PortalQueryService portalQueryService; private final CmsArticleRepository articleRepository; private final CmsSiteRepository siteRepository; private final ViewCounterService viewCounterService; private final PortalPageRenderer pageRenderer; + private final SvcItemQuery svcItemQuery; @Value("${cms.portal.detail-max-age:300}") private int detailMaxAge; @@ -276,6 +285,270 @@ public class PortalController { // ===== 内部装配 ===== + /** + * 服务中心列表(事项库 §5.1/§5.2):三维筛选 obj/cat/dept + 关键词 q + 分页。 + * 筛选/分页 URL 全部后端预生成(C-2:模板禁止手拼 query);service.html 为可选模板, + * 走 M0 整页回退(缺模板时视图名/themeBase/资产主题段统一退 default)。 + */ + @GetMapping("/portal/{siteCode}/service") + public String service(@PathVariable("siteCode") String siteCode, + @RequestParam(value = "obj", required = false) String obj, + @RequestParam(value = "cat", required = false) String cat, + @RequestParam(value = "dept", required = false) Integer dept, + @RequestParam(value = "q", required = false, defaultValue = "") String q, + @RequestParam(value = "page", required = false, defaultValue = "1") Integer page, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + return doServiceList(siteCode, obj, cat, dept, q, page, request, response, model); + } + + /** W4 嵌套变体:/portal/{p}/{s}/service */ + @GetMapping("/portal/{p}/{s}/service") + public String serviceNested(@PathVariable("p") String p, @PathVariable("s") String s, + @RequestParam(value = "obj", required = false) String obj, + @RequestParam(value = "cat", required = false) String cat, + @RequestParam(value = "dept", required = false) Integer dept, + @RequestParam(value = "q", required = false, defaultValue = "") String q, + @RequestParam(value = "page", required = false, defaultValue = "1") Integer page, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + return doServiceList(p + "/" + s, obj, cat, dept, q, page, request, response, model); + } + + /** 事项指南页(§5.3):富文本已清洗(保存期 jsoup)+ 渲染期补基准;applyUrl 三态优先级 */ + @GetMapping("/portal/{siteCode}/sv/{itemId}") + public String guide(@PathVariable("siteCode") String siteCode, + @PathVariable("itemId") Integer itemId, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + return doGuide(siteCode, itemId, request, response, model); + } + + /** W4 嵌套变体:/portal/{p}/{s}/sv/{itemId} */ + @GetMapping("/portal/{p}/{s}/sv/{itemId}") + public String guideNested(@PathVariable("p") String p, @PathVariable("s") String s, + @PathVariable("itemId") Integer itemId, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + return doGuide(p + "/" + s, itemId, request, response, model); + } + + private String doServiceList(String sitePath, String obj, String cat, Integer dept, String q, + Integer page, HttpServletRequest request, + HttpServletResponse response, Model model) throws Exception { + PortalSiteSnapshot site = requireSiteByPath(sitePath, "service", request, response); + if (site == null) { + return null; + } + String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "service.html"); + PortalUrlHelper url = buildUrl(site, request, effectiveTheme); + + SvcItemPortalQuery query = new SvcItemPortalQuery(); + query.setSiteId(site.getId()); + query.setObjectKey(trimToNull(obj)); + query.setCategoryKey(trimToNull(cat)); + query.setDeptGroupId(dept); + query.setKeyword(trimToNull(q)); + query.setPage(page == null || page < 1 ? 1 : page); + PortalServiceListData serviceList = svcItemQuery.portalPage(query); + + // 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1 + Map currentFilters = currentServiceFilters(obj, cat, dept, q); + model.addAttribute("serviceFilters", + buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters)); + Map activeFilters = new HashMap<>(); + activeFilters.put("obj", trimToNull(obj)); + activeFilters.put("cat", trimToNull(cat)); + activeFilters.put("dept", dept); + activeFilters.put("q", q == null ? "" : q); + activeFilters.put("clearUrl", url.serviceList()); + activeFilters.put("hasAny", trimToNull(obj) != null || trimToNull(cat) != null + || dept != null || (q != null && !q.trim().isEmpty())); + model.addAttribute("activeFilters", activeFilters); + for (int p = 1; p <= serviceList.getTotalPages(); p++) { + Map pageQuery = new LinkedHashMap<>(currentFilters); + if (p > 1) { + pageQuery.put("page", p); + } + serviceList.getPageUrls().add(new PortalServiceListData.PageUrl( + p, url.serviceList(pageQuery), p == serviceList.getPage())); + } + model.addAttribute("serviceList", serviceList); + + 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("q", q == null ? "" : q); + model.addAttribute("pageTitle", "服务中心 - " + site.getName()); + model.addAttribute("seoDescription", null); + Map canonicalQuery = new LinkedHashMap<>(currentFilters); + if (serviceList.getPage() != null && serviceList.getPage() > 1) { + canonicalQuery.put("page", serviceList.getPage()); + } + model.addAttribute("canonical", url.serviceList(canonicalQuery)); + return "themes/" + effectiveTheme + "/service"; + } + + private String doGuide(String sitePath, Integer itemId, + HttpServletRequest request, HttpServletResponse response, + Model model) throws Exception { + PortalSiteSnapshot site = requireSiteByPath(sitePath, "sv/" + itemId, request, response); + if (site == null) { + return null; + } + PortalServiceItem item = svcItemQuery.portalItem(itemId, site.getId()); + if (item == null) { + 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("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()); + // 富文本渲染期补基准(copy-on-write,不污染缓存体;内容已在保存期过 jsoup 白名单) + model.addAttribute("item", item.withRewrittenHtml( + url.rewriteUploadUrls(item.getProcessHtml()), + url.rewriteUploadUrls(item.getMaterialHtml()), + url.rewriteUploadUrls(item.getNoticeHtml()))); + model.addAttribute("attachments", svcItemQuery.attachments(itemId)); + List> related = new ArrayList<>(); + for (PortalServiceItem rel : svcItemQuery.relatedItems(site.getId(), itemId, + item.getCategoryKey(), RELATED_ITEMS_SIZE)) { + Map row = new HashMap<>(); + row.put("id", rel.getId()); + row.put("title", rel.getTitle()); + row.put("deptName", rel.getDeptName()); + row.put("handleMode", rel.getHandleMode()); + row.put("url", url.serviceItem(rel.getId())); + related.add(row); + } + model.addAttribute("relatedItems", related); + if (item.getFaqCategoryId() != null) { + model.addAttribute("faqCategoryUrl", url.category(item.getFaqCategoryId())); + } + if (item.getNoticeCategoryId() != null) { + model.addAttribute("noticeCategoryUrl", url.category(item.getNoticeCategoryId())); + } + if (item.getRuleCategoryId() != null) { + model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId())); + } + // "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先) + // ② 未填且站点配了 svc_hall_base → 生成 svhall 发起页 URL ③ 都没有 → 不注入(模板不渲染按钮) + String applyUrl = item.getApplyUrl(); + if ((applyUrl == null || applyUrl.trim().isEmpty()) + && site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) { + String base = site.getSvcHallBase().trim(); + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + applyUrl = base + "/#/apply?siteCode=" + urlEncodeValue(site.getCode()) + + "&itemCode=" + urlEncodeValue(item.getItemCode()); + } + if (applyUrl != null && !applyUrl.trim().isEmpty()) { + model.addAttribute("applyUrl", applyUrl); + } + if (item.getCoverImage() != null && !item.getCoverImage().trim().isEmpty()) { + model.addAttribute("ogImage", url.upload(item.getCoverImage())); + } + model.addAttribute("pageTitle", item.getTitle() + " - " + site.getName()); + model.addAttribute("seoDescription", item.getSummary()); + model.addAttribute("canonical", url.serviceItem(itemId)); + response.setHeader("Cache-Control", "max-age=" + detailMaxAge); + return "themes/" + effectiveTheme + "/guide"; + } + + /** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */ + private Map currentServiceFilters(String obj, String cat, Integer dept, String q) { + Map filters = new LinkedHashMap<>(); + if (trimToNull(obj) != null) { + filters.put("obj", obj.trim()); + } + if (trimToNull(cat) != null) { + filters.put("cat", cat.trim()); + } + if (dept != null) { + filters.put("dept", dept); + } + if (q != null && !q.trim().isEmpty()) { + filters.put("q", q.trim()); + } + return filters; + } + + /** 三组筛选项 → 模板 Facet({code|id, name, count, active, url},url 直用于 th:href) */ + private Map buildServiceFilters(Integer siteId, PortalUrlHelper url, + String obj, String cat, Integer dept, + Map currentFilters) { + PortalServiceFilters filters = svcItemQuery.portalFilters(siteId); + Map model = new HashMap<>(); + List objects = new ArrayList<>(); + for (PortalServiceFilters.Option option : filters.getObjects()) { + boolean active = option.getKey().equals(trimToNull(obj)); + objects.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(), + option.getCount(), active, + url.serviceList(toggleFilter(currentFilters, "obj", active ? null : option.getKey())))); + } + List categories = new ArrayList<>(); + for (PortalServiceFilters.Option option : filters.getCategories()) { + boolean active = option.getKey().equals(trimToNull(cat)); + categories.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(), + option.getCount(), active, + url.serviceList(toggleFilter(currentFilters, "cat", active ? null : option.getKey())))); + } + List depts = new ArrayList<>(); + for (PortalServiceFilters.DeptOption option : filters.getDepts()) { + boolean active = option.getId() != null && option.getId().equals(dept); + depts.add(PortalServiceFilters.Facet.ofId(option.getId(), option.getName(), + option.getCount(), active, + url.serviceList(toggleFilter(currentFilters, "dept", + active ? null : String.valueOf(option.getId()))))); + } + model.put("objects", objects); + model.put("categories", categories); + model.put("depts", depts); + return model; + } + + /** 复制当前筛选并替换/移除某一维(其余维度保留,page 永不进入——选项切换即归第 1 页) */ + private Map toggleFilter(Map current, String key, String value) { + Map next = new LinkedHashMap<>(current); + next.remove("page"); + if (value == null) { + next.remove(key); + } else { + next.put(key, value); + } + return next; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String urlEncodeValue(String value) { + try { + return java.net.URLEncoder.encode(value == null ? "" : value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return value; + } + } + + /** * 站点身份解析(W4):优先按门户 path(siteSnapshotByPath); * 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)—— @@ -290,7 +563,7 @@ public class PortalController { return site; } if (sitePath != null && !sitePath.contains("/")) { - CmsSite legacy = siteRepository.findByCode(sitePath).orElse(null); + CmsSite legacy = siteRepository.findFirstByCodeOrderByIdAsc(sitePath).orElse(null); if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty() && !legacy.getPath().equals(sitePath)) { StringBuilder location = new StringBuilder(request.getContextPath()) @@ -320,13 +593,23 @@ public class PortalController { * W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。 */ private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) { + return buildUrl(site, request, null); + } + + /** + * M0 可选模板整页回退配套(事项库 §4.2):effectiveTheme 非空时以它构造 URL 生成器—— + * 资产 URL 主题段(asset())必须与视图名/themeBase 同主题,否则 default 的 HTML 配别家皮肤的 CSS。 + * 三级基准决策不变(public_base_url → base-origin → 当前请求派生)。 + */ + private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request, String effectiveTheme) { + String theme = effectiveTheme == null ? site.getTheme() : effectiveTheme; if (site.getPublicBaseUrl() != null) { - return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), site.getTheme(), baseOrigin); + return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), theme, baseOrigin); } if (baseOrigin != null && !baseOrigin.trim().isEmpty()) { - return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), site.getTheme()); + return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), theme); } - return PortalUrlHelper.fromRequest(request, site.getPath(), site.getTheme()); + return PortalUrlHelper.fromRequest(request, site.getPath(), theme); } /** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */ diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java index 3ac7f2b..6f8483b 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java @@ -8,8 +8,10 @@ import com.yunhai.cms.model.portal.PortalListData; import com.yunhai.cms.model.portal.PortalSideNav; import com.yunhai.cms.model.portal.PortalSiteSnapshot; import com.yunhai.cms.repository.CmsArticleRepository; +import com.yunhai.cms.theme.ThemeRegistry; import com.yunhai.cms.utils.PortalUrlHelper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @@ -26,6 +28,7 @@ import java.util.Set; * 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用, * 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。 */ +@Slf4j @Component @RequiredArgsConstructor public class PortalPageRenderer { @@ -37,6 +40,24 @@ public class PortalPageRenderer { private static final int HOME_TOP_SIZE = 6; private final CmsArticleRepository articleRepository; + private final ThemeRegistry themeRegistry; + + /** + * M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html + * 这类 REQUIRED_TEMPLATES 之外的可选页)时,整页回退 default,而非仅换模板。 + * 返回的 effectiveTheme 必须同时用于视图名、themeBase 与 URL 生成器(asset() 的 + * 资产主题段)——只换模板不换主题 = default 的 HTML 配别家皮肤的 CSS。 + * 必备四页由 ThemePackageValidator 注册期保证,不走本判定。 + */ + public String resolveEffectiveTheme(PortalSiteSnapshot site, String requiredTemplate) { + String theme = site.getTheme(); + if (themeRegistry.resource(theme, requiredTemplate).exists()) { + return theme; + } + log.warn("主题 {} 缺可选模板 {}(site={}),整页回退 {}", theme, requiredTemplate, + site.getCode(), ThemeRegistry.DEFAULT_THEME); + return ThemeRegistry.DEFAULT_THEME; + } /** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。 * docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时 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 0311eb3..877d6a1 100644 --- a/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java +++ b/src/main/java/com/yunhai/cms/event/listener/PortalCacheEvictListener.java @@ -4,6 +4,7 @@ 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; @@ -44,6 +45,33 @@ 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()); + } + private void evictArticle(Integer articleId) { Cache cache = cacheManager.getCache(CacheNames.ARTICLE); if (cache != null && articleId != null) { diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java index ad4f287..d565ca5 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java @@ -42,6 +42,12 @@ public class PortalSiteSnapshot { /** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */ private String publicBaseUrl; + /** + * 服务大厅基地址(config_json.svc_hall_base,事项库 §3.7):非空时指南页"我要办理" + * 按其生成 svhall 发起页 URL(优先级低于事项手填 ext_json.apply_url);空=不生成。 + */ + private String svcHallBase; + /** 启用栏目树(status=1,已组树) */ private List categories = new ArrayList<>(); @@ -90,6 +96,7 @@ public class PortalSiteSnapshot { snapshot.setConfig(config); snapshot.setDomain(ExtJsonUtils.getString(config, "domain")); snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url")); + snapshot.setSvcHallBase(ExtJsonUtils.getString(config, "svc_hall_base")); snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css")); snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout"))); snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses"))); @@ -136,6 +143,7 @@ public class PortalSiteSnapshot { copy.setConfig(config); copy.setDomain(domain); copy.setPublicBaseUrl(publicBaseUrl); + copy.setSvcHallBase(svcHallBase); copy.setCategories(categories); copy.setBanners(banners); copy.setLinkGroups(linkGroups); diff --git a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java index 14e89d9..d84652d 100644 --- a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java +++ b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java @@ -8,8 +8,24 @@ import java.util.Optional; public interface CmsSiteRepository extends JpaRepository { + /** + * 按站点编码取站点(严格版:命中多条时抛 IncorrectResultSizeDataAccessException)。 + * + *

    语义说明:{@code code} 自 W4 起不是全局唯一(唯一键为 {@code uk_site_parent_code(parent_site_id, code)}), + * 多校部署下不同父站可挂同名 code 子站。**生产代码请用** + * {@link #findFirstByCodeOrderByIdAsc(String)}(确定性取最早一条,不会因歧义 500); + * 本方法语义为"断言 code 唯一",保留给集成测试做唯一性/落库断言。 + */ Optional findByCode(String code); + /** + * 按站点编码取站点(取 id 最小者)——生产路径口径。 + * + *

    歧义时不再抛异常导致 500,而是确定性取最早创建的站点;跨服务单键定位的正解仍是 + * (siteCode,itemCode) 成对 + 建站保证 code 全局唯一(设计 §1.3/R4)。 + */ + Optional findFirstByCodeOrderByIdAsc(String code); + /** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */ Optional findByPath(String path); 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 2413f6f..48760c7 100644 --- a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java +++ b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java @@ -27,11 +27,17 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。 @@ -46,6 +52,10 @@ public class AttachmentService { private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM"); + /** 富文本资产地址(img src / a href,单双引号皆可) */ + private static final Pattern ASSET_URL_PATTERN = + Pattern.compile("(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE); + /** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */ private static final List ALLOWED_EXTS = Arrays.asList( "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", @@ -56,8 +66,14 @@ public class AttachmentService { private static final List IMAGE_EXTS = Arrays.asList( "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"); - /** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / category=单页栏目贴图 */ - private static final List ALLOWED_SCENES = Arrays.asList("banner", "link", "site", "category"); + /** + * 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / + * category=单页栏目贴图 / svc_item=事项指南附件(列表可见) / svc_item_asset=事项封面与正文内嵌资产。 + * 事项侧拆两个场景的原因:指南附件查询(refType=svc_item, refId=itemId)必须只返回"用户显式上传的附件", + * 封面与正文图片若同场景会被混进指南页附件列表。 + */ + private static final List ALLOWED_SCENES = + Arrays.asList("banner", "link", "site", "category", "svc_item", "svc_item_asset"); private final CmsAttachmentRepository attachmentRepository; private final SiteService siteService; @@ -69,12 +85,13 @@ public class AttachmentService { @Value("${FILE_WEB_PATH:/cms/upload/}") private String fileWebPath; - /** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category,B2 初步标记) */ + /** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category/svc_item/svc_item_asset,B2 初步标记) */ @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)"); + throw new BizException(ErrorCode.PARAM_ERROR, + "scene 非法:" + scene + "(仅 banner/link/site/category/svc_item/svc_item_asset)"); } if (file == null || file.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空"); @@ -155,6 +172,64 @@ public class AttachmentService { }); } + /** + * 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。 + * + *

    兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空 + * (外链、data: 等一律不参与场景引用)。 + */ + public Optional localWebUrl(String url) { + if (!StringUtils.hasText(url) || !StringUtils.hasText(fileWebPath)) { + return Optional.empty(); + } + int start = url.indexOf(fileWebPath); + return start < 0 ? Optional.empty() : Optional.of(url.substring(start)); + } + + /** + * 抽出 HTML 中指向本站上传目录的资产地址(img src / a href),已归一为 webUrl。 + * + *

    用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。 + */ + public Set localAssetUrls(String html) { + Set webUrls = new LinkedHashSet<>(); + if (!StringUtils.hasText(html)) { + return webUrls; + } + Matcher matcher = ASSET_URL_PATTERN.matcher(html); + while (matcher.find()) { + localWebUrl(matcher.group(1)).ifPresent(webUrls::add); + } + return webUrls; + } + + /** + * 内容派生引用的全量同步(事项库 R2):把 (refType, refId) 下的引用对齐到给定 webUrl 集合—— + * 集合内标记归属、集合外解除归属(转回未使用),幂等。 + * + *

    为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步, + * 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。 + */ + @Transactional + public int syncAssets(Integer siteId, String refType, Integer refId, Set webUrls) { + Set desired = webUrls == null ? Collections.emptySet() : webUrls; + List stale = new ArrayList<>(); + for (CmsAttachment a : attachmentRepository.findByRefTypeAndRefId(refType, refId)) { + if (!desired.contains(a.getWebUrl())) { + a.setRefType(null); + a.setRefId(null); + stale.add(a); + } + } + if (!stale.isEmpty()) { + attachmentRepository.saveAll(stale); + } + for (String webUrl : desired) { + markUsed(siteId, webUrl, refType, refId); + } + return desired.size(); + } + /** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */ @Transactional public void release(String refType, Integer refId) { diff --git a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java index f87a73e..65646a6 100644 --- a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java +++ b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java @@ -54,7 +54,7 @@ public class PortalQueryService { /** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */ @Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode") public PortalSiteSnapshot siteSnapshot(String siteCode) { - CmsSite site = siteRepository.findByCode(siteCode).orElse(null); + CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null); return assembleSnapshot(site); } diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index bcd44a3..3d5126e 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -123,7 +123,7 @@ public class SiteService { } public CmsSite getByCode(String code) { - return siteRepository.findByCode(code).orElse(null); + return siteRepository.findFirstByCodeOrderByIdAsc(code).orElse(null); } public String configValue(CmsSite site, String key) { @@ -353,6 +353,7 @@ public class SiteService { } break; case "public_base_url": + case "svc_hall_base": case "social_wechat": case "social_weibo": case "social_douyin": diff --git a/src/main/java/com/yunhai/cms/svcitem/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/cms/svcitem/config/OpenApiAuthEnforceConfiguration.java new file mode 100644 index 0000000..2db9344 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/config/OpenApiAuthEnforceConfiguration.java @@ -0,0 +1,49 @@ +package com.yunhai.cms.svcitem.config; + +import com.yunhai.platform.appauth.AppAuthFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 开放接口签名强制(事项库详细设计 §3.4,照 UserBase OpenApiAuthEnforceConfiguration 范式): + * yh-app-auth 的 {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 + * CAS 流;本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。 + * CMS 的 /oapi/** 无用户会话语义,一律应用签名(X-App-Key/X-Timestamp/X-Signature 三件套)。 + */ +@Configuration +public class OpenApiAuthEnforceConfiguration { + + @Bean + public FilterRegistrationBean requireAppSignFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new RequireAppSignFilter()); + bean.addUrlPatterns("/oapi/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); + return bean; + } + + public static class RequireAppSignFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature"); + return; + } + chain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java b/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java new file mode 100644 index 0000000..a82da51 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceDict.java @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..f066725 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/entity/CmsServiceItem.java @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..ee14545 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/enums/HandleModeEnum.java @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..639c4b5 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/enums/ServiceDictTypeEnum.java @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..a23ed82 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/enums/ServiceItemStatusEnum.java @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..c4a119e --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/event/ServiceItemChangedEvent.java @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..9767ca1 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/dto/IdSortItem.java @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..1294fb6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcDictSaveRequest.java @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..36fd0a8 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/dto/SvcItemSaveRequest.java @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..b928ad3 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPageQuery.java @@ -0,0 +1,30 @@ +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/query/SvcItemPortalQuery.java b/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPortalQuery.java new file mode 100644 index 0000000..937839e --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/query/SvcItemPortalQuery.java @@ -0,0 +1,31 @@ +package com.yunhai.cms.svcitem.model.query; + +import lombok.Data; + +/** + * 门户服务中心列表查询(事项库详细设计 §5.2:全新查询面——obj/cat/dept 三维任意组合 + 关键词)。 + * 空即不筛;状态固定 status=1(服务端收口,不由调用方传入)。 + */ +@Data +public class SvcItemPortalQuery { + + private Integer siteId; + + /** 服务对象 dict_key(可空) */ + private String objectKey; + + /** 事项类别 dict_key(可空) */ + private String categoryKey; + + /** 服务部门组节点 id(可空) */ + private Integer deptGroupId; + + /** 关键词(title/summary LIKE,可空) */ + private String keyword; + + /** 页码,1 起 */ + private Integer page = 1; + + /** 页长 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceFilters.java b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceFilters.java new file mode 100644 index 0000000..8052222 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/model/vo/PortalServiceFilters.java @@ -0,0 +1,81 @@ +package com.yunhai.cms.svcitem.model.vo; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 门户服务中心三组筛选项 + 计数(已发布口径,事项库详细设计 §5.2/§6.1)。 + * 本对象为 cms:svcItem 缓存体(key=filters:{siteId}):仅承载选项与计数, + * active/url 由控制器按当前请求装配为 Facet(缓存体不可被请求态污染)。 + */ +@Data +public class PortalServiceFilters { + + /** 服务对象(object 字典,仅启用且有已发布事项引用的键) */ + private List

    包一层 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 new file mode 100644 index 0000000..1000d66 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/SvcItemQuery.java @@ -0,0 +1,83 @@ +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 实现类,三个消费方零改动。 + */ +public interface SvcItemQuery { + + /** 门户:服务中心列表分页(三维筛选 + 关键词;实时查询不缓存) */ + PortalServiceListData portalPage(SvcItemPortalQuery query); + + /** 门户:三组筛选项 + 计数(已发布口径;缓存 filters:{siteId}) */ + PortalServiceFilters portalFilters(Integer siteId); + + /** 门户:指南页模型(不存在/未发布返回 null,缓存 null 哨兵 30s 防穿透) */ + PortalServiceItem portalItem(Integer itemId, Integer siteId); + + /** 门户:关联事项(同类别已发布 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) */ + List attachments(Integer itemId); + + /** 门户:指南页浏览量异步累加(不承诺精确,失败忽略) */ + 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 new file mode 100644 index 0000000..f88bd85 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcDictService.java @@ -0,0 +1,169 @@ +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/SvcItemService.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java new file mode 100644 index 0000000..8105a62 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemService.java @@ -0,0 +1,760 @@ +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 new file mode 100644 index 0000000..c9c3bc6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/YschBaseSvcDeptSource.java @@ -0,0 +1,25 @@ +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/java/com/yunhai/cms/utils/PortalUrlHelper.java b/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java index d34f0ee..db0e13a 100644 --- a/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java +++ b/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java @@ -132,6 +132,41 @@ public class PortalUrlHelper { return page != null && page > 1 ? url + "&page=" + page : url; } + /** + * 服务中心列表基址(事项库 §5.4:模板禁止手拼 query,筛选/分页 URL 一律后端预生成)。 + */ + public String serviceList() { + return baseUrl + "/service"; + } + + /** 服务中心列表带筛选/分页参数:null 与空串值跳过,值按 UTF-8 编码(LinkedHashMap 保序) */ + public String serviceList(java.util.Map query) { + if (query == null || query.isEmpty()) { + return serviceList(); + } + StringBuilder qs = new StringBuilder(); + for (java.util.Map.Entry entry : query.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isEmpty()) { + continue; + } + String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue()); + if (value.isEmpty()) { + continue; + } + if (qs.length() > 0) { + qs.append('&'); + } + qs.append(urlEncode(key)).append('=').append(urlEncode(value)); + } + return qs.length() == 0 ? serviceList() : serviceList() + "?" + qs; + } + + /** 事项指南页 */ + public String serviceItem(Object itemId) { + return baseUrl + "/sv/" + itemId; + } + /** * 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。 * URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。 diff --git a/src/main/resources/sql/cms_init.sql b/src/main/resources/sql/cms_init.sql index 13574ec..8a7f62b 100644 --- a/src/main/resources/sql/cms_init.sql +++ b/src/main/resources/sql/cms_init.sql @@ -138,6 +138,69 @@ CREATE TABLE IF NOT EXISTS `cms_link` ( KEY `idx_link` (`site_id`, `group_name`, `sort_no`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接'; +-- 7. 事项表(L1 事项库主表,事项库与办事大厅详细设计 §2.1;跨服务键 = siteCode + item_code) +CREATE TABLE IF NOT EXISTS `cms_service_item` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)', + `title` VARCHAR(200) NOT NULL COMMENT '事项名称', + `summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)', + `object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)', + `category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)', + `dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)', + `dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)', + `handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下', + `online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)', + `handle_place` VARCHAR(200) NULL COMMENT '办理地点', + `handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")', + `promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)', + `contact_phone` VARCHAR(64) NULL COMMENT '咨询电话', + `supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话', + `process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)', + `material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)', + `notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)', + `faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)', + `notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)', + `rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)', + `cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架', + `publish_time` DATETIME NULL COMMENT '发布时间', + `offline_time` DATETIME NULL COMMENT '下架时间', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)', + `ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`), + KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`), + KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`), + KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`), + KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`), + KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)'; + +-- 8. 事项字典表(服务对象/事项类别;不复用 cms_category——栏目树是内容目录,语义不同) +CREATE TABLE IF NOT EXISTS `cms_service_dict` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别', + `dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)', + `dict_name` VARCHAR(128) NOT NULL COMMENT '展示名', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)', + `ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`), + KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)'; + -- ============================================================ -- 示例数据(联调首屏用,按需执行) -- ============================================================ diff --git a/src/main/resources/templates/themes/default/assets/css/main.css b/src/main/resources/templates/themes/default/assets/css/main.css index d438edd..d104b5a 100644 --- a/src/main/resources/templates/themes/default/assets/css/main.css +++ b/src/main/resources/templates/themes/default/assets/css/main.css @@ -994,3 +994,253 @@ time, .item-meta { color: var(--text-muted); font-size: var(--fs-sm); } .side-nav { display: none; } .page-cols { display: block; } } + +/* ===== 服务中心 / 事项指南(事项库 L2,docs/10 §3.4 契约的消费端样式)===== */ +.svc-page, .svc-guide { display: block; } + +/* 筛选区:三组 chip(对象/类别/部门),active 态标记当前勾选,URL 由后端预生成 */ +.svc-filters { + margin: var(--sp-4) 0 var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: var(--surface-variant); + border-radius: var(--r-md); +} +.svc-filter-group { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + padding: var(--sp-2) 0; +} +.svc-filter-group + .svc-filter-group { border-top: 1px dashed var(--border-strong); } +.svc-filter-label { + flex: none; + width: 4.5em; + padding-top: var(--sp-1); + color: var(--text-light); + font-size: var(--fs-sm); + font-weight: 600; +} +.svc-filter-options { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.svc-chip { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 2px var(--sp-3); + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 999px; + font-size: var(--fs-sm); + color: var(--text); + transition: border-color .15s ease, color .15s ease; +} +.svc-chip:hover { border-color: var(--primary); color: var(--primary); } +.svc-chip em { font-style: normal; color: var(--text-muted); font-size: var(--fs-xs); } +.svc-chip.active { + background: var(--primary); + border-color: var(--primary); + color: var(--on-dark); +} +.svc-chip.active em { color: var(--on-dark-muted); } + +/* 工具条:关键词搜索 + 清除筛选 */ +.svc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} +.svc-search { display: flex; } +.svc-search input { + width: 260px; + padding: 7px var(--sp-3); + border: 1px solid var(--border-strong); + border-right: none; + border-radius: var(--r-sm) 0 0 var(--r-sm); + font-size: var(--fs-base); + font-family: inherit; +} +.svc-search input:focus { outline: none; border-color: var(--primary); } +.svc-search button { + padding: 7px var(--sp-4); + border: none; + border-radius: 0 var(--r-sm) var(--r-sm) 0; + background: var(--primary); + color: var(--on-dark); + cursor: pointer; + font-size: var(--fs-base); +} +.svc-search button:hover { background: var(--primary-dark); } +.svc-clear { font-size: var(--fs-sm); color: var(--text-muted); } +.svc-clear:hover { color: var(--primary); } + +/* 事项列表:卡片行(有封面出图,无封面纯文字,与文章列表同哲学不造占位图) */ +.svc-list { list-style: none; } +.svc-list li { border-bottom: 1px solid var(--border); } +.svc-list li:last-child { border-bottom: none; } +.svc-item { + display: flex; + gap: var(--sp-4); + padding: var(--sp-4) var(--sp-2); +} +.svc-list li:hover { background: var(--surface-variant); } +.svc-item-cover { + flex: none; + width: 96px; + height: 64px; + border-radius: var(--r-sm); + overflow: hidden; + border: 1px solid var(--border); + background: var(--surface-variant); +} +.svc-item-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.svc-item-body { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; } +.svc-item-title { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } +.svc-item-name { font-size: var(--fs-lg); font-weight: 600; color: var(--text); } +.svc-item:hover .svc-item-name { color: var(--primary); } +.svc-tag { + flex: none; + padding: 1px var(--sp-2); + border-radius: var(--r-sm); + background: var(--surface-variant); + border: 1px solid var(--border); + color: var(--text-light); + font-size: var(--fs-xs); + line-height: 1.6; +} +.svc-tag.online { + background: var(--primary); + border-color: var(--primary); + color: var(--on-dark); +} +.svc-item-summary { + color: var(--text-light); + font-size: var(--fs-base); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.svc-item-meta { display: flex; gap: var(--sp-4); color: var(--text-muted); font-size: var(--fs-sm); } + +/* 指南页 */ +.svc-guide-body { display: block; } +.svc-apply { + display: flex; + align-items: center; + gap: var(--sp-3); + margin: var(--sp-4) 0; +} +.svc-apply-btn { + display: inline-block; + padding: var(--sp-2) var(--sp-8); + border-radius: var(--r-md); + background: var(--primary); + color: var(--on-dark); + font-size: var(--fs-lg); + font-weight: 600; +} +.svc-apply-btn:hover { background: var(--primary-dark); color: var(--on-dark); } +.svc-apply-hint { color: var(--text-muted); font-size: var(--fs-sm); } +.svc-summary { + padding: var(--sp-3) var(--sp-4); + background: var(--surface-variant); + border-left: 3px solid var(--primary); + border-radius: 0 var(--r-sm) var(--r-sm) 0; + color: var(--text-light); +} +.svc-info { + width: 100%; + margin: var(--sp-4) 0; + border-collapse: collapse; + font-size: var(--fs-base); +} +.svc-info th, .svc-info td { + border: 1px solid var(--border); + padding: var(--sp-2) var(--sp-4); + text-align: left; + vertical-align: top; +} +.svc-info th { + width: 7em; + background: var(--surface-variant); + color: var(--text-light); + font-weight: 600; + white-space: nowrap; +} +.svc-section { margin: var(--sp-6) 0; } +.svc-section h2 { + font-size: var(--fs-xl); + padding-bottom: var(--sp-2); + border-bottom: 2px solid var(--primary); + margin-bottom: var(--sp-3); +} +.svc-attachments ul { list-style: none; } +.svc-attachments li { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; + border-bottom: 1px dashed var(--border); +} +.svc-attachments li:last-child { border-bottom: none; } +.svc-attachments a { color: var(--primary); } +.svc-attachments a:hover { text-decoration: underline; } +.svc-att-size { color: var(--text-muted); font-size: var(--fs-sm); } + +.svc-links { + display: flex; + gap: var(--sp-3); + margin: var(--sp-6) 0 0; + padding-top: var(--sp-4); + border-top: 1px dashed var(--border-strong); +} +.svc-links a { + padding: var(--sp-1) var(--sp-4); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + font-size: var(--fs-sm); + color: var(--text-light); +} +.svc-links a:hover { border-color: var(--primary); color: var(--primary); } + +/* 相关服务侧区 */ +.svc-related { margin-top: var(--sp-8); } +.svc-related-list { list-style: none; margin-top: var(--sp-3); } +.svc-related-list li { border-bottom: 1px solid var(--border); } +.svc-related-list li:last-child { border-bottom: none; } +.svc-related-list a { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-2); +} +.svc-related-list li:hover { background: var(--surface-variant); } +.svc-related-name { + font-size: var(--fs-md); + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.svc-related-list a:hover .svc-related-name { color: var(--primary); } +.svc-related-meta { display: flex; align-items: center; gap: var(--sp-2); flex: none; } +.svc-related-meta > span:not(.svc-tag) { color: var(--text-muted); font-size: var(--fs-sm); } + +/* 窄屏适配(契约 §4.7) */ +@media (max-width: 768px) { + .svc-toolbar { flex-direction: column; align-items: stretch; } + .svc-search input { width: auto; flex: 1; } + .svc-filter-group { flex-direction: column; gap: var(--sp-1); } + .svc-filter-label { width: auto; padding-top: 0; } + .svc-item { flex-direction: column; gap: var(--sp-2); } + .svc-item-cover { width: 100%; height: 120px; } + .svc-info th { width: 5.5em; } + .svc-related-list a { flex-direction: column; align-items: flex-start; } +} + +/* 打印:按钮/筛选不参与打印 */ +@media print { + .svc-apply, .svc-filters, .svc-toolbar, .svc-related { display: none; } +} diff --git a/src/main/resources/templates/themes/default/guide.html b/src/main/resources/templates/themes/default/guide.html new file mode 100644 index 0000000..3f7c9c1 --- /dev/null +++ b/src/main/resources/templates/themes/default/guide.html @@ -0,0 +1,126 @@ + + + + +
    + + +
    +
    + + +
    +

    事项名称

    + + + +
    + 我要办理 + 进入服务大厅在线办理 +
    + +

    简介

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    事项基本信息
    服务对象服务对象
    事项类别事项类别
    办理地点办理地点
    办理时限办理时限
    承诺时限承诺时限
    咨询电话咨询电话
    监督投诉监督投诉电话
    + +
    +

    办理流程

    +
    办理流程
    +
    +
    +

    所需材料

    +
    所需材料
    +
    +
    +

    注意事项

    +
    注意事项
    +
    + +
    +

    相关材料下载

    + +
    + + +
    + + +
    +
    + +
    + + + diff --git a/src/main/resources/templates/themes/default/service.html b/src/main/resources/templates/themes/default/service.html new file mode 100644 index 0000000..0ddd712 --- /dev/null +++ b/src/main/resources/templates/themes/default/service.html @@ -0,0 +1,105 @@ + + + + +
    + + +
    +
    + + +

    服务中心

    + +
    +
    + 服务对象 + +
    +
    + 事项类别 + +
    +
    + 服务部门 + +
    +
    + +
    + + 清除筛选 +
    + + +
    +

    未找到符合条件的服务事项

    +

    服务中心暂未上架事项

    +

    换个筛选条件或关键词试试

    +

    事项发布后将在此展示

    +
    + + + +
    +
    + +
    + + + diff --git a/src/test/java/com/yunhai/cms/controller/portal/PortalPageRendererTest.java b/src/test/java/com/yunhai/cms/controller/portal/PortalPageRendererTest.java new file mode 100644 index 0000000..ca42a22 --- /dev/null +++ b/src/test/java/com/yunhai/cms/controller/portal/PortalPageRendererTest.java @@ -0,0 +1,64 @@ +package com.yunhai.cms.controller.portal; + +import com.yunhai.cms.model.portal.PortalSiteSnapshot; +import com.yunhai.cms.theme.ThemeRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * M0 可选模板整页回退(事项库详细设计 §四): + * 皮肤包缺可选模板(service.html/guide.html)时整页回退 default—— + * 不存在逐模板回退,effectiveTheme 必须同时驱动视图名/themeBase/URL 资产段, + * 否则出现 default 的 HTML 配别家皮肤的 CSS。 + */ +@DisplayName("PortalPageRenderer.resolveEffectiveTheme - 可选模板整页回退") +class PortalPageRendererTest { + + private PortalSiteSnapshot siteOf(String theme) { + PortalSiteSnapshot site = new PortalSiteSnapshot(); + site.setCode("demo"); + site.setTheme(theme); + return site; + } + + @Test + @DisplayName("主题缺可选模板 → 整页回退 default") + void missingTemplate_fallsBackToDefault() { + ThemeRegistry registry = mock(ThemeRegistry.class); + when(registry.resource("scholar-blue", "service.html")) + .thenReturn(new ClassPathResource("templates/themes/scholar-blue/service.html")); + PortalPageRenderer renderer = new PortalPageRenderer(null, registry); + + assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html")) + .isEqualTo(ThemeRegistry.DEFAULT_THEME); + } + + @Test + @DisplayName("主题备有可选模板 → 用主题自身") + void templatePresent_keepsTheme() { + ThemeRegistry registry = mock(ThemeRegistry.class); + when(registry.resource("scholar-blue", "service.html")) + .thenReturn(new ClassPathResource("templates/themes/scholar-blue/home.html")); + PortalPageRenderer renderer = new PortalPageRenderer(null, registry); + + assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html")) + .isEqualTo("scholar-blue"); + } + + @Test + @DisplayName("default 自身缺模板仍返回 default(不抛错,保持兜底语义)") + void defaultTheme_missingTemplate_staysDefault() { + ThemeRegistry registry = mock(ThemeRegistry.class); + when(registry.resource("default", "guide.html")) + .thenReturn(new ClassPathResource("templates/themes/default/__none__.html")); + PortalPageRenderer renderer = new PortalPageRenderer(null, registry); + + assertThat(renderer.resolveEffectiveTheme(siteOf("default"), "guide.html")) + .isEqualTo(ThemeRegistry.DEFAULT_THEME); + } +} diff --git a/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java b/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java index 2b19c8e..f1c3109 100644 --- a/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java +++ b/src/test/java/com/yunhai/cms/portal/ThemeContractIT.java @@ -22,7 +22,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. /** * 皮肤包渲染契约护栏(方案 §8.3 F 组 T22,§4.3 数据契约自动化守护): - * 遍历 Registry 全部已注册主题,五路由断言 200 + 站点名渲染(200 已隐含无模板异常)。 + * 遍历 Registry 全部已注册主题,七路由断言 200 + 站点名渲染(200 已隐含无模板异常)。 + * 事项库 R2:service/guide 为可选模板——仅 default 包具备,其余主题必须靠 M0 整页回退, + * 故对缺模板的主题额外断言资产段落到 default(防止"default 的 HTML 配别家皮肤的 CSS")。 * P0 对 default+t-demo-red 绿;P2 新增皮肤自动纳入,无需改动本测试。 */ @DisplayName("ThemeContract - 皮肤包渲染契约护栏(方案 §4.3)") @@ -38,6 +40,7 @@ class ThemeContractIT extends CmsIntegrationTestBase { private Integer listCatId; private Integer singleCatId; private Integer articleId; + private Integer serviceItemId; @BeforeEach void purgeOnly() { @@ -65,6 +68,9 @@ class ThemeContractIT extends CmsIntegrationTestBase { }); articleId = persistArticle(site.getId(), listCatId, "契约测试文章", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)).getId(); + // 事项库 R2:服务中心/指南页路由种子(已发布事项;缺可选模板的主题走 M0 整页回退) + serviceItemId = persistSvcItem(site.getId(), "CT-001", "契约测试事项", + com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum.PUBLISHED).getId(); } private String body(MvcResult result, String theme, String route) @@ -76,7 +82,7 @@ class ThemeContractIT extends CmsIntegrationTestBase { } @Test - @DisplayName("T22: 每个注册主题渲染五路由(home/list/single/detail/search 全 200 含站点名)") + @DisplayName("T22: 每个注册主题渲染七路由(home/list/single/detail/search/service/guide 全 200 含站点名)") void everyRegisteredThemeRendersAllRoutes() throws Exception { List themes = themeRegistry.list(); assertThat(themes).as("注册表不可为空(红阶段护栏)").isNotEmpty(); @@ -99,6 +105,23 @@ class ThemeContractIT extends CmsIntegrationTestBase { body(mockMvc.perform(get("/portal/{code}/search", siteCode).param("q", "契约")) .andExpect(status().isOk()).andReturn(), theme.getName(), "搜索页"); + // 事项库 R2(C-1):service/guide 为可选模板,缺模板主题经 M0 整页回退仍须 200(default 渲染) + String serviceHtml = body(mockMvc.perform(get("/portal/{code}/service", siteCode)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "服务中心"); + String guideHtml = body(mockMvc.perform(get("/portal/{code}/sv/{id}", siteCode, serviceItemId)) + .andExpect(status().isOk()).andReturn(), theme.getName(), "事项指南页"); + + // 可选模板缺失时:整页(视图 + 资产主题段 + URL 主题名)必须一起退到 default, + // 只验 200 会漏掉"default HTML + 别家 CSS"的混搭回归 + boolean hasOptionalTemplates = getClass().getClassLoader() + .getResource("templates/themes/" + theme.getName() + "/service.html") != null; + if (!hasOptionalTemplates) { + assertThat(serviceHtml).as("主题 %s 服务中心缺模板 → 资产退 default", theme.getName()) + .contains("/assets/default/").doesNotContain("/assets/" + theme.getName() + "/"); + assertThat(guideHtml).as("主题 %s 指南页缺模板 → 资产退 default", theme.getName()) + .contains("/assets/default/").doesNotContain("/assets/" + theme.getName() + "/"); + } + purgeTables(); } } diff --git a/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java index 751818d..93c5a25 100644 --- a/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java @@ -286,6 +286,27 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { .hasMessageContaining("scene"); } + @Test + @DisplayName("事项库 R2: scene=svc_item 放行且错误文案含 svc_item;ref_type/ref_id 查询命中") + void upload_svcItemSceneAllowedAndReferenced() { + // 场景入白名单:svc_item 上传落 refType 初步标记 + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "svc_item", imageFile("jpg")); + assertThat(vo.getRefType()).isEqualTo("svc_item"); + + // 错误文案同步(仅 banner/link/site/category/svc_item) + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "svc_item2", imageFile("jpg"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("svc_item"); + + // 指南附件查询通道:markUsed(refType=svc_item, refId) 后 findByRefTypeAndRefId 命中 + attachmentService.markUsed(siteId, vo.getUrl(), "svc_item", 1001); + assertThat(attachmentRepository.findByRefTypeAndRefId("svc_item", 1001)) + .hasSize(1) + .first().extracting(CmsAttachment::getRefId).isEqualTo(1001); + } + @Test @DisplayName("B2: markUsed 后附件不再是未使用(不出现在 unusedOnly 列表)") @SuppressWarnings("unchecked") diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java index 2870af1..6156031 100644 --- a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java @@ -220,6 +220,25 @@ class SiteServiceIT extends CmsIntegrationTestBase { .contains("邮编:464300"); } + @Test + @DisplayName("事项库 svc_hall_base:非完整 http(s) URL 拒绝、合法 URL 放行(§3.7 显式 case)") + void save_svcHallBaseUrlValidation() { + SiteSaveRequest base = new SiteSaveRequest(); + base.setCode("svchall"); + base.setSchId(1); + base.setName("服务大厅配置站"); + + base.setConfigJson("{\"svc_hall_base\":\"svhall.xxx.com\"}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base)) + .isInstanceOf(BizException.class) + .hasMessageContaining("http"); + + base.setConfigJson("{\"svc_hall_base\":\"https://svhall.xxx.edu.cn\"}"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), base); + assertThat(siteService.configValue(siteService.getByCode(vo.getCode()), "svc_hall_base")) + .isEqualTo("https://svhall.xxx.edu.cn"); + } + // ===== 主题白名单与 themeParams 合并(方案 §5.1/§5.3,T9-T12)===== @Test diff --git a/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java index 0e68df6..454cea8 100644 --- a/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java +++ b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java @@ -1,6 +1,7 @@ package com.yunhai.cms.support; import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsAttachment; import com.yunhai.cms.entity.CmsBanner; import com.yunhai.cms.entity.CmsCategory; import com.yunhai.cms.entity.CmsLink; @@ -13,6 +14,11 @@ import com.yunhai.cms.repository.CmsBannerRepository; import com.yunhai.cms.repository.CmsCategoryRepository; import com.yunhai.cms.repository.CmsLinkRepository; import com.yunhai.cms.repository.CmsSiteRepository; +import com.yunhai.cms.svcitem.entity.CmsServiceDict; +import com.yunhai.cms.svcitem.entity.CmsServiceItem; +import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; +import com.yunhai.cms.svcitem.repository.CmsServiceDictRepository; +import com.yunhai.cms.svcitem.repository.CmsServiceItemRepository; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -50,6 +56,10 @@ public abstract class CmsIntegrationTestBase { protected CmsLinkRepository linkRepository; @Autowired protected CmsAttachmentRepository attachmentRepository; + @Autowired + protected CmsServiceItemRepository serviceItemRepository; + @Autowired + protected CmsServiceDictRepository serviceDictRepository; @BeforeEach void clearCachesBeforeEachTest() { @@ -63,13 +73,15 @@ public abstract class CmsIntegrationTestBase { } } - /** 清空 6 张业务表(无外键约束,顺序无碍),非事务用例的隔离手段 */ + /** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2) */ protected void purgeTables() { attachmentRepository.deleteAllInBatch(); articleRepository.deleteAllInBatch(); categoryRepository.deleteAllInBatch(); bannerRepository.deleteAllInBatch(); linkRepository.deleteAllInBatch(); + serviceItemRepository.deleteAllInBatch(); + serviceDictRepository.deleteAllInBatch(); siteRepository.deleteAllInBatch(); } @@ -150,6 +162,50 @@ public abstract class CmsIntegrationTestBase { return linkRepository.saveAndFlush(l); } + /** 附件种子(svc_item 场景引用由调用方按需补设 refType/refId) */ + protected CmsAttachment persistAttachment(Integer siteId, String scene, String fileName) { + CmsAttachment attachment = new CmsAttachment(); + attachment.setSiteId(siteId); + attachment.setRefType(scene); + attachment.setFileName(fileName); + attachment.setFilePath("cms/" + siteId + "/202609/" + fileName.hashCode() + ".pdf"); + attachment.setWebUrl("/cms/upload/" + attachment.getFilePath()); + attachment.setFileSize(1024L); + attachment.setMimeType("application/pdf"); + return attachmentRepository.saveAndFlush(attachment); + } + + /** 事项字典种子(status 默认启用) */ + protected CmsServiceDict persistSvcDict(Integer siteId, String dictType, String dictKey, + String dictName, Integer status) { + CmsServiceDict dict = new CmsServiceDict(); + dict.setSiteId(siteId); + dict.setDictType(dictType); + dict.setDictKey(dictKey); + dict.setDictName(dictName); + dict.setSortNo(0); + dict.setStatus(status == null ? 1 : status); + return serviceDictRepository.saveAndFlush(dict); + } + + /** 事项种子(必需字段齐备,其余可后续补设后 saveAndFlush) */ + protected CmsServiceItem persistSvcItem(Integer siteId, String itemCode, String title, + ServiceItemStatusEnum status) { + CmsServiceItem item = new CmsServiceItem(); + item.setSiteId(siteId); + item.setItemCode(itemCode); + item.setTitle(title); + item.setHandleMode(1); + item.setOnlineFlag(1); + item.setSortNo(0); + item.setStatus(status.getCode()); + item.setViewCount(0); + if (status == ServiceItemStatusEnum.PUBLISHED) { + item.setPublishTime(nowMinusSeconds(60)); + } + return serviceItemRepository.saveAndFlush(item); + } + /** 简易轮询等待(无 awaitility 依赖):最长 waitMillis 内每 50ms 检查一次期望 */ protected static void awaitTrue(long waitMillis, java.util.function.BooleanSupplier condition) throws InterruptedException { diff --git a/src/test/java/com/yunhai/cms/svcitem/SvcDeptChainIT.java b/src/test/java/com/yunhai/cms/svcitem/SvcDeptChainIT.java new file mode 100644 index 0000000..af5635c --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/SvcDeptChainIT.java @@ -0,0 +1,193 @@ +package com.yunhai.cms.svcitem; + +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest; +import com.yunhai.cms.svcitem.model.vo.SvcDeptVo; +import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo; +import com.yunhai.cms.svcitem.model.vo.PortalServiceItem; +import com.yunhai.cms.svcitem.service.SvcDeptSource; +import com.yunhai.cms.svcitem.service.SvcItemQuery; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaGroup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 服务部门链集成测试(审计批次 4 补口:此前 dept_group_id 整条链零覆盖)。 + * + *

    覆盖:组树类型过滤 + 层级构建、保存时按权威名落快照、id 不在树上拒绝、组树不可达降级、 + * syncDeptName 刷新快照并失效指南页缓存。组树经 {@link SvcDeptSource} 接缝注入固定树, + * 使"部门可达"这条主路径可断言(生产实现代理 UserBase 静态调用)。 + */ +@DisplayName("SvcItemService - 服务部门链(组树接缝)") +class SvcDeptChainIT extends CmsIntegrationTestBase { + + /** 可变组树桩:用例按需替换;null 模拟平台不可达 */ + static BoaGroup stubTree; + + private static final Integer DEPT_ID = 11; + private static final Integer SCHOOL_ID = 10; + private static final Integer NOT_DEPT_ID = 12; + private static final Integer UNKNOWN_DEPT_ID = 999; + + @TestConfiguration + static class StubDeptSourceConfig { + @Bean + @Primary + SvcDeptSource stubSvcDeptSource() { + return () -> stubTree; + } + } + + @Autowired + private SvcItemQuery svcItemQuery; + + private Integer siteId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + persistSvcDict(siteId, "object", "person", "个人", 1); + persistSvcDict(siteId, "category", "jw", "教务服务", 1); + stubTree = tree("第一中学", "教务处"); + } + + @AfterEach + void clean() { + stubTree = null; + purgeTables(); + } + + /** 根(type=1) → 学校(type=2) → [教务处(type=2), 非部门节点(type=9)];另加一所兄弟学校 */ + private static BoaGroup tree(String schoolName, String deptName) { + BoaGroup root = group(1, "云海集团", 1); + BoaGroup school = group(SCHOOL_ID, schoolName, 2); + BoaGroup dept = group(DEPT_ID, deptName, 2); + BoaGroup notDept = group(NOT_DEPT_ID, "非部门节点", 9); + BoaGroup sibling = group(20, "第二中学", 2); + school.setChildren(Arrays.asList(dept, notDept)); + root.setChildren(Arrays.asList(school, sibling)); + return root; + } + + private static BoaGroup group(Integer id, String name, Integer type) { + BoaGroup g = new BoaGroup(id, name); + g.setType(type); + return g; + } + + private SvcItemSaveRequest request() { + SvcItemSaveRequest req = new SvcItemSaveRequest(); + req.setSiteId(siteId); + req.setItemCode("BG-ZX-001"); + req.setTitle("转学办理"); + return req; + } + + private static List names(List nodes) { + List out = new ArrayList<>(); + for (SvcDeptVo node : nodes) { + out.add(node.getName()); + } + return out; + } + + @Test + @DisplayName("deptTree:按配置类型过滤(非部门节点不下发)且保留层级") + void deptTree_nestedAndTypeFiltered() { + List top = svcItemQuery.deptTree(TestUsers.superAdmin(), siteId); + assertThat(names(top)).containsExactly("第一中学", "第二中学"); + SvcDeptVo school = top.get(0); + assertThat(school.getId()).isEqualTo(SCHOOL_ID); + assertThat(names(school.getChildren())).as("type=9 的节点被过滤,部门子节点保留").containsExactly("教务处"); + assertThat(school.getChildren().get(0).getId()).isEqualTo(DEPT_ID); + assertThat(school.getChildren().get(0).getChildren()).isEmpty(); + assertThat(top.get(1).getChildren()).isEmpty(); + } + + @Test + @DisplayName("deptTree:组树不可达降级为空列表(不抛异常)") + void deptTree_unreachableReturnsEmpty() { + stubTree = null; + assertThat(svcItemQuery.deptTree(TestUsers.superAdmin(), siteId)).isEmpty(); + } + + @Test + @DisplayName("保存:合法部门 id 落权威名快照") + void save_validDeptIdSnapshotsName() { + SvcItemSaveRequest req = request(); + req.setDeptGroupId(DEPT_ID); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id); + assertThat(vo.getDeptName()).isEqualTo("教务处"); + assertThat(vo.getDeptGroupId()).isEqualTo(DEPT_ID); + } + + @Test + @DisplayName("保存:组树不可达时带部门保存被拒(防绕过选择器写脏快照)") + void save_unreachableDeptRejected() { + stubTree = null; + SvcItemSaveRequest req = request(); + req.setDeptGroupId(DEPT_ID); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("部门服务不可达"); + } + + @Test + @DisplayName("保存:id 不在组树上被拒;留空可保存") + void save_unknownDeptRejectedAndBlankAllowed() { + SvcItemSaveRequest bad = request(); + bad.setDeptGroupId(UNKNOWN_DEPT_ID); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), bad)) + .isInstanceOf(BizException.class) + .hasMessageContaining("服务部门不存在"); + + SvcItemSaveRequest blank = request(); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), blank); + assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isNull(); + } + + @Test + @DisplayName("syncDeptName:组树改名后刷新快照条数,并失效指南页缓存") + void syncDeptName_refreshesSnapshotAndCache() { + SvcItemSaveRequest req = request(); + req.setDeptGroupId(DEPT_ID); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id); + assertThat(svcItemQuery.portalItem(id, siteId).getDeptName()).isEqualTo("教务处"); + + // 组树改名后:未同步前指南页仍读缓存中的旧名 + stubTree = tree("第一中学", "教务处(改)"); + Map result = svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId); + assertThat(result.get("updated")).isEqualTo(1); + PortalServiceItem refreshed = svcItemQuery.portalItem(id, siteId); + assertThat(refreshed.getDeptName()).as("syncDeptName 事件已整表失效指南页缓存").isEqualTo("教务处(改)"); + assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isEqualTo("教务处(改)"); + } + + @Test + @DisplayName("syncDeptName:组树不可达时报错且不改数据") + void syncDeptName_unreachableRejected() { + stubTree = null; + assertThatThrownBy(() -> svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不可达或为空"); + } +} diff --git a/src/test/java/com/yunhai/cms/svcitem/SvcDictServiceIT.java b/src/test/java/com/yunhai/cms/svcitem/SvcDictServiceIT.java new file mode 100644 index 0000000..2e25343 --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/SvcDictServiceIT.java @@ -0,0 +1,219 @@ +package com.yunhai.cms.svcitem; + +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.cms.svcitem.enums.ServiceDictTypeEnum; +import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; +import com.yunhai.cms.svcitem.model.dto.SvcDictSaveRequest; +import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters; +import com.yunhai.cms.svcitem.model.vo.SvcDictVo; +import com.yunhai.cms.svcitem.service.SvcItemQuery; +import com.yunhai.cms.svcitem.service.impl.SvcDictService; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 事项字典集成测试(事项库详细设计 §2.2/§3.3):键唯一、稳定键引用守护、 + * 停用后不出现在筛选与选择器、存量引用仍可渲染。 + */ +@DisplayName("SvcDictService - 事项字典") +class SvcDictServiceIT extends CmsIntegrationTestBase { + + @Autowired + private SvcDictService svcDictService; + @Autowired + private SvcItemQuery svcItemQuery; + + private Integer siteId; + private Integer otherSiteId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + /** 事项种子(带字典键)——落库直插,绕过 adminSave 的字典启用校验便于构造引用态 */ + private com.yunhai.cms.svcitem.entity.CmsServiceItem persistItemWithKeys( + String code, String title, String objectKey, String categoryKey) { + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + persistSvcItem(siteId, code, title, ServiceItemStatusEnum.DRAFT); + item.setObjectKey(objectKey); + item.setCategoryKey(categoryKey); + return serviceItemRepository.saveAndFlush(item); + } + + private SvcDictSaveRequest request(String dictType, String dictKey, String dictName) { + SvcDictSaveRequest req = new SvcDictSaveRequest(); + req.setSiteId(siteId); + req.setDictType(dictType); + req.setDictKey(dictKey); + req.setDictName(dictName); + return req; + } + + @Test + @DisplayName("键唯一:站内同类型撞键拒绝;同键异类型/异站互不冲突(uk_svc_dict 语义)") + void save_uniqueKeyPerSiteAndType() { + svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人")); + assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(), + request("object", "person", "个人重复"))) + .isInstanceOf(BizException.class).hasMessageContaining("字典键已存在"); + // 同键异类型放行 + assertThat(svcDictService.save(TestUsers.superAdmin(), + request("category", "person", "个人类别"))).isNotNull(); + // 异站同键放行 + SvcDictSaveRequest other = request("object", "person", "他站个人"); + other.setSiteId(otherSiteId); + assertThat(svcDictService.save(TestUsers.superAdmin(), other)).isNotNull(); + } + + @Test + @DisplayName("类型收口:非 object/category 拒绝(保存与列表两条入口)") + void save_dictTypeWhitelist() { + assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(), + request("hack", "x", "非法类型"))) + .isInstanceOf(BizException.class).hasMessageContaining("字典类型非法"); + assertThatThrownBy(() -> svcDictService.list(TestUsers.superAdmin(), siteId, "hack")) + .isInstanceOf(BizException.class).hasMessageContaining("字典类型非法"); + } + + @Test + @DisplayName("稳定键守护:被事项引用后改键拒绝(只能改名);未引用可改键") + void save_referencedKeyImmutable() { + Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务")); + // 未引用:改键放行 + SvcDictSaveRequest rename = request("category", "jwc", "教务处服务"); + rename.setId(dictId); + svcDictService.save(TestUsers.superAdmin(), rename); + + // 被引用后改键拒绝 + persistItemWithKeys("S1", "事项一", null, "jwc"); + + SvcDictSaveRequest changeKey = request("category", "jw2", "教务服务"); + changeKey.setId(dictId); + assertThatThrownBy(() -> svcDictService.save(TestUsers.superAdmin(), changeKey)) + .isInstanceOf(BizException.class).hasMessageContaining("不可修改"); + + // 被引用后改名放行(dict_name 可改) + SvcDictSaveRequest renameOnly = request("category", "jwc", "教务处综合服务"); + renameOnly.setId(dictId); + svcDictService.save(TestUsers.superAdmin(), renameOnly); + assertThat(svcDictService.list(TestUsers.superAdmin(), siteId, "category")) + .extracting("dictName").containsExactly("教务处综合服务"); + } + + @Test + @DisplayName("删除守护:被引用的字典不能删(可停用);未引用可删") + void delete_referencedRejected() { + Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人")); + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(dictId); + svcDictService.delete(TestUsers.superAdmin(), del); + assertThat(serviceDictRepository.findById(dictId)).isEmpty(); + + Integer dictId2 = svcDictService.save(TestUsers.superAdmin(), request("object", "legal", "法人")); + persistItemWithKeys("S1", "事项一", "legal", null); + + DeleteRequest del2 = new DeleteRequest(); + del2.setSiteId(siteId); + del2.setId(dictId2); + assertThatThrownBy(() -> svcDictService.delete(TestUsers.superAdmin(), del2)) + .isInstanceOf(BizException.class).hasMessageContaining("不能删除"); + } + + @Test + @DisplayName("停用语义:不出现在门户筛选(有已发布引用也不出);存量引用仍可渲染名称") + void disable_excludedFromFiltersButStillRenders() { + Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人")); + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + persistItemWithKeys("S1", "事项一", "person", null); + item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + serviceItemRepository.saveAndFlush(item); + + PortalServiceFilters before = svcItemQuery.portalFilters(siteId); + assertThat(before.getObjects()).extracting("key").containsExactly("person"); + + // 停用 → 事件失效 filters 缓存 → 选项消失 + SvcDictSaveRequest disable = request("object", "person", "个人"); + disable.setId(dictId); + disable.setStatus(0); + svcDictService.save(TestUsers.superAdmin(), disable); + assertThat(svcItemQuery.portalFilters(siteId).getObjects()).isEmpty(); + + // 存量引用仍可渲染:指南页模型 objectName 回填(全状态字典口径) + com.yunhai.cms.svcitem.model.vo.PortalServiceItem portal = + svcItemQuery.portalItem(item.getId(), siteId); + assertThat(portal).isNotNull(); + assertThat(portal.getObjectName()).isEqualTo("个人"); + } + + @Test + @DisplayName("列表 referenced 计数:被引用条数回填(全状态口径),未引用为 0") + void list_referencedCountBackfilled() { + Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务")); + svcDictService.save(TestUsers.superAdmin(), request("category", "log", "后勤服务")); + for (int i = 0; i < 2; i++) { + persistItemWithKeys("S" + i, "事项" + i, null, "jw"); + } + List list = svcDictService.list(TestUsers.superAdmin(), siteId, "category"); + assertThat(list).extracting("dictKey").containsExactly("jw", "log"); + assertThat(list).extracting("referenced").containsExactly(2L, 0L); + assertThat(dictId).isNotNull(); + } + + @Test + @DisplayName("nameMap/enabledDicts:全状态 vs 仅启用的口径差") + void nameMapAndEnabledDicts_semantics() { + svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人")); + Integer stopId = svcDictService.save(TestUsers.superAdmin(), request("object", "legal", "法人")); + SvcDictSaveRequest disable = request("object", "legal", "法人"); + disable.setId(stopId); + disable.setStatus(0); + svcDictService.save(TestUsers.superAdmin(), disable); + + assertThat(svcDictService.nameMap(siteId, ServiceDictTypeEnum.OBJECT).keySet()) + .as("全状态:渲染口径含停用键").containsExactlyInAnyOrder("person", "legal"); + assertThat(svcDictService.enabledDicts(siteId, ServiceDictTypeEnum.OBJECT)) + .extracting("dictKey").containsExactly("person"); + } + + @Test + @DisplayName("字典改名:指南页已缓存模型立即刷新 categoryName(审计 D4 修复锚点)") + void rename_refreshesCachedGuideModel() { + Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务")); + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + persistItemWithKeys("S1", "转学办理", null, "jw"); + item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + serviceItemRepository.saveAndFlush(item); + + assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName()).isEqualTo("教务服务"); + + // 改名:scope=dict 事件整表清 cms:svcItem → 已缓存指南页不再滞留旧名(修复前最多滞后 5min) + SvcDictSaveRequest rename = request("category", "jw", "教务与学籍服务"); + rename.setId(dictId); + svcDictService.save(TestUsers.superAdmin(), rename); + + assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName()) + .as("改名后指南页模型立即可见新名").isEqualTo("教务与学籍服务"); + assertThat(svcItemQuery.portalFilters(siteId).getCategories()) + .extracting("name").containsExactly("教务与学籍服务"); + } +} diff --git a/src/test/java/com/yunhai/cms/svcitem/SvcItemOapiIT.java b/src/test/java/com/yunhai/cms/svcitem/SvcItemOapiIT.java new file mode 100644 index 0000000..b51da7e --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/SvcItemOapiIT.java @@ -0,0 +1,241 @@ +package com.yunhai.cms.svcitem; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; +import com.yunhai.platform.utils.crypto.SignUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 跨服务快照 oapi 集成测试(事项库详细设计 §3.4,C-3/C-4 校正): + * 未签名 401;缺参/不存在/未发布走信封 code=2(HTTP 恒 200 平台口径); + * 正常返回发布态要点快照;真实签名端到端(test 专属 trusted-apps)。 + */ +@DisplayName("SvcItemOapi - 跨服务快照契约(M2)") +@SpringBootTest(properties = "yh.app-auth.trusted-apps.t-svhall=test-secret-1234567890") +class SvcItemOapiIT extends CmsIntegrationTestBase { + + private static final String APP_KEY = "t-svhall"; + private static final String APP_SECRET = "test-secret-1234567890"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Autowired + private MockMvc mockMvc; + + private Integer siteId; + private Integer itemId; + + @BeforeEach + void seed() throws Exception { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + persistSvcItem(siteId, "BG-ZX-001", "转学办理", ServiceItemStatusEnum.PUBLISHED); + item.setDeptName("教务处"); + item.setContactPhone("0571-88888888"); + item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}"); + itemId = serviceItemRepository.saveAndFlush(item).getId(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + /** 手工建站(指定 path/parent):基类 persistSite 会先按 code 插一次、无法构造同名 code 的两站 */ + private com.yunhai.cms.entity.CmsSite manualSite(String code, String path, Integer parentSiteId, + Integer schId, String name) { + com.yunhai.cms.entity.CmsSite site = new com.yunhai.cms.entity.CmsSite(); + site.setCode(code); + site.setPath(path); + site.setParentSiteId(parentSiteId); + site.setSchId(schId); + site.setSchName("学校-" + schId); + site.setName(name); + site.setTheme("default"); + site.setStatus(1); + return siteRepository.saveAndFlush(site); + } + + private JsonNode envelope(MvcResult result) throws Exception { + return MAPPER.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)); + } + + /** 模拟 AppAuthFilter 已验签(直接打请求属性),聚焦业务断言 */ + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signed( + String path, String query) { + return get(path + (query == null || query.isEmpty() ? "" : "?" + query)) + .requestAttr(com.yunhai.platform.appauth.AppAuthFilter.APP_AUTH_ATTR, APP_KEY); + } + + @Test + @DisplayName("未签名访问 /oapi/** → HTTP 401(签名强制过滤器,非信封)") + void unsignedRequest_unauthorized401() throws Exception { + mockMvc.perform(get("/oapi/svc/item/snapshot") + .param("siteCode", "demo1").param("itemCode", "BG-ZX-001")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("缺 siteCode/itemCode → 信封 code=2(HTTP 200),不做全局 itemCode 降级查询") + void missingParams_envelopeParamError() throws Exception { + JsonNode noSite = envelope(mockMvc.perform(signed("/oapi/svc/item/snapshot", "itemCode=BG-ZX-001")) + .andExpect(status().isOk()).andReturn()); + assertThat(noSite.get("code").asInt()).isEqualTo(2); + assertThat(noSite.get("msg").asText()).contains("不得为空"); + + JsonNode noItem = envelope(mockMvc.perform(signed("/oapi/svc/item/snapshot", "siteCode=demo1")) + .andExpect(status().isOk()).andReturn()); + assertThat(noItem.get("code").asInt()).isEqualTo(2); + } + + @Test + @DisplayName("站点不存在/事项不存在/未发布 → 信封 code=2「事项不存在或未发布」") + void notFoundOrUnpublished_envelopeParamError() throws Exception { + JsonNode noSite = envelope(mockMvc.perform( + signed("/oapi/svc/item/snapshot", "siteCode=ghost&itemCode=BG-ZX-001")) + .andExpect(status().isOk()).andReturn()); + assertThat(noSite.get("code").asInt()).isEqualTo(2); + assertThat(noSite.get("msg").asText()).contains("事项不存在或未发布"); + + JsonNode noItem = envelope(mockMvc.perform( + signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=NOT-EXIST")) + .andExpect(status().isOk()).andReturn()); + assertThat(noItem.get("code").asInt()).isEqualTo(2); + + // 未发布:下架后快照同样 code=2(办件隔离由 svhall 侧快照承担) + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + serviceItemRepository.findById(itemId).orElseThrow(IllegalStateException::new); + item.setStatus(com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum.OFFLINE.getCode()); + serviceItemRepository.saveAndFlush(item); + JsonNode offline = envelope(mockMvc.perform( + signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=BG-ZX-001")) + .andExpect(status().isOk()).andReturn()); + assertThat(offline.get("code").asInt()).isEqualTo(2); + } + + @Test + @DisplayName("正常快照:code=0 + 发布态字段(双键 siteCode/itemCode 成对返回)") + void snapshot_happyPath() throws Exception { + JsonNode data = envelope(mockMvc.perform( + signed("/oapi/svc/item/snapshot", "siteCode=demo1&itemCode=BG-ZX-001")) + .andExpect(status().isOk()).andReturn()).get("data"); + assertThat(data.get("siteCode").asText()).isEqualTo("demo1"); + assertThat(data.get("itemCode").asText()).isEqualTo("BG-ZX-001"); + assertThat(data.get("title").asText()).isEqualTo("转学办理"); + assertThat(data.get("deptName").asText()).isEqualTo("教务处"); + assertThat(data.get("handleMode").asInt()).isEqualTo(1); + assertThat(data.get("onlineFlag").asInt()).isEqualTo(1); + assertThat(data.get("contactPhone").asText()).isEqualTo("0571-88888888"); + assertThat(data.get("applyUrl").asText()).isEqualTo("https://apply.example.com/zx"); + assertThat(data.hasNonNull("publishTime")).isTrue(); + } + + @Test + @DisplayName("真实签名端到端:HmacSHA256(Base64) 三件套通过 AppAuthFilter 验签") + void realSignature_endToEnd() throws Exception { + // canonical target = path + 排序后 query(SignCanonical 口径:itemCode < siteCode) + String ts = String.valueOf(System.currentTimeMillis()); + String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1"; + String signature = SignUtil.signRequest("GET", target, ts, APP_SECRET); + // query 经 URI 模板直传(MockMvc param() 不进 getQueryString(),而验签 target 含排序 query) + MvcResult ok = mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", APP_KEY) + .header("X-Timestamp", ts) + .header("X-Signature", signature)) + .andExpect(status().isOk()).andReturn(); + JsonNode data = envelope(ok).get("data"); + assertThat(data.get("title").asText()).isEqualTo("转学办理"); + } + + @Test + @DisplayName("真实签名失败(错 secret)→ 401(AppAuthFilter 拦截,未到业务层)") + void realSignature_wrongSecretUnauthorized() throws Exception { + String ts = String.valueOf(System.currentTimeMillis()); + String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1"; + String signature = SignUtil.signRequest("GET", target, ts, "wrong-secret-1234567890"); + mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", APP_KEY) + .header("X-Timestamp", ts) + .header("X-Signature", signature)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("签名失效分支:时间戳超窗(>5min)→ 401") + void realSignature_expiredTimestampUnauthorized() throws Exception { + // 时间窗默认 5 分钟(AppAuthProperties.expireMs=300000):签名算法本身正确,仅时间戳过期 + String ts = String.valueOf(System.currentTimeMillis() - 10 * 60 * 1000L); + String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1"; + String signature = SignUtil.signRequest("GET", target, ts, APP_SECRET); + mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", APP_KEY) + .header("X-Timestamp", ts) + .header("X-Signature", signature)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("签名失效分支:未登记 appKey / 签名头缺失或畸形 → 401") + void realSignature_unknownAppKeyAndMalformedHeadersUnauthorized() throws Exception { + String ts = String.valueOf(System.currentTimeMillis()); + String target = "/oapi/svc/item/snapshot?itemCode=BG-ZX-001&siteCode=demo1"; + + // 未登记 appKey(签名用某个 secret 算过,但白名单里没有该 key) + mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", "ghost-app") + .header("X-Timestamp", ts) + .header("X-Signature", SignUtil.signRequest("GET", target, ts, APP_SECRET))) + .andExpect(status().isUnauthorized()); + + // 缺签名头 + mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", APP_KEY) + .header("X-Timestamp", ts)) + .andExpect(status().isUnauthorized()); + + // 时间戳非数字 + mockMvc.perform(get("/oapi/svc/item/snapshot?siteCode=demo1&itemCode=BG-ZX-001") + .header("X-App-Key", APP_KEY) + .header("X-Timestamp", "not-a-number") + .header("X-Signature", "whatever")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("siteCode 同名歧义(uk 为 parent+code):确定性取最早站点,不 500(设计 §1.3 行为固化)") + void siteCodeAmbiguity_resolvesDeterministically() throws Exception { + // 两个不同父站下的同名子站 code(uk_site_path 由 path 区分:pa/dupcode vs pb/dupcode)—— + // 这正是 uk_site_parent_code 允许、而 findFirstByCodeOrderByIdAsc 需要兜住的场景 + Integer parentA = manualSite("pa", "pa", 0, 1, "学校一").getId(); + Integer parentB = manualSite("pb", "pb", 0, 2, "学校二").getId(); + Integer childA = manualSite("dupcode", "pa/dupcode", parentA, 1, "学校一子站").getId(); + Integer childB = manualSite("dupcode", "pb/dupcode", parentB, 2, "学校二子站").getId(); + assertThat(childB).isGreaterThan(childA); + + serviceItemRepository.saveAndFlush( + persistSvcItem(childA, "DUP-1", "先建子站事项", ServiceItemStatusEnum.PUBLISHED)); + serviceItemRepository.saveAndFlush( + persistSvcItem(childB, "DUP-1", "后建子站事项", ServiceItemStatusEnum.PUBLISHED)); + + // 修复前:findByCode 命中两条 → IncorrectResultSizeDataAccessException(500) + JsonNode data = envelope(mockMvc.perform( + signed("/oapi/svc/item/snapshot", "siteCode=dupcode&itemCode=DUP-1")) + .andExpect(status().isOk()).andReturn()).get("data"); + assertThat(data.get("title").asText()).as("取 id 最小的站点(最早创建)").isEqualTo("先建子站事项"); + } +} diff --git a/src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java b/src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java new file mode 100644 index 0000000..e678f46 --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/SvcItemPortalIT.java @@ -0,0 +1,299 @@ +package com.yunhai.cms.svcitem; + +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 门户服务中心/事项指南集成测试(事项库详细设计 §5/§6 + M0 回退 IT 0.3): + * 路由双变体、301 兼容、404 语义、筛选与分页 URL 预生成(C-2)、富文本补基准、 + * applyUrl 三态、可选模板整页回退(缺模板主题的资产段退 default)。 + */ +@DisplayName("SvcItemPortal - 门户公开层(L2)") +class SvcItemPortalIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + @Autowired + private com.yunhai.cms.svcitem.service.SvcItemQuery svcItemQuery; + + private Integer siteId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + persistSvcDict(siteId, "object", "person", "个人", 1); + persistSvcDict(siteId, "object", "legal", "法人", 1); + persistSvcDict(siteId, "category", "jw", "教务服务", 1); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private Integer publishItem(String code, String title, String objectKey, String categoryKey) { + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + persistSvcItem(siteId, code, title, ServiceItemStatusEnum.PUBLISHED); + item.setObjectKey(objectKey); + item.setCategoryKey(categoryKey); + item.setDeptName("教务处"); + return serviceItemRepository.saveAndFlush(item).getId(); + } + + private String body(MvcResult result) throws java.io.UnsupportedEncodingException { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + // ===== 路由(§5.1:单段 + 嵌套双变体、301、404)===== + + @Test + @DisplayName("路由双变体:/portal/{code}/service 与 /portal/{p}/{s}/service 均 200") + void routes_singleAndNested() throws Exception { + publishItem("S1", "转学办理", "person", "jw"); + assertThat(body(mockMvc.perform(get("/portal/demo1/service")) + .andExpect(status().isOk()).andReturn())) + .contains("服务中心").contains("转学办理"); + + Integer parent = persistSite("schx", 1, "主站").getId(); + Integer childId = persistSite("child", 1, "子站", parent).getId(); + com.yunhai.cms.svcitem.entity.CmsServiceItem child = + persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED); + serviceItemRepository.saveAndFlush(child); + assertThat(body(mockMvc.perform(get("/portal/schx/child/service")) + .andExpect(status().isOk()).andReturn())) + .contains("服务中心").contains("子站事项"); + } + + @Test + @DisplayName("旧单段 code 访问子站服务页 301 到嵌套形态(保留子路径与 query)") + void routes_legacySingleSegmentRedirects301() throws Exception { + Integer parent = persistSite("schy", 1, "主站Y").getId(); + persistSite("childy", 1, "子站Y", parent); + + // query 串需走 URI 模板直传(MockMvc param() 不进 getQueryString(),真实请求无此差异) + mockMvc.perform(get("/portal/childy/service?obj=person")) + .andExpect(status().isMovedPermanently()) + .andExpect(header().string("Location", "/portal/schy/childy/service?obj=person")); + } + + @Test + @DisplayName("指南页路由:存在且已发布 200;不存在/草稿 404;响应头带 Cache-Control") + void guideItem_statusSemantics() throws Exception { + Integer id = publishItem("S1", "转学办理", "person", "jw"); + mockMvc.perform(get("/portal/demo1/sv/{id}", id)) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=300")); + + mockMvc.perform(get("/portal/demo1/sv/{id}", 999999)) + .andExpect(status().isNotFound()); + + Integer draft = persistSvcItem(siteId, "S2", "草稿事项", ServiceItemStatusEnum.DRAFT).getId(); + mockMvc.perform(get("/portal/demo1/sv/{id}", draft)) + .andExpect(status().isNotFound()); + } + + // ===== M0 整页回退(§4.2/§4.3:缺可选模板的主题整页退 default,资产段同步退)===== + + @Test + @DisplayName("M0: 无 service.html 的主题访问服务中心 → 200 且资产 URL 主题段为 default(防 default HTML 配他家 CSS)") + void fallback_themeWithoutServiceTemplateFallsBackWholePage() throws Exception { + publishItem("S1", "转学办理", "person", "jw"); + // t-demo-red 为测试夹具包:注册成功(必备四页齐全)但无 service.html/guide.html + com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); + site.setTheme("t-demo-red"); + siteRepository.saveAndFlush(site); + clearCaches(); + + String html = body(mockMvc.perform(get("/portal/demo1/service")) + .andExpect(status().isOk()).andReturn()); + assertThat(html).contains("服务中心").contains("转学办理"); + assertThat(html).contains("/assets/default/css/main.css"); + assertThat(html).doesNotContain("/assets/t-demo-red/"); + + Integer id = serviceItemRepository.findBySiteIdAndItemCode(siteId, "S1") + .orElseThrow(IllegalStateException::new).getId(); + String guideHtml = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id)) + .andExpect(status().isOk()).andReturn()); + assertThat(guideHtml).contains("/assets/default/css/main.css"); + assertThat(guideHtml).doesNotContain("/assets/t-demo-red/"); + } + + // ===== 列表筛选与分页 URL 预生成(C-2,§5.4)===== + + @Test + @DisplayName("三维筛选:计数正确、facet URL 后端预生成(勾选带参/取消去参),模板零拼 query") + void serviceList_filtersAndFacetUrls() throws Exception { + publishItem("S1", "转学办理", "person", "jw"); + publishItem("S2", "休学办理", "person", "jw"); + publishItem("S3", "法人年检", "legal", "jw"); + + String html = body(mockMvc.perform(get("/portal/demo1/service")) + .andExpect(status().isOk()).andReturn()); + assertThat(html).contains("个人").contains("法人").contains("教务服务"); + // 未勾选态 facet URL:带本维参数(page 归 1,无 page 参数) + assertThat(html).contains("/portal/demo1/service?obj=person\">"); + assertThat(html).contains("/portal/demo1/service?obj=legal\">"); + + // 勾选 obj=person 后:仅个人事项;取消链接回到无参基址;cat facet URL 保留 obj 维 + String filtered = body(mockMvc.perform(get("/portal/demo1/service") + .param("obj", "person")) + .andExpect(status().isOk()).andReturn()); + assertThat(filtered).contains("转学办理").contains("休学办理"); + assertThat(filtered).doesNotContain("法人年检"); + assertThat(filtered).contains("/portal/demo1/service\">"); + assertThat(filtered).contains("/portal/demo1/service?obj=person&cat=jw\">"); + assertThat(filtered).contains("清除筛选"); + } + + @Test + @DisplayName("分页 URL 预生成:携带当前筛选与关键词(编码),第 2 页起带 page 参数") + void serviceList_paginationUrlsCarryFilters() throws Exception { + for (int i = 1; i <= 12; i++) { + publishItem("S" + i, "事项" + String.format("%02d", i), "person", "jw"); + } + String html = body(mockMvc.perform(get("/portal/demo1/service") + .param("obj", "person").param("q", "事项")) + .andExpect(status().isOk()).andReturn()); + // 第 2 页链接保留 obj + 编码后的 q,并追加 page=2(链接组装顺序 = obj, q, page) + assertThat(html).contains("obj=person&q=%E4%BA%8B%E9%A1%B9&page=2"); + // 第 1 页(active)不带 page 参数 + assertThat(html).doesNotContain("page=1\""); + } + + @Test + @DisplayName("空结果与空站点:优雅空态不 500") + void serviceList_emptyState() throws Exception { + String html = body(mockMvc.perform(get("/portal/demo1/service")) + .andExpect(status().isOk()).andReturn()); + assertThat(html).contains("暂未上架"); + } + + @Test + @DisplayName("关键词命中标题与摘要;canonical 预生成;嵌套指南页路由可用") + void serviceList_keywordCanonicalAndNestedGuide() throws Exception { + Integer id = publishItem("S1", "转学办理", "person", "jw"); + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new); + item.setSummary("适用于在校学生转学申请"); + serviceItemRepository.saveAndFlush(item); + publishItem("S2", "休学办理", "person", "jw"); + + String html = body(mockMvc.perform(get("/portal/demo1/service").param("q", "在校学生")) + .andExpect(status().isOk()).andReturn()); + assertThat(html).as("关键词命中摘要").contains("转学办理").doesNotContain("休学办理"); + assertThat(html).contains("rel=\"canonical\"").contains("/portal/demo1/service"); + + // 嵌套指南页路由(设计 §5.1 成对路由:此前只测了嵌套列表页) + Integer parent = persistSite("schg", 1, "主站G").getId(); + Integer childId = persistSite("childg", 1, "子站G", parent).getId(); + com.yunhai.cms.svcitem.entity.CmsServiceItem childItem = + persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED); + childItem.setProcessHtml("

    子站流程

    "); + serviceItemRepository.saveAndFlush(childItem); + assertThat(body(mockMvc.perform(get("/portal/schg/childg/sv/{id}", childItem.getId())) + .andExpect(status().isOk()).andReturn())) + .contains("子站事项").contains("子站流程").contains("rel=\"canonical\""); + } + + @Test + @DisplayName("指南页浏览量异步累加:HTTP 访问触发 + 计数器原子自增(不承诺即时,轮询等待)") + void guideItem_viewCountIncrementsAsync() throws Exception { + Integer id = publishItem("S1", "转学办理", "person", "jw"); + // ① HTTP 路径:指南页渲染成功并触发一次计数(异步) + mockMvc.perform(get("/portal/demo1/sv/{id}", id)).andExpect(status().isOk()); + // ② 再补 4 次显式计数:把"异步累加链路是否工作"与"MockMvc 渲染耗时"解耦 + for (int i = 0; i < 4; i++) { + svcItemQuery.countView(id); + } + + int expected = 5; + int viewCount = 0; + // 轮询预算 30s:异步任务是 fire-and-forget(设计不承诺即时),且满载环境下取连接可能等待 + // Hikari 默认 connectionTimeout(30s),故预算需覆盖该量级,避免把连接竞争误判为功能缺陷 + for (int i = 0; i < 300; i++) { + viewCount = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new) + .getViewCount(); + if (viewCount >= expected) { + break; + } + java.lang.Thread.sleep(100); + } + assertThat(viewCount).as("1 次指南页访问 + 4 次显式计数应累加为 %s", expected) + .isGreaterThanOrEqualTo(expected); + } + + // ===== 指南页装配(§5.3)===== + + @Test + @DisplayName("指南页:富文本补上传基准、附件下载、关联栏目 url.category、关联事项、手填外链按钮") + void guide_modelAssembly() throws Exception { + Integer faqCat = persistCategory(siteId, 0, "FAQ", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); + com.yunhai.cms.svcitem.entity.CmsServiceItem item = + serviceItemRepository.findById(publishItem("S1", "转学办理", "person", "jw")).orElseThrow(IllegalStateException::new); + item.setProcessHtml("

    "); + item.setFaqCategoryId(faqCat); + item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}"); + serviceItemRepository.saveAndFlush(item); + com.yunhai.cms.entity.CmsAttachment att = persistAttachment(siteId, "svc_item", "申请表.pdf"); + att.setRefId(item.getId()); + attachmentRepository.saveAndFlush(att); + publishItem("S2", "休学办理", "person", "jw"); // 同类别关联事项 + + String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", item.getId())) + .andExpect(status().isOk()).andReturn()); + // 上传地址渲染期补基准(B-1 口径:MockMvc 请求派生基准 = http://localhost) + assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/a.jpg\""); + // 附件与下载链接(webUrl 相对路径经 url.upload 补基准) + assertThat(html).contains("申请表.pdf"); + assertThat(html).contains("http://localhost/cms/upload/cms/" + siteId + "/"); + // 关联栏目链接经 url.category 生成 + assertThat(html).contains("/portal/demo1/c/" + faqCat); + // 关联事项(同类别已发布,排除自身) + assertThat(html).contains("休学办理"); + // 我要办理三态①:手填外链优先 + assertThat(html).contains("https://apply.example.com/zx"); + assertThat(html).contains("我要办理"); + } + + @Test + @DisplayName("我要办理三态:② svc_hall_base 生成 svhall 发起页 URL;③ 都没有 → 不渲染按钮") + void applyUrl_generatedAndAbsentStates() throws Exception { + // ② 未手填且站点配 svc_hall_base → 生成(尾斜杠归一) + com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); + site.setConfigJson("{\"svc_hall_base\":\"https://svhall.example.com/\"}"); + siteRepository.saveAndFlush(site); + clearCaches(); + Integer id = publishItem("S1", "转学办理", "person", "jw"); + String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id)) + .andExpect(status().isOk()).andReturn()); + assertThat(html).contains("我要办理"); + assertThat(html).contains("https://svhall.example.com/#/apply?siteCode=demo1&itemCode=S1"); + + // ③ 都没有 → 按钮不渲染 + com.yunhai.cms.entity.CmsSite bare = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); + bare.setConfigJson(null); + siteRepository.saveAndFlush(bare); + clearCaches(); + Integer id2 = publishItem("S2", "休学办理", "person", "jw"); + String html2 = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id2)) + .andExpect(status().isOk()).andReturn()); + // 以按钮类名断言(模板源码注释含"我要办理"字样会随注释输出) + assertThat(html2).doesNotContain("svc-apply-btn"); + assertThat(html2).doesNotContain("svhall.example.com"); + } +} diff --git a/src/test/java/com/yunhai/cms/svcitem/SvcItemServiceIT.java b/src/test/java/com/yunhai/cms/svcitem/SvcItemServiceIT.java new file mode 100644 index 0000000..bddb54b --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/SvcItemServiceIT.java @@ -0,0 +1,489 @@ +package com.yunhai.cms.svcitem; + +import com.yunhai.cms.entity.CmsAttachment; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.model.dto.DeleteRequest; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; +import com.yunhai.cms.svcitem.model.dto.IdSortItem; +import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest; +import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery; +import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters; +import com.yunhai.cms.svcitem.model.vo.PortalServiceItem; +import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo; +import com.yunhai.cms.svcitem.model.vo.SvcItemPageItem; +import com.yunhai.cms.svcitem.service.SvcItemQuery; +import com.yunhai.cms.svcitem.service.impl.SvcItemService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 事项管理集成测试(事项库详细设计 §3.3 保存校验清单 / 状态机 / 权限 / 附件回收 / 缓存失效链)。 + */ +@DisplayName("SvcItemService - 事项管理(L1 事项库)") +class SvcItemServiceIT extends CmsIntegrationTestBase { + + @Autowired + private SvcItemQuery svcItemQuery; + @Autowired + private SvcItemService svcItemService; + + private Integer siteId; + private Integer otherSiteId; + + @BeforeEach + void seed() { + purgeTables(); + siteId = persistSite("demo1", 1, "演示站点一").getId(); + otherSiteId = persistSite("demo2", 2, "演示站点二").getId(); + persistSvcDict(siteId, "object", "person", "个人", 1); + persistSvcDict(siteId, "object", "legal", "法人", 1); + persistSvcDict(siteId, "category", "jw", "教务服务", 1); + persistSvcDict(siteId, "category", "logistics", "后勤服务", 1); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private SvcItemSaveRequest request() { + SvcItemSaveRequest req = new SvcItemSaveRequest(); + req.setSiteId(siteId); + req.setItemCode("BG-ZX-001"); + req.setTitle("转学办理"); + req.setSummary("适用于在校学生转学申请"); + return req; + } + + // ===== 保存与校验规则(§3.3 清单)===== + + @Test + @DisplayName("保存草稿:富文本 jsoup 清洗(script 剥除)、apply_url 落 ext_json、回显一致") + void save_cleansHtmlAndStoresApplyUrl() { + SvcItemSaveRequest req = request(); + req.setObjectKey("person"); + req.setCategoryKey("jw"); + req.setProcessHtml("

    流程一

    流程二

    "); + req.setApplyUrl("https://apply.example.com/zx"); + + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id); + assertThat(vo.getStatus()).isEqualTo(ServiceItemStatusEnum.DRAFT.getCode()); + assertThat(vo.getProcessHtml()).contains("流程一").contains("流程二") + .doesNotContain(" page = svcItemQuery.adminPage(TestUsers.superAdmin(), + queryOf(null, null)); + assertThat(page.get("total")).isEqualTo(1L); + @SuppressWarnings("unchecked") + List list = (List) page.get("list"); + assertThat(list.get(0).getObjectName()).isEqualTo("个人"); + assertThat(list.get(0).getCategoryName()).isEqualTo("教务服务"); + } + + @Test + @DisplayName("事项编码站内唯一:新建撞码/编辑与他项撞码均拒绝(uk_svc_item_code 语义)") + void save_duplicateItemCodeRejected() { + Integer first = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + // 新建撞码 + SvcItemSaveRequest dup = request(); + dup.setTitle("另一个转学"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), dup)) + .isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在"); + // 编辑改为他项编码 + SvcItemSaveRequest edit = request(); + edit.setId(first); + edit.setItemCode("BG-ZX-002"); + svcItemQuery.adminSave(TestUsers.superAdmin(), edit); + SvcItemSaveRequest clash = request(); + clash.setId(first); + clash.setItemCode("BG-ZX-001"); + // 站内已有 BG-ZX-001?——已改名,此处验证编辑撞“现存他码”:先再建一条 + SvcItemSaveRequest second = request(); + second.setItemCode("BG-ZX-003"); + second.setTitle("第二条"); + Integer secondId = svcItemQuery.adminSave(TestUsers.superAdmin(), second); + SvcItemSaveRequest clash2 = request(); + clash2.setId(secondId); + clash2.setItemCode("BG-ZX-002"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), clash2)) + .isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在"); + // 同码不同站互不冲突(site 隔离) + SvcItemSaveRequest otherSite = request(); + otherSite.setSiteId(otherSiteId); + otherSite.setItemCode("BG-ZX-001"); + assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), otherSite)).isNotNull(); + } + + @Test + @DisplayName("字典键校验:不存在的键拒绝;已停用字典拒绝;空串视同未填") + void save_dictKeyMustBeEnabled() { + SvcItemSaveRequest req = request(); + req.setObjectKey("ghost"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class).hasMessageContaining("服务对象不存在"); + req.setObjectKey("person"); + + persistSvcDict(siteId, "category", "stopped", "已停用类别", 0); + req.setCategoryKey("stopped"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class).hasMessageContaining("已停用"); + + req.setCategoryKey(""); + assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull(); + } + + @Test + @DisplayName("apply_url 格式校验:非 http(s) 完整地址拒绝;空值不落 ext_json") + void save_applyUrlMustBeHttp() { + SvcItemSaveRequest req = request(); + req.setApplyUrl("javascript:alert(1)"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class).hasMessageContaining("http"); + req.setApplyUrl("www.example.com"); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class).hasMessageContaining("http"); + req.setApplyUrl(null); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + assertThat(serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new) + .getExtJson()).isNull(); + } + + @Test + @DisplayName("关联栏目校验:跨站栏目 id 拒绝(防跨站注入)") + void save_crossSiteCategoryRejected() { + Integer otherCat = persistCategory(otherSiteId, 0, "他站栏目", CategoryTypeEnum.LIST).getId(); + SvcItemSaveRequest req = request(); + req.setFaqCategoryId(otherCat); + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于本站点"); + Integer ownCat = persistCategory(siteId, 0, "本站栏目", CategoryTypeEnum.LIST).getId(); + req.setFaqCategoryId(ownCat); + assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull(); + } + + @Test + @DisplayName("权限:子站/他校管理员 FORBIDDEN(requireManageable),本站通过") + void permission_schoolScoped() { + assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.schoolUser(2), request())) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + SvcItemSaveRequest own = request(); + own.setSiteId(otherSiteId); + assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull(); + } + + // ===== 状态机(§2.4)===== + + @Test + @DisplayName("状态机:草稿→发布(门户可见)→下架(门户 404)→再发布(又可见)") + void stateMachine_publishOfflineRepublish() { + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + assertThat(svcItemQuery.portalItem(id, siteId)).as("草稿不可见(null 哨兵)").isNull(); + + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id); + PortalServiceItem published = svcItemQuery.portalItem(id, siteId); + assertThat(published).as("发布后事件已失效 null 哨兵缓存").isNotNull(); + assertThat(published.getPublishTime()).isNotNull(); + + svcItemQuery.adminOffline(TestUsers.superAdmin(), siteId, id); + assertThat(svcItemQuery.portalItem(id, siteId)).as("下架后缓存已失效且返回 null").isNull(); + + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id); + assertThat(svcItemQuery.portalItem(id, siteId)).isNotNull(); + } + + @Test + @DisplayName("编辑已发布事项:事件失效指南页缓存,新内容立即可见") + void editPublishedItem_evictsCache() { + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id); + assertThat(svcItemQuery.portalItem(id, siteId).getTitle()).isEqualTo("转学办理"); + + SvcItemSaveRequest edit = request(); + edit.setId(id); + edit.setTitle("转学办理(2026 版)"); + svcItemQuery.adminSave(TestUsers.superAdmin(), edit); + assertThat(svcItemQuery.portalItem(id, siteId).getTitle()) + .as("保存后 AFTER_COMMIT 事件已 evict itemId").isEqualTo("转学办理(2026 版)"); + } + + // ===== 附件回收(§3.5)===== + + @Test + @DisplayName("附件绑定与回收:attachmentIds 全量重绑;删除事项释放 svc_item 引用") + void attachments_bindAndRelease() { + CmsAttachment att1 = persistAttachment(siteId, "svc_item", "材料一.pdf"); + CmsAttachment att2 = persistAttachment(siteId, "svc_item", "材料二.pdf"); + SvcItemSaveRequest req = request(); + req.setAttachmentIds(Arrays.asList(att1.getId(), att2.getId())); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + assertThat(svcItemQuery.attachments(id)).hasSize(2); + + // 重绑:仅保留 att1 → att2 释放为未使用 + SvcItemSaveRequest edit = request(); + edit.setId(id); + edit.setAttachmentIds(Collections.singletonList(att1.getId())); + svcItemQuery.adminSave(TestUsers.superAdmin(), edit); + assertThat(svcItemQuery.attachments(id)).extracting("fileName") + .containsExactly("材料一.pdf"); + assertThat(attachmentRepository.findById(att2.getId()).orElseThrow(IllegalStateException::new) + .getRefId()).as("未在列表中的附件回到未使用").isNull(); + + // 删除事项:svc_item 引用整体释放 + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(id); + svcItemQuery.adminDelete(TestUsers.superAdmin(), del); + assertThat(svcItemQuery.attachments(id)).isEmpty(); + assertThat(attachmentRepository.findById(att1.getId()).orElseThrow(IllegalStateException::new) + .getRefType()).isNull(); + assertThat(serviceItemRepository.findById(id)).isEmpty(); + } + + // ===== 附件引用:内容派生重绑(审计 D1/D5 修复锚点)===== + + /** 造附件:webUrl 走 FILE_WEB_PATH(/cms/upload/) 前缀,与线上上传产物同形 */ + private CmsAttachment attachment(String fileName, Integer ownerItemId) { + CmsAttachment att = new CmsAttachment(); + att.setSiteId(siteId); + att.setFileName(fileName); + att.setFilePath("cms/" + siteId + "/202609/" + fileName.hashCode() + ".png"); + att.setWebUrl("/cms/upload/" + att.getFilePath()); + att.setFileSize(2048L); + att.setMimeType("image/png"); + if (ownerItemId != null) { + att.setRefType("svc_item"); + att.setRefId(ownerItemId); + } + return attachmentRepository.saveAndFlush(att); + } + + private CmsAttachment reload(CmsAttachment att) { + return attachmentRepository.findById(att.getId()).orElseThrow(IllegalStateException::new); + } + + @Test + @DisplayName("封面与正文内嵌图按内容归属;编辑二次保存不丢引用;清空即解绑(原 D1 缺陷)") + void attachments_contentDerivedCoverAndHtml() { + CmsAttachment cover = attachment("封面.png", null); + CmsAttachment inline = attachment("流程图.png", null); + + SvcItemSaveRequest req = request(); + req.setCoverImage(cover.getWebUrl()); + req.setProcessHtml("

    流程

    \"流程图\""); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + + assertThat(reload(cover).getRefType()).as("封面归属事项内容资产场景").isEqualTo("svc_item_asset"); + assertThat(reload(cover).getRefId()).isEqualTo(id); + assertThat(reload(inline).getRefType()).isEqualTo("svc_item_asset"); + assertThat(reload(inline).getRefId()).isEqualTo(id); + assertThat(svcItemQuery.attachments(id)).as("内容资产不进指南附件列表").isEmpty(); + + // 二次保存(内容不变、未带 attachmentIds):引用必须存活——修复前会被 release 清掉且永不恢复 + SvcItemSaveRequest edit = request(); + edit.setId(id); + edit.setCoverImage(cover.getWebUrl()); + edit.setProcessHtml(req.getProcessHtml()); + svcItemQuery.adminSave(TestUsers.superAdmin(), edit); + assertThat(reload(cover).getRefId()).as("二次保存后封面仍归属本事项").isEqualTo(id); + assertThat(reload(inline).getRefId()).as("二次保存后内嵌图仍归属本事项").isEqualTo(id); + + // 清空封面并移除正文图:两者回到未使用(可被附件管理页清理) + SvcItemSaveRequest cleared = request(); + cleared.setId(id); + cleared.setProcessHtml("

    流程

    "); + svcItemQuery.adminSave(TestUsers.superAdmin(), cleared); + assertThat(reload(cover).getRefType()).isNull(); + assertThat(reload(cover).getRefId()).isNull(); + assertThat(reload(inline).getRefType()).isNull(); + } + + @Test + @DisplayName("附件归属校验:已被他事项占用的不抢走、跨站附件跳过、空列表幂等") + void attachments_ownershipGuards() { + Integer otherItem = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + CmsAttachment stolen = attachment("他项材料.pdf", otherItem); + CmsAttachment crossSite = new CmsAttachment(); + crossSite.setSiteId(otherSiteId); + crossSite.setFileName("他站材料.pdf"); + crossSite.setFilePath("cms/" + otherSiteId + "/202609/x.pdf"); + crossSite.setWebUrl("/cms/upload/cms/" + otherSiteId + "/202609/x.pdf"); + crossSite = attachmentRepository.saveAndFlush(crossSite); + + SvcItemSaveRequest req = request(); + req.setItemCode("BG-ZX-002"); + req.setTitle("补办学生证"); + req.setAttachmentIds(Arrays.asList(stolen.getId(), crossSite.getId())); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + + assertThat(reload(stolen).getRefId()).as("他事项占用的引用不被改写").isEqualTo(otherItem); + assertThat(reload(crossSite).getRefType()).as("跨站附件不绑定").isNull(); + assertThat(svcItemQuery.attachments(id)).isEmpty(); + + // 空列表幂等:以本事项自身编码(BG-ZX-002)重复保存两次均不抛异常,附件仍为空 + SvcItemSaveRequest again = request(); + again.setId(id); + again.setItemCode("BG-ZX-002"); + again.setTitle("补办学生证"); + again.setAttachmentIds(null); + svcItemQuery.adminSave(TestUsers.superAdmin(), again); + svcItemQuery.adminSave(TestUsers.superAdmin(), again); + assertThat(svcItemQuery.attachments(id)).isEmpty(); + } + + @Test + @DisplayName("权限:管理端全部方法对非本站管理员均 FORBIDDEN") + void permission_allAdminMethodsScoped() { + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + BoaUser intruder = TestUsers.schoolUser(999); + + assertThatThrownBy(() -> svcItemQuery.adminSave(intruder, request())) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminPage(intruder, queryOf(null, null))) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminGet(intruder, siteId, id)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminPublish(intruder, siteId, id)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminOffline(intruder, siteId, id)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminDelete(intruder, deleteRequestOf(id))) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminSort(intruder, siteId, + Collections.singletonList(sortItemOf(id, 1)))) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.adminSelList(intruder, siteId)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.itemAttachments(intruder, siteId, id)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.deptTree(intruder, siteId)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + assertThatThrownBy(() -> svcItemQuery.syncDeptName(intruder, siteId)) + .isInstanceOf(BizException.class).hasMessageContaining("无权"); + + // 本校(otherSiteId 的 schId=2)通过:确认拦截来自站点归属而非"用户无角色" + SvcItemSaveRequest own = request(); + own.setSiteId(otherSiteId); + assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull(); + } + + private DeleteRequest deleteRequestOf(Integer id) { + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(id); + return del; + } + + private IdSortItem sortItemOf(Integer id, Integer sortNo) { + IdSortItem item = new IdSortItem(); + item.setId(id); + item.setSortNo(sortNo); + return item; + } + + // ===== 门户筛选计数与缓存(§3.6/§5.2)===== + + @Test + @DisplayName("portalFilters:仅统计已发布事项;发布后 filters 缓存被事件失效并更新计数") + void portalFilters_countsAndCacheInvalidation() { + persistSvcItem(siteId, "S1", "事项一", ServiceItemStatusEnum.PUBLISHED); + persistSvcItem(siteId, "S2", "事项二", ServiceItemStatusEnum.PUBLISHED); + persistSvcItem(siteId, "S3", "事项草稿", ServiceItemStatusEnum.DRAFT); + // 直接落库种子无字典键 → 先补:用 adminSave 造带键事项更贴近真实 + PortalServiceFilters filters = svcItemQuery.portalFilters(siteId); + assertThat(filters.getObjects()).as("种子无字典键,对象组为空").isEmpty(); + + SvcItemSaveRequest req = request(); + req.setObjectKey("person"); + req.setCategoryKey("jw"); + Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req); + // 草稿不进筛选计数(filters 缓存此时是旧值;草稿保存也已发事件失效,重查仍应无此键) + assertThat(svcItemQuery.portalFilters(siteId).getObjects()).isEmpty(); + + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id); + PortalServiceFilters after = svcItemQuery.portalFilters(siteId); + assertThat(after.getObjects()).extracting("name").containsExactly("个人"); + assertThat(after.getObjects()).extracting("count").containsExactly(1L); + } + + // ===== 查询面(§3.3 page / §5.2 portalPage)===== + + private SvcItemPageQuery queryOf(Integer status, String keyword) { + SvcItemPageQuery query = new SvcItemPageQuery(); + query.setSiteId(siteId); + query.setStatus(status); + query.setKeyword(keyword); + return query; + } + + @Test + @DisplayName("adminPage:关键字命中标题/编码,状态过滤,pageSize 上限 100") + void adminPage_filters() { + Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + SvcItemSaveRequest req2 = request(); + req2.setItemCode("BG-HQ-100"); + req2.setTitle("宿舍报修"); + svcItemQuery.adminSave(TestUsers.superAdmin(), req2); + + assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "报修")).get("total")) + .isEqualTo(1L); + assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "BG-ZX")).get("total")) + .as("关键字也命中编码").isEqualTo(1L); + + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a); + assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(1, null)).get("total")) + .isEqualTo(1L); + assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(0, null)).get("total")) + .isEqualTo(1L); + } + + @Test + @DisplayName("排序:sort_no 升序为列表第一优先级;adminSort 落库") + void sort_sortNoFirstPriority() { + Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + SvcItemSaveRequest req2 = request(); + req2.setItemCode("BG-HQ-100"); + req2.setTitle("宿舍报修"); + Integer b = svcItemQuery.adminSave(TestUsers.superAdmin(), req2); + + com.yunhai.cms.svcitem.model.dto.IdSortItem s1 = new com.yunhai.cms.svcitem.model.dto.IdSortItem(); + s1.setId(b); + s1.setSortNo(1); + com.yunhai.cms.svcitem.model.dto.IdSortItem s2 = new com.yunhai.cms.svcitem.model.dto.IdSortItem(); + s2.setId(a); + s2.setSortNo(9); + svcItemService.adminSort(TestUsers.superAdmin(), siteId, Arrays.asList(s1, s2)); + + Map page = svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, null)); + @SuppressWarnings("unchecked") + List list = (List) page.get("list"); + assertThat(list).extracting("title").containsExactly("宿舍报修", "转学办理"); + } + + @Test + @DisplayName("选择器 selList:全状态返回精简行") + void selList_allStatuses() { + Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request()); + svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a); + SvcItemSaveRequest req2 = request(); + req2.setItemCode("BG-HQ-100"); + req2.setTitle("宿舍报修"); + svcItemQuery.adminSave(TestUsers.superAdmin(), req2); + assertThat(svcItemQuery.adminSelList(TestUsers.superAdmin(), siteId)).hasSize(2); + } +} diff --git a/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java index d81ba1f..5aae94f 100644 --- a/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java +++ b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java @@ -214,6 +214,43 @@ class PortalUrlHelperTest { .isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/assets/default/css/main.css"); } + // ===== 服务中心(事项库 M0,详细设计 §5.4:模板不拼 query,URL 由后端预生成)===== + + @Test + @DisplayName("serviceList/serviceItem:基址与指南页路径拼接") + void serviceList_andServiceItem_paths() { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default"); + assertThat(url.serviceList()).isEqualTo("https://www.xxx.edu.cn/service"); + assertThat(url.serviceItem(9)).isEqualTo("https://www.xxx.edu.cn/sv/9"); + // 嵌套 sitePath 形态同既有路由 + PortalUrlHelper nested = new PortalUrlHelper( + "https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy", "sch-3/jdxy", "default"); + assertThat(nested.serviceList()).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/service"); + assertThat(nested.serviceItem(12)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/sv/12"); + } + + @Test + @DisplayName("serviceList(Map):null/空串值跳过、中文 URL 编码、& 连接") + void serviceList_mapSkipsBlankAndEncodes() throws Exception { + PortalUrlHelper url = new PortalUrlHelper("https://www.xxx.edu.cn", "demo", "default"); + java.util.Map query = new java.util.LinkedHashMap<>(); + query.put("obj", "个人"); + query.put("cat", null); + query.put("dept", ""); + query.put("q", "转 学"); + query.put("page", 2); + assertThat(url.serviceList(query)).isEqualTo( + "https://www.xxx.edu.cn/service?obj=" + java.net.URLEncoder.encode("个人", "UTF-8") + + "&q=" + java.net.URLEncoder.encode("转 学", "UTF-8") + "&page=2"); + // 全空值 = 纯基址(不带尾问号) + java.util.Map blanks = new java.util.LinkedHashMap<>(); + blanks.put("obj", null); + blanks.put("q", ""); + assertThat(url.serviceList(blanks)).isEqualTo("https://www.xxx.edu.cn/service"); + assertThat(url.serviceList(null)).isEqualTo("https://www.xxx.edu.cn/service"); + assertThat(url.serviceList(new java.util.HashMap<>())).isEqualTo("https://www.xxx.edu.cn/service"); + } + // ===== W4 嵌套路径(方案 §8.3 T-W4-11/12:构造参语义 code→sitePath)===== @Test diff --git a/src/test/resources/schema-cms-test.sql b/src/test/resources/schema-cms-test.sql index 198fb3c..ec29281 100644 --- a/src/test/resources/schema-cms-test.sql +++ b/src/test/resources/schema-cms-test.sql @@ -4,6 +4,8 @@ -- 2026-09-05 起本机为 MySQL 8.0.45,JSON 列恢复原生类型(与 cms_init.sql / 实体 columnDefinition 一致) -- ============================================================ +DROP TABLE IF EXISTS `cms_service_item`; +DROP TABLE IF EXISTS `cms_service_dict`; DROP TABLE IF EXISTS `cms_attachment`; DROP TABLE IF EXISTS `cms_article`; DROP TABLE IF EXISTS `cms_category`; @@ -137,3 +139,64 @@ CREATE TABLE `cms_link` ( PRIMARY KEY (`id`), KEY `idx_link` (`site_id`, `group_name`, `sort_no`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接'; + +CREATE TABLE `cms_service_item` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一)', + `title` VARCHAR(200) NOT NULL COMMENT '事项名称', + `summary` VARCHAR(500) NULL COMMENT '一句话简介', + `object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key)', + `category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key)', + `dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)', + `dept_name` VARCHAR(128) NULL COMMENT '部门名快照', + `handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下', + `online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否', + `handle_place` VARCHAR(200) NULL COMMENT '办理地点', + `handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本)', + `promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)', + `contact_phone` VARCHAR(64) NULL COMMENT '咨询电话', + `supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话', + `process_html` LONGTEXT NULL COMMENT '办理流程 HTML', + `material_html` LONGTEXT NULL COMMENT '所需材料 HTML', + `notice_html` LONGTEXT NULL COMMENT '注意事项 HTML', + `faq_category_id` INT NULL COMMENT '关联栏目:常见问题', + `notice_category_id` INT NULL COMMENT '关联栏目:公告信息', + `rule_category_id` INT NULL COMMENT '关联栏目:文件规定', + `cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=草稿 1=已发布 2=已下架', + `publish_time` DATETIME NULL COMMENT '发布时间', + `offline_time` DATETIME NULL COMMENT '下架时间', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量', + `ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`), + KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`), + KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`), + KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`), + KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`), + KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)'; + +CREATE TABLE `cms_service_dict` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_id` INT NOT NULL COMMENT '所属站点', + `dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object/category', + `dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一)', + `dict_name` VARCHAR(128) NOT NULL COMMENT '展示名', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0=停用 1=启用', + `ext_json` JSON NULL COMMENT '预留扩展', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`), + KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';