落地(依据《事项库与办事大厅详细设计 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 两跑幂等(含缺索引自动补建)
This commit is contained in:
+13
-9
@@ -1,13 +1,13 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
|
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
|
||||||
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
|
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
|
||||||
-- 范围:cms_* 六表全空(站点/栏目/文章/轮播/友链/附件),AUTO_INCREMENT 归位
|
-- 范围:cms_* 八表全空(站点/栏目/文章/轮播/友链/附件/事项/事项字典),AUTO_INCREMENT 归位
|
||||||
-- 不动的东西:
|
-- 不动的东西:
|
||||||
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
|
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
|
||||||
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
|
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
|
||||||
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
|
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
|
||||||
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
|
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
|
||||||
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"六表为空",本脚本正好满足)。
|
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"各表为空",本脚本正好满足)。
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
@@ -18,17 +18,21 @@ TRUNCATE TABLE cms_banner;
|
|||||||
TRUNCATE TABLE cms_link;
|
TRUNCATE TABLE cms_link;
|
||||||
TRUNCATE TABLE cms_article;
|
TRUNCATE TABLE cms_article;
|
||||||
TRUNCATE TABLE cms_category;
|
TRUNCATE TABLE cms_category;
|
||||||
|
TRUNCATE TABLE cms_service_item;
|
||||||
|
TRUNCATE TABLE cms_service_dict;
|
||||||
TRUNCATE TABLE cms_site;
|
TRUNCATE TABLE cms_site;
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
-- 验证:六表应全为 0
|
-- 验证:八表应全为 0
|
||||||
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
|
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
|
||||||
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
|
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
|
||||||
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
|
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
|
||||||
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
|
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
|
||||||
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
|
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
|
||||||
UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment;
|
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 内):清掉测试期上传的物理文件,只留种子静态资产。
|
-- 可选(不在 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;
|
||||||
@@ -51,7 +51,8 @@ public class CacheConfig implements CachingConfigurer {
|
|||||||
manager.setCaches(Arrays.asList(
|
manager.setCaches(Arrays.asList(
|
||||||
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
|
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
|
||||||
new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).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;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ package com.yunhai.cms.config;
|
|||||||
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
|
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
|
||||||
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
|
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
|
||||||
* 失效事件 = 该站点文章发布/下架/修改</li>
|
* 失效事件 = 该站点文章发布/下架/修改</li>
|
||||||
|
* <li>{@link #SERVICE_ITEM}:itemId → 事项指南页模型;filters:{siteId} → 三组筛选项+计数,
|
||||||
|
* TTL 5min±60s(itemId 不存在时 null 哨兵 30s 防穿透);
|
||||||
|
* 失效事件 = 事项发布/修改/下架/删除、字典变更(ServiceItemChangedEvent,仅事项域,
|
||||||
|
* 不触碰 cms:article/cms:list/cms:site)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public final class CacheNames {
|
public final class CacheNames {
|
||||||
@@ -20,6 +24,8 @@ public final class CacheNames {
|
|||||||
public static final String ARTICLE = "cms:article";
|
public static final String ARTICLE = "cms:article";
|
||||||
/** 门户栏目列表页 */
|
/** 门户栏目列表页 */
|
||||||
public static final String LIST = "cms:list";
|
public static final String LIST = "cms:list";
|
||||||
|
/** 事项指南页模型 + 筛选项(key=itemId / filters:{siteId},事项库 §3.6) */
|
||||||
|
public static final String SERVICE_ITEM = "cms:svcItem";
|
||||||
|
|
||||||
private CacheNames() {
|
private CacheNames() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SvcDictVo> list(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId,
|
||||||
|
@RequestParam(value = "dictType", required = false) String dictType) {
|
||||||
|
return svcDictService.list(authUserWithDept, siteId, dictType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存(新建/改名;稳定键被引用后不可改、被引用不可删——服务端校验) */
|
||||||
|
@PostMapping("/dict/save")
|
||||||
|
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcDictSaveRequest request) {
|
||||||
|
return svcDictService.save(authUserWithDept, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/dict/delete")
|
||||||
|
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
|
||||||
|
svcDictService.delete(authUserWithDept, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务部门选项(UserBase 组树代理;平台不可达降级空列表) */
|
||||||
|
@GetMapping("/dept/tree")
|
||||||
|
public List<SvcDeptVo> deptTree(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId) {
|
||||||
|
return svcItemQuery.deptTree(authUserWithDept, siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> page(BoaUser authUserWithDept, SvcItemPageQuery query) {
|
||||||
|
return svcItemQuery.adminPage(authUserWithDept, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑回显(ext_json.apply_url 已提取) */
|
||||||
|
@GetMapping("/get")
|
||||||
|
public SvcItemEditVo get(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId,
|
||||||
|
@RequestParam("id") Integer id) {
|
||||||
|
return svcItemQuery.adminGet(authUserWithDept, siteId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存(新建草稿/编辑;富文本 jsoup 清洗,apply_url 写 ext_json) */
|
||||||
|
@PostMapping("/save")
|
||||||
|
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcItemSaveRequest request) {
|
||||||
|
return svcItemQuery.adminSave(authUserWithDept, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/publish/{id}")
|
||||||
|
public void publish(BoaUser authUserWithDept,
|
||||||
|
@PathVariable("id") Integer id,
|
||||||
|
@RequestParam("siteId") Integer siteId) {
|
||||||
|
svcItemQuery.adminPublish(authUserWithDept, siteId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/offline/{id}")
|
||||||
|
public void offline(BoaUser authUserWithDept,
|
||||||
|
@PathVariable("id") Integer id,
|
||||||
|
@RequestParam("siteId") Integer siteId) {
|
||||||
|
svcItemQuery.adminOffline(authUserWithDept, siteId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除(附件 svc_item 引用同步解引用) */
|
||||||
|
@PostMapping("/delete")
|
||||||
|
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
|
||||||
|
svcItemQuery.adminDelete(authUserWithDept, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自定义排序(按传入 (id, sortNo) 落库) */
|
||||||
|
@PostMapping("/sort")
|
||||||
|
public void sort(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId,
|
||||||
|
@RequestBody List<IdSortItem> items) {
|
||||||
|
svcItemQuery.adminSort(authUserWithDept, siteId, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步部门名快照(可选动作:UserBase 组树改名后刷新 dept_name) */
|
||||||
|
@PostMapping("/syncDeptName")
|
||||||
|
public Map<String, Object> syncDeptName(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId) {
|
||||||
|
return svcItemQuery.syncDeptName(authUserWithDept, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择器精简列表 */
|
||||||
|
@GetMapping("/selList")
|
||||||
|
public List<SvcItemSimpleVo> selList(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId) {
|
||||||
|
return svcItemQuery.adminSelList(authUserWithDept, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指南附件回显(ref_type=svc_item, ref_id=id,编辑页列表) */
|
||||||
|
@GetMapping("/attList")
|
||||||
|
public List<SvcItemAttachmentVo> attList(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("siteId") Integer siteId,
|
||||||
|
@RequestParam("id") Integer id) {
|
||||||
|
return svcItemQuery.itemAttachments(authUserWithDept, siteId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ import com.yunhai.cms.repository.CmsArticleRepository;
|
|||||||
import com.yunhai.cms.repository.CmsSiteRepository;
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
import com.yunhai.cms.service.impl.PortalQueryService;
|
import com.yunhai.cms.service.impl.PortalQueryService;
|
||||||
import com.yunhai.cms.service.impl.ViewCounterService;
|
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 com.yunhai.cms.utils.PortalUrlHelper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -30,6 +35,7 @@ import javax.servlet.http.HttpServletResponse;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -47,12 +53,15 @@ public class PortalController {
|
|||||||
private static final int HOME_BLOCK_SIZE = 8;
|
private static final int HOME_BLOCK_SIZE = 8;
|
||||||
/** 首页置顶区取 N 条 */
|
/** 首页置顶区取 N 条 */
|
||||||
private static final int HOME_TOP_SIZE = 6;
|
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 PortalQueryService portalQueryService;
|
||||||
private final CmsArticleRepository articleRepository;
|
private final CmsArticleRepository articleRepository;
|
||||||
private final CmsSiteRepository siteRepository;
|
private final CmsSiteRepository siteRepository;
|
||||||
private final ViewCounterService viewCounterService;
|
private final ViewCounterService viewCounterService;
|
||||||
private final PortalPageRenderer pageRenderer;
|
private final PortalPageRenderer pageRenderer;
|
||||||
|
private final SvcItemQuery svcItemQuery;
|
||||||
|
|
||||||
@Value("${cms.portal.detail-max-age:300}")
|
@Value("${cms.portal.detail-max-age:300}")
|
||||||
private int detailMaxAge;
|
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<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
|
||||||
|
model.addAttribute("serviceFilters",
|
||||||
|
buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters));
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> related = new ArrayList<>();
|
||||||
|
for (PortalServiceItem rel : svcItemQuery.relatedItems(site.getId(), itemId,
|
||||||
|
item.getCategoryKey(), RELATED_ITEMS_SIZE)) {
|
||||||
|
Map<String, Object> 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<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
|
||||||
|
Map<String, Object> 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<String, Object> buildServiceFilters(Integer siteId, PortalUrlHelper url,
|
||||||
|
String obj, String cat, Integer dept,
|
||||||
|
Map<String, Object> currentFilters) {
|
||||||
|
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
|
||||||
|
Map<String, Object> model = new HashMap<>();
|
||||||
|
List<PortalServiceFilters.Facet> 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<PortalServiceFilters.Facet> 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<PortalServiceFilters.Facet> 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<String, Object> toggleFilter(Map<String, Object> current, String key, String value) {
|
||||||
|
Map<String, Object> 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);
|
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
|
||||||
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
|
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
|
||||||
@@ -290,7 +563,7 @@ public class PortalController {
|
|||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
if (sitePath != null && !sitePath.contains("/")) {
|
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()
|
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
|
||||||
&& !legacy.getPath().equals(sitePath)) {
|
&& !legacy.getPath().equals(sitePath)) {
|
||||||
StringBuilder location = new StringBuilder(request.getContextPath())
|
StringBuilder location = new StringBuilder(request.getContextPath())
|
||||||
@@ -320,13 +593,23 @@ public class PortalController {
|
|||||||
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
|
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
|
||||||
*/
|
*/
|
||||||
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
|
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) {
|
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()) {
|
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) */
|
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import com.yunhai.cms.model.portal.PortalListData;
|
|||||||
import com.yunhai.cms.model.portal.PortalSideNav;
|
import com.yunhai.cms.model.portal.PortalSideNav;
|
||||||
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
||||||
import com.yunhai.cms.repository.CmsArticleRepository;
|
import com.yunhai.cms.repository.CmsArticleRepository;
|
||||||
|
import com.yunhai.cms.theme.ThemeRegistry;
|
||||||
import com.yunhai.cms.utils.PortalUrlHelper;
|
import com.yunhai.cms.utils.PortalUrlHelper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -26,6 +28,7 @@ import java.util.Set;
|
|||||||
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
||||||
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
|
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PortalPageRenderer {
|
public class PortalPageRenderer {
|
||||||
@@ -37,6 +40,24 @@ public class PortalPageRenderer {
|
|||||||
private static final int HOME_TOP_SIZE = 6;
|
private static final int HOME_TOP_SIZE = 6;
|
||||||
|
|
||||||
private final CmsArticleRepository articleRepository;
|
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 顶层属性在此统一挂载)。
|
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
|
||||||
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
|
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.yunhai.cms.config.CacheNames;
|
|||||||
import com.yunhai.cms.event.ArticleChangedEvent;
|
import com.yunhai.cms.event.ArticleChangedEvent;
|
||||||
import com.yunhai.cms.event.ArticlePublishedEvent;
|
import com.yunhai.cms.event.ArticlePublishedEvent;
|
||||||
import com.yunhai.cms.event.SiteContentChangedEvent;
|
import com.yunhai.cms.event.SiteContentChangedEvent;
|
||||||
|
import com.yunhai.cms.svcitem.event.ServiceItemChangedEvent;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.Cache;
|
import org.springframework.cache.Cache;
|
||||||
@@ -44,6 +45,33 @@ public class PortalCacheEvictListener {
|
|||||||
clear(CacheNames.LIST);
|
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) {
|
private void evictArticle(Integer articleId) {
|
||||||
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
|
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
|
||||||
if (cache != null && articleId != null) {
|
if (cache != null && articleId != null) {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ public class PortalSiteSnapshot {
|
|||||||
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
|
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务大厅基地址(config_json.svc_hall_base,事项库 §3.7):非空时指南页"我要办理"
|
||||||
|
* 按其生成 svhall 发起页 URL(优先级低于事项手填 ext_json.apply_url);空=不生成。
|
||||||
|
*/
|
||||||
|
private String svcHallBase;
|
||||||
|
|
||||||
/** 启用栏目树(status=1,已组树) */
|
/** 启用栏目树(status=1,已组树) */
|
||||||
private List<PortalCategory> categories = new ArrayList<>();
|
private List<PortalCategory> categories = new ArrayList<>();
|
||||||
|
|
||||||
@@ -90,6 +96,7 @@ public class PortalSiteSnapshot {
|
|||||||
snapshot.setConfig(config);
|
snapshot.setConfig(config);
|
||||||
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
||||||
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
||||||
|
snapshot.setSvcHallBase(ExtJsonUtils.getString(config, "svc_hall_base"));
|
||||||
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
||||||
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
|
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
|
||||||
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
|
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
|
||||||
@@ -136,6 +143,7 @@ public class PortalSiteSnapshot {
|
|||||||
copy.setConfig(config);
|
copy.setConfig(config);
|
||||||
copy.setDomain(domain);
|
copy.setDomain(domain);
|
||||||
copy.setPublicBaseUrl(publicBaseUrl);
|
copy.setPublicBaseUrl(publicBaseUrl);
|
||||||
|
copy.setSvcHallBase(svcHallBase);
|
||||||
copy.setCategories(categories);
|
copy.setCategories(categories);
|
||||||
copy.setBanners(banners);
|
copy.setBanners(banners);
|
||||||
copy.setLinkGroups(linkGroups);
|
copy.setLinkGroups(linkGroups);
|
||||||
|
|||||||
@@ -8,8 +8,24 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
|
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按站点编码取站点(严格版:命中多条时抛 IncorrectResultSizeDataAccessException)。
|
||||||
|
*
|
||||||
|
* <p>语义说明:{@code code} 自 W4 起不是全局唯一(唯一键为 {@code uk_site_parent_code(parent_site_id, code)}),
|
||||||
|
* 多校部署下不同父站可挂同名 code 子站。**生产代码请用**
|
||||||
|
* {@link #findFirstByCodeOrderByIdAsc(String)}(确定性取最早一条,不会因歧义 500);
|
||||||
|
* 本方法语义为"断言 code 唯一",保留给集成测试做唯一性/落库断言。
|
||||||
|
*/
|
||||||
Optional<CmsSite> findByCode(String code);
|
Optional<CmsSite> findByCode(String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按站点编码取站点(取 id 最小者)——生产路径口径。
|
||||||
|
*
|
||||||
|
* <p>歧义时不再抛异常导致 500,而是确定性取最早创建的站点;跨服务单键定位的正解仍是
|
||||||
|
* (siteCode,itemCode) 成对 + 建站保证 code 全局唯一(设计 §1.3/R4)。
|
||||||
|
*/
|
||||||
|
Optional<CmsSite> findFirstByCodeOrderByIdAsc(String code);
|
||||||
|
|
||||||
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
|
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
|
||||||
Optional<CmsSite> findByPath(String path);
|
Optional<CmsSite> findByPath(String path);
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
|
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
|
||||||
@@ -46,6 +52,10 @@ public class AttachmentService {
|
|||||||
|
|
||||||
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
|
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) */
|
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
|
||||||
private static final List<String> ALLOWED_EXTS = Arrays.asList(
|
private static final List<String> ALLOWED_EXTS = Arrays.asList(
|
||||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
|
||||||
@@ -56,8 +66,14 @@ public class AttachmentService {
|
|||||||
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
||||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
||||||
|
|
||||||
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / category=单页栏目贴图 */
|
/**
|
||||||
private static final List<String> 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<String> ALLOWED_SCENES =
|
||||||
|
Arrays.asList("banner", "link", "site", "category", "svc_item", "svc_item_asset");
|
||||||
|
|
||||||
private final CmsAttachmentRepository attachmentRepository;
|
private final CmsAttachmentRepository attachmentRepository;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
@@ -69,12 +85,13 @@ public class AttachmentService {
|
|||||||
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
||||||
private String fileWebPath;
|
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
|
@Transactional
|
||||||
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
|
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
|
||||||
CmsSite site = siteService.requireManageable(user, siteId);
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
|
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()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
||||||
@@ -155,6 +172,64 @@ public class AttachmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。
|
||||||
|
*
|
||||||
|
* <p>兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空
|
||||||
|
* (外链、data: 等一律不参与场景引用)。
|
||||||
|
*/
|
||||||
|
public Optional<String> 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。
|
||||||
|
*
|
||||||
|
* <p>用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。
|
||||||
|
*/
|
||||||
|
public Set<String> localAssetUrls(String html) {
|
||||||
|
Set<String> 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 集合——
|
||||||
|
* 集合内标记归属、集合外解除归属(转回未使用),幂等。
|
||||||
|
*
|
||||||
|
* <p>为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步,
|
||||||
|
* 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int syncAssets(Integer siteId, String refType, Integer refId, Set<String> webUrls) {
|
||||||
|
Set<String> desired = webUrls == null ? Collections.emptySet() : webUrls;
|
||||||
|
List<CmsAttachment> 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) */
|
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void release(String refType, Integer refId) {
|
public void release(String refType, Integer refId) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class PortalQueryService {
|
|||||||
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
|
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
|
||||||
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
|
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
|
||||||
public PortalSiteSnapshot siteSnapshot(String siteCode) {
|
public PortalSiteSnapshot siteSnapshot(String siteCode) {
|
||||||
CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
|
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
|
||||||
return assembleSnapshot(site);
|
return assembleSnapshot(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public class SiteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CmsSite getByCode(String code) {
|
public CmsSite getByCode(String code) {
|
||||||
return siteRepository.findByCode(code).orElse(null);
|
return siteRepository.findFirstByCodeOrderByIdAsc(code).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String configValue(CmsSite site, String key) {
|
public String configValue(CmsSite site, String key) {
|
||||||
@@ -353,6 +353,7 @@ public class SiteService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "public_base_url":
|
case "public_base_url":
|
||||||
|
case "svc_hall_base":
|
||||||
case "social_wechat":
|
case "social_wechat":
|
||||||
case "social_weibo":
|
case "social_weibo":
|
||||||
case "social_douyin":
|
case "social_douyin":
|
||||||
|
|||||||
@@ -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> requireAppSignFilter() {
|
||||||
|
FilterRegistrationBean<RequireAppSignFilter> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 生成)。
|
||||||
|
*
|
||||||
|
* <p>索引声明口径:带排序方向的门户列表索引 `idx_svc_item_portal`
|
||||||
|
* (site_id, status, sort_no ASC, publish_time DESC, id DESC) 含 DESC 列,Hibernate
|
||||||
|
* {@code @Index} 无法表达,故只声明在 cms_init.sql / upgrade_svcitem_r2.sql;
|
||||||
|
* 纯靠 ddl-auto=update 建立的开发库需跑一次 upgrade_svcitem_r2.sql 补建(脚本内为幂等守卫块)。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "cms_service_item",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "uk_svc_item_code", columnNames = {"site_id", "item_code"}),
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_svc_item_list", columnList = "site_id, status, online_flag, sort_no"),
|
||||||
|
@Index(name = "idx_svc_item_obj", columnList = "site_id, status, object_key"),
|
||||||
|
@Index(name = "idx_svc_item_cat", columnList = "site_id, status, category_key"),
|
||||||
|
@Index(name = "idx_svc_item_dept", columnList = "site_id, status, dept_group_id")})
|
||||||
|
public class CmsServiceItem extends BaseEntity {
|
||||||
|
|
||||||
|
/** 所属站点(隔离/查询维度) */
|
||||||
|
@Column(name = "site_id", nullable = false)
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
/** 事项编码,站内唯一;跨服务键的一半(另一半是 siteCode) */
|
||||||
|
@Column(name = "item_code", nullable = false, length = 64)
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
/** 事项名称 */
|
||||||
|
@Column(name = "title", nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** 一句话简介(列表卡片副标题、快照摘要) */
|
||||||
|
@Column(name = "summary", length = 500)
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
/** 服务对象,指向 cms_service_dict(dict_type=object) 的 dict_key */
|
||||||
|
@Column(name = "object_key", length = 64)
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
/** 事项类别,指向 cms_service_dict(dict_type=category) 的 dict_key */
|
||||||
|
@Column(name = "category_key", length = 64)
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
/** 服务部门 = UserBase 组树节点 id 快照(不建字典表) */
|
||||||
|
@Column(name = "dept_group_id")
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
/** 部门名快照(列表/指南免跨服务查名;改名不自动同步) */
|
||||||
|
@Column(name = "dept_name", length = 128)
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
/** 办理方式:1 线上 / 2 线下 / 3 线上线下(HandleModeEnum) */
|
||||||
|
@Column(name = "handle_mode", nullable = false)
|
||||||
|
private Integer handleMode = 1;
|
||||||
|
|
||||||
|
/** 是否可在线办理:1 是 / 0 否("在线办理"筛选维度,与 handle_mode 允许不一致) */
|
||||||
|
@Column(name = "online_flag", nullable = false)
|
||||||
|
private Integer onlineFlag = 1;
|
||||||
|
|
||||||
|
/** 办理地点 */
|
||||||
|
@Column(name = "handle_place", length = 200)
|
||||||
|
private String handlePlace;
|
||||||
|
|
||||||
|
/** 办理时限(文本,如"3 个工作日";一期不做工作日历) */
|
||||||
|
@Column(name = "handle_time_limit", length = 64)
|
||||||
|
private String handleTimeLimit;
|
||||||
|
|
||||||
|
/** 承诺时限(文本) */
|
||||||
|
@Column(name = "promise_time_limit", length = 64)
|
||||||
|
private String promiseTimeLimit;
|
||||||
|
|
||||||
|
/** 咨询电话 */
|
||||||
|
@Column(name = "contact_phone", length = 64)
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
/** 监督投诉电话 */
|
||||||
|
@Column(name = "supervise_phone", length = 64)
|
||||||
|
private String supervisePhone;
|
||||||
|
|
||||||
|
/** 办理流程(保存时 jsoup 白名单清洗,同 article.content 口径) */
|
||||||
|
@Column(name = "process_html", columnDefinition = "LONGTEXT")
|
||||||
|
private String processHtml;
|
||||||
|
|
||||||
|
/** 所需材料(同上) */
|
||||||
|
@Column(name = "material_html", columnDefinition = "LONGTEXT")
|
||||||
|
private String materialHtml;
|
||||||
|
|
||||||
|
/** 注意事项(同上) */
|
||||||
|
@Column(name = "notice_html", columnDefinition = "LONGTEXT")
|
||||||
|
private String noticeHtml;
|
||||||
|
|
||||||
|
/** 关联 CMS 栏目:常见问题(逻辑外键 → cms_category.id) */
|
||||||
|
@Column(name = "faq_category_id")
|
||||||
|
private Integer faqCategoryId;
|
||||||
|
|
||||||
|
/** 关联 CMS 栏目:公告信息 */
|
||||||
|
@Column(name = "notice_category_id")
|
||||||
|
private Integer noticeCategoryId;
|
||||||
|
|
||||||
|
/** 关联 CMS 栏目:文件规定 */
|
||||||
|
@Column(name = "rule_category_id")
|
||||||
|
private Integer ruleCategoryId;
|
||||||
|
|
||||||
|
/** 事项图标/封面(存上传相对路径,渲染走 url.upload(...)) */
|
||||||
|
@Column(name = "cover_image", length = 500)
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
/** 管理端自定义排序(列表默认排序第一优先级,升序) */
|
||||||
|
@Column(name = "sort_no", nullable = false)
|
||||||
|
private Integer sortNo = 0;
|
||||||
|
|
||||||
|
/** 状态:0 草稿 / 1 已发布 / 2 已下架(ServiceItemStatusEnum) */
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private Integer status = 0;
|
||||||
|
|
||||||
|
/** 发布时间 */
|
||||||
|
@Column(name = "publish_time")
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
/** 下架时间 */
|
||||||
|
@Column(name = "offline_time")
|
||||||
|
private LocalDateTime offlineTime;
|
||||||
|
|
||||||
|
/** 指南页浏览量(异步累加,不承诺精确) */
|
||||||
|
@Column(name = "view_count", nullable = false)
|
||||||
|
private Integer viewCount = 0;
|
||||||
|
|
||||||
|
/** 扩展字段 JSON(一期仅 apply_url:http(s) 外链,空则不渲染"我要办理") */
|
||||||
|
@Column(name = "ext_json", columnDefinition = "JSON")
|
||||||
|
private String extJson;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Integer> attachmentIds;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Option> objects = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 事项类别(category 字典,同上) */
|
||||||
|
private List<Option> categories = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 服务部门(有已发布事项引用的组节点) */
|
||||||
|
private List<DeptOption> depts = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Option {
|
||||||
|
|
||||||
|
private final String key;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
public Option(String key, String name, long count) {
|
||||||
|
this.key = key;
|
||||||
|
this.name = name;
|
||||||
|
this.count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class DeptOption {
|
||||||
|
|
||||||
|
private final Integer id;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
public DeptOption(Integer id, String name, long count) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板消费形态:{code|id, name, count, active, url}——url 由控制器预生成,th:href 直用 */
|
||||||
|
@Data
|
||||||
|
public static class Facet {
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
private final Integer id;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
private final boolean active;
|
||||||
|
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
public static Facet ofKey(String code, String name, long count, boolean active, String url) {
|
||||||
|
return new Facet(code, null, name, count, active, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Facet ofId(Integer id, String name, long count, boolean active, String url) {
|
||||||
|
return new Facet(null, id, name, count, active, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import com.yunhai.cms.utils.ExtJsonUtils;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户事项指南页模型(cms:svcItem 缓存体,key=itemId;未发布/不存在缓存 null 哨兵)。
|
||||||
|
* applyUrl 为 ext_json 手填原始值;最终按钮 URL 三态优先级(手填 > svc_hall_base 生成 > 不渲染)
|
||||||
|
* 由 PortalController.doGuide 决定——本缓存体不掺入站点配置。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalServiceItem {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
private String objectName;
|
||||||
|
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private String handlePlace;
|
||||||
|
|
||||||
|
private String handleTimeLimit;
|
||||||
|
|
||||||
|
private String promiseTimeLimit;
|
||||||
|
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
private String supervisePhone;
|
||||||
|
|
||||||
|
/** 已清洗入库内容;渲染期经 url.rewriteUploadUrls 补基准(withRewrittenHtml 拷贝,不污染缓存体) */
|
||||||
|
private String processHtml;
|
||||||
|
|
||||||
|
private String materialHtml;
|
||||||
|
|
||||||
|
private String noticeHtml;
|
||||||
|
|
||||||
|
private Integer faqCategoryId;
|
||||||
|
|
||||||
|
private Integer noticeCategoryId;
|
||||||
|
|
||||||
|
private Integer ruleCategoryId;
|
||||||
|
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
private Integer viewCount;
|
||||||
|
|
||||||
|
/** ext_json.apply_url 原始值(可空) */
|
||||||
|
private String applyUrl;
|
||||||
|
|
||||||
|
public static PortalServiceItem of(CmsServiceItem item, String objectName, String categoryName) {
|
||||||
|
PortalServiceItem vo = new PortalServiceItem();
|
||||||
|
vo.setId(item.getId());
|
||||||
|
vo.setSiteId(item.getSiteId());
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setSummary(item.getSummary());
|
||||||
|
vo.setObjectKey(item.getObjectKey());
|
||||||
|
vo.setObjectName(objectName);
|
||||||
|
vo.setCategoryKey(item.getCategoryKey());
|
||||||
|
vo.setCategoryName(categoryName);
|
||||||
|
vo.setDeptGroupId(item.getDeptGroupId());
|
||||||
|
vo.setDeptName(item.getDeptName());
|
||||||
|
vo.setHandleMode(item.getHandleMode());
|
||||||
|
vo.setOnlineFlag(item.getOnlineFlag());
|
||||||
|
vo.setHandlePlace(item.getHandlePlace());
|
||||||
|
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||||
|
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||||
|
vo.setContactPhone(item.getContactPhone());
|
||||||
|
vo.setSupervisePhone(item.getSupervisePhone());
|
||||||
|
vo.setProcessHtml(item.getProcessHtml());
|
||||||
|
vo.setMaterialHtml(item.getMaterialHtml());
|
||||||
|
vo.setNoticeHtml(item.getNoticeHtml());
|
||||||
|
vo.setFaqCategoryId(item.getFaqCategoryId());
|
||||||
|
vo.setNoticeCategoryId(item.getNoticeCategoryId());
|
||||||
|
vo.setRuleCategoryId(item.getRuleCategoryId());
|
||||||
|
vo.setCoverImage(item.getCoverImage());
|
||||||
|
vo.setPublishTime(item.getPublishTime());
|
||||||
|
vo.setViewCount(item.getViewCount());
|
||||||
|
Map<String, Object> ext = ExtJsonUtils.parse(item.getExtJson());
|
||||||
|
vo.setApplyUrl(ExtJsonUtils.getString(ext, "apply_url"));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** copy-on-write:渲染期补基准(同 doArticle 的 detail.withContent 先例,避免污染缓存对象) */
|
||||||
|
public PortalServiceItem withRewrittenHtml(String processHtml, String materialHtml, String noticeHtml) {
|
||||||
|
PortalServiceItem copy = new PortalServiceItem();
|
||||||
|
copy.setId(id);
|
||||||
|
copy.setSiteId(siteId);
|
||||||
|
copy.setItemCode(itemCode);
|
||||||
|
copy.setTitle(title);
|
||||||
|
copy.setSummary(summary);
|
||||||
|
copy.setObjectKey(objectKey);
|
||||||
|
copy.setObjectName(objectName);
|
||||||
|
copy.setCategoryKey(categoryKey);
|
||||||
|
copy.setCategoryName(categoryName);
|
||||||
|
copy.setDeptGroupId(deptGroupId);
|
||||||
|
copy.setDeptName(deptName);
|
||||||
|
copy.setHandleMode(handleMode);
|
||||||
|
copy.setOnlineFlag(onlineFlag);
|
||||||
|
copy.setHandlePlace(handlePlace);
|
||||||
|
copy.setHandleTimeLimit(handleTimeLimit);
|
||||||
|
copy.setPromiseTimeLimit(promiseTimeLimit);
|
||||||
|
copy.setContactPhone(contactPhone);
|
||||||
|
copy.setSupervisePhone(supervisePhone);
|
||||||
|
copy.setProcessHtml(processHtml);
|
||||||
|
copy.setMaterialHtml(materialHtml);
|
||||||
|
copy.setNoticeHtml(noticeHtml);
|
||||||
|
copy.setFaqCategoryId(faqCategoryId);
|
||||||
|
copy.setNoticeCategoryId(noticeCategoryId);
|
||||||
|
copy.setRuleCategoryId(ruleCategoryId);
|
||||||
|
copy.setCoverImage(coverImage);
|
||||||
|
copy.setPublishTime(publishTime);
|
||||||
|
copy.setViewCount(viewCount);
|
||||||
|
copy.setApplyUrl(applyUrl);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户服务中心列表数据(事项库详细设计 §5.2/§6.1)。
|
||||||
|
* 字段形态与既有 PortalListData 同构(items + page/totalPages/total),便于模板与契约复用;
|
||||||
|
* pageUrls 由控制器预生成(模板禁止手拼 query,带筛选时手拼必错)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalServiceListData {
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private Long total = 0L;
|
||||||
|
|
||||||
|
private Integer page = 1;
|
||||||
|
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
|
||||||
|
private Integer totalPages = 0;
|
||||||
|
|
||||||
|
private java.util.List<Item> items = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
/** 分页链接(控制器装配:[{page, url, active}],url 携带当前筛选) */
|
||||||
|
private java.util.List<PageUrl> pageUrls = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Item {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
/** 1 线上 / 2 线下 / 3 线上线下 */
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
/** 1/0("在线办理"标识) */
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
public static Item of(CmsServiceItem item, String categoryName) {
|
||||||
|
Item vo = new Item();
|
||||||
|
vo.setId(item.getId());
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setSummary(item.getSummary());
|
||||||
|
vo.setCoverImage(item.getCoverImage());
|
||||||
|
vo.setDeptName(item.getDeptName());
|
||||||
|
vo.setCategoryName(categoryName);
|
||||||
|
vo.setHandleMode(item.getHandleMode());
|
||||||
|
vo.setOnlineFlag(item.getOnlineFlag());
|
||||||
|
vo.setPublishTime(item.getPublishTime());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class PageUrl {
|
||||||
|
|
||||||
|
private final int page;
|
||||||
|
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
private final boolean active;
|
||||||
|
|
||||||
|
public PageUrl(int page, String url, boolean active) {
|
||||||
|
this.page = page;
|
||||||
|
this.url = url;
|
||||||
|
this.active = active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务部门选项(UserBase 组树按类型过滤后的**嵌套**形态,/api/svc/dept/tree 返回体)。
|
||||||
|
* children 为空表示叶子;层级来自组树本身(非部门祖先节点不下发自身,但会被下钻穿透)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcDeptVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private List<SvcDeptVo> children = new ArrayList<>();
|
||||||
|
|
||||||
|
public SvcDeptVo() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public SvcDeptVo(Integer id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事项字典 VO(管理端列表/选择器共用)。
|
||||||
|
* referenced:被事项引用的条数(全状态口径)——改键拦截与停用提示的展示依据。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcDictVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private String dictType;
|
||||||
|
|
||||||
|
private String dictKey;
|
||||||
|
|
||||||
|
private String dictName;
|
||||||
|
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/** 0 停用 / 1 启用 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/** 被事项引用条数(服务端装配) */
|
||||||
|
private Long referenced;
|
||||||
|
|
||||||
|
public static SvcDictVo of(CmsServiceDict dict, Long referenced) {
|
||||||
|
SvcDictVo vo = new SvcDictVo();
|
||||||
|
vo.setId(dict.getId());
|
||||||
|
vo.setSiteId(dict.getSiteId());
|
||||||
|
vo.setDictType(dict.getDictType());
|
||||||
|
vo.setDictKey(dict.getDictKey());
|
||||||
|
vo.setDictName(dict.getDictName());
|
||||||
|
vo.setSortNo(dict.getSortNo());
|
||||||
|
vo.setStatus(dict.getStatus());
|
||||||
|
vo.setReferenced(referenced == null ? 0L : referenced);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.entity.CmsAttachment;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指南页附件(ref_type=svc_item, ref_id=事项id;fileName/webUrl/fileSize 三字段契约,§6.2)。
|
||||||
|
* webUrl 为入库相对路径,渲染期由 url.upload(...) 补基准。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemAttachmentVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
private String webUrl;
|
||||||
|
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
public static SvcItemAttachmentVo of(CmsAttachment attachment) {
|
||||||
|
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
|
||||||
|
vo.setId(attachment.getId());
|
||||||
|
vo.setFileName(attachment.getFileName());
|
||||||
|
vo.setWebUrl(attachment.getWebUrl());
|
||||||
|
vo.setFileSize(attachment.getFileSize());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import com.yunhai.cms.utils.ExtJsonUtils;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事项编辑回显 VO(含 ext_json 提取的 applyUrl 与状态/时间,供表单回显与按钮态)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemEditVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private String handlePlace;
|
||||||
|
|
||||||
|
private String handleTimeLimit;
|
||||||
|
|
||||||
|
private String promiseTimeLimit;
|
||||||
|
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
private String supervisePhone;
|
||||||
|
|
||||||
|
/** 已清洗入库内容原样回显 */
|
||||||
|
private String processHtml;
|
||||||
|
|
||||||
|
private String materialHtml;
|
||||||
|
|
||||||
|
private String noticeHtml;
|
||||||
|
|
||||||
|
private Integer faqCategoryId;
|
||||||
|
|
||||||
|
private Integer noticeCategoryId;
|
||||||
|
|
||||||
|
private Integer ruleCategoryId;
|
||||||
|
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
private LocalDateTime offlineTime;
|
||||||
|
|
||||||
|
private Integer viewCount;
|
||||||
|
|
||||||
|
/** ext_json.apply_url("我要办理"手填外链,可空) */
|
||||||
|
private String applyUrl;
|
||||||
|
|
||||||
|
public static SvcItemEditVo of(CmsServiceItem item) {
|
||||||
|
SvcItemEditVo vo = new SvcItemEditVo();
|
||||||
|
vo.setId(item.getId());
|
||||||
|
vo.setSiteId(item.getSiteId());
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setSummary(item.getSummary());
|
||||||
|
vo.setObjectKey(item.getObjectKey());
|
||||||
|
vo.setCategoryKey(item.getCategoryKey());
|
||||||
|
vo.setDeptGroupId(item.getDeptGroupId());
|
||||||
|
vo.setDeptName(item.getDeptName());
|
||||||
|
vo.setHandleMode(item.getHandleMode());
|
||||||
|
vo.setOnlineFlag(item.getOnlineFlag());
|
||||||
|
vo.setHandlePlace(item.getHandlePlace());
|
||||||
|
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||||
|
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||||
|
vo.setContactPhone(item.getContactPhone());
|
||||||
|
vo.setSupervisePhone(item.getSupervisePhone());
|
||||||
|
vo.setProcessHtml(item.getProcessHtml());
|
||||||
|
vo.setMaterialHtml(item.getMaterialHtml());
|
||||||
|
vo.setNoticeHtml(item.getNoticeHtml());
|
||||||
|
vo.setFaqCategoryId(item.getFaqCategoryId());
|
||||||
|
vo.setNoticeCategoryId(item.getNoticeCategoryId());
|
||||||
|
vo.setRuleCategoryId(item.getRuleCategoryId());
|
||||||
|
vo.setCoverImage(item.getCoverImage());
|
||||||
|
vo.setSortNo(item.getSortNo());
|
||||||
|
vo.setStatus(item.getStatus());
|
||||||
|
vo.setPublishTime(item.getPublishTime());
|
||||||
|
vo.setOfflineTime(item.getOfflineTime());
|
||||||
|
vo.setViewCount(item.getViewCount());
|
||||||
|
Map<String, Object> ext = ExtJsonUtils.parse(item.getExtJson());
|
||||||
|
vo.setApplyUrl(ExtJsonUtils.getString(ext, "apply_url"));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事项管理端分页行(事项库详细设计 §3.3)。
|
||||||
|
* objectName/categoryName 由服务端按字典表回填(含停用字典——管理端需看到历史归属)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemPageItem {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
private String objectName;
|
||||||
|
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
/** 1 线上 / 2 线下 / 3 线上线下 */
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
/** 1/0 */
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
/** 0 草稿 / 1 已发布 / 2 已下架 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
private Integer viewCount;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private String createBy;
|
||||||
|
|
||||||
|
public static SvcItemPageItem of(CmsServiceItem item, String objectName, String categoryName) {
|
||||||
|
SvcItemPageItem vo = new SvcItemPageItem();
|
||||||
|
vo.setId(item.getId());
|
||||||
|
vo.setSiteId(item.getSiteId());
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setSummary(item.getSummary());
|
||||||
|
vo.setObjectKey(item.getObjectKey());
|
||||||
|
vo.setObjectName(objectName);
|
||||||
|
vo.setCategoryKey(item.getCategoryKey());
|
||||||
|
vo.setCategoryName(categoryName);
|
||||||
|
vo.setDeptGroupId(item.getDeptGroupId());
|
||||||
|
vo.setDeptName(item.getDeptName());
|
||||||
|
vo.setHandleMode(item.getHandleMode());
|
||||||
|
vo.setOnlineFlag(item.getOnlineFlag());
|
||||||
|
vo.setStatus(item.getStatus());
|
||||||
|
vo.setSortNo(item.getSortNo());
|
||||||
|
vo.setPublishTime(item.getPublishTime());
|
||||||
|
vo.setViewCount(item.getViewCount());
|
||||||
|
vo.setCreateTime(item.getCreateTime());
|
||||||
|
vo.setCreateBy(item.getCreateBy());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事项选择器精简行(id/itemCode/title/status)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemSimpleVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
public static SvcItemSimpleVo of(CmsServiceItem item) {
|
||||||
|
SvcItemSimpleVo vo = new SvcItemSimpleVo();
|
||||||
|
vo.setId(item.getId());
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setStatus(item.getStatus());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨服务要点快照(oapi /oapi/svc/item/snapshot 返回体,事项库详细设计 §3.4 冻结契约)。
|
||||||
|
* 供 svhall 发起办件时落 flw_instance;跨库不 join,历史办件不受事项后续改名/下架影响。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemSnapshotVo {
|
||||||
|
|
||||||
|
private String siteCode;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
/** 1 线上 / 2 线下 / 3 线上线下 */
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private String handleTimeLimit;
|
||||||
|
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
/** 可空:手填外链;svhall 原生办理模式可忽略 */
|
||||||
|
private String applyUrl;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.yunhai.cms.svcitem.repository;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceDict;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface CmsServiceDictRepository extends JpaRepository<CmsServiceDict, Integer> {
|
||||||
|
|
||||||
|
List<CmsServiceDict> findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(Integer siteId, String dictType);
|
||||||
|
|
||||||
|
List<CmsServiceDict> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
|
||||||
|
|
||||||
|
Optional<CmsServiceDict> findByIdAndSiteId(Integer id, Integer siteId);
|
||||||
|
|
||||||
|
Optional<CmsServiceDict> findBySiteIdAndDictTypeAndDictKey(Integer siteId, String dictType, String dictKey);
|
||||||
|
|
||||||
|
boolean existsBySiteIdAndDictTypeAndDictKey(Integer siteId, String dictType, String dictKey);
|
||||||
|
|
||||||
|
boolean existsBySiteIdAndDictTypeAndDictKeyAndIdNot(Integer siteId, String dictType, String dictKey,
|
||||||
|
Integer id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.yunhai.cms.svcitem.repository;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.entity.CmsServiceItem;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface CmsServiceItemRepository extends JpaRepository<CmsServiceItem, Integer>,
|
||||||
|
JpaSpecificationExecutor<CmsServiceItem> {
|
||||||
|
|
||||||
|
Optional<CmsServiceItem> findByIdAndSiteId(Integer id, Integer siteId);
|
||||||
|
|
||||||
|
/** 跨服务键查询:uk(site_id, item_code) 命中即唯一 */
|
||||||
|
Optional<CmsServiceItem> findBySiteIdAndItemCode(Integer siteId, String itemCode);
|
||||||
|
|
||||||
|
boolean existsBySiteIdAndItemCode(Integer siteId, String itemCode);
|
||||||
|
|
||||||
|
boolean existsBySiteIdAndItemCodeAndIdNot(Integer siteId, String itemCode, Integer id);
|
||||||
|
|
||||||
|
/** 字典键被事项引用计数(字典改键/停用守护用) */
|
||||||
|
long countBySiteIdAndObjectKey(Integer siteId, String objectKey);
|
||||||
|
|
||||||
|
long countBySiteIdAndCategoryKey(Integer siteId, String categoryKey);
|
||||||
|
|
||||||
|
/** 关联事项:同类别已发布,排除自身(Pageable 控制条数与排序) */
|
||||||
|
List<CmsServiceItem> findBySiteIdAndStatusAndCategoryKeyAndIdNot(Integer siteId, Integer status,
|
||||||
|
String categoryKey, Integer id,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
/** 管理端选择器:全状态按排序取(条数由 Pageable 控制) */
|
||||||
|
List<CmsServiceItem> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId, Pageable pageable);
|
||||||
|
|
||||||
|
// ===== 门户筛选项计数(已发布口径,group by 一次往返/维度,事项库 §5.2/R4)=====
|
||||||
|
|
||||||
|
@Query("select i.objectKey, count(i) from CmsServiceItem i"
|
||||||
|
+ " where i.siteId = :siteId and i.status = 1 and i.objectKey is not null"
|
||||||
|
+ " group by i.objectKey")
|
||||||
|
List<Object[]> countPublishedGroupByObjectKey(@Param("siteId") Integer siteId);
|
||||||
|
|
||||||
|
@Query("select i.categoryKey, count(i) from CmsServiceItem i"
|
||||||
|
+ " where i.siteId = :siteId and i.status = 1 and i.categoryKey is not null"
|
||||||
|
+ " group by i.categoryKey")
|
||||||
|
List<Object[]> countPublishedGroupByCategoryKey(@Param("siteId") Integer siteId);
|
||||||
|
|
||||||
|
/** 部门名取组内最新快照(组树改名后新保存的事项名覆盖旧值) */
|
||||||
|
@Query("select i.deptGroupId, max(i.deptName), count(i) from CmsServiceItem i"
|
||||||
|
+ " where i.siteId = :siteId and i.status = 1 and i.deptGroupId is not null"
|
||||||
|
+ " group by i.deptGroupId")
|
||||||
|
List<Object[]> countPublishedGroupByDept(@Param("siteId") Integer siteId);
|
||||||
|
|
||||||
|
/** 指南页浏览量异步累加(fire-and-forget,失败忽略,比照 ViewCounterService 口径) */
|
||||||
|
@Modifying
|
||||||
|
@Query("update CmsServiceItem i set i.viewCount = i.viewCount + 1 where i.id = :id")
|
||||||
|
int incrementViewCount(@Param("id") Integer id);
|
||||||
|
|
||||||
|
// ===== 字典引用计数(全状态口径,字典改键/停用守护与列表"被引用"展示用)=====
|
||||||
|
|
||||||
|
@Query("select i.objectKey, count(i) from CmsServiceItem i"
|
||||||
|
+ " where i.siteId = :siteId and i.objectKey is not null group by i.objectKey")
|
||||||
|
List<Object[]> countGroupByObjectKey(@Param("siteId") Integer siteId);
|
||||||
|
|
||||||
|
@Query("select i.categoryKey, count(i) from CmsServiceItem i"
|
||||||
|
+ " where i.siteId = :siteId and i.categoryKey is not null group by i.categoryKey")
|
||||||
|
List<Object[]> countGroupByCategoryKey(@Param("siteId") Integer siteId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.yunhai.cms.svcitem.service;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门组树来源接缝(事项库 R2 追加):默认实现代理 UserBase oapi 的静态调用
|
||||||
|
* {@code YschBaseOpenApi.getGroupTree()}。
|
||||||
|
*
|
||||||
|
* <p>包一层 bean 的理由:静态方法在测试中无法替身,导致"部门可达"这条主路径长期零覆盖
|
||||||
|
* (SchoolService 同款调用只能用"不可达"降级路径绕测)。抽出接缝后单测可注入固定组树,
|
||||||
|
* 断言"类型过滤 / 层级构建 / 快照取名 / id 不在树上拒绝"全链路;拆分独立应用时也只需换实现。
|
||||||
|
*/
|
||||||
|
public interface SvcDeptSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取组织机构组树根节点。
|
||||||
|
*
|
||||||
|
* @return 组树根;平台不可达或异常时返回 {@code null}(不抛出,降级口径由消费方决定)
|
||||||
|
*/
|
||||||
|
BoaGroup getGroupTree();
|
||||||
|
}
|
||||||
@@ -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<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit);
|
||||||
|
|
||||||
|
/** 跨服务:按 (siteCode,itemCode) 取发布态要点快照;查不到/未发布返回 null(不做全局降级查询) */
|
||||||
|
SvcItemSnapshotVo snapshot(String siteCode, String itemCode);
|
||||||
|
|
||||||
|
/** 门户:指南页附件(ref_type=svc_item, ref_id=itemId) */
|
||||||
|
List<SvcItemAttachmentVo> attachments(Integer itemId);
|
||||||
|
|
||||||
|
/** 门户:指南页浏览量异步累加(不承诺精确,失败忽略) */
|
||||||
|
void countView(Integer itemId);
|
||||||
|
|
||||||
|
// ===== 管理端 =====
|
||||||
|
|
||||||
|
/** 分页({total, list:[SvcItemPageItem]},pageSize 上限 100) */
|
||||||
|
Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query);
|
||||||
|
|
||||||
|
/** 编辑回显 */
|
||||||
|
SvcItemEditVo adminGet(BoaUser user, Integer siteId, Integer id);
|
||||||
|
|
||||||
|
/** 保存(新建草稿/编辑;返回 id) */
|
||||||
|
Integer adminSave(BoaUser user, SvcItemSaveRequest request);
|
||||||
|
|
||||||
|
/** 发布(草稿/已下架 → 已发布) */
|
||||||
|
void adminPublish(BoaUser user, Integer siteId, Integer id);
|
||||||
|
|
||||||
|
/** 下架(已发布 → 已下架) */
|
||||||
|
void adminOffline(BoaUser user, Integer siteId, Integer id);
|
||||||
|
|
||||||
|
/** 删除(物理删除 + 附件解引用) */
|
||||||
|
void adminDelete(BoaUser user, DeleteRequest request);
|
||||||
|
|
||||||
|
/** 选择器精简列表(上限 SEL_LIST_MAX,超出记 warn) */
|
||||||
|
List<SvcItemSimpleVo> adminSelList(BoaUser user, Integer siteId);
|
||||||
|
|
||||||
|
/** 自定义排序(按传入 (id, sortNo) 落库;列表默认排序第一优先级) */
|
||||||
|
void adminSort(BoaUser user, Integer siteId, List<IdSortItem> items);
|
||||||
|
|
||||||
|
/** 同步部门名快照(按组树刷新全站 dept_name,返回 {updated:n}) */
|
||||||
|
Map<String, Object> syncDeptName(BoaUser user, Integer siteId);
|
||||||
|
|
||||||
|
/** 服务部门选项(UserBase 组树按配置类型过滤,嵌套下发) */
|
||||||
|
List<SvcDeptVo> deptTree(BoaUser user, Integer siteId);
|
||||||
|
|
||||||
|
/** 事项当前绑定的指南附件(编辑回显;ref_type=svc_item, ref_id=itemId) */
|
||||||
|
List<SvcItemAttachmentVo> itemAttachments(BoaUser user, Integer siteId, Integer itemId);
|
||||||
|
}
|
||||||
@@ -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<SvcDictVo> list(BoaUser user, Integer siteId, String dictType) {
|
||||||
|
siteService.requireManageable(user, siteId);
|
||||||
|
if (dictType != null && !dictType.trim().isEmpty() && ServiceDictTypeEnum.byKey(dictType) == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + dictType + "(仅 object/category)");
|
||||||
|
}
|
||||||
|
List<CmsServiceDict> dicts = dictType == null || dictType.trim().isEmpty()
|
||||||
|
? dictRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId)
|
||||||
|
: dictRepository.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, dictType);
|
||||||
|
Map<String, Long> objectRefs = countMap(itemRepository.countGroupByObjectKey(siteId));
|
||||||
|
Map<String, Long> categoryRefs = countMap(itemRepository.countGroupByCategoryKey(siteId));
|
||||||
|
List<SvcDictVo> result = new ArrayList<>();
|
||||||
|
for (CmsServiceDict dict : dicts) {
|
||||||
|
Map<String, Long> refs = ServiceDictTypeEnum.CATEGORY.getKey().equals(dict.getDictType())
|
||||||
|
? categoryRefs : objectRefs;
|
||||||
|
result.add(SvcDictVo.of(dict, refs.get(dict.getDictKey())));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存:新建校验 uk;编辑改键需未被引用;停用不受限(存量引用仍可渲染) */
|
||||||
|
@Transactional
|
||||||
|
public Integer save(BoaUser user, SvcDictSaveRequest req) {
|
||||||
|
siteService.requireManageable(user, req.getSiteId());
|
||||||
|
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(req.getDictType());
|
||||||
|
if (type == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + req.getDictType() + "(仅 object/category)");
|
||||||
|
}
|
||||||
|
CmsServiceDict dict;
|
||||||
|
if (req.getId() == null) {
|
||||||
|
if (dictRepository.existsBySiteIdAndDictTypeAndDictKey(
|
||||||
|
req.getSiteId(), type.getKey(), req.getDictKey())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
|
||||||
|
}
|
||||||
|
dict = new CmsServiceDict();
|
||||||
|
dict.setSiteId(req.getSiteId());
|
||||||
|
dict.setDictType(type.getKey());
|
||||||
|
dict.setDictKey(req.getDictKey());
|
||||||
|
} else {
|
||||||
|
dict = dictRepository.findByIdAndSiteId(req.getId(), req.getSiteId())
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
|
||||||
|
if (!dict.getDictKey().equals(req.getDictKey())) {
|
||||||
|
long referenced = referencedCount(req.getSiteId(), type, dict.getDictKey());
|
||||||
|
if (referenced > 0) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"字典键已被 " + referenced + " 个事项引用,不可修改;只能修改名称");
|
||||||
|
}
|
||||||
|
if (dictRepository.existsBySiteIdAndDictTypeAndDictKeyAndIdNot(
|
||||||
|
req.getSiteId(), type.getKey(), req.getDictKey(), req.getId())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
|
||||||
|
}
|
||||||
|
dict.setDictKey(req.getDictKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dict.setDictName(req.getDictName());
|
||||||
|
dict.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||||
|
dict.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
|
dict = dictRepository.save(dict);
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(req.getSiteId(), null, "dict"));
|
||||||
|
return dict.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除:被引用即拦(与栏目删除一致的引用完整性规则);未被引用才可删 */
|
||||||
|
@Transactional
|
||||||
|
public void delete(BoaUser user, DeleteRequest req) {
|
||||||
|
siteService.requireManageable(user, req.getSiteId());
|
||||||
|
CmsServiceDict dict = dictRepository.findByIdAndSiteId(req.getId(), req.getSiteId())
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
|
||||||
|
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(dict.getDictType());
|
||||||
|
long referenced = referencedCount(req.getSiteId(), type, dict.getDictKey());
|
||||||
|
if (referenced > 0) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"字典已被 " + referenced + " 个事项引用,不能删除;可改为停用(存量引用仍可渲染)");
|
||||||
|
}
|
||||||
|
dictRepository.delete(dict);
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(req.getSiteId(), null, "dict"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键 → 展示名(全状态口径:停用字典的存量引用仍可渲染) */
|
||||||
|
public Map<String, String> nameMap(Integer siteId, ServiceDictTypeEnum type) {
|
||||||
|
Map<String, String> names = new HashMap<>();
|
||||||
|
for (CmsServiceDict dict : dictRepository
|
||||||
|
.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, type.getKey())) {
|
||||||
|
names.put(dict.getDictKey(), dict.getDictName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用字典(门户筛选/管理端选择器口径:停用不出现在筛选与选择器) */
|
||||||
|
public List<CmsServiceDict> enabledDicts(Integer siteId, ServiceDictTypeEnum type) {
|
||||||
|
List<CmsServiceDict> result = new ArrayList<>();
|
||||||
|
for (CmsServiceDict dict : dictRepository
|
||||||
|
.findBySiteIdAndDictTypeOrderBySortNoAscIdAsc(siteId, type.getKey())) {
|
||||||
|
if (dict.getStatus() != null && dict.getStatus() == 1) {
|
||||||
|
result.add(dict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键必须存在于启用字典(保存事项时的归属校验) */
|
||||||
|
public void requireEnabledKey(Integer siteId, ServiceDictTypeEnum type, String dictKey) {
|
||||||
|
CmsServiceDict dict = dictRepository
|
||||||
|
.findBySiteIdAndDictTypeAndDictKey(siteId, type.getKey(), dictKey)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别") + "不存在:" + dictKey));
|
||||||
|
if (dict.getStatus() == null || dict.getStatus() != 1) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别")
|
||||||
|
+ "已停用,请先启用或更换:" + dictKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long referencedCount(Integer siteId, ServiceDictTypeEnum type, String dictKey) {
|
||||||
|
return type == ServiceDictTypeEnum.CATEGORY
|
||||||
|
? itemRepository.countBySiteIdAndCategoryKey(siteId, dictKey)
|
||||||
|
: itemRepository.countBySiteIdAndObjectKey(siteId, dictKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Long> countMap(List<Object[]> rows) {
|
||||||
|
Map<String, Long> map = new HashMap<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<CmsServiceItem> page = itemRepository.findAll(publishedSpec(query), pageable);
|
||||||
|
Map<String, String> categoryNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.CATEGORY);
|
||||||
|
PortalServiceListData data = new PortalServiceListData();
|
||||||
|
data.setSiteId(query.getSiteId());
|
||||||
|
data.setTotal(page.getTotalElements());
|
||||||
|
data.setPage(pageIndex + 1);
|
||||||
|
data.setPageSize(pageable.getPageSize());
|
||||||
|
data.setTotalPages(page.getTotalPages());
|
||||||
|
for (CmsServiceItem item : page.getContent()) {
|
||||||
|
data.getItems().add(PortalServiceListData.Item.of(item,
|
||||||
|
categoryNames.get(item.getCategoryKey())));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三组筛选项 + 计数(已发布口径;选项=启用字典且有已发布引用的键) */
|
||||||
|
@Override
|
||||||
|
@Cacheable(cacheNames = CacheNames.SERVICE_ITEM, key = "'filters:' + #siteId")
|
||||||
|
public PortalServiceFilters portalFilters(Integer siteId) {
|
||||||
|
PortalServiceFilters filters = new PortalServiceFilters();
|
||||||
|
Map<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(siteId));
|
||||||
|
Map<String, Long> categoryCounts = countMap(itemRepository.countPublishedGroupByCategoryKey(siteId));
|
||||||
|
for (com.yunhai.cms.svcitem.entity.CmsServiceDict dict : dictService
|
||||||
|
.enabledDicts(siteId, ServiceDictTypeEnum.OBJECT)) {
|
||||||
|
Long count = objectCounts.get(dict.getDictKey());
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
filters.getObjects().add(new PortalServiceFilters.Option(
|
||||||
|
dict.getDictKey(), dict.getDictName(), count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (com.yunhai.cms.svcitem.entity.CmsServiceDict dict : dictService
|
||||||
|
.enabledDicts(siteId, ServiceDictTypeEnum.CATEGORY)) {
|
||||||
|
Long count = categoryCounts.get(dict.getDictKey());
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
filters.getCategories().add(new PortalServiceFilters.Option(
|
||||||
|
dict.getDictKey(), dict.getDictName(), count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Object[] row : itemRepository.countPublishedGroupByDept(siteId)) {
|
||||||
|
filters.getDepts().add(new PortalServiceFilters.DeptOption(
|
||||||
|
((Number) row[0]).intValue(), String.valueOf(row[1]), ((Number) row[2]).longValue()));
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指南页模型(不存在/未发布/跨站 → null 缓存哨兵 30s 防穿透) */
|
||||||
|
@Override
|
||||||
|
@Cacheable(cacheNames = CacheNames.SERVICE_ITEM, key = "#itemId")
|
||||||
|
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
|
||||||
|
CmsServiceItem item = itemRepository.findById(itemId).orElse(null);
|
||||||
|
if (item == null || item.getStatus() == null
|
||||||
|
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()
|
||||||
|
|| !item.getSiteId().equals(siteId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toPortalItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关联事项:同类别已发布 Top N(排除自身;无类别返回空表) */
|
||||||
|
@Override
|
||||||
|
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
|
||||||
|
if (!StringUtils.hasText(categoryKey) || limit <= 0) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<CmsServiceItem> items = itemRepository.findBySiteIdAndStatusAndCategoryKeyAndIdNot(
|
||||||
|
siteId, ServiceItemStatusEnum.PUBLISHED.getCode(), categoryKey, itemId,
|
||||||
|
PageRequest.of(0, limit, Sort.by(Sort.Order.asc("sortNo"),
|
||||||
|
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
|
||||||
|
List<PortalServiceItem> result = new ArrayList<>();
|
||||||
|
for (CmsServiceItem item : items) {
|
||||||
|
result.add(toPortalItem(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨服务要点快照(不缓存:调用频次=办件量级,直查 DB;查不到/未发布返回 null) */
|
||||||
|
@Override
|
||||||
|
public SvcItemSnapshotVo snapshot(String siteCode, String itemCode) {
|
||||||
|
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
|
||||||
|
if (site == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CmsServiceItem item = itemRepository.findBySiteIdAndItemCode(site.getId(), itemCode).orElse(null);
|
||||||
|
if (item == null || item.getStatus() == null
|
||||||
|
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SvcItemSnapshotVo vo = new SvcItemSnapshotVo();
|
||||||
|
vo.setSiteCode(siteCode);
|
||||||
|
vo.setItemCode(item.getItemCode());
|
||||||
|
vo.setTitle(item.getTitle());
|
||||||
|
vo.setSummary(item.getSummary());
|
||||||
|
vo.setDeptName(item.getDeptName());
|
||||||
|
vo.setHandleMode(item.getHandleMode());
|
||||||
|
vo.setOnlineFlag(item.getOnlineFlag());
|
||||||
|
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||||
|
vo.setContactPhone(item.getContactPhone());
|
||||||
|
vo.setApplyUrl(ExtJsonUtils.getString(ExtJsonUtils.parse(item.getExtJson()), "apply_url"));
|
||||||
|
vo.setPublishTime(item.getPublishTime());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指南页附件(ref_type=svc_item, ref_id=itemId) */
|
||||||
|
@Override
|
||||||
|
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
|
||||||
|
List<SvcItemAttachmentVo> result = new ArrayList<>();
|
||||||
|
for (CmsAttachment attachment : attachmentRepository.findByRefTypeAndRefId(REF_TYPE_SVC_ITEM, itemId)) {
|
||||||
|
result.add(SvcItemAttachmentVo.of(attachment));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 管理端 =====
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query) {
|
||||||
|
siteService.requireManageable(user, query.getSiteId());
|
||||||
|
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
|
||||||
|
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
|
||||||
|
Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100),
|
||||||
|
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("createTime"), Sort.Order.desc("id")));
|
||||||
|
Specification<CmsServiceItem> spec = (root, cq, cb) -> {
|
||||||
|
List<Predicate> predicates = new ArrayList<>();
|
||||||
|
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
|
||||||
|
if (query.getStatus() != null) {
|
||||||
|
predicates.add(cb.equal(root.get("status"), query.getStatus()));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(query.getKeyword())) {
|
||||||
|
String like = "%" + query.getKeyword().trim() + "%";
|
||||||
|
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||||
|
cb.like(root.get("itemCode"), like)));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(query.getObjectKey())) {
|
||||||
|
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(query.getCategoryKey())) {
|
||||||
|
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
|
||||||
|
}
|
||||||
|
if (query.getDeptGroupId() != null) {
|
||||||
|
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
|
||||||
|
}
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
Map<String, String> objectNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.OBJECT);
|
||||||
|
Map<String, String> categoryNames = dictService.nameMap(query.getSiteId(), ServiceDictTypeEnum.CATEGORY);
|
||||||
|
Page<CmsServiceItem> page = itemRepository.findAll(spec, pageable);
|
||||||
|
List<SvcItemPageItem> items = new ArrayList<>();
|
||||||
|
for (CmsServiceItem item : page.getContent()) {
|
||||||
|
items.add(SvcItemPageItem.of(item,
|
||||||
|
objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey())));
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("total", page.getTotalElements());
|
||||||
|
result.put("list", items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SvcItemEditVo adminGet(BoaUser user, Integer siteId, Integer id) {
|
||||||
|
siteService.requireManageable(user, siteId);
|
||||||
|
return SvcItemEditVo.of(requireItem(siteId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存:全部校验规则见设计 §3.3;附件全量重绑(release + 按新列表标记,幂等) */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Integer adminSave(BoaUser user, SvcItemSaveRequest req) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, req.getSiteId());
|
||||||
|
validateItemCode(req);
|
||||||
|
validateDictKeys(req);
|
||||||
|
validateCategoryRefs(site.getId(), req);
|
||||||
|
String deptName = resolveDeptName(req.getSiteId(), req.getDeptGroupId());
|
||||||
|
String applyUrl = validateApplyUrl(req.getApplyUrl());
|
||||||
|
|
||||||
|
CmsServiceItem item;
|
||||||
|
if (req.getId() == null) {
|
||||||
|
item = new CmsServiceItem();
|
||||||
|
item.setSiteId(site.getId());
|
||||||
|
item.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
|
||||||
|
item.setViewCount(0);
|
||||||
|
} else {
|
||||||
|
item = requireItem(site.getId(), req.getId());
|
||||||
|
}
|
||||||
|
item.setItemCode(req.getItemCode());
|
||||||
|
item.setTitle(req.getTitle());
|
||||||
|
item.setSummary(req.getSummary());
|
||||||
|
item.setObjectKey(emptyToNull(req.getObjectKey()));
|
||||||
|
item.setCategoryKey(emptyToNull(req.getCategoryKey()));
|
||||||
|
item.setDeptGroupId(req.getDeptGroupId());
|
||||||
|
item.setDeptName(deptName);
|
||||||
|
item.setHandleMode(req.getHandleMode() == null ? HandleModeEnum.ONLINE.getCode() : req.getHandleMode());
|
||||||
|
if (!HandleModeEnum.isValid(item.getHandleMode())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "办理方式非法:" + item.getHandleMode());
|
||||||
|
}
|
||||||
|
item.setOnlineFlag(req.getOnlineFlag() == null || req.getOnlineFlag() != 0 ? 1 : 0);
|
||||||
|
item.setHandlePlace(req.getHandlePlace());
|
||||||
|
item.setHandleTimeLimit(req.getHandleTimeLimit());
|
||||||
|
item.setPromiseTimeLimit(req.getPromiseTimeLimit());
|
||||||
|
item.setContactPhone(req.getContactPhone());
|
||||||
|
item.setSupervisePhone(req.getSupervisePhone());
|
||||||
|
item.setProcessHtml(HtmlSanitizer.clean(req.getProcessHtml()));
|
||||||
|
item.setMaterialHtml(HtmlSanitizer.clean(req.getMaterialHtml()));
|
||||||
|
item.setNoticeHtml(HtmlSanitizer.clean(req.getNoticeHtml()));
|
||||||
|
item.setFaqCategoryId(req.getFaqCategoryId());
|
||||||
|
item.setNoticeCategoryId(req.getNoticeCategoryId());
|
||||||
|
item.setRuleCategoryId(req.getRuleCategoryId());
|
||||||
|
item.setCoverImage(req.getCoverImage());
|
||||||
|
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||||
|
item.setExtJson(applyUrl == null ? null : "{\"apply_url\":" + quoteJson(applyUrl) + "}");
|
||||||
|
item = itemRepository.save(item);
|
||||||
|
|
||||||
|
bindAttachments(site.getId(), item, req.getAttachmentIds());
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
|
||||||
|
return item.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布:草稿/已下架 → 已发布(记录发布时间;事件失效指南页缓存与筛选项计数) */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adminPublish(BoaUser user, Integer siteId, Integer id) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
|
CmsServiceItem item = requireItem(site.getId(), id);
|
||||||
|
if (!StringUtils.hasText(item.getTitle())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "事项名称缺失,不可发布");
|
||||||
|
}
|
||||||
|
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||||
|
item.setPublishTime(LocalDateTime.now());
|
||||||
|
itemRepository.save(item);
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下架:已发布 → 已下架(门户即刻 404;svhall 已发办件不受影响——快照隔离) */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adminOffline(BoaUser user, Integer siteId, Integer id) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
|
CmsServiceItem item = requireItem(site.getId(), id);
|
||||||
|
item.setStatus(ServiceItemStatusEnum.OFFLINE.getCode());
|
||||||
|
item.setOfflineTime(LocalDateTime.now());
|
||||||
|
itemRepository.save(item);
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除:物理删除 + 附件解引用(指南附件 + 封面/正文资产两个场景都释放,转孤儿可清理);办件侧靠快照隔离不受影响 */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adminDelete(BoaUser user, DeleteRequest req) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, req.getSiteId());
|
||||||
|
CmsServiceItem item = requireItem(site.getId(), req.getId());
|
||||||
|
attachmentService.release(REF_TYPE_SVC_ITEM, item.getId());
|
||||||
|
attachmentService.release(REF_TYPE_SVC_ASSET, item.getId());
|
||||||
|
itemRepository.delete(item);
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), item.getId(), "item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SvcItemSimpleVo> adminSelList(BoaUser user, Integer siteId) {
|
||||||
|
siteService.requireManageable(user, siteId);
|
||||||
|
List<CmsServiceItem> items = itemRepository.findBySiteIdOrderBySortNoAscIdAsc(
|
||||||
|
siteId, PageRequest.of(0, SEL_LIST_MAX));
|
||||||
|
if (items.size() >= SEL_LIST_MAX) {
|
||||||
|
log.warn("[cms] 事项选择器命中上限 {} 条(siteId={}),结果已截断", SEL_LIST_MAX, siteId);
|
||||||
|
}
|
||||||
|
List<SvcItemSimpleVo> result = new ArrayList<>();
|
||||||
|
for (CmsServiceItem item : items) {
|
||||||
|
result.add(SvcItemSimpleVo.of(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序:按传入 (id, sortNo) 落库(列表默认排序第一优先级) */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adminSort(BoaUser user, Integer siteId, List<IdSortItem> items) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
|
for (IdSortItem pair : items == null ? new ArrayList<IdSortItem>() : items) {
|
||||||
|
CmsServiceItem item = requireItem(site.getId(), pair.getId());
|
||||||
|
item.setSortNo(pair.getSortNo());
|
||||||
|
itemRepository.save(item);
|
||||||
|
}
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), null, "item"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步部门名(可选动作,§2.3/R5):按组树刷新全站事项 dept_name 快照,返回更新条数 */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> syncDeptName(BoaUser user, Integer siteId) {
|
||||||
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
|
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||||
|
collectDeptNodes(fetchGroupTree(), deptNames);
|
||||||
|
if (deptNames.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名");
|
||||||
|
}
|
||||||
|
int updated = 0;
|
||||||
|
List<CmsServiceItem> candidates = itemRepository.findBySiteIdOrderBySortNoAscIdAsc(
|
||||||
|
site.getId(), PageRequest.of(0, SEL_LIST_MAX));
|
||||||
|
if (candidates.size() >= SEL_LIST_MAX) {
|
||||||
|
log.warn("[cms] 部门名同步命中上限 {} 条(siteId={}),超出部分未同步", SEL_LIST_MAX, site.getId());
|
||||||
|
}
|
||||||
|
for (CmsServiceItem item : candidates) {
|
||||||
|
if (item.getDeptGroupId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String fresh = deptNames.get(item.getDeptGroupId());
|
||||||
|
if (fresh != null && !fresh.equals(item.getDeptName())) {
|
||||||
|
item.setDeptName(fresh);
|
||||||
|
itemRepository.save(item);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated > 0) {
|
||||||
|
eventPublisher.publishEvent(new ServiceItemChangedEvent(site.getId(), null, "dept"));
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("updated", updated);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务部门选项(UserBase 组树按 deptGroupType 过滤并保留层级;平台不可达降级空列表,SchoolService 同款) */
|
||||||
|
@Override
|
||||||
|
public List<SvcDeptVo> deptTree(BoaUser user, Integer siteId) {
|
||||||
|
siteService.requireManageable(user, siteId);
|
||||||
|
return buildDeptTree(fetchGroupTree());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组树 → 只含部门节点的嵌套树(非部门祖先不下发自身,但继续下钻,避免层级被压平) */
|
||||||
|
private List<SvcDeptVo> buildDeptTree(BoaGroup root) {
|
||||||
|
List<SvcDeptVo> top = new ArrayList<>();
|
||||||
|
if (root == null) {
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
if (isDeptNode(root)) {
|
||||||
|
SvcDeptVo vo = new SvcDeptVo(root.getId(), root.getName());
|
||||||
|
vo.setChildren(buildDeptChildren(root));
|
||||||
|
top.add(vo);
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
return buildDeptChildren(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SvcDeptVo> buildDeptChildren(BoaGroup parent) {
|
||||||
|
List<SvcDeptVo> out = new ArrayList<>();
|
||||||
|
if (parent.getChildren() == null) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (BoaGroup child : parent.getChildren()) {
|
||||||
|
if (isDeptNode(child)) {
|
||||||
|
SvcDeptVo vo = new SvcDeptVo(child.getId(), child.getName());
|
||||||
|
vo.setChildren(buildDeptChildren(child));
|
||||||
|
out.add(vo);
|
||||||
|
} else {
|
||||||
|
out.addAll(buildDeptChildren(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDeptNode(BoaGroup node) {
|
||||||
|
return node.getType() != null && node.getType() == deptGroupType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理端:事项当前绑定的指南附件(编辑回显) */
|
||||||
|
@Override
|
||||||
|
public List<SvcItemAttachmentVo> itemAttachments(BoaUser user, Integer siteId, Integer itemId) {
|
||||||
|
siteService.requireManageable(user, siteId);
|
||||||
|
requireItem(siteId, itemId);
|
||||||
|
return attachments(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指南页浏览量异步累加(fire-and-forget,失败忽略;比照 ViewCounterService 口径) */
|
||||||
|
@Override
|
||||||
|
public void countView(Integer itemId) {
|
||||||
|
incrementView(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void incrementView(Integer itemId) {
|
||||||
|
if (itemId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult(
|
||||||
|
status -> itemRepository.incrementViewCount(itemId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[cms] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
log.warn("[cms] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览量累加单线程执行器:有界队列 + 拒绝即丢弃并告警——浏览量是 best-effort 指标,
|
||||||
|
* 队列满时宁可丢计数也不能反压页面渲染(故不用 CallerRunsPolicy)。
|
||||||
|
*/
|
||||||
|
private final ExecutorService executor = new ThreadPoolExecutor(
|
||||||
|
1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||||
|
new ArrayBlockingQueue<>(VIEW_QUEUE_CAPACITY),
|
||||||
|
runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "cms-svc-view-counter");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
(runnable, pool) -> log.warn("[cms] 事项浏览量队列已满(上限 {}),丢弃一次计数", VIEW_QUEUE_CAPACITY));
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdown() {
|
||||||
|
executor.shutdown();
|
||||||
|
try {
|
||||||
|
executor.awaitTermination(3, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 校验与装配 =====
|
||||||
|
|
||||||
|
private void validateItemCode(SvcItemSaveRequest req) {
|
||||||
|
boolean duplicated = req.getId() == null
|
||||||
|
? itemRepository.existsBySiteIdAndItemCode(req.getSiteId(), req.getItemCode())
|
||||||
|
: itemRepository.existsBySiteIdAndItemCodeAndIdNot(req.getSiteId(), req.getItemCode(), req.getId());
|
||||||
|
if (duplicated) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "事项编码已存在:" + req.getItemCode() + "(站内唯一)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDictKeys(SvcItemSaveRequest req) {
|
||||||
|
if (StringUtils.hasText(req.getObjectKey())) {
|
||||||
|
dictService.requireEnabledKey(req.getSiteId(), ServiceDictTypeEnum.OBJECT, req.getObjectKey());
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(req.getCategoryKey())) {
|
||||||
|
dictService.requireEnabledKey(req.getSiteId(), ServiceDictTypeEnum.CATEGORY, req.getCategoryKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三个关联栏目必须属于本站点(防跨站注入,比照 home_layout 的 treeIds 校验) */
|
||||||
|
private void validateCategoryRefs(Integer siteId, SvcItemSaveRequest req) {
|
||||||
|
requireSiteCategory(siteId, req.getFaqCategoryId(), "常见问题栏目");
|
||||||
|
requireSiteCategory(siteId, req.getNoticeCategoryId(), "公告信息栏目");
|
||||||
|
requireSiteCategory(siteId, req.getRuleCategoryId(), "文件规定栏目");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireSiteCategory(Integer siteId, Integer categoryId, String label) {
|
||||||
|
if (categoryId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CmsCategory category = categoryRepository.findById(categoryId).orElse(null);
|
||||||
|
if (category == null || !siteId.equals(category.getSiteId())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, label + "不存在或不属于本站点:" + categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 部门校验+取权威名:组树不可达时拒绝(防止绕过选择器写脏快照),id 不在树上拒绝 */
|
||||||
|
private String resolveDeptName(Integer siteId, Integer deptGroupId) {
|
||||||
|
if (deptGroupId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||||
|
collectDeptNodes(fetchGroupTree(), deptNames);
|
||||||
|
String name = deptNames.get(deptGroupId);
|
||||||
|
if (name == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"服务部门不存在或部门服务不可达(id=" + deptGroupId + "),请重新选择");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组树拉取(不可达返回 null,由调用方决定降级口径);type 过滤口径见 deptGroupType */
|
||||||
|
private BoaGroup fetchGroupTree() {
|
||||||
|
return deptSource.getGroupTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectDeptNodes(BoaGroup node, Map<Integer, String> out) {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDeptNode(node)) {
|
||||||
|
out.putIfAbsent(node.getId(), node.getName());
|
||||||
|
}
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (BoaGroup child : node.getChildren()) {
|
||||||
|
collectDeptNodes(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateApplyUrl(String applyUrl) {
|
||||||
|
if (!StringUtils.hasText(applyUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String url = applyUrl.trim();
|
||||||
|
if (!APPLY_URL_PATTERN.matcher(url).matches()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "我要办理外链必须是完整的 http(s):// 地址");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件引用重绑:以「内容派生」为权威口径(修复审计 D1——上传时 scene 只写 ref_type 不写 ref_id,
|
||||||
|
* 若仅按本次会话上传的 id 列表重绑,正文内嵌图与封面永远停在 ref_id=null 的无主态,
|
||||||
|
* 且编辑已有事项二次保存会清掉上一轮建立的引用)。三步:
|
||||||
|
* <ol>
|
||||||
|
* <li>解除本事项已绑定、但本次列表里已不存在的行("移除即解除引用"语义,只解本事项,不误伤他人);</li>
|
||||||
|
* <li>按列表绑定(本站 + 可认领校验,命中他事项引用或跨站的行跳过并告警,不再静默改写/丢弃);</li>
|
||||||
|
* <li>内容派生:封面 URL 与三段富文本里的本站上传资产按内容全量同步(syncAssets)——
|
||||||
|
* 内容里有的标记归属,已不在内容中的解除归属;既覆盖"编辑保存时正文既有内嵌图不丢引用",
|
||||||
|
* 也覆盖封面换图/清空与正文删图(旧资产转回未使用,不留悬挂引用)。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private void bindAttachments(Integer siteId, CmsServiceItem item, List<Integer> attachmentIds) {
|
||||||
|
Integer itemId = item.getId();
|
||||||
|
Set<Integer> keep = attachmentIds == null ? Collections.emptySet() : new HashSet<>(attachmentIds);
|
||||||
|
|
||||||
|
List<CmsAttachment> released = new ArrayList<>();
|
||||||
|
for (CmsAttachment attachment : attachmentRepository.findByRefTypeAndRefId(REF_TYPE_SVC_ITEM, itemId)) {
|
||||||
|
if (!keep.contains(attachment.getId())) {
|
||||||
|
attachment.setRefType(null);
|
||||||
|
attachment.setRefId(null);
|
||||||
|
released.add(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!released.isEmpty()) {
|
||||||
|
attachmentRepository.saveAll(released);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keep.isEmpty()) {
|
||||||
|
List<CmsAttachment> bound = new ArrayList<>();
|
||||||
|
for (CmsAttachment attachment : attachmentRepository.findAllById(keep)) {
|
||||||
|
if (!siteId.equals(attachment.getSiteId())) {
|
||||||
|
log.warn("[cms] 事项附件跳过:非本站点附件 itemId={} attachmentId={}", itemId, attachment.getId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!claimable(attachment, itemId)) {
|
||||||
|
log.warn("[cms] 事项附件跳过:已被其他引用占用 itemId={} attachmentId={} refType={} refId={}",
|
||||||
|
itemId, attachment.getId(), attachment.getRefType(), attachment.getRefId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attachment.setRefType(REF_TYPE_SVC_ITEM);
|
||||||
|
attachment.setRefId(itemId);
|
||||||
|
bound.add(attachment);
|
||||||
|
}
|
||||||
|
if (!bound.isEmpty()) {
|
||||||
|
attachmentRepository.saveAll(bound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 内容资产(封面 + 三段正文内嵌图):按内容全量同步——内容里有的标记归属,已不在内容中的解除归属
|
||||||
|
Set<String> contentAssets = new LinkedHashSet<>(attachmentService.localAssetUrls(item.getProcessHtml()));
|
||||||
|
contentAssets.addAll(attachmentService.localAssetUrls(item.getMaterialHtml()));
|
||||||
|
contentAssets.addAll(attachmentService.localAssetUrls(item.getNoticeHtml()));
|
||||||
|
attachmentService.localWebUrl(item.getCoverImage()).ifPresent(contentAssets::add);
|
||||||
|
attachmentService.syncAssets(siteId, REF_TYPE_SVC_ASSET, itemId, contentAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可认领判定:无引用(refType 空)、上传时的半绑定态(事项两场景且 refId 空——AttachmentService.upload
|
||||||
|
* 只写 refType 不写 refId 的产物)、或已属本事项,三者可绑;已被他事项/他场景占用则拒绝,
|
||||||
|
* 避免绑定动作把别人的引用偷走。
|
||||||
|
*/
|
||||||
|
private static boolean claimable(CmsAttachment attachment, Integer itemId) {
|
||||||
|
if (attachment.getRefType() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
boolean svcScene = REF_TYPE_SVC_ITEM.equals(attachment.getRefType())
|
||||||
|
|| REF_TYPE_SVC_ASSET.equals(attachment.getRefType());
|
||||||
|
return svcScene && (attachment.getRefId() == null || itemId.equals(attachment.getRefId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CmsServiceItem requireItem(Integer siteId, Integer id) {
|
||||||
|
return itemRepository.findByIdAndSiteId(id, siteId)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PortalServiceItem toPortalItem(CmsServiceItem item) {
|
||||||
|
String objectName = null;
|
||||||
|
String categoryName = null;
|
||||||
|
if (StringUtils.hasText(item.getObjectKey())) {
|
||||||
|
objectName = dictService.nameMap(item.getSiteId(), ServiceDictTypeEnum.OBJECT)
|
||||||
|
.get(item.getObjectKey());
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(item.getCategoryKey())) {
|
||||||
|
categoryName = dictService.nameMap(item.getSiteId(), ServiceDictTypeEnum.CATEGORY)
|
||||||
|
.get(item.getCategoryKey());
|
||||||
|
}
|
||||||
|
return PortalServiceItem.of(item, objectName, categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<CmsServiceItem> publishedSpec(SvcItemPortalQuery query) {
|
||||||
|
return (root, cq, cb) -> {
|
||||||
|
List<Predicate> predicates = new ArrayList<>();
|
||||||
|
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
|
||||||
|
predicates.add(cb.equal(root.get("status"), ServiceItemStatusEnum.PUBLISHED.getCode()));
|
||||||
|
if (StringUtils.hasText(query.getObjectKey())) {
|
||||||
|
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(query.getCategoryKey())) {
|
||||||
|
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
|
||||||
|
}
|
||||||
|
if (query.getDeptGroupId() != null) {
|
||||||
|
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(query.getKeyword())) {
|
||||||
|
String like = "%" + query.getKeyword().trim() + "%";
|
||||||
|
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||||
|
cb.like(root.get("summary"), like)));
|
||||||
|
}
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String emptyToNull(String value) {
|
||||||
|
return StringUtils.hasText(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 字符串字面量(apply_url 已过 http(s) 白名单,无引号/控制字符风险,仍做转义兜底) */
|
||||||
|
private static String quoteJson(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Long> countMap(List<Object[]> rows) {
|
||||||
|
Map<String, Long> map = new HashMap<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,41 @@ public class PortalUrlHelper {
|
|||||||
return page != null && page > 1 ? url + "&page=" + page : url;
|
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<String, Object> query) {
|
||||||
|
if (query == null || query.isEmpty()) {
|
||||||
|
return serviceList();
|
||||||
|
}
|
||||||
|
StringBuilder qs = new StringBuilder();
|
||||||
|
for (java.util.Map.Entry<String, Object> 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 按站点皮肤解析;预览模式走管理端代理。
|
* 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。
|
||||||
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
|
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
|
||||||
|
|||||||
@@ -138,6 +138,69 @@ CREATE TABLE IF NOT EXISTS `cms_link` (
|
|||||||
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接';
|
) 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 事项字典(服务对象/事项类别)';
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 示例数据(联调首屏用,按需执行)
|
-- 示例数据(联调首屏用,按需执行)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -994,3 +994,253 @@ time, .item-meta { color: var(--text-muted); font-size: var(--fs-sm); }
|
|||||||
.side-nav { display: none; }
|
.side-nav { display: none; }
|
||||||
.page-cols { display: block; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||||
|
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||||
|
<body>
|
||||||
|
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||||
|
|
||||||
|
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
|
||||||
|
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
|
||||||
|
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext -->
|
||||||
|
<main class="detail-main container" id="main-content">
|
||||||
|
<div class="page-card svc-guide">
|
||||||
|
<nav class="breadcrumb" aria-label="面包屑">
|
||||||
|
<a th:href="${url.home()}">首页</a>
|
||||||
|
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||||
|
<a th:href="${url.serviceList()}">服务中心</a>
|
||||||
|
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||||
|
<span class="current" th:text="${item.title}">事项名称</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article class="svc-guide-body">
|
||||||
|
<h1 class="article-title" th:text="${item.title}">事项名称</h1>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span th:if="${!#strings.isEmpty(item.deptName)}"
|
||||||
|
th:text="${'办理部门:' + item.deptName}">办理部门</span>
|
||||||
|
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
|
||||||
|
<time th:if="${item.publishTime != null}"
|
||||||
|
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||||
|
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
|
||||||
|
<div class="svc-apply" th:if="${applyUrl != null}">
|
||||||
|
<a class="svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
|
||||||
|
<span class="svc-apply-hint">进入服务大厅在线办理</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
|
||||||
|
|
||||||
|
<table class="svc-info">
|
||||||
|
<caption class="skip-link">事项基本信息</caption>
|
||||||
|
<tbody>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.objectName)}">
|
||||||
|
<th scope="row">服务对象</th>
|
||||||
|
<td th:text="${item.objectName}">服务对象</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
|
||||||
|
<th scope="row">事项类别</th>
|
||||||
|
<td th:text="${item.categoryName}">事项类别</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
|
||||||
|
<th scope="row">办理地点</th>
|
||||||
|
<td th:text="${item.handlePlace}">办理地点</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
|
||||||
|
<th scope="row">办理时限</th>
|
||||||
|
<td th:text="${item.handleTimeLimit}">办理时限</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
|
||||||
|
<th scope="row">承诺时限</th>
|
||||||
|
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
|
||||||
|
<th scope="row">咨询电话</th>
|
||||||
|
<td th:text="${item.contactPhone}">咨询电话</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
|
||||||
|
<th scope="row">监督投诉</th>
|
||||||
|
<td th:text="${item.supervisePhone}">监督投诉电话</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<section class="svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
|
||||||
|
<h2>办理流程</h2>
|
||||||
|
<div class="article-content" th:utext="${item.processHtml}">办理流程</div>
|
||||||
|
</section>
|
||||||
|
<section class="svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
|
||||||
|
<h2>所需材料</h2>
|
||||||
|
<div class="article-content" th:utext="${item.materialHtml}">所需材料</div>
|
||||||
|
</section>
|
||||||
|
<section class="svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
|
||||||
|
<h2>注意事项</h2>
|
||||||
|
<div class="article-content" th:utext="${item.noticeHtml}">注意事项</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="svc-section svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
|
||||||
|
<h2>相关材料下载</h2>
|
||||||
|
<ul>
|
||||||
|
<li th:each="att : ${attachments}">
|
||||||
|
<a th:href="${url.upload(att.webUrl)}" th:download
|
||||||
|
th:text="${att.fileName}">材料文件</a>
|
||||||
|
<span class="svc-att-size" th:if="${att.fileSize != null}"
|
||||||
|
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
|
||||||
|
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
|
||||||
|
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
|
||||||
|
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="svc-related" th:if="${!#lists.isEmpty(relatedItems)}">
|
||||||
|
<h2 class="section-title">相关服务</h2>
|
||||||
|
<ul class="svc-related-list">
|
||||||
|
<li th:each="rel : ${relatedItems}">
|
||||||
|
<a th:href="${rel.url}">
|
||||||
|
<span class="svc-related-name" th:text="${rel.title}">相关事项</span>
|
||||||
|
<span class="svc-related-meta">
|
||||||
|
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
|
||||||
|
<span class="svc-tag"
|
||||||
|
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||||
|
<script th:src="${url.asset('js/main.js')}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||||
|
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||||
|
<body>
|
||||||
|
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||||
|
|
||||||
|
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
|
||||||
|
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query -->
|
||||||
|
<main class="list-main container" id="main-content">
|
||||||
|
<div class="page-card svc-page">
|
||||||
|
<nav class="breadcrumb" aria-label="面包屑">
|
||||||
|
<a th:href="${url.home()}">首页</a>
|
||||||
|
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||||
|
<span class="current">服务中心</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="page-title">服务中心</h1>
|
||||||
|
|
||||||
|
<div class="svc-filters"
|
||||||
|
th:if="${!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts)}">
|
||||||
|
<div class="svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
|
||||||
|
<span class="svc-filter-label">服务对象</span>
|
||||||
|
<div class="svc-filter-options">
|
||||||
|
<a class="svc-chip" th:each="opt : ${serviceFilters.objects}"
|
||||||
|
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||||
|
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
|
||||||
|
<span class="svc-filter-label">事项类别</span>
|
||||||
|
<div class="svc-filter-options">
|
||||||
|
<a class="svc-chip" th:each="opt : ${serviceFilters.categories}"
|
||||||
|
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||||
|
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
|
||||||
|
<span class="svc-filter-label">服务部门</span>
|
||||||
|
<div class="svc-filter-options">
|
||||||
|
<a class="svc-chip" th:each="opt : ${serviceFilters.depts}"
|
||||||
|
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||||
|
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="svc-toolbar">
|
||||||
|
<form class="svc-search" th:action="${url.serviceList()}" method="get" role="search">
|
||||||
|
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
|
||||||
|
<button type="submit">搜索</button>
|
||||||
|
</form>
|
||||||
|
<a class="svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="svc-list">
|
||||||
|
<li th:each="it : ${serviceList.items}">
|
||||||
|
<a class="svc-item" th:href="${url.serviceItem(it.id)}">
|
||||||
|
<span class="svc-item-cover" th:if="${it.coverImage != null}">
|
||||||
|
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
|
||||||
|
</span>
|
||||||
|
<span class="svc-item-body">
|
||||||
|
<span class="svc-item-title">
|
||||||
|
<span class="svc-item-name" th:text="${it.title}">事项名称</span>
|
||||||
|
<span class="svc-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
|
||||||
|
<span class="svc-tag"
|
||||||
|
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
|
||||||
|
</span>
|
||||||
|
<span class="svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
|
||||||
|
th:text="${it.summary}">简介</span>
|
||||||
|
<span class="svc-item-meta">
|
||||||
|
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
|
||||||
|
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
|
||||||
|
<time th:if="${it.publishTime != null}"
|
||||||
|
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="empty-state" th:if="${#lists.isEmpty(serviceList.items)}">
|
||||||
|
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
|
||||||
|
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
|
||||||
|
<p class="empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
|
||||||
|
<p class="empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页) -->
|
||||||
|
<nav class="pagination" th:if="${serviceList.totalPages > 1}" aria-label="分页">
|
||||||
|
<a th:each="p : ${serviceList.pageUrls}"
|
||||||
|
th:classappend="${p.active} ? 'active'"
|
||||||
|
th:href="${p.url}"
|
||||||
|
th:aria-label="${'第' + p.page + '页'}"
|
||||||
|
th:aria-current="${p.active} ? 'page'"
|
||||||
|
th:text="${p.page}">1</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||||
|
<script th:src="${url.asset('js/main.js')}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 皮肤包渲染契约护栏(方案 §8.3 F 组 T22,§4.3 数据契约自动化守护):
|
* 皮肤包渲染契约护栏(方案 §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 新增皮肤自动纳入,无需改动本测试。
|
* P0 对 default+t-demo-red 绿;P2 新增皮肤自动纳入,无需改动本测试。
|
||||||
*/
|
*/
|
||||||
@DisplayName("ThemeContract - 皮肤包渲染契约护栏(方案 §4.3)")
|
@DisplayName("ThemeContract - 皮肤包渲染契约护栏(方案 §4.3)")
|
||||||
@@ -38,6 +40,7 @@ class ThemeContractIT extends CmsIntegrationTestBase {
|
|||||||
private Integer listCatId;
|
private Integer listCatId;
|
||||||
private Integer singleCatId;
|
private Integer singleCatId;
|
||||||
private Integer articleId;
|
private Integer articleId;
|
||||||
|
private Integer serviceItemId;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void purgeOnly() {
|
void purgeOnly() {
|
||||||
@@ -65,6 +68,9 @@ class ThemeContractIT extends CmsIntegrationTestBase {
|
|||||||
});
|
});
|
||||||
articleId = persistArticle(site.getId(), listCatId, "契约测试文章",
|
articleId = persistArticle(site.getId(), listCatId, "契约测试文章",
|
||||||
ArticleStatusEnum.PUBLISHED, nowMinusSeconds(120)).getId();
|
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)
|
private String body(MvcResult result, String theme, String route)
|
||||||
@@ -76,7 +82,7 @@ class ThemeContractIT extends CmsIntegrationTestBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("T22: 每个注册主题渲染五路由(home/list/single/detail/search 全 200 含站点名)")
|
@DisplayName("T22: 每个注册主题渲染七路由(home/list/single/detail/search/service/guide 全 200 含站点名)")
|
||||||
void everyRegisteredThemeRendersAllRoutes() throws Exception {
|
void everyRegisteredThemeRendersAllRoutes() throws Exception {
|
||||||
List<ThemeInfo> themes = themeRegistry.list();
|
List<ThemeInfo> themes = themeRegistry.list();
|
||||||
assertThat(themes).as("注册表不可为空(红阶段护栏)").isNotEmpty();
|
assertThat(themes).as("注册表不可为空(红阶段护栏)").isNotEmpty();
|
||||||
@@ -99,6 +105,23 @@ class ThemeContractIT extends CmsIntegrationTestBase {
|
|||||||
body(mockMvc.perform(get("/portal/{code}/search", siteCode).param("q", "契约"))
|
body(mockMvc.perform(get("/portal/{code}/search", siteCode).param("q", "契约"))
|
||||||
.andExpect(status().isOk()).andReturn(), theme.getName(), "搜索页");
|
.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();
|
purgeTables();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,27 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
.hasMessageContaining("scene");
|
.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
|
@Test
|
||||||
@DisplayName("B2: markUsed 后附件不再是未使用(不出现在 unusedOnly 列表)")
|
@DisplayName("B2: markUsed 后附件不再是未使用(不出现在 unusedOnly 列表)")
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@@ -220,6 +220,25 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
|||||||
.contains("邮编:464300");
|
.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)=====
|
// ===== 主题白名单与 themeParams 合并(方案 §5.1/§5.3,T9-T12)=====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yunhai.cms.support;
|
package com.yunhai.cms.support;
|
||||||
|
|
||||||
import com.yunhai.cms.entity.CmsArticle;
|
import com.yunhai.cms.entity.CmsArticle;
|
||||||
|
import com.yunhai.cms.entity.CmsAttachment;
|
||||||
import com.yunhai.cms.entity.CmsBanner;
|
import com.yunhai.cms.entity.CmsBanner;
|
||||||
import com.yunhai.cms.entity.CmsCategory;
|
import com.yunhai.cms.entity.CmsCategory;
|
||||||
import com.yunhai.cms.entity.CmsLink;
|
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.CmsCategoryRepository;
|
||||||
import com.yunhai.cms.repository.CmsLinkRepository;
|
import com.yunhai.cms.repository.CmsLinkRepository;
|
||||||
import com.yunhai.cms.repository.CmsSiteRepository;
|
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.junit.jupiter.api.BeforeEach;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
@@ -50,6 +56,10 @@ public abstract class CmsIntegrationTestBase {
|
|||||||
protected CmsLinkRepository linkRepository;
|
protected CmsLinkRepository linkRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
protected CmsAttachmentRepository attachmentRepository;
|
protected CmsAttachmentRepository attachmentRepository;
|
||||||
|
@Autowired
|
||||||
|
protected CmsServiceItemRepository serviceItemRepository;
|
||||||
|
@Autowired
|
||||||
|
protected CmsServiceDictRepository serviceDictRepository;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void clearCachesBeforeEachTest() {
|
void clearCachesBeforeEachTest() {
|
||||||
@@ -63,13 +73,15 @@ public abstract class CmsIntegrationTestBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空 6 张业务表(无外键约束,顺序无碍),非事务用例的隔离手段 */
|
/** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2) */
|
||||||
protected void purgeTables() {
|
protected void purgeTables() {
|
||||||
attachmentRepository.deleteAllInBatch();
|
attachmentRepository.deleteAllInBatch();
|
||||||
articleRepository.deleteAllInBatch();
|
articleRepository.deleteAllInBatch();
|
||||||
categoryRepository.deleteAllInBatch();
|
categoryRepository.deleteAllInBatch();
|
||||||
bannerRepository.deleteAllInBatch();
|
bannerRepository.deleteAllInBatch();
|
||||||
linkRepository.deleteAllInBatch();
|
linkRepository.deleteAllInBatch();
|
||||||
|
serviceItemRepository.deleteAllInBatch();
|
||||||
|
serviceDictRepository.deleteAllInBatch();
|
||||||
siteRepository.deleteAllInBatch();
|
siteRepository.deleteAllInBatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +162,50 @@ public abstract class CmsIntegrationTestBase {
|
|||||||
return linkRepository.saveAndFlush(l);
|
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 检查一次期望 */
|
/** 简易轮询等待(无 awaitility 依赖):最长 waitMillis 内每 50ms 检查一次期望 */
|
||||||
protected static void awaitTrue(long waitMillis, java.util.function.BooleanSupplier condition)
|
protected static void awaitTrue(long waitMillis, java.util.function.BooleanSupplier condition)
|
||||||
throws InterruptedException {
|
throws InterruptedException {
|
||||||
|
|||||||
@@ -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 整条链零覆盖)。
|
||||||
|
*
|
||||||
|
* <p>覆盖:组树类型过滤 + 层级构建、保存时按权威名落快照、id 不在树上拒绝、组树不可达降级、
|
||||||
|
* syncDeptName 刷新快照并失效指南页缓存。组树经 {@link SvcDeptSource} 接缝注入固定树,
|
||||||
|
* 使"部门可达"这条主路径可断言(生产实现代理 UserBase 静态调用)。
|
||||||
|
*/
|
||||||
|
@DisplayName("SvcItemService - 服务部门链(组树接缝)")
|
||||||
|
class SvcDeptChainIT extends CmsIntegrationTestBase {
|
||||||
|
|
||||||
|
/** 可变组树桩:用例按需替换;null 模拟平台不可达 */
|
||||||
|
static BoaGroup stubTree;
|
||||||
|
|
||||||
|
private static final Integer DEPT_ID = 11;
|
||||||
|
private static final Integer SCHOOL_ID = 10;
|
||||||
|
private static final Integer NOT_DEPT_ID = 12;
|
||||||
|
private static final Integer UNKNOWN_DEPT_ID = 999;
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class StubDeptSourceConfig {
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
SvcDeptSource stubSvcDeptSource() {
|
||||||
|
return () -> stubTree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SvcItemQuery svcItemQuery;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
purgeTables();
|
||||||
|
siteId = persistSite("demo1", 1, "演示站点一").getId();
|
||||||
|
persistSvcDict(siteId, "object", "person", "个人", 1);
|
||||||
|
persistSvcDict(siteId, "category", "jw", "教务服务", 1);
|
||||||
|
stubTree = tree("第一中学", "教务处");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clean() {
|
||||||
|
stubTree = null;
|
||||||
|
purgeTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根(type=1) → 学校(type=2) → [教务处(type=2), 非部门节点(type=9)];另加一所兄弟学校 */
|
||||||
|
private static BoaGroup tree(String schoolName, String deptName) {
|
||||||
|
BoaGroup root = group(1, "云海集团", 1);
|
||||||
|
BoaGroup school = group(SCHOOL_ID, schoolName, 2);
|
||||||
|
BoaGroup dept = group(DEPT_ID, deptName, 2);
|
||||||
|
BoaGroup notDept = group(NOT_DEPT_ID, "非部门节点", 9);
|
||||||
|
BoaGroup sibling = group(20, "第二中学", 2);
|
||||||
|
school.setChildren(Arrays.asList(dept, notDept));
|
||||||
|
root.setChildren(Arrays.asList(school, sibling));
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoaGroup group(Integer id, String name, Integer type) {
|
||||||
|
BoaGroup g = new BoaGroup(id, name);
|
||||||
|
g.setType(type);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SvcItemSaveRequest request() {
|
||||||
|
SvcItemSaveRequest req = new SvcItemSaveRequest();
|
||||||
|
req.setSiteId(siteId);
|
||||||
|
req.setItemCode("BG-ZX-001");
|
||||||
|
req.setTitle("转学办理");
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> names(List<SvcDeptVo> nodes) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (SvcDeptVo node : nodes) {
|
||||||
|
out.add(node.getName());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("deptTree:按配置类型过滤(非部门节点不下发)且保留层级")
|
||||||
|
void deptTree_nestedAndTypeFiltered() {
|
||||||
|
List<SvcDeptVo> top = svcItemQuery.deptTree(TestUsers.superAdmin(), siteId);
|
||||||
|
assertThat(names(top)).containsExactly("第一中学", "第二中学");
|
||||||
|
SvcDeptVo school = top.get(0);
|
||||||
|
assertThat(school.getId()).isEqualTo(SCHOOL_ID);
|
||||||
|
assertThat(names(school.getChildren())).as("type=9 的节点被过滤,部门子节点保留").containsExactly("教务处");
|
||||||
|
assertThat(school.getChildren().get(0).getId()).isEqualTo(DEPT_ID);
|
||||||
|
assertThat(school.getChildren().get(0).getChildren()).isEmpty();
|
||||||
|
assertThat(top.get(1).getChildren()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("deptTree:组树不可达降级为空列表(不抛异常)")
|
||||||
|
void deptTree_unreachableReturnsEmpty() {
|
||||||
|
stubTree = null;
|
||||||
|
assertThat(svcItemQuery.deptTree(TestUsers.superAdmin(), siteId)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("保存:合法部门 id 落权威名快照")
|
||||||
|
void save_validDeptIdSnapshotsName() {
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setDeptGroupId(DEPT_ID);
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(vo.getDeptName()).isEqualTo("教务处");
|
||||||
|
assertThat(vo.getDeptGroupId()).isEqualTo(DEPT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("保存:组树不可达时带部门保存被拒(防绕过选择器写脏快照)")
|
||||||
|
void save_unreachableDeptRejected() {
|
||||||
|
stubTree = null;
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setDeptGroupId(DEPT_ID);
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("部门服务不可达");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("保存:id 不在组树上被拒;留空可保存")
|
||||||
|
void save_unknownDeptRejectedAndBlankAllowed() {
|
||||||
|
SvcItemSaveRequest bad = request();
|
||||||
|
bad.setDeptGroupId(UNKNOWN_DEPT_ID);
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), bad))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("服务部门不存在");
|
||||||
|
|
||||||
|
SvcItemSaveRequest blank = request();
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), blank);
|
||||||
|
assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("syncDeptName:组树改名后刷新快照条数,并失效指南页缓存")
|
||||||
|
void syncDeptName_refreshesSnapshotAndCache() {
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setDeptGroupId(DEPT_ID);
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId).getDeptName()).isEqualTo("教务处");
|
||||||
|
|
||||||
|
// 组树改名后:未同步前指南页仍读缓存中的旧名
|
||||||
|
stubTree = tree("第一中学", "教务处(改)");
|
||||||
|
Map<String, Object> result = svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId);
|
||||||
|
assertThat(result.get("updated")).isEqualTo(1);
|
||||||
|
PortalServiceItem refreshed = svcItemQuery.portalItem(id, siteId);
|
||||||
|
assertThat(refreshed.getDeptName()).as("syncDeptName 事件已整表失效指南页缓存").isEqualTo("教务处(改)");
|
||||||
|
assertThat(svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id).getDeptName()).isEqualTo("教务处(改)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("syncDeptName:组树不可达时报错且不改数据")
|
||||||
|
void syncDeptName_unreachableRejected() {
|
||||||
|
stubTree = null;
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.syncDeptName(TestUsers.superAdmin(), siteId))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("不可达或为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SvcDictVo> list = svcDictService.list(TestUsers.superAdmin(), siteId, "category");
|
||||||
|
assertThat(list).extracting("dictKey").containsExactly("jw", "log");
|
||||||
|
assertThat(list).extracting("referenced").containsExactly(2L, 0L);
|
||||||
|
assertThat(dictId).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("nameMap/enabledDicts:全状态 vs 仅启用的口径差")
|
||||||
|
void nameMapAndEnabledDicts_semantics() {
|
||||||
|
svcDictService.save(TestUsers.superAdmin(), request("object", "person", "个人"));
|
||||||
|
Integer stopId = svcDictService.save(TestUsers.superAdmin(), request("object", "legal", "法人"));
|
||||||
|
SvcDictSaveRequest disable = request("object", "legal", "法人");
|
||||||
|
disable.setId(stopId);
|
||||||
|
disable.setStatus(0);
|
||||||
|
svcDictService.save(TestUsers.superAdmin(), disable);
|
||||||
|
|
||||||
|
assertThat(svcDictService.nameMap(siteId, ServiceDictTypeEnum.OBJECT).keySet())
|
||||||
|
.as("全状态:渲染口径含停用键").containsExactlyInAnyOrder("person", "legal");
|
||||||
|
assertThat(svcDictService.enabledDicts(siteId, ServiceDictTypeEnum.OBJECT))
|
||||||
|
.extracting("dictKey").containsExactly("person");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("字典改名:指南页已缓存模型立即刷新 categoryName(审计 D4 修复锚点)")
|
||||||
|
void rename_refreshesCachedGuideModel() {
|
||||||
|
Integer dictId = svcDictService.save(TestUsers.superAdmin(), request("category", "jw", "教务服务"));
|
||||||
|
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
|
||||||
|
persistItemWithKeys("S1", "转学办理", null, "jw");
|
||||||
|
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||||
|
serviceItemRepository.saveAndFlush(item);
|
||||||
|
|
||||||
|
assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName()).isEqualTo("教务服务");
|
||||||
|
|
||||||
|
// 改名:scope=dict 事件整表清 cms:svcItem → 已缓存指南页不再滞留旧名(修复前最多滞后 5min)
|
||||||
|
SvcDictSaveRequest rename = request("category", "jw", "教务与学籍服务");
|
||||||
|
rename.setId(dictId);
|
||||||
|
svcDictService.save(TestUsers.superAdmin(), rename);
|
||||||
|
|
||||||
|
assertThat(svcItemQuery.portalItem(item.getId(), siteId).getCategoryName())
|
||||||
|
.as("改名后指南页模型立即可见新名").isEqualTo("教务与学籍服务");
|
||||||
|
assertThat(svcItemQuery.portalFilters(siteId).getCategories())
|
||||||
|
.extracting("name").containsExactly("教务与学籍服务");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("先建子站事项");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<p>子站流程</p>");
|
||||||
|
serviceItemRepository.saveAndFlush(childItem);
|
||||||
|
assertThat(body(mockMvc.perform(get("/portal/schg/childg/sv/{id}", childItem.getId()))
|
||||||
|
.andExpect(status().isOk()).andReturn()))
|
||||||
|
.contains("子站事项").contains("子站流程").contains("rel=\"canonical\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("指南页浏览量异步累加:HTTP 访问触发 + 计数器原子自增(不承诺即时,轮询等待)")
|
||||||
|
void guideItem_viewCountIncrementsAsync() throws Exception {
|
||||||
|
Integer id = publishItem("S1", "转学办理", "person", "jw");
|
||||||
|
// ① HTTP 路径:指南页渲染成功并触发一次计数(异步)
|
||||||
|
mockMvc.perform(get("/portal/demo1/sv/{id}", id)).andExpect(status().isOk());
|
||||||
|
// ② 再补 4 次显式计数:把"异步累加链路是否工作"与"MockMvc 渲染耗时"解耦
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
svcItemQuery.countView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
int expected = 5;
|
||||||
|
int viewCount = 0;
|
||||||
|
// 轮询预算 30s:异步任务是 fire-and-forget(设计不承诺即时),且满载环境下取连接可能等待
|
||||||
|
// Hikari 默认 connectionTimeout(30s),故预算需覆盖该量级,避免把连接竞争误判为功能缺陷
|
||||||
|
for (int i = 0; i < 300; i++) {
|
||||||
|
viewCount = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new)
|
||||||
|
.getViewCount();
|
||||||
|
if (viewCount >= expected) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
java.lang.Thread.sleep(100);
|
||||||
|
}
|
||||||
|
assertThat(viewCount).as("1 次指南页访问 + 4 次显式计数应累加为 %s", expected)
|
||||||
|
.isGreaterThanOrEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 指南页装配(§5.3)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("指南页:富文本补上传基准、附件下载、关联栏目 url.category、关联事项、手填外链按钮")
|
||||||
|
void guide_modelAssembly() throws Exception {
|
||||||
|
Integer faqCat = persistCategory(siteId, 0, "FAQ", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
|
||||||
|
com.yunhai.cms.svcitem.entity.CmsServiceItem item =
|
||||||
|
serviceItemRepository.findById(publishItem("S1", "转学办理", "person", "jw")).orElseThrow(IllegalStateException::new);
|
||||||
|
item.setProcessHtml("<p><img src=\"/cms/upload/cms/demo1/202609/a.jpg\"></p>");
|
||||||
|
item.setFaqCategoryId(faqCat);
|
||||||
|
item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}");
|
||||||
|
serviceItemRepository.saveAndFlush(item);
|
||||||
|
com.yunhai.cms.entity.CmsAttachment att = persistAttachment(siteId, "svc_item", "申请表.pdf");
|
||||||
|
att.setRefId(item.getId());
|
||||||
|
attachmentRepository.saveAndFlush(att);
|
||||||
|
publishItem("S2", "休学办理", "person", "jw"); // 同类别关联事项
|
||||||
|
|
||||||
|
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", item.getId()))
|
||||||
|
.andExpect(status().isOk()).andReturn());
|
||||||
|
// 上传地址渲染期补基准(B-1 口径:MockMvc 请求派生基准 = http://localhost)
|
||||||
|
assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/a.jpg\"");
|
||||||
|
// 附件与下载链接(webUrl 相对路径经 url.upload 补基准)
|
||||||
|
assertThat(html).contains("申请表.pdf");
|
||||||
|
assertThat(html).contains("http://localhost/cms/upload/cms/" + siteId + "/");
|
||||||
|
// 关联栏目链接经 url.category 生成
|
||||||
|
assertThat(html).contains("/portal/demo1/c/" + faqCat);
|
||||||
|
// 关联事项(同类别已发布,排除自身)
|
||||||
|
assertThat(html).contains("休学办理");
|
||||||
|
// 我要办理三态①:手填外链优先
|
||||||
|
assertThat(html).contains("https://apply.example.com/zx");
|
||||||
|
assertThat(html).contains("我要办理");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("我要办理三态:② svc_hall_base 生成 svhall 发起页 URL;③ 都没有 → 不渲染按钮")
|
||||||
|
void applyUrl_generatedAndAbsentStates() throws Exception {
|
||||||
|
// ② 未手填且站点配 svc_hall_base → 生成(尾斜杠归一)
|
||||||
|
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new);
|
||||||
|
site.setConfigJson("{\"svc_hall_base\":\"https://svhall.example.com/\"}");
|
||||||
|
siteRepository.saveAndFlush(site);
|
||||||
|
clearCaches();
|
||||||
|
Integer id = publishItem("S1", "转学办理", "person", "jw");
|
||||||
|
String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id))
|
||||||
|
.andExpect(status().isOk()).andReturn());
|
||||||
|
assertThat(html).contains("我要办理");
|
||||||
|
assertThat(html).contains("https://svhall.example.com/#/apply?siteCode=demo1&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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<p>流程一</p><script>alert(1)</script><p onclick=\"x()\">流程二</p>");
|
||||||
|
req.setApplyUrl("https://apply.example.com/zx");
|
||||||
|
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
SvcItemEditVo vo = svcItemQuery.adminGet(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(vo.getStatus()).isEqualTo(ServiceItemStatusEnum.DRAFT.getCode());
|
||||||
|
assertThat(vo.getProcessHtml()).contains("流程一").contains("流程二")
|
||||||
|
.doesNotContain("<script").doesNotContain("onclick");
|
||||||
|
assertThat(vo.getApplyUrl()).isEqualTo("https://apply.example.com/zx");
|
||||||
|
Map<String, Object> page = svcItemQuery.adminPage(TestUsers.superAdmin(),
|
||||||
|
queryOf(null, null));
|
||||||
|
assertThat(page.get("total")).isEqualTo(1L);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<SvcItemPageItem> list = (List<SvcItemPageItem>) page.get("list");
|
||||||
|
assertThat(list.get(0).getObjectName()).isEqualTo("个人");
|
||||||
|
assertThat(list.get(0).getCategoryName()).isEqualTo("教务服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("事项编码站内唯一:新建撞码/编辑与他项撞码均拒绝(uk_svc_item_code 语义)")
|
||||||
|
void save_duplicateItemCodeRejected() {
|
||||||
|
Integer first = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
// 新建撞码
|
||||||
|
SvcItemSaveRequest dup = request();
|
||||||
|
dup.setTitle("另一个转学");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), dup))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在");
|
||||||
|
// 编辑改为他项编码
|
||||||
|
SvcItemSaveRequest edit = request();
|
||||||
|
edit.setId(first);
|
||||||
|
edit.setItemCode("BG-ZX-002");
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
|
||||||
|
SvcItemSaveRequest clash = request();
|
||||||
|
clash.setId(first);
|
||||||
|
clash.setItemCode("BG-ZX-001");
|
||||||
|
// 站内已有 BG-ZX-001?——已改名,此处验证编辑撞“现存他码”:先再建一条
|
||||||
|
SvcItemSaveRequest second = request();
|
||||||
|
second.setItemCode("BG-ZX-003");
|
||||||
|
second.setTitle("第二条");
|
||||||
|
Integer secondId = svcItemQuery.adminSave(TestUsers.superAdmin(), second);
|
||||||
|
SvcItemSaveRequest clash2 = request();
|
||||||
|
clash2.setId(secondId);
|
||||||
|
clash2.setItemCode("BG-ZX-002");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), clash2))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("事项编码已存在");
|
||||||
|
// 同码不同站互不冲突(site 隔离)
|
||||||
|
SvcItemSaveRequest otherSite = request();
|
||||||
|
otherSite.setSiteId(otherSiteId);
|
||||||
|
otherSite.setItemCode("BG-ZX-001");
|
||||||
|
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), otherSite)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("字典键校验:不存在的键拒绝;已停用字典拒绝;空串视同未填")
|
||||||
|
void save_dictKeyMustBeEnabled() {
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setObjectKey("ghost");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("服务对象不存在");
|
||||||
|
req.setObjectKey("person");
|
||||||
|
|
||||||
|
persistSvcDict(siteId, "category", "stopped", "已停用类别", 0);
|
||||||
|
req.setCategoryKey("stopped");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("已停用");
|
||||||
|
|
||||||
|
req.setCategoryKey("");
|
||||||
|
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("apply_url 格式校验:非 http(s) 完整地址拒绝;空值不落 ext_json")
|
||||||
|
void save_applyUrlMustBeHttp() {
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setApplyUrl("javascript:alert(1)");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("http");
|
||||||
|
req.setApplyUrl("www.example.com");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("http");
|
||||||
|
req.setApplyUrl(null);
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
assertThat(serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new)
|
||||||
|
.getExtJson()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("关联栏目校验:跨站栏目 id 拒绝(防跨站注入)")
|
||||||
|
void save_crossSiteCategoryRejected() {
|
||||||
|
Integer otherCat = persistCategory(otherSiteId, 0, "他站栏目", CategoryTypeEnum.LIST).getId();
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setFaqCategoryId(otherCat);
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.superAdmin(), req))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("不属于本站点");
|
||||||
|
Integer ownCat = persistCategory(siteId, 0, "本站栏目", CategoryTypeEnum.LIST).getId();
|
||||||
|
req.setFaqCategoryId(ownCat);
|
||||||
|
assertThat(svcItemQuery.adminSave(TestUsers.superAdmin(), req)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("权限:子站/他校管理员 FORBIDDEN(requireManageable),本站通过")
|
||||||
|
void permission_schoolScoped() {
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(TestUsers.schoolUser(2), request()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
SvcItemSaveRequest own = request();
|
||||||
|
own.setSiteId(otherSiteId);
|
||||||
|
assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 状态机(§2.4)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("状态机:草稿→发布(门户可见)→下架(门户 404)→再发布(又可见)")
|
||||||
|
void stateMachine_publishOfflineRepublish() {
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId)).as("草稿不可见(null 哨兵)").isNull();
|
||||||
|
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
|
||||||
|
PortalServiceItem published = svcItemQuery.portalItem(id, siteId);
|
||||||
|
assertThat(published).as("发布后事件已失效 null 哨兵缓存").isNotNull();
|
||||||
|
assertThat(published.getPublishTime()).isNotNull();
|
||||||
|
|
||||||
|
svcItemQuery.adminOffline(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId)).as("下架后缓存已失效且返回 null").isNull();
|
||||||
|
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("编辑已发布事项:事件失效指南页缓存,新内容立即可见")
|
||||||
|
void editPublishedItem_evictsCache() {
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId).getTitle()).isEqualTo("转学办理");
|
||||||
|
|
||||||
|
SvcItemSaveRequest edit = request();
|
||||||
|
edit.setId(id);
|
||||||
|
edit.setTitle("转学办理(2026 版)");
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
|
||||||
|
assertThat(svcItemQuery.portalItem(id, siteId).getTitle())
|
||||||
|
.as("保存后 AFTER_COMMIT 事件已 evict itemId").isEqualTo("转学办理(2026 版)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 附件回收(§3.5)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("附件绑定与回收:attachmentIds 全量重绑;删除事项释放 svc_item 引用")
|
||||||
|
void attachments_bindAndRelease() {
|
||||||
|
CmsAttachment att1 = persistAttachment(siteId, "svc_item", "材料一.pdf");
|
||||||
|
CmsAttachment att2 = persistAttachment(siteId, "svc_item", "材料二.pdf");
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setAttachmentIds(Arrays.asList(att1.getId(), att2.getId()));
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
assertThat(svcItemQuery.attachments(id)).hasSize(2);
|
||||||
|
|
||||||
|
// 重绑:仅保留 att1 → att2 释放为未使用
|
||||||
|
SvcItemSaveRequest edit = request();
|
||||||
|
edit.setId(id);
|
||||||
|
edit.setAttachmentIds(Collections.singletonList(att1.getId()));
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
|
||||||
|
assertThat(svcItemQuery.attachments(id)).extracting("fileName")
|
||||||
|
.containsExactly("材料一.pdf");
|
||||||
|
assertThat(attachmentRepository.findById(att2.getId()).orElseThrow(IllegalStateException::new)
|
||||||
|
.getRefId()).as("未在列表中的附件回到未使用").isNull();
|
||||||
|
|
||||||
|
// 删除事项:svc_item 引用整体释放
|
||||||
|
DeleteRequest del = new DeleteRequest();
|
||||||
|
del.setSiteId(siteId);
|
||||||
|
del.setId(id);
|
||||||
|
svcItemQuery.adminDelete(TestUsers.superAdmin(), del);
|
||||||
|
assertThat(svcItemQuery.attachments(id)).isEmpty();
|
||||||
|
assertThat(attachmentRepository.findById(att1.getId()).orElseThrow(IllegalStateException::new)
|
||||||
|
.getRefType()).isNull();
|
||||||
|
assertThat(serviceItemRepository.findById(id)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 附件引用:内容派生重绑(审计 D1/D5 修复锚点)=====
|
||||||
|
|
||||||
|
/** 造附件:webUrl 走 FILE_WEB_PATH(/cms/upload/) 前缀,与线上上传产物同形 */
|
||||||
|
private CmsAttachment attachment(String fileName, Integer ownerItemId) {
|
||||||
|
CmsAttachment att = new CmsAttachment();
|
||||||
|
att.setSiteId(siteId);
|
||||||
|
att.setFileName(fileName);
|
||||||
|
att.setFilePath("cms/" + siteId + "/202609/" + fileName.hashCode() + ".png");
|
||||||
|
att.setWebUrl("/cms/upload/" + att.getFilePath());
|
||||||
|
att.setFileSize(2048L);
|
||||||
|
att.setMimeType("image/png");
|
||||||
|
if (ownerItemId != null) {
|
||||||
|
att.setRefType("svc_item");
|
||||||
|
att.setRefId(ownerItemId);
|
||||||
|
}
|
||||||
|
return attachmentRepository.saveAndFlush(att);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CmsAttachment reload(CmsAttachment att) {
|
||||||
|
return attachmentRepository.findById(att.getId()).orElseThrow(IllegalStateException::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("封面与正文内嵌图按内容归属;编辑二次保存不丢引用;清空即解绑(原 D1 缺陷)")
|
||||||
|
void attachments_contentDerivedCoverAndHtml() {
|
||||||
|
CmsAttachment cover = attachment("封面.png", null);
|
||||||
|
CmsAttachment inline = attachment("流程图.png", null);
|
||||||
|
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setCoverImage(cover.getWebUrl());
|
||||||
|
req.setProcessHtml("<p>流程</p><img src=\"" + inline.getWebUrl() + "\" alt=\"流程图\">");
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
|
||||||
|
assertThat(reload(cover).getRefType()).as("封面归属事项内容资产场景").isEqualTo("svc_item_asset");
|
||||||
|
assertThat(reload(cover).getRefId()).isEqualTo(id);
|
||||||
|
assertThat(reload(inline).getRefType()).isEqualTo("svc_item_asset");
|
||||||
|
assertThat(reload(inline).getRefId()).isEqualTo(id);
|
||||||
|
assertThat(svcItemQuery.attachments(id)).as("内容资产不进指南附件列表").isEmpty();
|
||||||
|
|
||||||
|
// 二次保存(内容不变、未带 attachmentIds):引用必须存活——修复前会被 release 清掉且永不恢复
|
||||||
|
SvcItemSaveRequest edit = request();
|
||||||
|
edit.setId(id);
|
||||||
|
edit.setCoverImage(cover.getWebUrl());
|
||||||
|
edit.setProcessHtml(req.getProcessHtml());
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), edit);
|
||||||
|
assertThat(reload(cover).getRefId()).as("二次保存后封面仍归属本事项").isEqualTo(id);
|
||||||
|
assertThat(reload(inline).getRefId()).as("二次保存后内嵌图仍归属本事项").isEqualTo(id);
|
||||||
|
|
||||||
|
// 清空封面并移除正文图:两者回到未使用(可被附件管理页清理)
|
||||||
|
SvcItemSaveRequest cleared = request();
|
||||||
|
cleared.setId(id);
|
||||||
|
cleared.setProcessHtml("<p>流程</p>");
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), cleared);
|
||||||
|
assertThat(reload(cover).getRefType()).isNull();
|
||||||
|
assertThat(reload(cover).getRefId()).isNull();
|
||||||
|
assertThat(reload(inline).getRefType()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("附件归属校验:已被他事项占用的不抢走、跨站附件跳过、空列表幂等")
|
||||||
|
void attachments_ownershipGuards() {
|
||||||
|
Integer otherItem = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
CmsAttachment stolen = attachment("他项材料.pdf", otherItem);
|
||||||
|
CmsAttachment crossSite = new CmsAttachment();
|
||||||
|
crossSite.setSiteId(otherSiteId);
|
||||||
|
crossSite.setFileName("他站材料.pdf");
|
||||||
|
crossSite.setFilePath("cms/" + otherSiteId + "/202609/x.pdf");
|
||||||
|
crossSite.setWebUrl("/cms/upload/cms/" + otherSiteId + "/202609/x.pdf");
|
||||||
|
crossSite = attachmentRepository.saveAndFlush(crossSite);
|
||||||
|
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setItemCode("BG-ZX-002");
|
||||||
|
req.setTitle("补办学生证");
|
||||||
|
req.setAttachmentIds(Arrays.asList(stolen.getId(), crossSite.getId()));
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
|
||||||
|
assertThat(reload(stolen).getRefId()).as("他事项占用的引用不被改写").isEqualTo(otherItem);
|
||||||
|
assertThat(reload(crossSite).getRefType()).as("跨站附件不绑定").isNull();
|
||||||
|
assertThat(svcItemQuery.attachments(id)).isEmpty();
|
||||||
|
|
||||||
|
// 空列表幂等:以本事项自身编码(BG-ZX-002)重复保存两次均不抛异常,附件仍为空
|
||||||
|
SvcItemSaveRequest again = request();
|
||||||
|
again.setId(id);
|
||||||
|
again.setItemCode("BG-ZX-002");
|
||||||
|
again.setTitle("补办学生证");
|
||||||
|
again.setAttachmentIds(null);
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), again);
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), again);
|
||||||
|
assertThat(svcItemQuery.attachments(id)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("权限:管理端全部方法对非本站管理员均 FORBIDDEN")
|
||||||
|
void permission_allAdminMethodsScoped() {
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
BoaUser intruder = TestUsers.schoolUser(999);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSave(intruder, request()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminPage(intruder, queryOf(null, null)))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminGet(intruder, siteId, id))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminPublish(intruder, siteId, id))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminOffline(intruder, siteId, id))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminDelete(intruder, deleteRequestOf(id)))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSort(intruder, siteId,
|
||||||
|
Collections.singletonList(sortItemOf(id, 1))))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.adminSelList(intruder, siteId))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.itemAttachments(intruder, siteId, id))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.deptTree(intruder, siteId))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
assertThatThrownBy(() -> svcItemQuery.syncDeptName(intruder, siteId))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("无权");
|
||||||
|
|
||||||
|
// 本校(otherSiteId 的 schId=2)通过:确认拦截来自站点归属而非"用户无角色"
|
||||||
|
SvcItemSaveRequest own = request();
|
||||||
|
own.setSiteId(otherSiteId);
|
||||||
|
assertThat(svcItemQuery.adminSave(TestUsers.schoolUser(2), own)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeleteRequest deleteRequestOf(Integer id) {
|
||||||
|
DeleteRequest del = new DeleteRequest();
|
||||||
|
del.setSiteId(siteId);
|
||||||
|
del.setId(id);
|
||||||
|
return del;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IdSortItem sortItemOf(Integer id, Integer sortNo) {
|
||||||
|
IdSortItem item = new IdSortItem();
|
||||||
|
item.setId(id);
|
||||||
|
item.setSortNo(sortNo);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 门户筛选计数与缓存(§3.6/§5.2)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("portalFilters:仅统计已发布事项;发布后 filters 缓存被事件失效并更新计数")
|
||||||
|
void portalFilters_countsAndCacheInvalidation() {
|
||||||
|
persistSvcItem(siteId, "S1", "事项一", ServiceItemStatusEnum.PUBLISHED);
|
||||||
|
persistSvcItem(siteId, "S2", "事项二", ServiceItemStatusEnum.PUBLISHED);
|
||||||
|
persistSvcItem(siteId, "S3", "事项草稿", ServiceItemStatusEnum.DRAFT);
|
||||||
|
// 直接落库种子无字典键 → 先补:用 adminSave 造带键事项更贴近真实
|
||||||
|
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
|
||||||
|
assertThat(filters.getObjects()).as("种子无字典键,对象组为空").isEmpty();
|
||||||
|
|
||||||
|
SvcItemSaveRequest req = request();
|
||||||
|
req.setObjectKey("person");
|
||||||
|
req.setCategoryKey("jw");
|
||||||
|
Integer id = svcItemQuery.adminSave(TestUsers.superAdmin(), req);
|
||||||
|
// 草稿不进筛选计数(filters 缓存此时是旧值;草稿保存也已发事件失效,重查仍应无此键)
|
||||||
|
assertThat(svcItemQuery.portalFilters(siteId).getObjects()).isEmpty();
|
||||||
|
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, id);
|
||||||
|
PortalServiceFilters after = svcItemQuery.portalFilters(siteId);
|
||||||
|
assertThat(after.getObjects()).extracting("name").containsExactly("个人");
|
||||||
|
assertThat(after.getObjects()).extracting("count").containsExactly(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 查询面(§3.3 page / §5.2 portalPage)=====
|
||||||
|
|
||||||
|
private SvcItemPageQuery queryOf(Integer status, String keyword) {
|
||||||
|
SvcItemPageQuery query = new SvcItemPageQuery();
|
||||||
|
query.setSiteId(siteId);
|
||||||
|
query.setStatus(status);
|
||||||
|
query.setKeyword(keyword);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("adminPage:关键字命中标题/编码,状态过滤,pageSize 上限 100")
|
||||||
|
void adminPage_filters() {
|
||||||
|
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
SvcItemSaveRequest req2 = request();
|
||||||
|
req2.setItemCode("BG-HQ-100");
|
||||||
|
req2.setTitle("宿舍报修");
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
|
||||||
|
|
||||||
|
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "报修")).get("total"))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, "BG-ZX")).get("total"))
|
||||||
|
.as("关键字也命中编码").isEqualTo(1L);
|
||||||
|
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a);
|
||||||
|
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(1, null)).get("total"))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
assertThat(svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(0, null)).get("total"))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("排序:sort_no 升序为列表第一优先级;adminSort 落库")
|
||||||
|
void sort_sortNoFirstPriority() {
|
||||||
|
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
SvcItemSaveRequest req2 = request();
|
||||||
|
req2.setItemCode("BG-HQ-100");
|
||||||
|
req2.setTitle("宿舍报修");
|
||||||
|
Integer b = svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
|
||||||
|
|
||||||
|
com.yunhai.cms.svcitem.model.dto.IdSortItem s1 = new com.yunhai.cms.svcitem.model.dto.IdSortItem();
|
||||||
|
s1.setId(b);
|
||||||
|
s1.setSortNo(1);
|
||||||
|
com.yunhai.cms.svcitem.model.dto.IdSortItem s2 = new com.yunhai.cms.svcitem.model.dto.IdSortItem();
|
||||||
|
s2.setId(a);
|
||||||
|
s2.setSortNo(9);
|
||||||
|
svcItemService.adminSort(TestUsers.superAdmin(), siteId, Arrays.asList(s1, s2));
|
||||||
|
|
||||||
|
Map<String, Object> page = svcItemQuery.adminPage(TestUsers.superAdmin(), queryOf(null, null));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<SvcItemPageItem> list = (List<SvcItemPageItem>) page.get("list");
|
||||||
|
assertThat(list).extracting("title").containsExactly("宿舍报修", "转学办理");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("选择器 selList:全状态返回精简行")
|
||||||
|
void selList_allStatuses() {
|
||||||
|
Integer a = svcItemQuery.adminSave(TestUsers.superAdmin(), request());
|
||||||
|
svcItemQuery.adminPublish(TestUsers.superAdmin(), siteId, a);
|
||||||
|
SvcItemSaveRequest req2 = request();
|
||||||
|
req2.setItemCode("BG-HQ-100");
|
||||||
|
req2.setTitle("宿舍报修");
|
||||||
|
svcItemQuery.adminSave(TestUsers.superAdmin(), req2);
|
||||||
|
assertThat(svcItemQuery.adminSelList(TestUsers.superAdmin(), siteId)).hasSize(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,43 @@ class PortalUrlHelperTest {
|
|||||||
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/assets/default/css/main.css");
|
.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<String, Object> 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<String, Object> 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)=====
|
// ===== W4 嵌套路径(方案 §8.3 T-W4-11/12:构造参语义 code→sitePath)=====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
-- 2026-09-05 起本机为 MySQL 8.0.45,JSON 列恢复原生类型(与 cms_init.sql / 实体 columnDefinition 一致)
|
-- 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_attachment`;
|
||||||
DROP TABLE IF EXISTS `cms_article`;
|
DROP TABLE IF EXISTS `cms_article`;
|
||||||
DROP TABLE IF EXISTS `cms_category`;
|
DROP TABLE IF EXISTS `cms_category`;
|
||||||
@@ -137,3 +139,64 @@ CREATE TABLE `cms_link` (
|
|||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接';
|
) 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 事项字典(服务对象/事项类别)';
|
||||||
|
|||||||
Reference in New Issue
Block a user