From d47951abd7354b5d3fd0b175cfca70e9950798b1 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 11:00:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9E=E4=BA=8B=E5=A4=A7=E5=8E=85?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=BA=90=E7=A0=81=E9=A6=96=E6=AC=A1=E5=85=A5?= =?UTF-8?q?=E5=BA=93=EF=BC=88=E4=BA=8B=E9=A1=B9/=E5=AD=97=E5=85=B8/?= =?UTF-8?q?=E8=A1=A8=E5=8D=95/=E7=94=B3=E6=8A=A5=20+=20=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + pom.xml | 110 +++ sql/migrate_from_cms.sql | 132 +++ .../yunhai/svchall/SvcHallApplication.java | 20 + .../svchall/common/SvcHallAuditListener.java | 41 + .../svchall/config/FileStorageConfig.java | 29 + .../FlowCallbackFilterConfiguration.java | 151 ++++ .../svchall/config/MyWebAppConfigurer.java | 26 + .../OpenApiAuthEnforceConfiguration.java | 51 ++ .../controller/api/ApplyUserController.java | 76 ++ .../controller/api/BaseApiController.java | 80 ++ .../controller/api/FileController.java | 49 ++ .../controller/api/SiteOptionsController.java | 46 + .../api/svc/SvcApplyAdminController.java | 25 + .../controller/api/svc/SvcDictController.java | 56 ++ .../controller/api/svc/SvcFlowController.java | 36 + .../controller/api/svc/SvcFormController.java | 69 ++ .../controller/api/svc/SvcItemController.java | 101 +++ .../callback/FlowCallbackController.java | 47 + .../oapi/svc/SvcOapiController.java | 136 +++ .../com/yunhai/svchall/entity/BaseEntity.java | 43 + .../yunhai/svchall/entity/ServiceApply.java | 104 +++ .../svchall/entity/ServiceAttachment.java | 56 ++ .../yunhai/svchall/entity/ServiceDict.java | 56 ++ .../svchall/entity/ServiceFlowEvent.java | 49 ++ .../yunhai/svchall/entity/ServiceFlowReg.java | 49 ++ .../yunhai/svchall/entity/ServiceForm.java | 56 ++ .../yunhai/svchall/entity/ServiceItem.java | 171 ++++ .../svchall/enums/ApplyFlowStatusEnum.java | 47 + .../yunhai/svchall/enums/HandleModeEnum.java | 37 + .../svchall/enums/ServiceDictTypeEnum.java | 34 + .../svchall/enums/ServiceItemStatusEnum.java | 36 + .../yunhai/svchall/job/FlowReconcileJob.java | 101 +++ .../yunhai/svchall/model/oapi/OapiModels.java | 133 +++ .../yunhai/svchall/model/svc/SvcModels.java | 461 ++++++++++ .../repository/ServiceApplyRepository.java | 66 ++ .../ServiceAttachmentRepository.java | 18 + .../repository/ServiceDictRepository.java | 26 + .../ServiceFlowEventRepository.java | 9 + .../repository/ServiceFlowRegRepository.java | 15 + .../repository/ServiceFormRepository.java | 26 + .../repository/ServiceItemRepository.java | 74 ++ .../svchall/service/ApplyViewGuard.java | 114 +++ .../yunhai/svchall/service/DeptSource.java | 27 + .../yunhai/svchall/service/FlowClient.java | 58 ++ .../yunhai/svchall/service/SiteDirectory.java | 48 + .../com/yunhai/svchall/service/SiteGuard.java | 60 ++ .../service/impl/AttachmentService.java | 177 ++++ .../service/impl/CmsSiteDirectoryImpl.java | 129 +++ .../service/impl/FlowCallbackService.java | 231 +++++ .../service/impl/FlowCenterClient.java | 148 ++++ .../service/impl/ServiceApplyService.java | 416 +++++++++ .../service/impl/ServiceDictService.java | 164 ++++ .../service/impl/ServiceFormService.java | 149 ++++ .../service/impl/ServiceItemService.java | 820 ++++++++++++++++++ .../service/impl/YschBaseDeptSource.java | 38 + .../com/yunhai/svchall/utils/FileUtil.java | 130 +++ .../yunhai/svchall/utils/FormValidator.java | 258 ++++++ .../yunhai/svchall/utils/HtmlSanitizer.java | 68 ++ .../utils/ReReadableRequestWrapper.java | 63 ++ src/main/resources/application.yml | 110 +++ .../config/FlowCallbackVerifyFilterTest.java | 151 ++++ .../callback/FlowCallbackControllerTest.java | 55 ++ .../svchall/job/FlowReconcileJobTest.java | 118 +++ .../svchall/service/ApplyViewGuardTest.java | 135 +++ .../impl/CmsSiteDirectoryImplTest.java | 126 +++ .../service/impl/FlowCallbackServiceTest.java | 287 ++++++ .../impl/ServiceApplyFlowSubmitTest.java | 302 +++++++ .../service/impl/ServiceItemFlowRegTest.java | 126 +++ .../svchall/utils/FormValidatorTest.java | 107 +++ 70 files changed, 7569 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 sql/migrate_from_cms.sql create mode 100644 src/main/java/com/yunhai/svchall/SvcHallApplication.java create mode 100644 src/main/java/com/yunhai/svchall/common/SvcHallAuditListener.java create mode 100644 src/main/java/com/yunhai/svchall/config/FileStorageConfig.java create mode 100644 src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java create mode 100644 src/main/java/com/yunhai/svchall/config/MyWebAppConfigurer.java create mode 100644 src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/BaseApiController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/FileController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/SiteOptionsController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/svc/SvcApplyAdminController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/svc/SvcDictController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/api/svc/SvcItemController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java create mode 100644 src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java create mode 100644 src/main/java/com/yunhai/svchall/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceApply.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceDict.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceForm.java create mode 100644 src/main/java/com/yunhai/svchall/entity/ServiceItem.java create mode 100644 src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java create mode 100644 src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java create mode 100644 src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java create mode 100644 src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java create mode 100644 src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java create mode 100644 src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java create mode 100644 src/main/java/com/yunhai/svchall/model/svc/SvcModels.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java create mode 100644 src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java create mode 100644 src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java create mode 100644 src/main/java/com/yunhai/svchall/service/DeptSource.java create mode 100644 src/main/java/com/yunhai/svchall/service/FlowClient.java create mode 100644 src/main/java/com/yunhai/svchall/service/SiteDirectory.java create mode 100644 src/main/java/com/yunhai/svchall/service/SiteGuard.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/AttachmentService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/ServiceDictService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/YschBaseDeptSource.java create mode 100644 src/main/java/com/yunhai/svchall/utils/FileUtil.java create mode 100644 src/main/java/com/yunhai/svchall/utils/FormValidator.java create mode 100644 src/main/java/com/yunhai/svchall/utils/HtmlSanitizer.java create mode 100644 src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java create mode 100644 src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java create mode 100644 src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java create mode 100644 src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0564691 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +*.log +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e702a3d --- /dev/null +++ b/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + svc-hall + 0.1 + svc-hall + 办事大厅(事项库 + 自定义表单 + 办件记录;CMS 门户供数与流程中心托管接入) + + 1.8 + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + org.jsoup + jsoup + 1.17.2 + + + + com.github.ben-manes.caffeine + caffeine + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + svc-hall + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.svchall.SvcHallApplication + + + + + repackage + + + + + + + diff --git a/sql/migrate_from_cms.sql b/sql/migrate_from_cms.sql new file mode 100644 index 0000000..91ea4b3 --- /dev/null +++ b/sql/migrate_from_cms.sql @@ -0,0 +1,132 @@ +-- ===================================================================== +-- 办事大厅(svc-hall)数据迁移脚本:CMS cms_service_item/cms_service_dict +-- → bsch_svchall.service_item / service_dict / service_attachment +-- +-- ⚠️ 交付物,未执行(遵守"SQL 交付不代跑"约定)。执行前必须: +-- 1. 核对/修改 @cms_public_base(CMS 对外基址=域名根,见下方 ① 的取值规则; +-- 配错会全量断图——②④ 拼接处自带 /cms/upload/ 段,base 再含 /cms 即双段); +-- 2. 目标库 bsch_svchall 已由应用首次启动(ddl-auto=update)建好六表结构; +-- 3. 幂等性:可重复执行(先 DELETE 再 INSERT,MySQL 会话变量需在同一连接内); +-- 4. 回滚 = CMS 侧把 cms.svc.mode 切回 local 前的 git revert(CMS 库数据全程未改不删除, +-- 本脚本只从 CMS 读、向办事大厅写)。 +-- +-- 预检(执行前人工跑,结果非空先补配置再迁移): +-- 迁移前查 CMS 侧未配学校的站点清单(这些站点的数据将落到 sch_id=0 孤儿租户, +-- 管理端/门户均不可见,见 6.2 校验): +-- SELECT id, code, name FROM bsch_cms.cms_site WHERE sch_id IS NULL; +-- +-- 对应方案:《办事大厅独立应用与对接方案V2.0.md》Part E.2; +-- 字段语义见 docs/19 §九 与 V2.0 Part B.2。 +-- ===================================================================== + +-- ① 会话变量:CMS 对外基址=域名根(不含 context-path、末尾不带斜杠)。 +-- 取值规则:② 富文本 REPLACE 与 ④ 附件 CONCAT 在 base 之后拼的是库内既有形态 +-- `/cms/upload/...`(CMS FILE_WEB_PATH=/cms/upload/ 已含 context-path), +-- 故 base 必须是域名根——含 /cms 会产出 `.../cms/cms/upload/...` 双段 URL 全量断图。 +-- 本地示例:http://localhost:8072 ;生产示例:https://base.yunhaixingtong.com +SET @cms_public_base := 'https://base.yunhaixingtong.com'; + +-- ② 事项(主键 id 原值保留——CMS 门户 /sv/{id} 路由直接携带办事大厅主键,V2.0 C.2"id 语义") +DELETE FROM `bsch_svchall`.`service_item` WHERE id <= 2100000000; +INSERT INTO `bsch_svchall`.`service_item` + (id, sch_id, site_code, item_code, title, summary, object_key, category_key, + dept_group_id, dept_name, handle_mode, online_flag, handle_place, + handle_time_limit, promise_time_limit, contact_phone, supervise_phone, + process_html, material_html, notice_html, + faq_category_id, notice_category_id, rule_category_id, cover_image, + form_code, flow_guid, apply_url, sort_no, status, publish_time, offline_time, + view_count, ext_json, create_by, create_time, update_by, update_time) +SELECT + s.id, + COALESCE(site.sch_id, 0), + site.code, + s.item_code, + s.title, + s.summary, + s.object_key, + s.category_key, + s.dept_group_id, + s.dept_name, + s.handle_mode, + s.online_flag, + s.handle_place, + s.handle_time_limit, + s.promise_time_limit, + s.contact_phone, + s.supervise_phone, + -- 富文本内链绝对化(一次性 REPLACE:相对 /cms/upload/ → CMS 绝对地址;文件物理不动)。 + -- 取值规则:库内相对形态已含 context-path(FILE_WEB_PATH=/cms/upload/),故此处 + -- CONCAT 只补域名根(@cms_public_base 不含 /cms,否则产出 /cms/cms/ 双段断图) + REPLACE(REPLACE(s.process_html, + 'src="/cms/upload/', CONCAT('src="', @cms_public_base, '/cms/upload/')), + 'href="/cms/upload/', CONCAT('href="', @cms_public_base, '/cms/upload/')), + REPLACE(REPLACE(s.material_html, + 'src="/cms/upload/', CONCAT('src="', @cms_public_base, '/cms/upload/')), + 'href="/cms/upload/', CONCAT('href="', @cms_public_base, '/cms/upload/')), + REPLACE(REPLACE(s.notice_html, + 'src="/cms/upload/', CONCAT('src="', @cms_public_base, '/cms/upload/')), + 'href="/cms/upload/', CONCAT('href="', @cms_public_base, '/cms/upload/')), + s.faq_category_id, s.notice_category_id, s.rule_category_id, + -- 封面:相对路径 → CMS 绝对地址(已是 http(s) 的外链不受影响;相对形态 /cms/upload/ 自带段) + CASE WHEN s.cover_image LIKE 'http%' THEN s.cover_image + WHEN s.cover_image LIKE '/cms/upload/%' THEN CONCAT(@cms_public_base, s.cover_image) + ELSE s.cover_image END, + NULL, -- form_code:迁移数据无表单,运营后续在办事大厅配置 + NULL, -- flow_guid:迁移数据不绑流程;online_flag 需同步降级(见下方 UPDATE) + JSON_UNQUOTE(JSON_EXTRACT(s.ext_json, '$.apply_url')), + s.sort_no, s.status, s.publish_time, s.offline_time, + s.view_count, s.ext_json, + s.create_by, s.create_time, s.update_by, s.update_time +FROM `bsch_cms`.`cms_service_item` s +JOIN `bsch_cms`.`cms_site` site ON site.id = s.site_id; + +-- 一致性收敛(V2.0 B.2.1:online_flag=1 ⇒ flow_guid 非空): +-- 存量数据一律不绑流程,先全部降为"不可在线办理",由运营在办事大厅逐事项绑定流程后开启。 +UPDATE `bsch_svchall`.`service_item` SET online_flag = 0 WHERE flow_guid IS NULL; + +-- ③ 字典 +DELETE FROM `bsch_svchall`.`service_dict` WHERE id <= 2100000000; +INSERT INTO `bsch_svchall`.`service_dict` + (id, sch_id, site_code, dict_type, dict_key, dict_name, sort_no, status, ext_json, + create_by, create_time, update_by, update_time) +SELECT + d.id, COALESCE(site.sch_id, 0), site.code, d.dict_type, d.dict_key, d.dict_name, + d.sort_no, d.status, d.ext_json, d.create_by, d.create_time, d.update_by, d.update_time +FROM `bsch_cms`.`cms_service_dict` d +JOIN `bsch_cms`.`cms_site` site ON site.id = d.site_id; + +-- ④ 指南附件(仅 svc_item 显式列表场景;svc_item_asset 为内容派生归属, +-- 新模型下封面/正文图只存 URL 不建行,故不迁移) +DELETE FROM `bsch_svchall`.`service_attachment` WHERE id <= 2100000000; +INSERT INTO `bsch_svchall`.`service_attachment` + (id, sch_id, topic_type, topic_id, field_key, file_name, file_url, file_size, mime_type, + create_by, create_time, update_by, update_time) +SELECT + a.id, COALESCE(site.sch_id, 0), 'svc_item_guide', a.ref_id, NULL, a.file_name, + -- web_url 相对地址 → CMS 绝对地址(割接期文件物理在 CMS 卷,URL 保持可访问;长期归属见 V2.0 R3)。 + -- 取值规则:web_url 库内形态=/cms/upload/...(已含 context-path),故只补域名根 + CONCAT(@cms_public_base, a.web_url), + a.file_size, a.mime_type, a.create_by, a.create_time, a.update_by, a.update_time +FROM `bsch_cms`.`cms_attachment` a +JOIN `bsch_cms`.`cms_site` site ON site.id = a.site_id +WHERE a.ref_type = 'svc_item' AND a.ref_id IS NOT NULL; + +-- ⑤ 自增起点收敛(保证迁移 id 之后的新增不冲突;执行前需确认 max) +SET @max_item_id := (SELECT COALESCE(MAX(id), 0) + 10 FROM `bsch_svchall`.`service_item`); +SET @sql := CONCAT('ALTER TABLE `bsch_svchall`.`service_item` AUTO_INCREMENT = ', @max_item_id); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ⑥ 迁移校验(人工核对,任何一处不符先停) +-- 6.1 行数一致: +-- SELECT (SELECT COUNT(*) FROM bsch_cms.cms_service_item) AS src, +-- (SELECT COUNT(*) FROM bsch_svchall.service_item) AS dst; +-- 6.2 无孤儿租户(P1-2:三处 COALESCE(site.sch_id,0) 使 NULL 永不出现, +-- 未配学校的站点落到 sch_id=0——不属任何租户,管理端/门户均不可见。 +-- 结果非 0 = 预检漏了未配学校站点,回 CMS 补 sch_id 后重迁): +-- SELECT COUNT(*) FROM bsch_svchall.service_item WHERE sch_id = 0 OR site_code IS NULL; +-- SELECT COUNT(*) FROM bsch_svchall.service_dict WHERE sch_id = 0; +-- SELECT COUNT(*) FROM bsch_svchall.service_attachment WHERE sch_id = 0; +-- 6.3 字典行数一致(同上两张表); +-- 6.4 附件行数 = CMS 侧 ref_type='svc_item' 且 ref_id 非空的行数; +-- 6.5 抽一条事项走办事大厅 oapi:GET /oapi/svc/item/detail?siteCode=&itemCode=, +-- 核对三段富文本图片 URL 均为绝对地址且可访问。 diff --git a/src/main/java/com/yunhai/svchall/SvcHallApplication.java b/src/main/java/com/yunhai/svchall/SvcHallApplication.java new file mode 100644 index 0000000..3496422 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/SvcHallApplication.java @@ -0,0 +1,20 @@ +package com.yunhai.svchall; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 办事大厅(V2.0 独立应用版):事项库 + 自定义表单 + 办件记录。 + * 职责边界见《办事大厅独立应用与对接方案V2.0.md》Part A: + * 向 CMS 门户供数(/oapi/svc/**,appKey 签名)、以 Notice 同款托管模式接流程中心 + * (代发起 + 回调 + 对账);CMS 只保留门户主题化展示。 + */ +@EnableScheduling +@SpringBootApplication +public class SvcHallApplication { + + public static void main(String[] args) { + SpringApplication.run(SvcHallApplication.class, args); + } +} diff --git a/src/main/java/com/yunhai/svchall/common/SvcHallAuditListener.java b/src/main/java/com/yunhai/svchall/common/SvcHallAuditListener.java new file mode 100644 index 0000000..8c74eb3 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/common/SvcHallAuditListener.java @@ -0,0 +1,41 @@ +package com.yunhai.svchall.common; + +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.svchall.entity.BaseEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * 审计字段自动填充(CmsAuditListener/NoticeAuditListener 同款)。 + * 账号取 ThreadLocal 中的 CAS 会话用户;定时任务等无会话场景为 null。 + */ +@Slf4j +@Component +public class SvcHallAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + String account = currentAccount(); + LocalDateTime now = LocalDateTime.now(); + entity.setCreateBy(account); + entity.setCreateTime(now); + entity.setUpdateBy(account); + entity.setUpdateTime(now); + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateBy(currentAccount()); + entity.setUpdateTime(LocalDateTime.now()); + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/svchall/config/FileStorageConfig.java b/src/main/java/com/yunhai/svchall/config/FileStorageConfig.java new file mode 100644 index 0000000..abbfb8f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/config/FileStorageConfig.java @@ -0,0 +1,29 @@ +package com.yunhai.svchall.config; + +import com.yunhai.svchall.utils.FileUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +/** + * 文件存储配置(Notice FileStorageConfig 同款三件套之一): + * FILE_PATH 磁盘落盘根 + svchall.file.public-base-url 对外可达基址 → FileUtil.init()。 + */ +@Slf4j +@Configuration +public class FileStorageConfig { + + @Value("${FILE_PATH}") + private String filePath; + + @Value("${svchall.file.public-base-url}") + private String publicBaseUrl; + + @EventListener(ApplicationReadyEvent.class) + public void init() { + FileUtil.init(filePath, publicBaseUrl); + log.info("[svc-hall] 文件存储就绪:FILE_PATH={},publicBaseUrl={}", filePath, publicBaseUrl); + } +} diff --git a/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java b/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java new file mode 100644 index 0000000..65e1be9 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java @@ -0,0 +1,151 @@ +package com.yunhai.svchall.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.svchall.service.impl.FlowCallbackService; +import com.yunhai.svchall.utils.ReReadableRequestWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.util.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +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; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 流程中心回调验签(验收报告 §四.6,Notice FlowCallbackVerifyFilter 同构): + * 只拦 /callback/*(CAS url-pattern /api/* 之外,独立于 oapi 的 appKey 签名体系)。 + * 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/ + * X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)), + * secret 为 reg 接口按 flowGuid 下发的回调密钥(FlowCallbackService.secretOf 查找,须 status=0 且非空)。 + * 注册探活 ping 直接 200——flow-center 探活的签名是字面量 "ping" 不可能验签通过,且实测把 + * 5xx 判为不可达(§四.6),以 ping 200 保证注册顺畅;探活只判连接级可达,无状态语义。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FlowCallbackFilterConfiguration { + + /** ±5 分钟防重放窗口(对齐概览 §8.4) */ + private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L; + + private final FlowCallbackService callbackService; + private final ObjectMapper objectMapper; + + @Bean + public FilterRegistrationBean flowCallbackVerifyFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new FlowCallbackVerifyFilter()); + bean.addUrlPatterns("/callback/*"); + // 先于业务过滤器链(AppAuthFilter +10 与签名强制 +11 均在 HIGHEST_PRECEDENCE 之上) + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20); + return bean; + } + + class FlowCallbackVerifyFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (!"POST".equalsIgnoreCase(req.getMethod())) { + res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req); + String body = wrapper.bodyString(); + // 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过) + if (isPing(body)) { + byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + res.setContentType("application/json"); + res.setContentLength(ok.length); + res.setStatus(HttpServletResponse.SC_OK); + res.getOutputStream().write(ok); + return; + } + String eventId = req.getHeader("X-Flw-EventId"); + String timestamp = req.getHeader("X-Flw-Timestamp"); + String nonce = req.getHeader("X-Flw-Nonce"); + String signature = req.getHeader("X-Flw-Signature"); + if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp) + || !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers"); + return; + } + try { + long ts = Long.parseLong(timestamp.trim()); + if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window"); + return; + } + } catch (NumberFormatException e) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp"); + return; + } + String flowGuid = extractFlowGuid(body); + String secret = flowGuid == null ? null : callbackService.secretOf(flowGuid); + if (secret == null) { + log.warn("[svc-hall] 回调验签失败:无注册或无 secret,flowGuid={}", flowGuid); + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration"); + return; + } + String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body); + if (!constantTimeEquals(expected, signature.trim())) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature"); + return; + } + chain.doFilter(wrapper, response); + } + + /** 注册探活判定:event=ping(flow-center 注册时发送,签名头是字面量 "ping") */ + private boolean isPing(String body) { + try { + JsonNode payload = objectMapper.readTree(body); + return payload != null && "ping".equals(payload.path("event").asText(null)); + } catch (Exception e) { + return false; + } + } + + /** 从推送体提取 flowGuid(验签必需的最小解析) */ + private String extractFlowGuid(String body) { + try { + JsonNode payload = objectMapper.readTree(body); + return payload.has("flowGuid") && !payload.get("flowGuid").isNull() + ? payload.get("flowGuid").asText() : null; + } catch (Exception e) { + return null; + } + } + + private String hmacSha256Base64(String secret, String content) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException("HmacSHA256 计算失败", e); + } + } + + private boolean constantTimeEquals(String a, String b) { + return java.security.MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/src/main/java/com/yunhai/svchall/config/MyWebAppConfigurer.java b/src/main/java/com/yunhai/svchall/config/MyWebAppConfigurer.java new file mode 100644 index 0000000..6b6f58d --- /dev/null +++ b/src/main/java/com/yunhai/svchall/config/MyWebAppConfigurer.java @@ -0,0 +1,26 @@ +package com.yunhai.svchall.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Paths; + +/** + * 本地上传目录静态映射(Notice MyWebAppConfigurer 同款;仅本地开发生效—— + * 生产走 Projman 生成的 Caddy 静态段 /svc-hall/upload/* 直出,不进应用)。 + * 有 context-path 时该映射自动变成 /svc-hall/upload/**。 + */ +@Configuration +public class MyWebAppConfigurer implements WebMvcConfigurer { + + @org.springframework.beans.factory.annotation.Value("${FILE_PATH}") + private String basePath; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String root = Paths.get(basePath.replace("\\", "/")).toUri().toString(); + registry.addResourceHandler("/upload/image/**").addResourceLocations(root + "image/"); + registry.addResourceHandler("/upload/file/**").addResourceLocations(root + "file/"); + } +} diff --git a/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java new file mode 100644 index 0000000..f2b791e --- /dev/null +++ b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java @@ -0,0 +1,51 @@ +package com.yunhai.svchall.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; + +/** + * 开放接口签名强制(照 Cms OpenApiAuthEnforceConfiguration 范式):yh-app-auth 的 + * {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流; + * 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。 + * /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。 + * 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用 + * X-Flw-* 四头验签,两种签名体系互不干扰。 + */ +@Configuration +public class OpenApiAuthEnforceConfiguration { + + @Bean + public FilterRegistrationBean requireAppSignFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new RequireAppSignFilter()); + bean.addUrlPatterns("/oapi/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); + return bean; + } + + public static class RequireAppSignFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature"); + return; + } + chain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java b/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java new file mode 100644 index 0000000..5de73d0 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/ApplyUserController.java @@ -0,0 +1,76 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest; +import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; +import com.yunhai.svchall.service.impl.ServiceApplyService; +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.Map; + +/** + * 用户面办件(/api/apply/**,CAS 登录):发起/重试/我的办件/详情。 + * 审批动作(撤回/催办/轨迹)由前端直调流程中心 /api/flow/*(同主域 CAS cookie), + * 本控制器只管办事大厅侧的申请记录。 + */ +@RestController +@RequestMapping("/api/apply") +@RequiredArgsConstructor +public class ApplyUserController { + + private final ServiceApplyService applyService; + + /** 发起(表单数据强校验 → 快照落库 → 代发起;流程中心未接入时落待提交态) */ + @PostMapping + public ApplyVo submit(BoaUser authUserWithDept, @Valid @RequestBody ApplySubmitRequest request) { + return applyService.submit(authUserWithDept, request); + } + + /** 重试(仅待提交态;复用同一 bizId,流程中心 uk 幂等) */ + @PostMapping("/{id}/retry") + public ApplyVo retry(BoaUser authUserWithDept, @PathVariable("id") Integer id) { + return applyService.retry(authUserWithDept, id); + } + + /** 一键重提(仅驳回/撤回态;复用同一 bizId,flow-center R4 重启推进,轨迹跨轮连续) */ + @PostMapping("/{id}/resubmit") + public ApplyVo resubmit(BoaUser authUserWithDept, @PathVariable("id") Integer id) { + return applyService.resubmit(authUserWithDept, id); + } + + /** 我的办件 */ + @GetMapping("/my") + public Map my(BoaUser authUserWithDept, + @RequestParam(value = "flowStatus", required = false) Integer flowStatus, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return applyService.myApplies(authUserWithDept, flowStatus, page, pageSize); + } + + /** 详情(提交人本人或站点管理面) */ + @GetMapping("/{id}") + public ApplyVo detail(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam(value = "siteCode", required = false) String siteCode) { + return applyService.detail(authUserWithDept, siteCode, id); + } + + /** + * 发起页数据(CAS,本校用户):事项发布态要点 + 绑定表单的已发布 schema(前端渲染动态表单)。 + * 未发布/不存在报业务错;未绑流程报"未开通在线办理"。 + */ + @GetMapping("/item") + public java.util.Map applyItem(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("itemCode") String itemCode) { + return applyService.applyItem(authUserWithDept, siteCode, itemCode); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/BaseApiController.java b/src/main/java/com/yunhai/svchall/controller/api/BaseApiController.java new file mode 100644 index 0000000..c3bc3cc --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/BaseApiController.java @@ -0,0 +1,80 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(Notice/CMS BaseApiController 同款契约):GetUserInfo / GetUserMod。 + * GetUserInfo 直接返回 CAS 会话注入的 BoaUser;GetUserMod 经 Tier1 SDK 取当前用户在 + * 本应用(appKey 按 yh.open 身份)下已授权模块并组树,是前端左侧菜单与页头用户信息的数据源。 + * 返回裸对象,由统一响应包装为 {code, msg, data}。 + */ +@RestController +@RequestMapping("/api/base") +public class BaseApiController { + + /** 当前登录用户信息(页头用户名/头像) */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** 当前用户已授权模块列表(左侧菜单树) */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + List modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + return buildModuleTree(modules); + } + + /** 扁平模块列表组树(Notice UserCenterService.buildModuleTree 同构:modId/page/children) */ + private static List> buildModuleTree(List modules) { + Map moduleMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + moduleMap.put(m.getId(), m); + } + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = + (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + return roots; + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/FileController.java b/src/main/java/com/yunhai/svchall/controller/api/FileController.java new file mode 100644 index 0000000..e8716f8 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/FileController.java @@ -0,0 +1,49 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceAttachment; +import com.yunhai.svchall.service.impl.AttachmentService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件上传(/api/file/upload,Notice 上传通道契约):完整 URL 直接返回(直连访问), + * DB 存相对段;业务绑定(指南附件/办件材料)由保存与提交动作完成。 + */ +@RestController +@RequestMapping("/api/file") +@RequiredArgsConstructor +public class FileController { + + private final AttachmentService attachmentService; + + @PostMapping("/upload") + public UploadVo upload(BoaUser authUserWithDept, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "type", defaultValue = "file") String type, + @RequestParam(value = "siteCode", required = false) String siteCode) { + ServiceAttachment attachment = attachmentService.upload(authUserWithDept, siteCode, file, type); + UploadVo vo = new UploadVo(); + vo.setId(attachment.getId()); + vo.setFileName(attachment.getFileName()); + vo.setFileUrl(com.yunhai.svchall.utils.FileUtil.buildUrl(attachment.getFileUrl())); + vo.setFileSize(attachment.getFileSize()); + vo.setMimeType(attachment.getMimeType()); + return vo; + } + + /** 上传返回体(fileUrl 为完整可达地址) */ + @Data + public static class UploadVo { + private Integer id; + private String fileName; + private String fileUrl; + private Long fileSize; + private String mimeType; + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/SiteOptionsController.java b/src/main/java/com/yunhai/svchall/controller/api/SiteOptionsController.java new file mode 100644 index 0000000..a8e8ecb --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/SiteOptionsController.java @@ -0,0 +1,46 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.service.impl.ServiceFormService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 站点选项(/api/svc/site/options):管理端站点选择器数据源,代理 CMS 站点目录 + * (/oapi/site/list,本地缓存 5 分钟)。当前用户角色学校口径;超管同样按其角色学校下发 + * (跨校站点管理走超管在该校的角色,与 CMS requireManageable 的使用方式一致)。 + */ +@RestController +@RequestMapping("/api/svc/site") +@RequiredArgsConstructor +public class SiteOptionsController { + + private final SiteDirectory siteDirectory; + private final SiteGuard siteGuard; + private final ServiceFormService formService; + + @GetMapping("/options") + public List options(BoaUser authUserWithDept) { + if (authUserWithDept == null || authUserWithDept.getCurrentRole() == null + || authUserWithDept.getCurrentRole().getSchId() == null) { + throw new com.yunhai.platform.apistd.BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "当前用户无学校归属"); + } + Long schId = authUserWithDept.getCurrentRole().getSchId().longValue(); + return siteDirectory.listSites(schId); + } + + /** 站点解析校验(编辑页初始化时前端可调,确保站点可达且有权) */ + @GetMapping("/check") + public SiteInfo check(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) { + return siteGuard.requireSiteManager(authUserWithDept, siteCode); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcApplyAdminController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcApplyAdminController.java new file mode 100644 index 0000000..27ac57f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcApplyAdminController.java @@ -0,0 +1,25 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.ApplyPageQuery; +import com.yunhai.svchall.service.impl.ServiceApplyService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** 办件记录管理面(/api/svc/apply/**):本校在途与历史办件分页。 */ +@RestController +@RequestMapping("/api/svc/apply") +@RequiredArgsConstructor +public class SvcApplyAdminController { + + private final ServiceApplyService applyService; + + @GetMapping("/page") + public Map page(BoaUser authUserWithDept, ApplyPageQuery query) { + return applyService.adminPage(authUserWithDept, query); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcDictController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcDictController.java new file mode 100644 index 0000000..a37fbb2 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcDictController.java @@ -0,0 +1,56 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcDeptVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcDictSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcDictVo; +import com.yunhai.svchall.service.impl.ServiceDictService; +import com.yunhai.svchall.service.impl.ServiceItemService; +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; +import java.util.Map; + +/** + * 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree,迁移自 CMS SvcDictController)。 + */ +@RestController +@RequestMapping("/api/svc") +@RequiredArgsConstructor +public class SvcDictController { + + private final ServiceDictService dictService; + private final ServiceItemService itemService; + + @GetMapping("/dict/list") + public List list(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam(value = "dictType", required = false) String dictType) { + return dictService.list(authUserWithDept, siteCode, dictType); + } + + @PostMapping("/dict/save") + public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcDictSaveRequest request) { + return dictService.save(authUserWithDept, request); + } + + @PostMapping("/dict/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + dictService.delete(authUserWithDept, request); + } + + /** 服务部门选项(UserBase 组树代理;平台不可达降级空列表) */ + @GetMapping("/dept/tree") + public List deptTree(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode) { + return itemService.deptTree(authUserWithDept, siteCode); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java new file mode 100644 index 0000000..fd1723b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFlowController.java @@ -0,0 +1,36 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.FlowSummaryVo; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 流程选择器(/api/svc/flow/**):事项编辑页"办理流程"数据源,代理流程中心 + * /oapi/flow/list。流程中心未接入(svchall.flow.enabled=false)时抛业务异常, + * 前端据此降级提示(事项只能配为线下/纯指南)。 + */ +@RestController +@RequestMapping("/api/svc/flow") +@RequiredArgsConstructor +public class SvcFlowController { + + private final FlowClient flowClient; + private final SiteGuard siteGuard; + private final SiteDirectory siteDirectory; + + @GetMapping("/list") + public List list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) { + siteGuard.requireSiteManager(authUserWithDept, siteCode); + SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode); + return flowClient.listFlows(site.getSchId()); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java new file mode 100644 index 0000000..410355f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java @@ -0,0 +1,69 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo; +import com.yunhai.svchall.service.impl.ServiceFormService; +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; + +/** + * 自定义表单管理(/api/svc/form/**,V2.0 Part B.3):列表/版本轨迹/保存(发布版自动升版)/ + * 发布(静态校验)/删除(被绑定即拦)。表单为 sch 级,端点仍以 siteCode 定位做权限收口。 + */ +@RestController +@RequestMapping("/api/svc/form") +@RequiredArgsConstructor +public class SvcFormController { + + private final ServiceFormService formService; + + @GetMapping("/list") + public List list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) { + return formService.list(authUserWithDept, siteCode); + } + + @GetMapping("/versions") + public List versions(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("code") String code) { + return formService.versions(authUserWithDept, siteCode, code); + } + + @GetMapping("/get") + public SvcFormVo get(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("id") Integer id) { + return formService.get(authUserWithDept, siteCode, id); + } + + @PostMapping("/save") + public Integer save(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @Valid @RequestBody SvcFormSaveRequest request) { + return formService.save(authUserWithDept, siteCode, request); + } + + @PostMapping("/publish/{id}") + public void publish(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteCode") String siteCode) { + formService.publish(authUserWithDept, siteCode, id); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("code") String code) { + formService.delete(authUserWithDept, siteCode, code); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcItemController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcItemController.java new file mode 100644 index 0000000..83625ac --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcItemController.java @@ -0,0 +1,101 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; +import com.yunhai.svchall.model.svc.SvcModels.IdSortItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageQuery; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemSimpleVo; +import com.yunhai.svchall.service.impl.ServiceItemService; +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/**,迁移自 CMS SvcItemController;siteId → siteCode)。 + * 权限:站点管理面(SiteGuard:超管或本校角色,站点经 CMS 目录解析 fail-closed)。 + */ +@RestController +@RequestMapping("/api/svc/item") +@RequiredArgsConstructor +public class SvcItemController { + + private final ServiceItemService itemService; + + @GetMapping("/page") + public Map page(BoaUser authUserWithDept, SvcItemPageQuery query) { + return itemService.adminPage(authUserWithDept, query); + } + + @GetMapping("/get") + public SvcItemEditVo get(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("id") Integer id) { + return itemService.adminGet(authUserWithDept, siteCode, id); + } + + /** 保存(新建草稿/编辑;富文本 jsoup 清洗;flowGuid 绑定校验:onlineFlag=1 ⇒ 必绑流程) */ + @PostMapping("/save") + public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcItemSaveRequest request) { + return itemService.adminSave(authUserWithDept, request); + } + + @PostMapping("/publish/{id}") + public void publish(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteCode") String siteCode) { + itemService.adminPublish(authUserWithDept, siteCode, id); + } + + @PostMapping("/offline/{id}") + public void offline(BoaUser authUserWithDept, + @PathVariable("id") Integer id, + @RequestParam("siteCode") String siteCode) { + itemService.adminOffline(authUserWithDept, siteCode, id); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + itemService.adminDelete(authUserWithDept, request); + } + + @PostMapping("/sort") + public void sort(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestBody List items) { + itemService.adminSort(authUserWithDept, siteCode, items); + } + + /** 同步部门名快照(UserBase 组树改名后刷新 dept_name) */ + @PostMapping("/syncDeptName") + public Map syncDeptName(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode) { + return itemService.syncDeptName(authUserWithDept, siteCode); + } + + @GetMapping("/selList") + public List selList(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode) { + return itemService.adminSelList(authUserWithDept, siteCode); + } + + /** 指南附件回显(编辑页列表) */ + @GetMapping("/attList") + public List attList(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam("id") Integer id) { + return itemService.itemAttachments(authUserWithDept, siteCode, id); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java new file mode 100644 index 0000000..09d8081 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java @@ -0,0 +1,47 @@ +package com.yunhai.svchall.controller.callback; + +import com.yunhai.svchall.service.impl.FlowCallbackService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +import java.nio.charset.StandardCharsets; + +/** + * 流程中心回调接收端点(/callback/flow;不在 CAS pattern(/api/*)内、也不在 /oapi/* + * (appKey 签名)内——独立走 FlowCallbackVerifyFilter 的 X-Flw-* 四头验签后才到达这里)。 + *

+ * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/ + * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放); + * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/ + * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平 + * (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。 + */ +@Slf4j +@RestController +@RequestMapping("/callback") +@RequiredArgsConstructor +public class FlowCallbackController { + + private final FlowCallbackService callbackService; + + @PostMapping("/flow") + public ResponseEntity flow(@RequestBody byte[] body) { + try { + callbackService.process(new String(body, StandardCharsets.UTF_8)); + return ResponseEntity.ok("{\"ok\":true}"); + } catch (FlowCallbackService.TransientCallbackException e) { + log.error("[svc-hall] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); + } catch (Exception e) { + // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) + log.error("[svc-hall] 回调意外异常(回 5xx 触发流程中心重试)", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); + } + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java b/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java new file mode 100644 index 0000000..ffe9f11 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/oapi/svc/SvcOapiController.java @@ -0,0 +1,136 @@ +package com.yunhai.svchall.controller.oapi.svc; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.oapi.OapiModels; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.impl.ServiceItemService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 门户供数开放接口(/oapi/svc/**,appKey 签名访问,消费方=CMS;V2.0 Part C.2 契约冻结件)。 + * 错误纪律:未签名 401(RequireAppSignFilter);缺参/站点或事项不存在/未发布 → + * HTTP 200 + 信封 code=2(BizException);detail 支持 itemId/itemCode 双键。 + * 站点解析 fail-closed:站点目录不可达按"站点不存在"报 code=2,不降级全局查询。 + */ +@Slf4j +@RestController +@RequestMapping("/oapi/svc") +@RequiredArgsConstructor +public class SvcOapiController { + + private final ServiceItemService itemService; + private final SiteDirectory siteDirectory; + + /** 服务中心列表(分页 + 三维筛选 + 关键词;URL 由 CMS 生成,接口不返回 url) */ + @GetMapping("/items") + public OapiModels.ListData items(@RequestParam(value = "siteCode", required = false) String siteCode, + @RequestParam(value = "objectKey", required = false) String objectKey, + @RequestParam(value = "categoryKey", required = false) String categoryKey, + @RequestParam(value = "deptGroupId", required = false) Integer deptGroupId, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + SiteDirectory.SiteInfo site = requireSite(siteCode); + return itemService.oapiItems(site.getSchId(), site.getSiteCode(), emptyToNull(objectKey), + emptyToNull(categoryKey), deptGroupId, emptyToNull(keyword), page, pageSize); + } + + /** 三组筛选项 + 计数(已发布口径) */ + @GetMapping("/filters") + public OapiModels.Filters filters(@RequestParam(value = "siteCode", required = false) String siteCode) { + SiteDirectory.SiteInfo site = requireSite(siteCode); + return itemService.oapiFilters(site.getSchId(), site.getSiteCode()); + } + + /** 指南页详情(itemId 或 itemCode 二选一;三段 HTML/封面/附件已绝对化;含关联事项与 applyUrl) */ + @GetMapping("/item/detail") + public OapiModels.Detail detail(@RequestParam(value = "siteCode", required = false) String siteCode, + @RequestParam(value = "itemId", required = false) Integer itemId, + @RequestParam(value = "itemCode", required = false) String itemCode) { + SiteDirectory.SiteInfo site = requireSite(siteCode); + if (itemId == null && !StringUtils.hasText(itemCode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "itemId/itemCode 不得同时为空"); + } + OapiModels.Detail detail = itemService.oapiDetail(site.getSchId(), site.getSiteCode(), + itemId, emptyToNull(itemCode)); + if (detail == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布"); + } + return detail; + } + + /** 浏览量累加(CMS fire-and-forget;支持 itemId 直取;找不到事项静默忽略,保持信封 code=0) */ + @PostMapping("/item/view") + public void view(@RequestParam(value = "siteCode", required = false) String siteCode, + @RequestParam(value = "itemCode", required = false) String itemCode, + @RequestParam(value = "itemId", required = false) Integer itemId) { + if (itemId != null) { + itemService.countViewByItemId(itemId); + return; + } + SiteDirectory.SiteInfo site = requireSite(siteCode); + if (!StringUtils.hasText(itemCode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "itemCode/itemId 不得同时为空"); + } + itemService.countView(site.getSchId(), site.getSiteCode(), itemCode.trim()); + } + + /** 指南附件(按 itemId 直取;fileUrl 已绝对化) */ + @GetMapping("/item/attachments") + public java.util.List attachments( + @RequestParam(value = "itemId", required = false) Integer itemId) { + if (itemId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空"); + } + return itemService.attachmentsByItemId(itemId); + } + + /** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */ + @GetMapping("/item/related") + public java.util.List related( + @RequestParam(value = "itemId", required = false) Integer itemId, + @RequestParam(value = "limit", required = false) Integer limit) { + if (itemId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空"); + } + return itemService.relatedByItemId(itemId, limit); + } + + /** 跨服务要点快照(docs/17 §1.1 契约平移;原消费方流程中心已取消,保留为对外标准口) */ + @GetMapping("/item/snapshot") + public OapiModels.Snapshot 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 不得为空"); + } + OapiModels.Snapshot vo = itemService.oapiSnapshot(siteCode.trim(), itemCode.trim()); + if (vo == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布"); + } + return vo; + } + + /** 站点解析 fail-closed:不可达或不存在一律 code=2(门户侧不应渲染未知站点) */ + private SiteDirectory.SiteInfo requireSite(String siteCode) { + if (!StringUtils.hasText(siteCode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 不得为空"); + } + SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode.trim()); + if (site == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:" + siteCode); + } + return site; + } + + private static String emptyToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/src/main/java/com/yunhai/svchall/entity/BaseEntity.java b/src/main/java/com/yunhai/svchall/entity/BaseEntity.java new file mode 100644 index 0000000..912df32 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/BaseEntity.java @@ -0,0 +1,43 @@ +package com.yunhai.svchall.entity; + +import com.yunhai.svchall.common.SvcHallAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 审计四件套(SvcHallAuditListener 自动填充,CmsAuditListener 同款)。 + * 多租户键 sch_id 不在基类——各业务实体自带列(Notice 先例,语义不同不混入)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(SvcHallAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "create_by", updatable = false) + private String createBy; + + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + @Column(name = "update_by") + private String updateBy; + + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceApply.java b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java new file mode 100644 index 0000000..dcd6282 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java @@ -0,0 +1,104 @@ +package com.yunhai.svchall.entity; + +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 办件申请记录(V2.0 Part B.2.4)——办事大厅侧业务对象,对应 Notice 首接的 notice 行加列形态。 + * 与流程中心的隔离约定:本表保存提交快照(事项要点/表单数据/附件清单),事项后续改名/下架不影响 + * 在途与历史办件(跨库不 join);审批唯一真相在流程中心 flw_instance,本表 flow_status 为 + * 回调驱动的镜像,按 lastInstanceVersion 单调丢弃乱序旧事件,每日对账兜底。 + * + *

幂等键:flow-center 侧 uk(sch_id, biz_type, biz_id),本表 bizType 固定 "svc_item"、bizId=id—— + * 重试/重提复用同一行即自动幂等。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_apply", + indexes = { + @Index(name = "idx_svc_apply_submitter", columnList = "sch_id, submitter_id, flow_status"), + @Index(name = "idx_svc_apply_site", columnList = "sch_id, site_code, flow_status"), + @Index(name = "idx_svc_apply_instance", columnList = "flow_instance_id"), + @Index(name = "idx_svc_apply_item", columnList = "sch_id, item_id")}) +public class ServiceApply extends BaseEntity { + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 站点编码(冗余,管理端筛选) */ + @Column(name = "site_code", nullable = false, length = 64) + private String siteCode; + + /** 归属事项(逻辑外键 → service_item.id) */ + @Column(name = "item_id", nullable = false) + private Integer itemId; + + /** 事项编码(冗余) */ + @Column(name = "item_code", nullable = false, length = 64) + private String itemCode; + + /** 事项标题快照(发起时) */ + @Column(name = "item_title", nullable = false, length = 200) + private String itemTitle; + + /** 事项要点快照 JSON(= biz_snapshot 内容源:title/summary/deptName/handleTimeLimit/contactPhone/attachmentNames) */ + @Column(name = "item_snapshot_json", columnDefinition = "LONGTEXT") + private String itemSnapshotJson; + + /** 提交时表单版本(form_code 的已发布版本号;无表单事项为 null) */ + @Column(name = "form_version") + private Integer formVersion; + + /** 表单提交快照 JSON({key:value};无表单模式 NULL) */ + @Column(name = "form_data_json", columnDefinition = "LONGTEXT") + private String formDataJson; + + /** 申请级材料清单 JSON(无表单事项的附件;[{fieldKey,fileUrl,fileName,fileSize,mimeType}]) */ + @Column(name = "attachments_json", columnDefinition = "LONGTEXT") + private String attachmentsJson; + + /** 提交人 id(CAS 当前登录人快照,传给 flow-center 作 submitterId) */ + @Column(name = "submitter_id", nullable = false) + private Integer submitterId; + + /** 提交人姓名快照 */ + @Column(name = "submitter_name", nullable = false, length = 128) + private String submitterName; + + /** 提交时的流程绑定 */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + /** 流程中心办件实例 id(代发起成功后回填) */ + @Column(name = "flow_instance_id") + private Long flowInstanceId; + + /** 镜像状态(ApplyFlowStatusEnum:0 待提交/10 审批中/20 完成/30 拒绝/40 撤回) */ + @Column(name = "flow_status", nullable = false) + private Integer flowStatus = ApplyFlowStatusEnum.PENDING.getCode(); + + /** 已消费的最大回调 instanceVersion(乱序防护:小于等于它的回调丢弃) */ + @Column(name = "last_instance_version") + private Integer lastInstanceVersion = 0; + + /** 回调明细 JSON(终态事件 result/opinion、拒绝节点等,详情页展示) */ + @Column(name = "flow_result_json", columnDefinition = "LONGTEXT") + private String flowResultJson; + + /** 提交时间(首次代发起成功时间) */ + @Column(name = "submit_time") + private LocalDateTime submitTime; + + /** 终态时间 */ + @Column(name = "finish_time") + private LocalDateTime finishTime; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java b/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java new file mode 100644 index 0000000..5558e90 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceAttachment.java @@ -0,0 +1,56 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 附件(Notice up_file + 业务绑定形态,V2.0 Part B.2.5)。 + * 文件本体走 FILE_PATH 上传通道(FileUtil),本表存相对 URL 与可选业务绑定: + * topic_type=svc_item_guide(指南附件,topic_id=itemId)/ svc_apply_field(办件材料,topic_id=applyId); + * 封面与富文本内嵌图只存 URL 不建行(无"半绑定"归属问题——docs/19 平移教训 1 的取简解)。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_attachment", + indexes = { + @Index(name = "idx_svc_att_topic", columnList = "sch_id, topic_type, topic_id")}) +public class ServiceAttachment extends BaseEntity { + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 业务绑定类型(空=未绑定,仅上传) */ + @Column(name = "topic_type", length = 32) + private String topicType; + + /** 业务绑定 id(事项 id 或办件 id) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 表单字段 key(svc_apply_field 场景:材料归属的表单字段) */ + @Column(name = "field_key", length = 64) + private String fieldKey; + + /** 展示文件名 */ + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + /** 相对 URL(/upload/{type}/{uuid}.{ext};出参经 FileUtil.buildUrl 拼完整地址) */ + @Column(name = "file_url", nullable = false, length = 255) + private String fileUrl; + + /** 字节大小 */ + @Column(name = "file_size") + private Long fileSize; + + /** MIME 类型 */ + @Column(name = "mime_type", length = 128) + private String mimeType; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceDict.java b/src/main/java/com/yunhai/svchall/entity/ServiceDict.java new file mode 100644 index 0000000..14e2dc9 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceDict.java @@ -0,0 +1,56 @@ +package com.yunhai.svchall.entity; + +import com.yunhai.svchall.enums.ServiceDictTypeEnum; +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; + +/** + * 事项字典表(承袭 CMS cms_service_dict):dict_key 为稳定键——被事项引用后不可改,只允许改名。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_dict", + uniqueConstraints = @UniqueConstraint(name = "uk_svc_dict", + columnNames = {"sch_id", "site_code", "dict_type", "dict_key"}), + indexes = @Index(name = "idx_svc_dict_list", columnList = "sch_id, site_code, dict_type, status, sort_no")) +public class ServiceDict extends BaseEntity { + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 所属站点编码 */ + @Column(name = "site_code", nullable = false, length = 64) + private String siteCode; + + /** object=服务对象 / category=事项类别(ServiceDictTypeEnum 收口) */ + @Column(name = "dict_type", nullable = false, length = 32) + private String dictType; + + /** 稳定键(站内同类型唯一;被事项引用后不可改) */ + @Column(name = "dict_key", nullable = false, length = 64) + private String dictKey; + + /** 展示名 */ + @Column(name = "dict_name", nullable = false, length = 128) + private String dictName; + + /** 排序(升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** 0 停用 / 1 启用(停用后不出现在筛选与选择器,存量引用仍可渲染) */ + @Column(name = "status", nullable = false) + private Integer status = 1; + + /** 预留扩展 */ + @Column(name = "ext_json", columnDefinition = "JSON") + private String extJson; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java b/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java new file mode 100644 index 0000000..9bc0133 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceFlowEvent.java @@ -0,0 +1,49 @@ +package com.yunhai.svchall.entity; + +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; + +/** + * 回调事件接收日志(eventId 去重表,Notice 方案 §8.6"eventId 去重表"选型; + * 本应用无 Redis,落库去重 + 事件留档排查)。处理幂等另有 service_apply.last_instance_version + * 单调兜底,本表是第一道闸:同 eventId 重投直接 200 跳过。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_flow_event", + uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_event", columnNames = "event_id"), + indexes = @Index(name = "idx_svc_flow_event_instance", columnList = "instance_id")) +public class ServiceFlowEvent extends BaseEntity { + + /** 事件唯一键(32 位 uuid,重试重放不变) */ + @Column(name = "event_id", nullable = false, length = 32) + private String eventId; + + /** 事件类型(flow.completed 等) */ + @Column(name = "event", nullable = false, length = 32) + private String event; + + /** 租户键(payload.schId) */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 流程中心办件实例 */ + @Column(name = "instance_id", nullable = false) + private Long instanceId; + + /** 接收时间 */ + @Column(name = "receive_time", nullable = false) + private LocalDateTime receiveTime; + + /** 完整推送体(排障留档) */ + @Column(name = "payload_json", columnDefinition = "LONGTEXT") + private String payloadJson; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java b/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java new file mode 100644 index 0000000..8920397 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceFlowReg.java @@ -0,0 +1,49 @@ +package com.yunhai.svchall.entity; + +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; + +/** + * 流程回调注册(V2.0 Part B.2.6):管理端在事项绑定 flow_guid 时经 + * POST /oapi/flow/callback/reg 向流程中心注册并保存下发的 secret(回调签名密钥, + * 与应用级 appSecret 分离、粒度到"应用×流程"——流程中心概览 §4.8)。 + * 同 flow_guid 多事项共享一条注册。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_flow_reg", + uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_reg", columnNames = {"sch_id", "flow_guid"}), + indexes = @Index(name = "idx_svc_flow_reg_guid", columnList = "flow_guid")) +public class ServiceFlowReg extends BaseEntity { + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 订阅的流程 */ + @Column(name = "flow_guid", nullable = false, length = 32) + private String flowGuid; + + /** 回调接收端点(= 本应用对外可达地址 + /callback/flow) */ + @Column(name = "callback_url", nullable = false, length = 500) + private String callbackUrl; + + /** 订阅事件逗号分隔(空=全部;flow.submitted/task.created/task.handled/completed/rejected/withdrawn) */ + @Column(name = "event_types", length = 200) + private String eventTypes; + + /** reg 接口下发的回调签名密钥(HmacSHA256;不回显给前端) */ + @Column(name = "secret", nullable = false, length = 64) + private String secret; + + /** 0 启用 / 1 停用 */ + @Column(name = "status", nullable = false) + private Integer status = 0; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java new file mode 100644 index 0000000..6d78757 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java @@ -0,0 +1,56 @@ +package com.yunhai.svchall.entity; + +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; + +/** + * 自定义表单定义(V2.0 Part B.2.3,自流程中心 flw_form 平移——表单能力归办事大厅)。 + * 行不可变原则:已发布版本不可改,编辑已发布表单 = 克隆出新草稿版本(version+1)。 + * 事项经 form_code 绑定,发起时取**最新已发布版本**并快照 form_version。 + * schema_json 契约见 FormSchemaValidator(14 类命名冻结、声明式校验白名单、groups 分组)。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_form", + uniqueConstraints = @UniqueConstraint(name = "uk_svc_form", columnNames = {"sch_id", "code", "version"}), + indexes = { + @Index(name = "idx_svc_form_code", columnList = "sch_id, code, status, version"), + @Index(name = "idx_svc_form_status", columnList = "sch_id, status")}) +public class ServiceForm extends BaseEntity { + + /** 租户键(表单为 sch_id 级:校内跨站点共享) */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 表单编码(稳定键,事项经它绑定) */ + @Column(name = "code", nullable = false, length = 64) + private String code; + + /** 表单名 */ + @Column(name = "name", nullable = false, length = 128) + private String name; + + /** 版本:发布后不可改,改定义=升版本 */ + @Column(name = "version", nullable = false) + private Integer version = 1; + + /** 0 草稿 / 1 已发布 / 2 停用 */ + @Column(name = "status", nullable = false) + private Integer status = 0; + + /** 表单结构 JSON({"groups":[{title,fields:[…]}]};M1 仅放行 7 类控件) */ + @Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT") + private String schemaJson; + + /** 发布时间 */ + @Column(name = "publish_time") + private LocalDateTime publishTime; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java new file mode 100644 index 0000000..fcf440f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java @@ -0,0 +1,171 @@ +package com.yunhai.svchall.entity; + +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +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; + +/** + * 事项主表(承袭 CMS cms_service_item 全字段,V2.0 Part B.2.1)。 + * 变化点:site_id → site_code(跨应用后 CMS 自增 id 无意义,sch_id 定租户); + * 新增 form_code(绑定自定义表单)/ flow_guid(绑定流程中心流程,NULL=纯指南事项); + * apply_url 由 ext_json 提升为独立列。 + * + *

主键 id 语义(V2.0 C.2"id 语义"):CMS 迁移时**原值 identity insert**——CMS 门户指南页 + * 路由 /sv/{itemId} 直接携带本表主键;本应用自增起点须大于迁移 max(id)。 + * + *

索引声明口径:带排序方向的门户列表索引(sch_id, site_code, status, sort_no ASC, + * publish_time DESC, id DESC)含 DESC 列,Hibernate {@code @Index} 无法表达,由 sql/ 升级脚本补建。 + */ +@Getter +@Setter +@Entity +@Table(name = "service_item", + uniqueConstraints = @UniqueConstraint(name = "uk_svc_item_code", + columnNames = {"sch_id", "site_code", "item_code"}), + indexes = { + @Index(name = "idx_svc_item_list", columnList = "sch_id, site_code, status, online_flag, sort_no"), + @Index(name = "idx_svc_item_obj", columnList = "sch_id, site_code, status, object_key"), + @Index(name = "idx_svc_item_cat", columnList = "sch_id, site_code, status, category_key"), + @Index(name = "idx_svc_item_dept", columnList = "sch_id, site_code, status, dept_group_id"), + @Index(name = "idx_svc_item_flow", columnList = "flow_guid"), + @Index(name = "idx_svc_item_form", columnList = "form_code")}) +public class ServiceItem extends BaseEntity { + + /** 租户键(= UserBase 学校 id,承袭 CMS site.sch_id 口径) */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 站点编码(CMS cms_site.code 引用;办事大厅不建 site 表,站点目录经 CMS oapi 解析) */ + @Column(name = "site_code", nullable = false, length = 64) + private String siteCode; + + /** 事项编码,站点内唯一;跨服务键的一半(另一半是 siteCode) */ + @Column(name = "item_code", nullable = false, length = 64) + private String itemCode; + + /** 事项名称 */ + @Column(name = "title", nullable = false, length = 200) + private String title; + + /** 一句话简介(列表卡片副标题、办件 biz_snapshot 摘要) */ + @Column(name = "summary", length = 500) + private String summary; + + /** 服务对象,指向 service_dict(dict_type=object) 的 dict_key */ + @Column(name = "object_key", length = 64) + private String objectKey; + + /** 事项类别,指向 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; + + /** 部门名快照(列表/指南免跨服务查名;改名不自动同步,靠 syncDeptName) */ + @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 否。一致性校验:=1 时 flowGuid 必须非空(V2.0 B.2.1) */ + @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 白名单清洗;库内相对 /upload/ 路径,oapi 出参绝对化) */ + @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,本应用不校验存在性, + * CMS detail 转调时解析为 URL;V2.0 Part C.2/R2) + */ + @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; + + /** 事项封面(库内相对 /upload/ 路径,出参绝对化) */ + @Column(name = "cover_image", length = 500) + private String coverImage; + + /** 绑定自定义表单 code(service_form.code;NULL=发起无需表单);发起时取最新已发布版本 */ + @Column(name = "form_code", length = 64) + private String formCode; + + /** 绑定流程中心流程(flw_def.flow_guid;NULL=纯指南事项不可在线办理) */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + /** "我要办理"手填外链(http(s)://;三态优先级最高,承袭 CMS ext_json.apply_url 语义) */ + @Column(name = "apply_url", length = 500) + private String applyUrl; + + /** 管理端自定义排序(列表默认排序第一优先级,升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** 状态:0 草稿 / 1 已发布 / 2 已下架(ServiceItemStatusEnum) */ + @Column(name = "status", nullable = false) + private Integer status = ServiceItemStatusEnum.DRAFT.getCode(); + + /** 发布时间 */ + @Column(name = "publish_time") + private LocalDateTime publishTime; + + /** 下架时间 */ + @Column(name = "offline_time") + private LocalDateTime offlineTime; + + /** 指南页浏览量(CMS 经 /oapi/svc/item/view 异步累加,不承诺精确) */ + @Column(name = "view_count", nullable = false) + private Integer viewCount = 0; + + /** 扩展字段 JSON(预留,一期不写) */ + @Column(name = "ext_json", columnDefinition = "JSON") + private String extJson; +} diff --git a/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java b/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java new file mode 100644 index 0000000..55fb65f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/enums/ApplyFlowStatusEnum.java @@ -0,0 +1,47 @@ +package com.yunhai.svchall.enums; + +/** + * 办件在办事大厅侧的镜像状态(V2.0 Part B.2.4)。 + * 由流程中心回调驱动**单向推进**(按 payload.instanceVersion 丢弃乱序旧事件); + * 数值对齐 flw_instance.status:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回, + * 另有本地态 0=待提交(已落库但代发起尚未成功,可重试)。 + */ +public enum ApplyFlowStatusEnum { + + /** 待提交(本地落库,FlowClient 代发起未成功;重试复用同一 bizId) */ + PENDING(0), + /** 审批中 */ + APPROVING(10), + /** 已完成 */ + COMPLETED(20), + /** 已拒绝 */ + REJECTED(30), + /** 已撤回 */ + WITHDRAWN(40); + + private final int code; + + ApplyFlowStatusEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static ApplyFlowStatusEnum byCode(Integer code) { + if (code != null) { + for (ApplyFlowStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + } + return PENDING; + } + + /** 是否为终态(终态只接受例外性回退——对账以 flow-center 为准修正) */ + public boolean terminal() { + return this == COMPLETED || this == REJECTED || this == WITHDRAWN; + } +} diff --git a/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java b/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java new file mode 100644 index 0000000..650c037 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/enums/HandleModeEnum.java @@ -0,0 +1,37 @@ +package com.yunhai.svchall.enums; + +/** + * 办理方式(承袭 CMS HandleModeEnum)。与 onlineFlag 语义不同、允许不一致: + * 如「线上线下」但本期未开通在线入口时 online_flag=0。 + */ +public enum HandleModeEnum { + + /** 线上办理 */ + ONLINE(1), + /** 线下办理 */ + OFFLINE(2), + /** 线上线下均可 */ + BOTH(3); + + private final int code; + + HandleModeEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static boolean isValid(Integer code) { + if (code == null) { + return false; + } + for (HandleModeEnum e : values()) { + if (e.code == code) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java b/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java new file mode 100644 index 0000000..46df0ff --- /dev/null +++ b/src/main/java/com/yunhai/svchall/enums/ServiceDictTypeEnum.java @@ -0,0 +1,34 @@ +package com.yunhai.svchall.enums; + +/** + * 事项字典类型(承袭 CMS ServiceDictTypeEnum):object=服务对象、category=事项类别。 + */ +public enum ServiceDictTypeEnum { + + /** 服务对象 */ + OBJECT("object"), + /** 事项类别 */ + CATEGORY("category"); + + private final String key; + + ServiceDictTypeEnum(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + /** 非法值(含 null)返回 null,由调用方决定报错口径 */ + public static ServiceDictTypeEnum byKey(String key) { + if (key != null) { + for (ServiceDictTypeEnum e : values()) { + if (e.key.equals(key)) { + return e; + } + } + } + return null; + } +} diff --git a/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java b/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java new file mode 100644 index 0000000..193119d --- /dev/null +++ b/src/main/java/com/yunhai/svchall/enums/ServiceItemStatusEnum.java @@ -0,0 +1,36 @@ +package com.yunhai.svchall.enums; + +/** + * 事项状态(承袭 CMS ServiceItemStatusEnum):草稿→发布→下架; + * 门户可见性:仅已发布。 + */ +public enum ServiceItemStatusEnum { + + /** 草稿 */ + DRAFT(0), + /** 已发布 */ + PUBLISHED(1), + /** 已下架 */ + OFFLINE(2); + + private final int code; + + ServiceItemStatusEnum(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static ServiceItemStatusEnum byCode(Integer code) { + if (code != null) { + for (ServiceItemStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + } + return DRAFT; + } +} diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java new file mode 100644 index 0000000..20d94f8 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java @@ -0,0 +1,101 @@ +package com.yunhai.svchall.job; + +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.impl.FlowCallbackService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 对账兜底 Job(降级为应急闸,V2.0 Part B.4#5 → 契约冻结 v1.4): + * 升级后 flow-center 回调为持续重试(1m→5m→30m→2h→6h→60m 封顶)+ retryCount=6 挂起并 + * UserMsg 告警恰一次 + 死信管理端手工处理——消费方免每日对账(本应用 5xx 语义已对齐), + * 故本 Job 默认停用(svchall.flow.reconcile-enabled=false);仅在大面积漂移事故应急时置 true + * 手工开闸。开启时语义不变:拉在途办件(审批中)→ 按 biz 键查 flow-center 真实状态 → + * 漂移以 flow-center 为准修正(对账直改状态,不写 service_flow_event 去重表与结果明细—— + * 与回调链路的差异,仅应急用不混淆镜像口径)。 + */ +@Slf4j +@Component +public class FlowReconcileJob { + + private final ServiceApplyRepository applyRepository; + private final FlowClient flowClient; + private final boolean reconcileEnabled; + + public FlowReconcileJob(ServiceApplyRepository applyRepository, FlowClient flowClient, + @Value("${svchall.flow.reconcile-enabled:false}") boolean reconcileEnabled) { + this.applyRepository = applyRepository; + this.flowClient = flowClient; + this.reconcileEnabled = reconcileEnabled; + } + + @Scheduled(cron = "0 0 4 * * ?") + public void reconcile() { + if (!reconcileEnabled) { + return; // 默认停用:免对账契约下推拉主链路由回调持续重试承担 + } + if (!flowClient.enabled()) { + return; + } + // 全租户口径:审批中的在途办件(分页上限 500,量级评估:全校审批 << 千/周) + List pending = applyRepository.findByFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode(), + org.springframework.data.domain.PageRequest.of(0, 500)); + int fixed = 0; + for (ServiceApply apply : pending) { + if (apply.getFlowInstanceId() == null) { + continue; + } + try { + Map remote = flowClient.getInstanceByBiz( + apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId())); + if (remote == null) { + continue; + } + Integer remoteStatus = toInt(remote.get("status")); + Integer remoteVersion = toInt(remote.get("version")); + if (remoteStatus != null && !remoteStatus.equals(apply.getFlowStatus()) + && (remoteVersion == null || remoteVersion > apply.getLastInstanceVersion())) { + apply.setFlowStatus(remoteStatus); + if (remoteVersion != null) { + apply.setLastInstanceVersion(remoteVersion); + } + if (remoteStatus.equals(ApplyFlowStatusEnum.COMPLETED.getCode()) + || remoteStatus.equals(ApplyFlowStatusEnum.REJECTED.getCode()) + || remoteStatus.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) { + apply.setFinishTime(LocalDateTime.now()); + } + applyRepository.save(apply); + fixed++; + } + } catch (Exception e) { + log.warn("[svc-hall] 对账跳过 applyId={}:{}", apply.getId(), e.toString()); + } + } + if (fixed > 0 || !pending.isEmpty()) { + log.info("[svc-hall] 对账完成(应急闸开启中):在途 {} 条,修正 {} 条", pending.size(), fixed); + } + } + + private static Integer toInt(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java new file mode 100644 index 0000000..54c5f9d --- /dev/null +++ b/src/main/java/com/yunhai/svchall/model/oapi/OapiModels.java @@ -0,0 +1,133 @@ +package com.yunhai.svchall.model.oapi; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 开放接口(供 CMS)出参模型收口(V2.0 Part C.2 契约冻结件)。 + * 平台统一响应信封 {code,msg,data} 由 starter 包装,本类只定义 data 部分。 + * 签名/信封纪律:未签名 401(RequireAppSignFilter);缺参/不存在/未发布 → HTTP 200 + code=2。 + */ +public final class OapiModels { + + private OapiModels() { + } + + /** GET /oapi/svc/items 行(id 为办事大厅主键——CMS 门户 /sv/{id} 路由直接携带) */ + @Data + public static class ItemRow { + private Integer id; + private String itemCode; + private String title; + private String summary; + private String coverImage; + private String deptName; + private String categoryName; + private Integer handleMode; + private Integer onlineFlag; + private LocalDateTime publishTime; + } + + /** GET /oapi/svc/items 出参 */ + @Data + public static class ListData { + private Long total = 0L; + private Integer page = 1; + private Integer pageSize = 10; + private Integer totalPages = 0; + private List items = new ArrayList<>(); + } + + /** 筛选项(key 或 id 二选一:object/category 用 key,dept 用组 id) */ + @Data + public static class FilterOption { + private final String key; + private final Integer id; + private final String name; + private final long count; + } + + /** GET /oapi/svc/filters 出参 */ + @Data + public static class Filters { + private List objects = new ArrayList<>(); + private List categories = new ArrayList<>(); + private List depts = new ArrayList<>(); + } + + /** 指南附件(fileUrl 为完整可达地址) */ + @Data + public static class AttachmentRow { + private Integer id; + private String fileName; + private String fileUrl; + private Long fileSize; + } + + /** 关联事项行 */ + @Data + public static class RelatedItemRow { + private Integer id; + private String itemCode; + private String title; + private String deptName; + private Integer handleMode; + } + + /** GET /oapi/svc/item/detail 出参(三段 HTML 与封面已绝对化) */ + @Data + public static class Detail { + private Integer id; + 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; + private String processHtml; + private String materialHtml; + private String noticeHtml; + private String coverImage; + private LocalDateTime publishTime; + private Integer viewCount; + /** 关联 CMS 栏目(不透明引用,CMS 侧解析为 URL) */ + private Integer faqCategoryId; + private Integer noticeCategoryId; + private Integer ruleCategoryId; + private List attachments = new ArrayList<>(); + private List relatedItems = new ArrayList<>(); + /** 办事大厅发起页地址({front-base}/#/apply?siteCode=&itemCode=;CMS 三态兜底用) */ + private String applyUrl; + /** 绑定流程 GUID(§3.7:CMS"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空;null=未绑流程) */ + private String flowGuid; + } + + /** GET /oapi/svc/item/snapshot 出参(docs/17 §1.1 契约原样平移,11 字段) */ + @Data + public static class Snapshot { + private String siteCode; + private String itemCode; + private String title; + private String summary; + private String deptName; + private Integer handleMode; + private Integer onlineFlag; + private String handleTimeLimit; + private String contactPhone; + private String applyUrl; + private LocalDateTime publishTime; + } +} diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java new file mode 100644 index 0000000..f883217 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java @@ -0,0 +1,461 @@ +package com.yunhai.svchall.model.svc; + +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 办事大厅业务模型收口(管理端/用户面 DTO 与 VO;嵌套静态类收口减少文件数)。 + * oapi(供 CMS)模型另见 model/oapi/OapiModels。 + */ +public final class SvcModels { + + private SvcModels() { + } + + // ===== 通用 ===== + + /** 删除入参(siteCode 定位站点 + id) */ + @Data + public static class DeleteRequest { + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + @NotNull(message = "id 不得为空") + private Integer id; + } + + /** 排序项 */ + @Data + public static class IdSortItem { + @NotNull(message = "id 不得为空") + private Integer id; + @NotNull(message = "sortNo 不得为空") + private Integer sortNo; + } + + /** 站点选项(CMS 站点目录代理,管理端站点选择器) */ + @Data + public static class SiteOptionVo { + private String siteCode; + private String name; + private Long schId; + } + + // ===== 字典 ===== + + @Data + public static class SvcDictSaveRequest { + private Integer id; + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + @NotBlank(message = "字典类型不得为空") + private String dictType; + @NotBlank(message = "字典键不得为空") + @Pattern(regexp = "^[A-Za-z0-9_-]{1,64}$", message = "字典键仅限字母/数字/下划线/中划线") + private String dictKey; + @NotBlank(message = "字典名称不得为空") + @Size(max = 128, message = "字典名称过长") + private String dictName; + private Integer sortNo; + private Integer status; + } + + @Data + public static class SvcDictVo { + private Integer id; + private String siteCode; + private String dictType; + private String dictKey; + private String dictName; + private Integer sortNo; + private Integer status; + private Long referenced; + + public static SvcDictVo of(com.yunhai.svchall.entity.ServiceDict d, Long referenced) { + SvcDictVo vo = new SvcDictVo(); + vo.setId(d.getId()); + vo.setSiteCode(d.getSiteCode()); + vo.setDictType(d.getDictType()); + vo.setDictKey(d.getDictKey()); + vo.setDictName(d.getDictName()); + vo.setSortNo(d.getSortNo()); + vo.setStatus(d.getStatus()); + vo.setReferenced(referenced == null ? 0L : referenced); + return vo; + } + } + + /** 服务部门选项(UserBase 组树嵌套下发,承袭 CMS SvcDeptVo) */ + @Data + public static class SvcDeptVo { + private Integer id; + private String name; + private List children = new ArrayList<>(); + + public SvcDeptVo() { + } + + public SvcDeptVo(Integer id, String name) { + this.id = id; + this.name = name; + } + } + + // ===== 事项 ===== + + @Data + public static class SvcItemPageQuery { + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + private Integer status; + private String keyword; + private String objectKey; + private String categoryKey; + private Integer deptGroupId; + private Integer page = 1; + private Integer pageSize = 10; + } + + @Data + public static class SvcItemSaveRequest { + private Integer id; + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + @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; + @Size(max = 64) + private String objectKey; + @Size(max = 64) + private String categoryKey; + private Integer deptGroupId; + private Integer handleMode; + 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; + private String processHtml; + private String materialHtml; + private String noticeHtml; + private Integer faqCategoryId; + private Integer noticeCategoryId; + private Integer ruleCategoryId; + @Size(max = 500) + private String coverImage; + /** 绑定自定义表单 code(可空;发起无需表单) */ + @Size(max = 64) + private String formCode; + /** 绑定流程中心流程 flow_guid(可空;onlineFlag=1 时必填) */ + @Size(max = 32) + private String flowGuid; + @Size(max = 500) + private String applyUrl; + private Integer sortNo; + /** 指南附件 id 列表(保存时全量重绑 topic_type=svc_item_guide) */ + private List attachmentIds; + } + + @Data + public static class SvcItemEditVo { + private Integer id; + private String siteCode; + 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 String formCode; + private String flowGuid; + private String applyUrl; + private Integer sortNo; + private Integer status; + private LocalDateTime publishTime; + private LocalDateTime offlineTime; + private Integer viewCount; + + public static SvcItemEditVo of(com.yunhai.svchall.entity.ServiceItem item) { + SvcItemEditVo vo = new SvcItemEditVo(); + vo.setId(item.getId()); + vo.setSiteCode(item.getSiteCode()); + 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.setFormCode(item.getFormCode()); + vo.setFlowGuid(item.getFlowGuid()); + vo.setApplyUrl(item.getApplyUrl()); + vo.setSortNo(item.getSortNo()); + vo.setStatus(item.getStatus()); + vo.setPublishTime(item.getPublishTime()); + vo.setOfflineTime(item.getOfflineTime()); + vo.setViewCount(item.getViewCount()); + return vo; + } + } + + @Data + public static class SvcItemPageItem { + private Integer id; + private String siteCode; + 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 formCode; + private String flowGuid; + private Integer status; + private Integer sortNo; + private LocalDateTime publishTime; + private Integer viewCount; + private LocalDateTime createTime; + private String createBy; + + public static SvcItemPageItem of(com.yunhai.svchall.entity.ServiceItem item, String objectName, + String categoryName) { + SvcItemPageItem vo = new SvcItemPageItem(); + vo.setId(item.getId()); + vo.setSiteCode(item.getSiteCode()); + 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.setFormCode(item.getFormCode()); + vo.setFlowGuid(item.getFlowGuid()); + 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; + } + } + + @Data + public static class SvcItemSimpleVo { + private Integer id; + private String itemCode; + private String title; + private Integer status; + + public static SvcItemSimpleVo of(com.yunhai.svchall.entity.ServiceItem item) { + SvcItemSimpleVo vo = new SvcItemSimpleVo(); + vo.setId(item.getId()); + vo.setItemCode(item.getItemCode()); + vo.setTitle(item.getTitle()); + vo.setStatus(item.getStatus()); + return vo; + } + } + + @Data + public static class SvcItemAttachmentVo { + private Integer id; + private String fileName; + private String fileUrl; + private Long fileSize; + + public static SvcItemAttachmentVo of(com.yunhai.svchall.entity.ServiceAttachment a) { + SvcItemAttachmentVo vo = new SvcItemAttachmentVo(); + vo.setId(a.getId()); + vo.setFileName(a.getFileName()); + vo.setFileUrl(com.yunhai.svchall.utils.FileUtil.buildUrl(a.getFileUrl())); + vo.setFileSize(a.getFileSize()); + return vo; + } + } + + // ===== 表单 ===== + + @Data + public static class SvcFormSaveRequest { + private Integer id; + @NotBlank(message = "表单编码不得为空") + @Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$", message = "表单编码仅限字母/数字/下划线/中划线,2~64 位") + private String code; + @NotBlank(message = "表单名称不得为空") + @Size(max = 128, message = "表单名称过长") + private String name; + @NotBlank(message = "表单结构不得为空") + private String schemaJson; + } + + @Data + public static class SvcFormVo { + private Integer id; + private String code; + private String name; + private Integer version; + private Integer status; + private String schemaJson; + private LocalDateTime publishTime; + private Boolean referenced; + + public static SvcFormVo of(com.yunhai.svchall.entity.ServiceForm form, Boolean referenced) { + SvcFormVo vo = new SvcFormVo(); + vo.setId(form.getId()); + vo.setCode(form.getCode()); + vo.setName(form.getName()); + vo.setVersion(form.getVersion()); + vo.setStatus(form.getStatus()); + vo.setSchemaJson(form.getSchemaJson()); + vo.setPublishTime(form.getPublishTime()); + vo.setReferenced(referenced); + return vo; + } + } + + // ===== 办件 ===== + + /** 申请级材料项(无表单事项的附件;先经 /api/file/upload 上传,再携 attachmentId 绑定) */ + @Data + public static class ApplyAttachmentItem { + /** 表单/材料字段标识(可空) */ + private String fieldKey; + /** 上传接口返回的附件行 id */ + @NotNull(message = "attachmentId 不得为空") + private Integer attachmentId; + } + + @Data + public static class ApplySubmitRequest { + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + @NotBlank(message = "itemCode 不得为空") + private String itemCode; + /** 表单数据 JSON 字符串({key:value};无表单事项传空) */ + private String formData; + /** 申请级材料(可空) */ + private List attachments; + } + + @Data + public static class ApplyVo { + private Integer id; + private String siteCode; + private Integer itemId; + private String itemCode; + private String itemTitle; + private String itemSnapshotJson; + private Integer formVersion; + private String formDataJson; + private String attachmentsJson; + private Integer submitterId; + private String submitterName; + private String flowGuid; + private Long flowInstanceId; + private Integer flowStatus; + private String flowResultJson; + private LocalDateTime submitTime; + private LocalDateTime finishTime; + private LocalDateTime createTime; + + public static ApplyVo of(com.yunhai.svchall.entity.ServiceApply a) { + ApplyVo vo = new ApplyVo(); + vo.setId(a.getId()); + vo.setSiteCode(a.getSiteCode()); + vo.setItemId(a.getItemId()); + vo.setItemCode(a.getItemCode()); + vo.setItemTitle(a.getItemTitle()); + vo.setItemSnapshotJson(a.getItemSnapshotJson()); + vo.setFormVersion(a.getFormVersion()); + vo.setFormDataJson(a.getFormDataJson()); + vo.setAttachmentsJson(a.getAttachmentsJson()); + vo.setSubmitterId(a.getSubmitterId()); + vo.setSubmitterName(a.getSubmitterName()); + vo.setFlowGuid(a.getFlowGuid()); + vo.setFlowInstanceId(a.getFlowInstanceId()); + vo.setFlowStatus(a.getFlowStatus()); + vo.setFlowResultJson(a.getFlowResultJson()); + vo.setSubmitTime(a.getSubmitTime()); + vo.setFinishTime(a.getFinishTime()); + vo.setCreateTime(a.getCreateTime()); + return vo; + } + } + + @Data + public static class ApplyPageQuery { + private String siteCode; + private String itemCode; + private Integer flowStatus; + private String keyword; + private Integer page = 1; + private Integer pageSize = 10; + } + + // ===== 流程(管理端回显) ===== + + @Data + public static class FlowSummaryVo { + private String flowGuid; + private String name; + private Integer version; + private String nodeSummary; + } +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java new file mode 100644 index 0000000..eee3d49 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java @@ -0,0 +1,66 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceApply; +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.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +public interface ServiceApplyRepository extends JpaRepository, + JpaSpecificationExecutor { + + List findBySchIdAndSubmitterIdOrderByCreateTimeDesc(Long schId, Integer submitterId, Pageable pageable); + + Optional findByIdAndSchId(Integer id, Long schId); + + Optional findBySchIdAndFlowInstanceId(Long schId, Long flowInstanceId); + + /** 回调侧行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新 */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select a from ServiceApply a where a.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); + + /** 对账:在途办件(待提交/审批中) */ + List findBySchIdAndFlowStatusIn(Long schId, List statuses); + + /** 全租户对账:审批中的在途办件(分页上限防御) */ + List findByFlowStatus(Integer flowStatus, Pageable pageable); + + /** 待提交且已产生 flowInstanceId 之外的挂起单(代发起未返回的重试候选) */ + List findBySchIdAndFlowStatus(Long schId, Integer flowStatus, Pageable pageable); + + /** + * 代发起成功回填(窄列原子更新,Notice markApproving 同构): + * 置 flow_instance_id + flow_status=10;若回调已并发先行落定终态(20 完成/30 拒绝/40 撤回, + * code 对齐 ApplyFlowStatusEnum)则保持终态不回退(验收报告 §四.2:旧快照盲写会把状态打回); + * submitTime 仅首次回填(coalesce 保留既有值)。 + */ + @Modifying + @Query("update ServiceApply a set a.flowInstanceId = :instanceId," + + " a.flowStatus = (case when a.flowStatus in (20, 30, 40) then a.flowStatus else 10 end)," + + " a.submitTime = coalesce(a.submitTime, :submitTime)" + + " where a.id = :id") + int markApproving(@Param("id") Integer id, @Param("instanceId") Long instanceId, + @Param("submitTime") java.time.LocalDateTime submitTime); + + /** + * 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。 + * 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫 + * 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作, + * submitTime 覆盖为当下、finishTime 清空。WHERE 限定 30/40,并发窗口内若状态已被推进 + * (如新一轮回调先置 10)则本次为幂等空更新。 + */ + @Modifying + @Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10," + + " a.submitTime = :submitTime, a.finishTime = null" + + " where a.id = :id and a.flowStatus in (30, 40)") + int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId, + @Param("submitTime") java.time.LocalDateTime submitTime); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java new file mode 100644 index 0000000..a0591d5 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceAttachmentRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ServiceAttachmentRepository extends JpaRepository { + + List findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(Long schId, String topicType, + Integer topicId); + + List findBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId); + + List findAllById(Iterable ids); + + void deleteBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java new file mode 100644 index 0000000..04a11ac --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceDictRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceDict; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ServiceDictRepository extends JpaRepository { + + List findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(Long schId, String siteCode, + String dictType); + + List findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode); + + Optional findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode); + + Optional findBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType, + String dictKey); + + boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType, + String dictKey); + + boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKeyAndIdNot(Long schId, String siteCode, String dictType, + String dictKey, Integer id); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java new file mode 100644 index 0000000..964f285 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceFlowEventRepository.java @@ -0,0 +1,9 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceFlowEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ServiceFlowEventRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java new file mode 100644 index 0000000..113d8be --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceFlowRegRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceFlowReg; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ServiceFlowRegRepository extends JpaRepository { + + Optional findBySchIdAndFlowGuid(Long schId, String flowGuid); + + Optional findByFlowGuid(String flowGuid); + + void deleteBySchIdAndFlowGuid(Long schId, String flowGuid); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java new file mode 100644 index 0000000..bfa066f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceFormRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceForm; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ServiceFormRepository extends JpaRepository { + + List findBySchIdOrderByCodeAscVersionDescIdAsc(Long schId); + + List findBySchIdAndCodeOrderByVersionDesc(Long schId, String code); + + Optional findByIdAndSchId(Integer id, Long schId); + + Optional findBySchIdAndCodeAndVersion(Long schId, String code, Integer version); + + /** 事项绑定校验/发起取数:code 的最新已发布版本 */ + Optional findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(Long schId, String code, Integer status); + + boolean existsBySchIdAndCode(Long schId, String code); + + /** 事项引用计数(删除守护:任一版本被绑定即算被引用,按 code 判) */ + long countBySchIdAndCode(Long schId, String code); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java new file mode 100644 index 0000000..2ff26e9 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/ServiceItemRepository.java @@ -0,0 +1,74 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.ServiceItem; +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 ServiceItemRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode); + + Optional findBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode); + + boolean existsBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode); + + boolean existsBySchIdAndSiteCodeAndItemCodeAndIdNot(Long schId, String siteCode, String itemCode, Integer id); + + /** 字典键被事项引用计数(字典改键/停用守护用) */ + long countBySchIdAndSiteCodeAndObjectKey(Long schId, String siteCode, String objectKey); + + long countBySchIdAndSiteCodeAndCategoryKey(Long schId, String siteCode, String categoryKey); + + /** 关联事项:同类别已发布,排除自身(Pageable 控制条数与排序) */ + List findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(Long schId, String siteCode, + Integer status, String categoryKey, + Integer id, Pageable pageable); + + /** 管理端选择器/部门名同步:全状态按排序取(条数由 Pageable 控制) */ + List findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode, Pageable pageable); + + List findBySchIdAndFlowGuid(Long schId, String flowGuid); + + /** 表单被事项绑定计数(表单删除守护,按 form_code 判) */ + long countBySchIdAndFormCode(Long schId, String formCode); + + // ===== 门户筛选项计数(已发布口径,group by 一次往返/维度,承袭 CMS 口径)===== + + @Query("select i.objectKey, count(i) from ServiceItem i" + + " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.objectKey is not null" + + " group by i.objectKey") + List countPublishedGroupByObjectKey(@Param("schId") Long schId, @Param("siteCode") String siteCode); + + @Query("select i.categoryKey, count(i) from ServiceItem i" + + " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.categoryKey is not null" + + " group by i.categoryKey") + List countPublishedGroupByCategoryKey(@Param("schId") Long schId, @Param("siteCode") String siteCode); + + @Query("select i.deptGroupId, max(i.deptName), count(i) from ServiceItem i" + + " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.deptGroupId is not null" + + " group by i.deptGroupId") + List countPublishedGroupByDept(@Param("schId") Long schId, @Param("siteCode") String siteCode); + + /** 指南页浏览量异步累加(fire-and-forget,失败忽略) */ + @Modifying + @Query("update ServiceItem i set i.viewCount = i.viewCount + 1 where i.id = :id") + int incrementViewCount(@Param("id") Integer id); + + // ===== 字典引用计数(全状态口径,字典守护与列表"被引用"展示用)===== + + @Query("select i.objectKey, count(i) from ServiceItem i" + + " where i.schId = :schId and i.siteCode = :siteCode and i.objectKey is not null group by i.objectKey") + List countGroupByObjectKey(@Param("schId") Long schId, @Param("siteCode") String siteCode); + + @Query("select i.categoryKey, count(i) from ServiceItem i" + + " where i.schId = :schId and i.siteCode = :siteCode and i.categoryKey is not null group by i.categoryKey") + List countGroupByCategoryKey(@Param("schId") Long schId, @Param("siteCode") String siteCode); +} diff --git a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java new file mode 100644 index 0000000..c312607 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java @@ -0,0 +1,114 @@ +package com.yunhai.svchall.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.service.impl.FlowCallbackService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 办件详情放行守卫(BP-R2-3 修复,对齐 Notice NoticeFlowGuard 三分支口径): + * 仅三类人可看非本人办件——管理员(type=1 或 roleId∈{2,5,7} 白名单)/ 办理人 + * (flow-center getInstanceByBiz 的 assigneeIds 含当前用户,60s 短缓存+fail-closed—— + * 审批中途换人不实时,可接受)。提交人本人在调用方先行判定(owner 短路)。 + * 其余同校普通用户一律"办件不存在或无权查看"(查不到语义,不泄露他人表单与审批意见)。 + * 注意:SiteGuard.requireSiteManager 的"同校即过"管理面口径本轮维持不动(影响 5 个管理页, + * 已登记);本守卫是详情页专用收紧,不回退该口径。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ApplyViewGuard { + + /** 管理员角色白名单(与 Notice NoticeFlowGuard 对齐:校级管理员/分管领导等) */ + private static final List ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7); + + private static final long ASSIGNEE_CACHE_MS = 60_000L; + + private final FlowClient flowClient; + + private final Map assigneeCache = new ConcurrentHashMap<>(); + + private static class CachedAssignees { + final List userIds; + final long expireAt; + + CachedAssignees(List userIds) { + this.userIds = userIds; + this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS; + } + } + + /** + * 校验当前用户可查看该办件(调用方已按 schId 定位行,即本校上下文成立)。 + * 不可见抛 FORBIDDEN(文案=查不到语义)。 + */ + public void requireVisible(ServiceApply apply, BoaUser user) { + if (apply == null || user == null || user.getId() == null) { + throw notFound(); + } + // 1) 提交人本人 + if (user.getId().equals(apply.getSubmitterId())) { + return; + } + // 2) 管理员(超管 type=1 或校级管理员角色白名单——Notice NoticeFlowGuard 同款口径) + if (isAdmin(user)) { + return; + } + // 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见) + if (apply.getFlowInstanceId() != null && flowClient.enabled() + && currentAssignees(apply).contains(user.getId())) { + return; + } + throw notFound(); + } + + private boolean isAdmin(BoaUser user) { + if (user.getType() != null && user.getType() == 1) { + return true; + } + if (user.getRoleIds() != null) { + for (Integer roleId : user.getRoleIds()) { + if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) { + return true; + } + } + } + return false; + } + + @SuppressWarnings("unchecked") + private List currentAssignees(ServiceApply apply) { + long now = System.currentTimeMillis(); + CachedAssignees cached = assigneeCache.get(apply.getFlowInstanceId()); + if (cached != null && cached.expireAt > now) { + return cached.userIds; + } + List assignees = Collections.emptyList(); + try { + Map instance = flowClient.getInstanceByBiz( + apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId())); + if (instance != null && instance.get("assigneeIds") instanceof List) { + assignees = (List) instance.get("assigneeIds"); + } + } catch (Exception e) { + // 流程中心不可达:fail-closed(不放行),日志留痕 + log.warn("[svc-hall] 办理人查询失败(fail-closed):applyId={} {}", apply.getId(), e.toString()); + } + assigneeCache.put(apply.getFlowInstanceId(), new CachedAssignees(assignees)); + return assignees; + } + + private BizException notFound() { + return new BizException(ErrorCode.FORBIDDEN, "办件不存在或无权查看"); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/DeptSource.java b/src/main/java/com/yunhai/svchall/service/DeptSource.java new file mode 100644 index 0000000..6b922a9 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/DeptSource.java @@ -0,0 +1,27 @@ +package com.yunhai.svchall.service; + +import com.yunhai.platform.utils.model.BoaGroup; + +/** + * 部门组树来源接缝(Cms SvcDeptSource 原样平移):默认实现代理 UserBase oapi 静态调用 + * {@code YschBaseOpenApi.getGroupById/getGroupTree}。包一层 bean 的理由:静态方法在测试中 + * 无法替身;且隔离了外部依赖,未来平台调整只换实现。 + */ +public interface DeptSource { + + /** + * 学校组子树(含该学校节点自身,children 递归全部后代)。 + * 服务部门"跨校隔离"的唯一来源:部门只在**本校**子树内取。 + * + * @param schoolId UserBase 组 id(= 事项所属学校的 schId) + * @return 子树根;入参为空、组不存在或平台不可达时返回 {@code null}(不抛出,降级口径由消费方决定) + */ + BoaGroup getGroupSubtree(Integer schoolId); + + /** + * 全平台组树根节点。仅用于本地未挂学校(schId 缺失)的兼容回退;正常路径不应使用。 + * + * @return 组树根;平台不可达或异常时返回 {@code null} + */ + BoaGroup getGroupTree(); +} diff --git a/src/main/java/com/yunhai/svchall/service/FlowClient.java b/src/main/java/com/yunhai/svchall/service/FlowClient.java new file mode 100644 index 0000000..0650261 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/FlowClient.java @@ -0,0 +1,58 @@ +package com.yunhai.svchall.service; + +import com.yunhai.svchall.model.svc.SvcModels; + +import java.util.List; +import java.util.Map; + +/** + * 流程中心客户端接缝(V2.0 Part B.4):Notice 同款托管模式的出站面。 + * 默认实现经共享 IDHttp(出站自动签名,yh.app-auth 身份)调 flow-center /oapi/flow/**。 + * enabled=false(本地未部署 flow-center)时所有方法抛业务异常,消费方按需降级 + * (事项不绑流程=纯指南,不经过本接口)。 + */ +public interface FlowClient { + + /** 流程中心是否已接入(未接入时管理端选择器降级提示,事项绑 flow_guid 校验跳过) */ + boolean enabled(); + + /** 已发布流程列表(事项编辑页"办理流程"选择器数据源) */ + List listFlows(Long schId); + + /** 流程详情回显(节点与办理科室摘要) */ + SvcModels.FlowSummaryVo getFlow(Long schId, String flowGuid); + + /** + * 代发起(POST /oapi/flow/apply,V2.0 B.4#3)。 + * + * @return {instanceId, duplicated}(duplicated=true 表示命中 uk 幂等返回既有办件) + */ + ApplyResult apply(Long schId, String flowGuid, String bizType, String bizId, String bizTitle, + String bizUrl, String bizSnapshot, Integer submitterId, String submitterName, + String formDataJson); + + /** 按业务键查办件(对账兜底;返回 {instanceId,status,version,nodeSummary,traceJson},查不到返回 null) */ + Map getInstanceByBiz(Long schId, String bizType, String bizId); + + /** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */ + String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes); + + /** 代发起结果 */ + class ApplyResult { + private final Long instanceId; + private final boolean duplicated; + + public ApplyResult(Long instanceId, boolean duplicated) { + this.instanceId = instanceId; + this.duplicated = duplicated; + } + + public Long getInstanceId() { + return instanceId; + } + + public boolean isDuplicated() { + return duplicated; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/service/SiteDirectory.java b/src/main/java/com/yunhai/svchall/service/SiteDirectory.java new file mode 100644 index 0000000..25f0e6f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/SiteDirectory.java @@ -0,0 +1,48 @@ +package com.yunhai.svchall.service; + +import com.yunhai.svchall.model.svc.SvcModels; + +import java.util.List; + +/** + * CMS 站点目录接缝(V2.0 Part B):办事大厅不建 site 表,站点的 schId/名称经 CMS + * {@code GET /oapi/site/list}(签名)解析,本地 Caffeine 缓存。用于: + * ① 管理端权限校验(siteCode → schId 对比 currentRole.schId); + * ② 数据落库时确定 schId;③ 管理端站点选择器。 + */ +public interface SiteDirectory { + + /** 站点信息(不可达/不存在返回 null——消费方 fail-closed) */ + SiteInfo resolveSite(String siteCode); + + /** 本校可选站点列表(管理端站点选择器;不可达抛业务异常) */ + List listSites(Long schId); + + /** fail-closed 语义:解析不到站点(或 CMS 不可达)时抛业务异常 */ + SiteInfo requireSite(String siteCode); + + /** 站点信息 */ + class SiteInfo { + private final String siteCode; + private final String name; + private final Long schId; + + public SiteInfo(String siteCode, String name, Long schId) { + this.siteCode = siteCode; + this.name = name; + this.schId = schId; + } + + public String getSiteCode() { + return siteCode; + } + + public String getName() { + return name; + } + + public Long getSchId() { + return schId; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/service/SiteGuard.java b/src/main/java/com/yunhai/svchall/service/SiteGuard.java new file mode 100644 index 0000000..b20828f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/SiteGuard.java @@ -0,0 +1,60 @@ +package com.yunhai.svchall.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 站点级权限守卫(承袭 CMS SiteService.requireManageable 语义,跨应用版): + * 超管判定 {@code BoaUser.type == 1}(UserBase 平台约定);校级按 + * {@code currentRole.schId} 与站点 schId(CMS 站点目录解析)比对。 + * 站点解析不到时 fail-closed(SiteDirectory.requireSite 抛异常),绝不静默放行。 + */ +@Service +@RequiredArgsConstructor +public class SiteGuard { + + /** 超管类型(UserBase sys_user.type=1 平台约定) */ + private static final int SUPER_ADMIN_TYPE = 1; + + private final SiteDirectory siteDirectory; + + /** 管理面:可管理该校站点(超管或本校角色),返回解析后的站点信息(含权威 schId) */ + public SiteInfo requireSiteManager(BoaUser user, String siteCode) { + SiteInfo site = siteDirectory.requireSite(siteCode); + if (isSuperAdmin(user)) { + return site; + } + Long schId = currentSchId(user); + if (schId != null && schId.equals(site.getSchId())) { + return site; + } + throw new BizException(ErrorCode.FORBIDDEN, "无该站点管理权限:" + siteCode); + } + + /** 用户面:本校用户才可办本校事项(超管豁免;跨校办件属脏数据,V2.0 B.4 权限细节) */ + public void requireSchoolMember(BoaUser user, Long schId) { + if (isSuperAdmin(user)) { + return; + } + Long userSchId = currentSchId(user); + if (userSchId == null || !userSchId.equals(schId)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅本校用户可办理本校事项"); + } + } + + private static boolean isSuperAdmin(BoaUser user) { + return user != null && user.getType() != null && user.getType() == SUPER_ADMIN_TYPE; + } + + private static Long currentSchId(BoaUser user) { + if (user == null || user.getCurrentRole() == null) { + return null; + } + Integer schId = user.getCurrentRole().getSchId(); + return schId == null ? null : schId.longValue(); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/AttachmentService.java b/src/main/java/com/yunhai/svchall/service/impl/AttachmentService.java new file mode 100644 index 0000000..2153cc8 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/AttachmentService.java @@ -0,0 +1,177 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceAttachment; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo; +import com.yunhai.svchall.repository.ServiceAttachmentRepository; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.utils.FileUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 附件服务(Notice 上传通道 + 业务绑定,V2.0 Part B.2.5)。 + * 与 CMS svc 场景的差异(docs/19 平移教训 1 的取简解):封面与富文本内嵌图只存 URL 不建行, + * 不做"内容派生归属"——附件行只有两类显式绑定:指南附件(svc_item_guide)与办件材料(svc_apply_field), + * 绑定即授权,不绑定不产生无主引用问题。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AttachmentService { + + /** 指南附件(事项编辑页显式列表) */ + public static final String TOPIC_GUIDE = "svc_item_guide"; + /** 办件材料(发起提交时绑定) */ + public static final String TOPIC_APPLY = "svc_apply_field"; + + private final ServiceAttachmentRepository attachmentRepository; + private final SiteDirectory siteDirectory; + + /** 上传:落盘 + 建行(topic 未绑定;schId 取站点或用户角色,二者必知其一) */ + @Transactional + public ServiceAttachment upload(BoaUser user, String siteCode, MultipartFile file, String type) { + String url; + try { + url = FileUtil.upload(file, type); + } catch (IOException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传失败:" + e.getMessage()); + } + Long schId = resolveSchId(user, siteCode); + ServiceAttachment attachment = new ServiceAttachment(); + attachment.setSchId(schId); + attachment.setFileName(file.getOriginalFilename() == null ? "file" : file.getOriginalFilename()); + attachment.setFileUrl(toRelative(url)); + attachment.setFileSize(file.getSize()); + attachment.setMimeType(file.getContentType()); + return attachmentRepository.save(attachment); + } + + /** 指南页附件(ref:topic_type=svc_item_guide, topic_id=itemId) */ + public List guideAttachments(Long schId, Integer itemId) { + List result = new ArrayList<>(); + for (ServiceAttachment a : attachmentRepository + .findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(schId, TOPIC_GUIDE, itemId)) { + result.add(SvcItemAttachmentVo.of(a)); + } + return result; + } + + /** 指南附件全量重绑(Cms bindAttachments 的显式部分平移:移除即解除;认领防抢占) */ + @Transactional + public void bindGuide(Long schId, Integer itemId, List attachmentIds) { + Set keep = attachmentIds == null ? new HashSet<>() : new HashSet<>(attachmentIds); + List bound = attachmentRepository + .findBySchIdAndTopicTypeAndTopicId(schId, TOPIC_GUIDE, itemId); + List released = new ArrayList<>(); + for (ServiceAttachment a : bound) { + if (!keep.contains(a.getId())) { + a.setTopicType(null); + a.setTopicId(null); + released.add(a); + } + } + if (!released.isEmpty()) { + attachmentRepository.saveAll(released); + } + if (!keep.isEmpty()) { + List claiming = new ArrayList<>(); + for (ServiceAttachment a : attachmentRepository.findAllById(keep)) { + if (!schId.equals(a.getSchId())) { + log.warn("[svc-hall] 指南附件跳过:非本校附件 itemId={} attachmentId={}", itemId, a.getId()); + continue; + } + boolean claimable = a.getTopicType() == null + || (TOPIC_GUIDE.equals(a.getTopicType()) + && (a.getTopicId() == null || itemId.equals(a.getTopicId()))); + if (!claimable) { + log.warn("[svc-hall] 指南附件跳过:已被其他业务占用 attachmentId={} topicType={}", + a.getId(), a.getTopicType()); + continue; + } + a.setTopicType(TOPIC_GUIDE); + a.setTopicId(itemId); + claiming.add(a); + } + if (!claiming.isEmpty()) { + attachmentRepository.saveAll(claiming); + } + } + } + + /** 办件材料绑定(发起提交时;行必须未绑定且属本校) */ + @Transactional + public void bindApply(Long schId, Integer applyId, + List items) { + if (items == null || items.isEmpty()) { + return; + } + List claiming = new ArrayList<>(); + for (com.yunhai.svchall.model.svc.SvcModels.ApplyAttachmentItem item : items) { + ServiceAttachment a = attachmentRepository.findById(item.getAttachmentId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "附件不存在:" + item.getAttachmentId())); + if (!schId.equals(a.getSchId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "附件不属于本校:" + item.getAttachmentId()); + } + if (a.getTopicType() != null) { + throw new BizException(ErrorCode.PARAM_ERROR, "附件已被其他业务绑定:" + item.getAttachmentId()); + } + a.setTopicType(TOPIC_APPLY); + a.setTopicId(applyId); + a.setFieldKey(item.getFieldKey()); + claiming.add(a); + } + attachmentRepository.saveAll(claiming); + } + + /** 办件材料清单 */ + public List applyAttachments(Long schId, Integer applyId) { + List result = new ArrayList<>(); + for (ServiceAttachment a : attachmentRepository + .findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(schId, TOPIC_APPLY, applyId)) { + result.add(SvcItemAttachmentVo.of(a)); + } + return result; + } + + /** 事项删除时释放指南附件引用(行保留为未绑定,物理文件不删) */ + @Transactional + public void releaseGuide(Long schId, Integer itemId) { + attachmentRepository.deleteBySchIdAndTopicTypeAndTopicId(schId, TOPIC_GUIDE, itemId); + } + + /** 上传时确定租户:站点目录优先(管理端场景),退回用户角色学校 */ + private Long resolveSchId(BoaUser user, String siteCode) { + if (siteCode != null && !siteCode.trim().isEmpty()) { + SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode.trim()); + if (site != null && site.getSchId() != null) { + return site.getSchId(); + } + } + if (user != null && user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null) { + return user.getCurrentRole().getSchId().longValue(); + } + throw new BizException(ErrorCode.PARAM_ERROR, "无法确定附件所属学校(站点不可达且当前角色无学校归属)"); + } + + /** 完整 URL → 相对(/upload/…),DB 只存相对段(存储与契约分离) */ + private String toRelative(String fullUrl) { + if (fullUrl == null) { + return null; + } + int idx = fullUrl.indexOf("/upload/"); + return idx >= 0 ? fullUrl.substring(idx) : fullUrl; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java new file mode 100644 index 0000000..b508c08 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java @@ -0,0 +1,129 @@ +package com.yunhai.svchall.service.impl; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.TargetSpec; +import com.yunhai.platform.openapi.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.service.SiteDirectory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * CMS 站点目录默认实现:经 Tier 0 {@link YschOpenCall}(ad-hoc TargetSpec)调 CMS + * {@code GET /oapi/site/list},结果按 schId 缓存 5 分钟(CMS 侧缓存纪律的镜像:站点列表 + * 低频变化,失效走 TTL)。解析不到/fail 时 fail-closed(权限校验绝不放行)。 + * + *

必须走 YschOpenCall 而非共享 IDHttp:CMS 部署带 context-path(/cms),共享拦截器的 + * basePath 取自 {@code yh.open.base-path}(本应用=/userbase),剥不掉 /cms 前缀,签名 + * target 与服务端不一致会 401;YschOpenCall 的 per-target base-path 按提供方 context-path + * 剥离,与 {@code AppAuthFilter} 对齐(同 Notice FlowCenterClient 范式)。 + * {@code svchall.cms.base-url} 可配带/不带 context-path 两种形态,构造时拆成 host+basePath。 + */ +@Slf4j +@Component +public class CmsSiteDirectoryImpl implements SiteDirectory { + + /** schId → 该校站点列表(TTL 5min;站点低频变化,失效靠 TTL) */ + private final Cache> siteCache = Caffeine.newBuilder() + .maximumSize(200) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + private final YschOpenCall openCall; + private final TargetSpec cmsSpec; + + public CmsSiteDirectoryImpl(YschOpenCall openCall, + @Value("${svchall.cms.base-url}") String cmsBaseUrl) { + this.openCall = openCall; + String base = trimTrailing(cmsBaseUrl == null ? "" : cmsBaseUrl.trim()); + URI uri = URI.create(base); + String host = uri.getScheme() + "://" + uri.getRawAuthority(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + this.cmsSpec = TargetSpec.of(host, path); + } + + @Override + public SiteInfo resolveSite(String siteCode) { + if (siteCode == null || siteCode.trim().isEmpty()) { + return null; + } + // 站点列表按 schId 下发,siteCode → schId 需要反向索引:本地缓存"siteCode → 站点"二级缓存 + List sites = siteCache.getIfPresent(ALL_SITES_KEY); + if (sites == null) { + try { + // 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId, + // 由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解,权限判定仍走 requireSchoolMember。 + sites = fetchSites(null); + } catch (Exception e) { + log.warn("[svc-hall] CMS 站点目录不可达:{}", e.getMessage()); + return null; + } + if (sites != null) { + siteCache.put(ALL_SITES_KEY, sites); + } + } + if (sites == null) { + return null; + } + String code = siteCode.trim(); + for (SvcModels.SiteOptionVo site : sites) { + if (code.equals(site.getSiteCode())) { + return new SiteInfo(site.getSiteCode(), site.getName(), site.getSchId()); + } + } + return null; + } + + @Override + public List listSites(Long schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "schId 不得为空"); + } + List sites = siteCache.get(String.valueOf(schId), k -> { + try { + return fetchSites(schId); + } catch (Exception e) { + log.warn("[svc-hall] CMS 站点列表获取失败(schId={}):{}", schId, e.getMessage()); + throw new BizException(ErrorCode.PARAM_ERROR, "站点服务不可达,请稍后重试"); + } + }); + return sites == null ? java.util.Collections.emptyList() : sites; + } + + private List fetchSites(Long schId) { + Map query = schId == null + ? Collections.emptyMap() + : Collections.singletonMap("schId", String.valueOf(schId)); + return openCall.getList(cmsSpec, "/oapi/site/list", query, SvcModels.SiteOptionVo.class); + } + + @Override + public SiteInfo requireSite(String siteCode) { + SiteInfo site = resolveSite(siteCode); + if (site == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "站点不存在或站点服务不可达:" + siteCode); + } + return site; + } + + private static String trimTrailing(String s) { + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } + + /** resolveSite 的全量缓存键(与真实 schId 不冲突的哨兵) */ + private static final String ALL_SITES_KEY = "-1"; +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java new file mode 100644 index 0000000..12bdf4f --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java @@ -0,0 +1,231 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.entity.ServiceFlowEvent; +import com.yunhai.svchall.entity.ServiceFlowReg; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceFlowEventRepository; +import com.yunhai.svchall.repository.ServiceFlowRegRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 流程中心回调事件处理(验收报告 §五遗留批次"回调语义修订",Notice FlowCallbackService D2 同构)。 + * 返回语义(契约冻结,免对账核心):瞬时失败(DB 异常/锁冲突)抛 {@link TransientCallbackException} + * → controller 回 5xx → flow-center 持续重试直至送达;毒 JSON/缺字段/eventId 去重命中/ + * 乱序丢弃/非本应用办件 → 正常返回(HTTP 200,防无限重放毒消息)。 + * 定位:按 payload.bizId(本应用行已先于代发起提交,竞态闭合)+ {@code findByIdForUpdate} + * 行锁串行化并发推送;事件映射:submitted/task.created/task.handled → 10;completed → 20; + * rejected → 30;withdrawn → 40(对齐流程中心概览 §8.3)。 + * 乱序闸门:instanceVersion 严格小于已消费版本才丢弃——flow-center 存在同版本多事件 + * (submitted+task.created 同 v1,两会签 handled 同 v3),≤ 会误杀合法事件(验收报告 §四.3)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FlowCallbackService { + + /** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */ + public static final String BIZ_TYPE = "svc_item"; + + /** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */ + public static class TransientCallbackException extends RuntimeException { + public TransientCallbackException(String message, Throwable cause) { + super(message, cause); + } + } + + private final ServiceFlowEventRepository eventRepository; + private final ServiceFlowRegRepository regRepository; + private final ServiceApplyRepository applyRepository; + private final ObjectMapper objectMapper; + + /** + * 处理一条回调推送体(已通过 FlowCallbackVerifyFilter 验签)。 + * 毒消息静默 200;瞬时失败抛 TransientCallbackException 且事务回滚(含去重留档行),重试从头再来。 + */ + @Transactional + public void process(String body) { + JsonNode payload; + try { + payload = objectMapper.readTree(body); + } catch (Exception e) { + // 协议毒消息:不可重试,静默 200 防无限重放 + log.warn("[svc-hall] 回调体不是合法 JSON(丢弃)"); + return; + } + String eventId = text(payload, "eventId"); + String event = text(payload, "event"); + String bizType = text(payload, "bizType"); + String bizId = text(payload, "bizId"); + Long schId = longVal(payload, "schId"); + Long instanceId = longVal(payload, "instanceId"); + if (eventId == null || event == null || bizType == null || bizId == null + || schId == null || instanceId == null) { + log.warn("[svc-hall] 回调缺少必要字段 eventId/event/bizType/bizId/schId/instanceId(丢弃):event={}", event); + return; + } + if ("ping".equals(event)) { + return; // 注册探活回声(过滤器已直接 200,防御性兜底) + } + if (!BIZ_TYPE.equals(bizType)) { + log.warn("[svc-hall] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId); + return; + } + // 第一道闸:eventId 去重(唯一约束兜底并发);重复投递在持续重试语义下必然发生 + if (eventRepository.existsByEventId(eventId)) { + log.info("[svc-hall] 回调 eventId={} 重复投递,跳过", eventId); + return; + } + ServiceFlowEvent record = new ServiceFlowEvent(); + record.setEventId(eventId); + record.setEvent(event); + record.setSchId(schId); + record.setInstanceId(instanceId); + record.setReceiveTime(LocalDateTime.now()); + record.setPayloadJson(body); + try { + eventRepository.saveAndFlush(record); + } catch (DataIntegrityViolationException e) { + log.info("[svc-hall] 回调 eventId={} 并发重复(uk 命中),跳过", eventId); + return; // 事务回滚无副作用(尚未写办件) + } + + // 第二道闸前置:按 bizId 行锁定位(并发推送对同一办件排队,防丢更新) + Integer applyId; + try { + applyId = Integer.valueOf(bizId.trim()); + } catch (NumberFormatException e) { + log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", bizId); + return; + } + ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null); + if (apply == null) { + log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)", bizId, eventId); + return; + } + Integer version = intVal(payload, "instanceVersion"); + // 乱序闸门:严格小于已消费版本才丢弃(同版本多事件合法) + if (version != null && apply.getLastInstanceVersion() != null + && version < apply.getLastInstanceVersion()) { + log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}", + instanceId, version, apply.getLastInstanceVersion()); + return; + } + try { + advance(apply, event, version, payload); + } catch (TransientCallbackException e) { + throw e; + } catch (Exception e) { + // DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 持续重试追平(免对账核心语义) + throw new TransientCallbackException("回调状态推进失败:apply=" + applyId + " event=" + event, e); + } + log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}", + applyId, instanceId, event, apply.getFlowStatus()); + } + + /** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */ + private void advance(ServiceApply apply, String event, Integer version, JsonNode payload) { + Integer status = mapStatus(event); + if (status == null) { + log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event); + return; + } + // P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled + // 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10 + // 并用 handled 明细覆盖已落定的 flow_result_json。命中守卫记 log 后正常返回(200, + // 不触发重试)。重提不冲突:resubmit 成功后先以窄列原子置 10,新一轮事件到达时行已非终态。 + if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) { + if (!ApplyFlowStatusEnum.byCode(status).terminal()) { + log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})", + apply.getId(), apply.getFlowStatus(), event, version); + return; + } + if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) { + log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})", + apply.getId(), apply.getFlowStatus(), event); + return; + } + } + Map result = new HashMap<>(); + result.put("event", event); + JsonNode data = payload.get("data"); + if (data != null && data.isObject()) { + if (data.has("result") && !data.get("result").isNull()) { + result.put("result", data.get("result").asText()); + } + if (data.has("opinion") && !data.get("opinion").isNull()) { + result.put("opinion", data.get("opinion").asText()); + } + if (data.has("rejectNode") && !data.get("rejectNode").isNull()) { + result.put("rejectNode", data.get("rejectNode").asText()); + } + } + try { + apply.setFlowResultJson(objectMapper.writeValueAsString(result)); + } catch (Exception ignored) { + // 明细序列化失败不影响状态推进 + } + apply.setFlowStatus(status); + if (version != null) { + apply.setLastInstanceVersion(version); + } + if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode()) + || status.equals(ApplyFlowStatusEnum.REJECTED.getCode()) + || status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) { + apply.setFinishTime(LocalDateTime.now()); + } + applyRepository.save(apply); + } + + /** + * 回调验签密钥查找(FlowCallbackVerifyFilter 用;按 flowGuid 定注册)。 + * 仅有效注册(status=0 且 secret 非空)返回——失效注册按无注册处理(401), + * 修复路径=管理端重新保存绑定触发重注册(重复注册会轮换 secret,绝不能静默轮换)。 + */ + public String secretOf(String flowGuid) { + ServiceFlowReg reg = regRepository.findByFlowGuid(flowGuid).orElse(null); + boolean valid = reg != null && reg.getStatus() != null && reg.getStatus() == 0 + && reg.getSecret() != null && !reg.getSecret().isEmpty(); + return valid ? reg.getSecret() : null; + } + + private static Integer mapStatus(String event) { + switch (event) { + case "flow.submitted": + case "flow.task.created": + case "flow.task.handled": + return ApplyFlowStatusEnum.APPROVING.getCode(); + case "flow.completed": + return ApplyFlowStatusEnum.COMPLETED.getCode(); + case "flow.rejected": + return ApplyFlowStatusEnum.REJECTED.getCode(); + case "flow.withdrawn": + return ApplyFlowStatusEnum.WITHDRAWN.getCode(); + default: + return null; + } + } + + private static String text(JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; + } + + private static Long longVal(JsonNode node, String field) { + return node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null; + } + + private static Integer intVal(JsonNode node, String field) { + return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java new file mode 100644 index 0000000..911c77b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java @@ -0,0 +1,148 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.TargetSpec; +import com.yunhai.platform.openapi.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.service.FlowClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 流程中心客户端默认实现:经 Tier 0 {@link YschOpenCall}(ad-hoc TargetSpec)调 flow-center + * /oapi/flow/**(flow-center 的 RequireAppSignFilter 验本应用 appKey)。flow-center 未部署时 + * enabled=false,方法全部抛"流程中心未接入"。 + * + *

必须走 YschOpenCall 而非共享 IDHttp:flow-center 生产部署带 context-path(/flow-center), + * 共享拦截器的 basePath 取自 {@code yh.open.base-path}(本应用=/userbase),剥不掉该前缀,签名 + * target 与服务端不一致会 401;YschOpenCall 的 per-target base-path 按提供方 context-path 剥离 + * (同 {@link CmsSiteDirectoryImpl})。{@code svchall.flow.base-url} 可配带/不带 context-path + * 两种形态,构造时拆成 host+basePath。 + */ +@Slf4j +@Component +public class FlowCenterClient implements FlowClient { + + private final YschOpenCall openCall; + private final ObjectMapper objectMapper; + private final boolean enabled; + private final TargetSpec flowSpec; + + public FlowCenterClient(YschOpenCall openCall, ObjectMapper objectMapper, + @Value("${svchall.flow.enabled:false}") boolean enabled, + @Value("${svchall.flow.base-url:http://localhost:8085}") String baseUrl) { + this.openCall = openCall; + this.objectMapper = objectMapper; + this.enabled = enabled; + String base = trimTrailing(baseUrl == null ? "" : baseUrl.trim()); + URI uri = URI.create(base); + String host = uri.getScheme() + "://" + uri.getRawAuthority(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + this.flowSpec = TargetSpec.of(host, path); + } + + @Override + public boolean enabled() { + return enabled; + } + + private void requireEnabled() { + if (!enabled) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未接入(svchall.flow.enabled=false)," + + "该操作需要流程中心支持"); + } + } + + @Override + public List listFlows(Long schId) { + requireEnabled(); + return openCall.getList(flowSpec, "/oapi/flow/list", + Collections.singletonMap("schId", String.valueOf(schId)), SvcModels.FlowSummaryVo.class); + } + + @Override + public SvcModels.FlowSummaryVo getFlow(Long schId, String flowGuid) { + requireEnabled(); + return openCall.get(flowSpec, "/oapi/flow/" + flowGuid, + Collections.singletonMap("schId", String.valueOf(schId)), SvcModels.FlowSummaryVo.class); + } + + @Override + public ApplyResult apply(Long schId, String flowGuid, String bizType, String bizId, String bizTitle, + String bizUrl, String bizSnapshot, Integer submitterId, String submitterName, + String formDataJson) { + requireEnabled(); + Map body = new HashMap<>(); + body.put("flowGuid", flowGuid); + body.put("bizType", bizType); + body.put("bizId", bizId); + body.put("bizTitle", bizTitle); + if (bizUrl != null) { + body.put("bizUrl", bizUrl); + } + if (bizSnapshot != null) { + body.put("bizSnapshot", bizSnapshot); + } + body.put("submitterId", submitterId); + body.put("submitterName", submitterName); + if (formDataJson != null && !formDataJson.isEmpty()) { + try { + body.put("formData", objectMapper.readValue(formDataJson, Map.class)); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单数据不是合法 JSON 对象"); + } + } + JsonNode data = openCall.post(flowSpec, "/oapi/flow/apply", + Collections.singletonMap("schId", String.valueOf(schId)), body, JsonNode.class); + if (data == null || data.get("instanceId") == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心代发起返回异常(缺少 instanceId)"); + } + return new ApplyResult(data.get("instanceId").asLong(), + data.has("duplicated") && data.get("duplicated").asBoolean()); + } + + @Override + public Map getInstanceByBiz(Long schId, String bizType, String bizId) { + requireEnabled(); + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("bizType", bizType); + query.put("bizId", bizId); + return openCall.get(flowSpec, "/oapi/flow/instance", query, Map.class); + } + + @Override + public String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes) { + requireEnabled(); + Map body = new HashMap<>(); + body.put("flowGuid", flowGuid); + body.put("callbackUrl", callbackUrl); + if (eventTypes != null && !eventTypes.isEmpty()) { + body.put("eventTypes", eventTypes); + } + Map data = openCall.post(flowSpec, "/oapi/flow/callback/reg", + Collections.singletonMap("schId", String.valueOf(schId)), body, Map.class); + Object secret = data == null ? null : data.get("secret"); + if (secret == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心回调注册返回异常(缺少 secret)"); + } + return String.valueOf(secret); + } + + private static String trimTrailing(String s) { + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java new file mode 100644 index 0000000..dfc7bdb --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java @@ -0,0 +1,416 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.entity.ServiceForm; +import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.svc.SvcModels.ApplyPageQuery; +import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest; +import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.utils.FormValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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.TransactionSynchronizationAdapter; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 办件服务(V2.0 Part B.4 + 验收报告 §四 D9/§四.2 范式对齐):用户发起(事项/表单快照落库 → + * AFTER_COMMIT 后独立线程 FlowClient 代发起 → 回调驱动镜像状态)、我的办件/详情、管理端分页、 + * 重试(复用同一 bizId 命中 flow-center uk 幂等)。提交纪律:快照自包含(item_snapshot_json/ + * form_data_json),事项后续改名/下架不影响在途与历史办件;代发起出站不进 DB 事务(HTTP 长调用 + * 拖住连接 + 回调先于提交到达找不到行),成功回填走窄列 markApproving(终态不回退)。 + * 用户面撤回/催办/审批轨迹由前端直调 flow-center CAS 接口(同主域 cookie),本服务不代理。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ServiceApplyService { + + private final ServiceApplyRepository applyRepository; + private final ServiceItemRepository itemRepository; + private final ServiceFormService formService; + private final AttachmentService attachmentService; + private final FlowClient flowClient; + private final SiteGuard siteGuard; + private final SiteDirectory siteDirectory; + private final com.yunhai.svchall.service.ApplyViewGuard applyViewGuard; + private final ObjectMapper objectMapper; + private final org.springframework.transaction.support.TransactionTemplate transactionTemplate; + + /** 办事大厅前端对外地址(bizUrl 落地页用;projman 注入,本地默认 dev server) */ + @Value("${svchall.front.base-url:http://localhost:8087}") + private String frontBaseUrl; + + // ===== 用户面(CAS)===== + + /** + * 发起:本校成员 + 事项发布态 + 表单数据强校验 → 落 service_apply(PENDING, bizId=id) + * → AFTER_COMMIT 后独立线程代发起(失败保留 PENDING 行,重试复用同一 id)。 + * 返回办件 VO(PENDING 态——代发起异步执行,状态秒级翻转,前端以"待提交/审批中"刷新可见)。 + */ + @Transactional + public ApplyVo submit(BoaUser user, ApplySubmitRequest req) { + if (user == null || user.getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "未获取到登录用户"); + } + SiteInfo site = siteDirectory.requireSite(req.getSiteCode()); + siteGuard.requireSchoolMember(user, site.getSchId()); + ServiceItem item = itemRepository + .findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), req.getItemCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在")); + if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理"); + } + if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } + // 表单解析 + 提交数据强校验(按最新已发布版本;发起时快照 form_version) + ServiceForm form = null; + if (item.getFormCode() != null && !item.getFormCode().isEmpty()) { + form = formService.latestPublished(site.getSchId(), item.getFormCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "办理表单未发布,请联系管理员(事项 " + item.getItemCode() + ")")); + FormValidator.validateSubmit(req.getFormData(), form.getSchemaJson()); + } else if (StringUtils.hasText(req.getFormData())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项无办理表单,不应提交表单数据"); + } + + ServiceApply apply = new ServiceApply(); + apply.setSchId(site.getSchId()); + apply.setSiteCode(site.getSiteCode()); + apply.setItemId(item.getId()); + apply.setItemCode(item.getItemCode()); + apply.setItemTitle(item.getTitle()); + apply.setItemSnapshotJson(itemSnapshotJson(item)); + apply.setFormVersion(form == null ? null : form.getVersion()); + apply.setFormDataJson(req.getFormData()); + apply.setSubmitterId(user.getId()); + apply.setSubmitterName(user.getName()); + apply.setFlowGuid(item.getFlowGuid()); + apply = applyRepository.save(apply); + + // 办件材料绑定(必须落库拿到 applyId 之后再绑) + attachmentService.bindApply(site.getSchId(), apply.getId(), req.getAttachments()); + apply.setItemSnapshotJson(itemSnapshotJson(item, attachmentService + .applyAttachments(site.getSchId(), apply.getId()))); + apply = applyRepository.save(apply); + + flowSubmitAfterCommit(apply.getId()); + return ApplyVo.of(apply); + } + + /** 重试:PENDING 行复用同一 bizId 再次代发起(幂等);仅提交人本人可重试自己的办件(同步路径,无长事务) */ + public ApplyVo retry(BoaUser user, Integer applyId) { + ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + if (!user.getId().equals(apply.getSubmitterId())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅可重试自己的办件"); + } + if (apply.getFlowStatus() == null || apply.getFlowStatus() != ApplyFlowStatusEnum.PENDING.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该办件不在待提交状态,无需重试"); + } + doFlowApply(applyId, false); + return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply)); + } + + /** + * 一键重提(P2-5/BP-R2-2):驳回(30)/撤回(40)后复用同一办件行重新发起——flow-center R4 + * 按 bizType+bizId 命中 30/40 → 重启推进(定义刷新、快照覆盖、version 自增、轨迹跨轮连续)。 + * 校验:提交人本人 + 终态 30/40 + flowGuid 非空;表单数据沿用原 formData + * ("改后重提"属后续产品项,登记于验收报告)。出站失败保持原态可重入。 + */ + public ApplyVo resubmit(BoaUser user, Integer applyId) { + ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + if (!user.getId().equals(apply.getSubmitterId())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅可重提自己的办件"); + } + Integer status = apply.getFlowStatus(); + if (status == null || (status != ApplyFlowStatusEnum.REJECTED.getCode() + && status != ApplyFlowStatusEnum.WITHDRAWN.getCode())) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅驳回/已撤回状态的办件可重新提交"); + } + if (apply.getFlowGuid() == null || apply.getFlowGuid().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该办件未绑定流程,不可重提"); + } + doFlowApply(applyId, true); + return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply)); + } + + /** 我的办件(提交人视角,flowStatus 可选筛选) */ + public Map myApplies(BoaUser user, Integer flowStatus, Integer page, Integer pageSize) { + int pageIndex = page == null || page < 1 ? 0 : page - 1; + int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100); + Pageable pageable = PageRequest.of(pageIndex, size, + Sort.by(Sort.Order.desc("createTime"), Sort.Order.desc("id"))); + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), userSchId(user))); + predicates.add(cb.equal(root.get("submitterId"), user.getId())); + if (flowStatus != null) { + predicates.add(cb.equal(root.get("flowStatus"), flowStatus)); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + Page result = applyRepository.findAll(spec, pageable); + Map out = new HashMap<>(); + out.put("total", result.getTotalElements()); + List list = new ArrayList<>(); + for (ServiceApply apply : result.getContent()) { + list.add(ApplyVo.of(apply)); + } + out.put("list", list); + return out; + } + + /** + * 详情:提交人本人 / 管理员(type=1 或 roleId∈{2,5,7})/ 办理人(assigneeIds 实时含当前用户) + * 三分支放行(BP-R2-3 修复,ApplyViewGuard=NoticeFlowGuard 同构;原先"同校即放行"越权已收紧)。 + */ + public ApplyVo detail(BoaUser user, String siteCode, Integer id) { + ServiceApply apply; + if (isSuperAdmin(user)) { + apply = applyRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + } else { + Long schId = userSchId(user); + apply = applyRepository.findByIdAndSchId(id, schId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + applyViewGuard.requireVisible(apply, user); + } + return ApplyVo.of(apply); + } + + // ===== 管理面 ===== + + /** + * 发起页数据(本校用户):事项发布态要点 + 绑定表单的已发布 schema。 + * 校验链与 submit 一致(同校成员/发布态/已绑流程),前端据此渲染动态表单。 + */ + public Map applyItem(BoaUser user, String siteCode, String itemCode) { + SiteInfo site = siteDirectory.requireSite(siteCode); + siteGuard.requireSchoolMember(user, site.getSchId()); + ServiceItem item = itemRepository + .findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), itemCode) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在")); + if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理"); + } + if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } + Map out = new HashMap<>(); + out.put("siteCode", site.getSiteCode()); + out.put("itemCode", item.getItemCode()); + out.put("title", item.getTitle()); + out.put("summary", item.getSummary()); + out.put("deptName", item.getDeptName()); + out.put("handleMode", item.getHandleMode()); + out.put("handleTimeLimit", item.getHandleTimeLimit()); + out.put("promiseTimeLimit", item.getPromiseTimeLimit()); + out.put("contactPhone", item.getContactPhone()); + out.put("coverImage", com.yunhai.svchall.utils.FileUtil.absolutizePlain(item.getCoverImage())); + out.put("flowGuid", item.getFlowGuid()); + if (item.getFormCode() != null && !item.getFormCode().isEmpty()) { + ServiceForm form = formService.latestPublished(site.getSchId(), item.getFormCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "办理表单未发布,请联系管理员")); + out.put("formVersion", form.getVersion()); + out.put("schemaJson", form.getSchemaJson()); + } + return out; + } + + public Map adminPage(BoaUser user, ApplyPageQuery query) { + SiteInfo site = siteGuard.requireSiteManager(user, query.getSiteCode()); + int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1; + int size = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : Math.min(query.getPageSize(), 100); + Pageable pageable = PageRequest.of(pageIndex, size, + Sort.by(Sort.Order.desc("createTime"), Sort.Order.desc("id"))); + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), site.getSchId())); + predicates.add(cb.equal(root.get("siteCode"), site.getSiteCode())); + if (StringUtils.hasText(query.getItemCode())) { + predicates.add(cb.equal(root.get("itemCode"), query.getItemCode().trim())); + } + if (query.getFlowStatus() != null) { + predicates.add(cb.equal(root.get("flowStatus"), query.getFlowStatus())); + } + if (StringUtils.hasText(query.getKeyword())) { + String like = "%" + query.getKeyword().trim() + "%"; + predicates.add(cb.or(cb.like(root.get("itemTitle"), like), + cb.like(root.get("submitterName"), like))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + Page result = applyRepository.findAll(spec, pageable); + Map out = new HashMap<>(); + out.put("total", result.getTotalElements()); + List list = new ArrayList<>(); + for (ServiceApply apply : result.getContent()) { + list.add(ApplyVo.of(apply)); + } + out.put("list", list); + return out; + } + + // ===== 内部:代发起(D9 范式,Notice NoticeFlowService 同构)===== + + /** + * 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底)。 + * E2E 实测教训(验收报告 §四.1):afterCommit 回调若留在原请求线程执行,OSIV 绑定的 + * EntityManager 已随事务提交关闭,后续 repository 写会话静默丢失——提交动作一律切独立线程。 + */ + private static final ExecutorService APPLY_EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "svchall-flow-submit"); + t.setDaemon(true); + return t; + }); + + /** + * 事务提交后再代发起(出站 HTTP 不进 DB 事务,防长调用拖住连接;且 flow-center 收到 apply + * 后会立即推送回调,须保证办件行已提交可见)。无活动事务时立即执行(测试/兜底)。 + */ + private void flowSubmitAfterCommit(Integer applyId) { + Runnable task = () -> tryApplyQuietly(applyId); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { + @Override + public void afterCommit() { + APPLY_EXECUTOR.submit(task); + } + }); + } else { + task.run(); + } + } + + /** 异步路径提交失败静默保持 PENDING(行已落库不回滚;列表"待提交"态即提示,可重试) */ + private void tryApplyQuietly(Integer applyId) { + try { + doFlowApply(applyId, false); + } catch (Exception e) { + log.warn("[svc-hall] 代发起失败(保持待提交,可重试)applyId={}:{}", applyId, e.toString()); + } + } + + /** + * 代发起(幂等:bizId=apply.id,flow-center uk 防重复建件)。 + * 成功回填走窄列 markApproving——整实体 save 是旧快照盲写,会覆盖出站期间并发送达的回调状态 + * (验收报告 §四.2);BizException 透传(retry 同步路径给用户提示,异步路径由静默包装消化)。 + * resubmit=true(一键重提):R4 命中 30/40 重启推进后改用 markResubmitted 回开为 10。 + */ + private void doFlowApply(Integer applyId, boolean resubmit) { + if (!flowClient.enabled()) { + log.warn("[svc-hall] 流程中心未接入,办件保留待提交状态 applyId={}", applyId); + return; + } + ServiceApply apply = applyRepository.findById(applyId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + try { + FlowClient.ApplyResult result = flowClient.apply( + apply.getSchId(), + apply.getFlowGuid(), + FlowCallbackService.BIZ_TYPE, + String.valueOf(apply.getId()), + apply.getSubmitterName() + "的" + apply.getItemTitle(), + // 前端详情页是 hash 路由 + query 传参(routes.ts path="/apply-detail"、 + // Apply.vue router.replace("/apply-detail?id=")):路径参数形态会 no-match 空白页 + frontBaseUrl + "/#/apply-detail?id=" + apply.getId(), + apply.getItemSnapshotJson(), + apply.getSubmitterId(), + apply.getSubmitterName(), + apply.getFormDataJson()); + // @Modifying 更新须在事务内执行;本方法出站 HTTP 后运行在 AFTER_COMMIT 线程, + // 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted) + final Integer applyIdFinal = apply.getId(); + final Long instanceId = result.getInstanceId(); + transactionTemplate.executeWithoutResult(tx -> { + if (resubmit) { + applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now()); + } else { + applyRepository.markApproving(applyIdFinal, instanceId, LocalDateTime.now()); + } + }); + if (result.isDuplicated()) { + log.info("[svc-hall] 代发起命中幂等(flow-center 返回既有办件)applyId={} instanceId={}", + apply.getId(), result.getInstanceId()); + } + } catch (BizException e) { + throw e; + } catch (Exception e) { + // PENDING 行保留:用户可重试(复用同一 bizId,flow-center uk 幂等防重复建件) + log.error("[svc-hall] 代发起失败,办件保留待提交 applyId={}", applyId, e); + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心提交失败,请稍后重试"); + } + } + + // ===== 内部:快照与工具 ===== + + /** 事项要点快照(= biz_snapshot 内容源;附件清单仅文件名——flow-center biz_snapshot 内容边界) */ + private String itemSnapshotJson(ServiceItem item) { + return itemSnapshotJson(item, new ArrayList<>()); + } + + private String itemSnapshotJson(ServiceItem item, List attachments) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("itemTitle", item.getTitle()); + snapshot.put("summary", item.getSummary()); + snapshot.put("deptName", item.getDeptName()); + snapshot.put("handleTimeLimit", item.getHandleTimeLimit()); + snapshot.put("contactPhone", item.getContactPhone()); + List names = new ArrayList<>(); + for (com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo a : attachments) { + names.add(a.getFileName()); + } + snapshot.put("attachmentNames", names); + try { + return objectMapper.writeValueAsString(snapshot); + } catch (Exception e) { + log.warn("[svc-hall] 事项快照序列化失败(存 null)itemCode={}", item.getItemCode()); + return null; + } + } + + private Long userSchId(BoaUser user) { + if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前用户无学校归属,无法操作办件"); + } + return user.getCurrentRole().getSchId().longValue(); + } + + private static boolean isSuperAdmin(BoaUser user) { + return user != null && user.getType() != null && user.getType() == 1; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceDictService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceDictService.java new file mode 100644 index 0000000..b61d332 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceDictService.java @@ -0,0 +1,164 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceDict; +import com.yunhai.svchall.enums.ServiceDictTypeEnum; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcDictSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcDictVo; +import com.yunhai.svchall.repository.ServiceDictRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * 事项字典服务(Cms SvcDictService 原样平移,siteId → schId+siteCode 双键)。 + * 纪律:dict_key 稳定键——被事项引用后不可改(只能改名)、被引用不可删除(只能停用)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ServiceDictService { + + private final ServiceDictRepository dictRepository; + private final ServiceItemRepository itemRepository; + private final SiteGuard siteGuard; + + /** 字典列表(管理端;referenced=被事项引用条数,全状态口径) */ + public List list(BoaUser user, String siteCode, String dictType) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + if (dictType != null && !dictType.trim().isEmpty() && ServiceDictTypeEnum.byKey(dictType) == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + dictType + "(仅 object/category)"); + } + boolean filterType = dictType != null && !dictType.trim().isEmpty(); + List dicts = filterType + ? dictRepository.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc( + site.getSchId(), siteCode, dictType.trim()) + : dictRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(site.getSchId(), siteCode); + Map objectRefs = countMap(itemRepository.countGroupByObjectKey(site.getSchId(), siteCode)); + Map categoryRefs = countMap(itemRepository.countGroupByCategoryKey(site.getSchId(), siteCode)); + List result = new ArrayList<>(); + for (ServiceDict dict : dicts) { + Map refs = ServiceDictTypeEnum.CATEGORY.getKey().equals(dict.getDictType()) + ? categoryRefs : objectRefs; + result.add(SvcDictVo.of(dict, refs.get(dict.getDictKey()))); + } + return result; + } + + @Transactional + public Integer save(BoaUser user, SvcDictSaveRequest req) { + SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode()); + ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(req.getDictType()); + if (type == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + req.getDictType() + "(仅 object/category)"); + } + ServiceDict dict; + if (req.getId() == null) { + if (dictRepository.existsBySchIdAndSiteCodeAndDictTypeAndDictKey( + site.getSchId(), site.getSiteCode(), type.getKey(), req.getDictKey())) { + throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey()); + } + dict = new ServiceDict(); + dict.setSchId(site.getSchId()); + dict.setSiteCode(site.getSiteCode()); + dict.setDictType(type.getKey()); + dict.setDictKey(req.getDictKey()); + } else { + dict = dictRepository.findByIdAndSchIdAndSiteCode(req.getId(), site.getSchId(), site.getSiteCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在")); + if (!dict.getDictKey().equals(req.getDictKey())) { + long referenced = referencedCount(site, type, dict.getDictKey()); + if (referenced > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "字典键已被 " + referenced + " 个事项引用,不可修改;只能修改名称"); + } + if (dictRepository.existsBySchIdAndSiteCodeAndDictTypeAndDictKeyAndIdNot( + site.getSchId(), site.getSiteCode(), 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()); + return dictRepository.save(dict).getId(); + } + + @Transactional + public void delete(BoaUser user, DeleteRequest req) { + SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode()); + ServiceDict dict = dictRepository.findByIdAndSchIdAndSiteCode(req.getId(), site.getSchId(), site.getSiteCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在")); + ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(dict.getDictType()); + long referenced = referencedCount(site, type, dict.getDictKey()); + if (referenced > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "字典已被 " + referenced + " 个事项引用,不能删除;可改为停用(存量引用仍可渲染)"); + } + dictRepository.delete(dict); + } + + /** 键 → 展示名(全状态口径:停用字典的存量引用仍可渲染) */ + public Map nameMap(Long schId, String siteCode, ServiceDictTypeEnum type) { + Map names = new HashMap<>(); + for (ServiceDict dict : dictRepository + .findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(schId, siteCode, type.getKey())) { + names.put(dict.getDictKey(), dict.getDictName()); + } + return names; + } + + /** 启用字典(oapi 筛选/选择器口径:停用不出现在筛选与选择器) */ + public List enabledDicts(Long schId, String siteCode, ServiceDictTypeEnum type) { + List result = new ArrayList<>(); + for (ServiceDict dict : dictRepository + .findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(schId, siteCode, type.getKey())) { + if (dict.getStatus() != null && dict.getStatus() == 1) { + result.add(dict); + } + } + return result; + } + + /** 键必须存在于启用字典(保存事项时的归属校验) */ + public void requireEnabledKey(Long schId, String siteCode, ServiceDictTypeEnum type, String dictKey) { + ServiceDict dict = dictRepository + .findBySchIdAndSiteCodeAndDictTypeAndDictKey(schId, siteCode, 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(SiteDirectory.SiteInfo site, ServiceDictTypeEnum type, String dictKey) { + return type == ServiceDictTypeEnum.CATEGORY + ? itemRepository.countBySchIdAndSiteCodeAndCategoryKey(site.getSchId(), site.getSiteCode(), dictKey) + : itemRepository.countBySchIdAndSiteCodeAndObjectKey(site.getSchId(), site.getSiteCode(), dictKey); + } + + private static Map countMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(String.valueOf(row[0]), ((Number) row[1]).longValue()); + } + return map; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java new file mode 100644 index 0000000..9d94007 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java @@ -0,0 +1,149 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceForm; +import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo; +import com.yunhai.svchall.repository.ServiceFormRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.utils.FormValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 自定义表单服务(V2.0 Part B.3):行不可变原则——已发布版本不可改,编辑已发布表单自动 + * 克隆出新草稿版本(version+1);发布跑 FormValidator 静态校验;事项绑定 code、发起取最新已发布版本。 + */ +@Service +@RequiredArgsConstructor +public class ServiceFormService { + + private final ServiceFormRepository formRepository; + private final ServiceItemRepository itemRepository; + private final SiteGuard siteGuard; + + /** 表单列表(sch 级;同 code 多版本聚合成一行最新版,referenced=是否被事项绑定) */ + public List list(BoaUser user, String siteCode) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + List forms = formRepository.findBySchIdOrderByCodeAscVersionDescIdAsc(site.getSchId()); + List result = new ArrayList<>(); + String lastCode = null; + for (ServiceForm form : forms) { + if (form.getCode().equals(lastCode)) { + continue; + } + lastCode = form.getCode(); + result.add(SvcFormVo.of(form, itemRepository.countBySchIdAndFormCode(site.getSchId(), form.getCode()) > 0)); + } + return result; + } + + /** 版本轨迹(同 code 全部版本) */ + public List versions(BoaUser user, String siteCode, String code) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + List result = new ArrayList<>(); + for (ServiceForm form : formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), code)) { + result.add(SvcFormVo.of(form, null)); + } + return result; + } + + public SvcFormVo get(BoaUser user, String siteCode, Integer id) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + ServiceForm form = formRepository.findByIdAndSchId(id, site.getSchId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在")); + return SvcFormVo.of(form, null); + } + + /** + * 保存:新建时若 code 已有发布/停用版本 → 自动克隆新草稿版本(升版本);已有草稿 → 就地更新; + * 已存在行编辑仅限草稿态。schema 服务端静态校验(FormValidator.validateSchema)。 + */ + @Transactional + public Integer save(BoaUser user, String siteCode, SvcFormSaveRequest req) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + FormValidator.validateSchema(req.getSchemaJson()); + ServiceForm form; + if (req.getId() == null) { + List versions = + formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), req.getCode()); + if (versions.isEmpty()) { + form = new ServiceForm(); + form.setSchId(site.getSchId()); + form.setCode(req.getCode()); + form.setVersion(1); + form.setStatus(0); + } else if (versions.get(0).getStatus() != null && versions.get(0).getStatus() == 0) { + form = versions.get(0); + } else { + ServiceForm latest = versions.get(0); + form = new ServiceForm(); + form.setSchId(site.getSchId()); + form.setCode(latest.getCode()); + form.setVersion(latest.getVersion() + 1); + form.setStatus(0); + } + } else { + form = formRepository.findByIdAndSchId(req.getId(), site.getSchId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在")); + if (form.getStatus() == null || form.getStatus() != 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "该版本已发布不可修改;保存将自动创建新草稿版本(去掉 id 重新提交)"); + } + if (!form.getCode().equals(req.getCode())) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单编码不可修改:" + form.getCode()); + } + } + form.setName(req.getName()); + form.setSchemaJson(req.getSchemaJson()); + return formRepository.save(form).getId(); + } + + /** 发布:草稿 → 已发布(版本固化;事项侧经 code 取到本版本) */ + @Transactional + public void publish(BoaUser user, String siteCode, Integer id) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + ServiceForm form = formRepository.findByIdAndSchId(id, site.getSchId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在")); + if (form.getStatus() == null || form.getStatus() != 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅草稿可发布"); + } + // 发布前重跑静态校验(防草稿期间契约调整) + FormValidator.validateSchema(form.getSchemaJson()); + form.setStatus(1); + form.setPublishTime(LocalDateTime.now()); + formRepository.save(form); + } + + /** 删除:整 code 删除(全部版本);被事项绑定即拦(可绑定空版本表单会造成发起 500) */ + @Transactional + public void delete(BoaUser user, String siteCode, String code) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + if (!formRepository.existsBySchIdAndCode(site.getSchId(), code)) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在"); + } + long referenced = itemRepository.countBySchIdAndFormCode(site.getSchId(), code); + if (referenced > 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单已被 " + referenced + " 个事项绑定,不能删除;可先解除事项绑定"); + } + List versions = formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), code); + formRepository.deleteAll(versions); + } + + /** 发起取数:code 的最新已发布版本(无 → Optional.empty) */ + public Optional latestPublished(Long schId, String code) { + return formRepository.findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(schId, code, 1); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java new file mode 100644 index 0000000..95e7291 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java @@ -0,0 +1,820 @@ +package com.yunhai.svchall.service.impl; + +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 com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.enums.HandleModeEnum; +import com.yunhai.svchall.enums.ServiceDictTypeEnum; +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.oapi.OapiModels; +import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest; +import com.yunhai.svchall.model.svc.SvcModels.IdSortItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcDeptVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageQuery; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemSimpleVo; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.DeptSource; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.utils.FileUtil; +import com.yunhai.svchall.utils.HtmlSanitizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +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; + +/** + * 事项服务(Cms SvcItemService 迁移版,V2.0 Part B): + * 管理端 CRUD/发布/下架/删除/排序/部门名同步 + oapi 供数查询(items/filters/detail/view/snapshot)。 + * 迁移差异:① 权限由 siteCode + CMS 站点目录解析(SiteGuard);② 关联 CMS 栏目为不透明引用不校验; + * ③ 附件为显式绑定(无内容派生归属);④ 新增 formCode/flowGuid 绑定校验(onlineFlag=1 ⇒ flowGuid); + * ⑤ oapi 出参 HTML/封面/附件绝对化(FileUtil)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ServiceItemService { + + /** applyUrl 形态(三态优先级最高的一态) */ + private static final Pattern APPLY_URL_PATTERN = Pattern.compile("^https?://\\S+$"); + + /** 选择器/同步部门名的全量扫描上限(防御异常库容) */ + private static final int SEL_LIST_MAX = 500; + + /** 浏览量异步队列容量(满则丢弃计数并告警,不反压请求线程) */ + private static final int VIEW_QUEUE_CAPACITY = 1000; + + /** 部门"容器组"类型(默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type) */ + @Value("${svchall.item.dept-group-type:7}") + private int deptGroupType = 7; + + /** 本应用对外可达基址(回调注册 callbackUrl 前缀;projman 注入,本地默认 dev) */ + @Value("${svchall.public-base-url:http://localhost:8086}") + private String callbackPublicBase; + + private final ServiceItemRepository itemRepository; + private final com.yunhai.svchall.repository.ServiceFormRepository formRepository; + private final com.yunhai.svchall.repository.ServiceFlowRegRepository flowRegRepository; + private final SiteDirectory siteDirectory; + private final ServiceDictService dictService; + private final ServiceFormService formService; + private final AttachmentService attachmentService; + private final SiteGuard siteGuard; + private final DeptSource deptSource; + private final FlowClient flowClient; + private final TransactionTemplate transactionTemplate; + + // ===== 管理端 ===== + + public Map adminPage(BoaUser user, SvcItemPageQuery query) { + SiteInfo site = siteGuard.requireSiteManager(user, query.getSiteCode()); + int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1; + int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize(); + Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100), + Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("createTime"), Sort.Order.desc("id"))); + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), site.getSchId())); + predicates.add(cb.equal(root.get("siteCode"), site.getSiteCode())); + if (query.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), query.getStatus())); + } + if (StringUtils.hasText(query.getKeyword())) { + String like = "%" + query.getKeyword().trim() + "%"; + predicates.add(cb.or(cb.like(root.get("title"), like), + cb.like(root.get("itemCode"), like))); + } + if (StringUtils.hasText(query.getObjectKey())) { + predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey())); + } + if (StringUtils.hasText(query.getCategoryKey())) { + predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey())); + } + if (query.getDeptGroupId() != null) { + predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + Map objectNames = dictService.nameMap( + site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.OBJECT); + Map categoryNames = dictService.nameMap( + site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY); + Page page = itemRepository.findAll(spec, pageable); + List items = new ArrayList<>(); + for (ServiceItem item : page.getContent()) { + items.add(SvcItemPageItem.of(item, + objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey()))); + } + Map result = new HashMap<>(); + result.put("total", page.getTotalElements()); + result.put("list", items); + return result; + } + + public SvcItemEditVo adminGet(BoaUser user, String siteCode, Integer id) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + return SvcItemEditVo.of(requireItem(site, id)); + } + + /** 保存:itemCode 唯一/字典键归属/部门快照/applyUrl 白名单/表单与流程绑定校验;指南附件全量重绑 */ + @Transactional + public Integer adminSave(BoaUser user, SvcItemSaveRequest req) { + SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode()); + validateItemCode(site, req); + validateDictKeys(site, req); + validateBindings(site, req); + String deptName = resolveDeptName(site, req.getDeptGroupId()); + String applyUrl = validateApplyUrl(req.getApplyUrl()); + + ServiceItem item; + if (req.getId() == null) { + item = new ServiceItem(); + item.setSchId(site.getSchId()); + item.setSiteCode(site.getSiteCode()); + item.setStatus(ServiceItemStatusEnum.DRAFT.getCode()); + item.setViewCount(0); + } else { + item = requireItem(site, 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())); + // 关联 CMS 栏目:不透明引用(V2.0 Part C.2/R2),不校验存在性 + item.setFaqCategoryId(req.getFaqCategoryId()); + item.setNoticeCategoryId(req.getNoticeCategoryId()); + item.setRuleCategoryId(req.getRuleCategoryId()); + item.setCoverImage(toRelativeIfLocal(req.getCoverImage())); + item.setFormCode(emptyToNull(req.getFormCode())); + item.setFlowGuid(emptyToNull(req.getFlowGuid())); + item.setApplyUrl(applyUrl); + item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo()); + item = itemRepository.save(item); + + attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds()); + ensureFlowReg(site, item.getFlowGuid()); + return item.getId(); + } + + /** + * 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换):事项绑 flow_guid 时确保有效回调注册存在 + * (reg 下发 secret 落 service_flow_reg)。仅 reg 缺失/失效(secret 空或 status≠0)时才注册—— + * flow-center 重复注册会轮换 secret,导致已存密钥验签失配;失效注册原地更新同一行拿回新密钥。 + * best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。 + */ + void ensureFlowReg(SiteInfo site, String flowGuid) { + if (flowGuid == null || !flowClient.enabled()) { + return; + } + com.yunhai.svchall.entity.ServiceFlowReg reg = flowRegRepository + .findBySchIdAndFlowGuid(site.getSchId(), flowGuid).orElse(null); + if (reg != null && reg.getStatus() != null && reg.getStatus() == 0 + && reg.getSecret() != null && !reg.getSecret().isEmpty()) { + return; + } + String callbackUrl = callbackPublicBase + "/callback/flow"; + try { + String secret = flowClient.registerCallback(site.getSchId(), flowGuid, callbackUrl, null); + if (reg == null) { + reg = new com.yunhai.svchall.entity.ServiceFlowReg(); + reg.setSchId(site.getSchId()); + reg.setFlowGuid(flowGuid); + } + reg.setCallbackUrl(callbackUrl); + reg.setSecret(secret); + reg.setStatus(0); + flowRegRepository.save(reg); + } catch (Exception e) { + log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}", + site.getSiteCode(), flowGuid, e.getMessage()); + } + } + + @Transactional + public void adminPublish(BoaUser user, String siteCode, Integer id) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + ServiceItem item = requireItem(site, id); + if (!StringUtils.hasText(item.getTitle())) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项名称缺失,不可发布"); + } + item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + item.setPublishTime(LocalDateTime.now()); + itemRepository.save(item); + } + + /** 下架:已发布 → 已下架(门户经 oapi 即刻不可见;在途办件不受影响——快照隔离) */ + @Transactional + public void adminOffline(BoaUser user, String siteCode, Integer id) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + ServiceItem item = requireItem(site, id); + item.setStatus(ServiceItemStatusEnum.OFFLINE.getCode()); + item.setOfflineTime(LocalDateTime.now()); + itemRepository.save(item); + } + + /** 删除:物理删除 + 指南附件解绑(行保留未绑定态);办件侧靠快照隔离不受影响 */ + @Transactional + public void adminDelete(BoaUser user, DeleteRequest req) { + SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode()); + ServiceItem item = requireItem(site, req.getId()); + attachmentService.releaseGuide(site.getSchId(), item.getId()); + itemRepository.delete(item); + } + + public List adminSelList(BoaUser user, String siteCode) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + List items = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc( + site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX)); + if (items.size() >= SEL_LIST_MAX) { + log.warn("[svc-hall] 事项选择器命中上限 {} 条(siteCode={}),结果已截断", SEL_LIST_MAX, siteCode); + } + List result = new ArrayList<>(); + for (ServiceItem item : items) { + result.add(SvcItemSimpleVo.of(item)); + } + return result; + } + + @Transactional + public void adminSort(BoaUser user, String siteCode, List items) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + for (IdSortItem pair : items == null ? new ArrayList() : items) { + ServiceItem item = requireItem(site, pair.getId()); + item.setSortNo(pair.getSortNo()); + itemRepository.save(item); + } + } + + /** 同步部门名快照(承袭 CMS syncDeptName):按组树刷新全站 dept_name,返回更新条数 */ + @Transactional + public Map syncDeptName(BoaUser user, String siteCode) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + Map deptNames = new LinkedHashMap<>(); + collectDeptNodes(deptRootOfSite(site), deptNames); + if (deptNames.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名"); + } + int updated = 0; + List candidates = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc( + site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX)); + for (ServiceItem 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++; + } + } + Map result = new HashMap<>(); + result.put("updated", updated); + return result; + } + + /** 服务部门选项(本站所属学校子树内、容器组下的全部子节点;平台不可达降级空列表) */ + public List deptTree(BoaUser user, String siteCode) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + return buildDeptTree(deptRootOfSite(site)); + } + + /** 管理端:事项当前绑定的指南附件(编辑回显) */ + public List itemAttachments(BoaUser user, String siteCode, Integer itemId) { + SiteInfo site = siteGuard.requireSiteManager(user, siteCode); + requireItem(site, itemId); + return attachmentService.guideAttachments(site.getSchId(), itemId); + } + + // ===== oapi 供数(V2.0 Part C.2 契约;oapi 控制器负责信封与缺参报错)===== + + public OapiModels.ListData oapiItems(Long schId, String siteCode, String objectKey, String categoryKey, + Integer deptGroupId, String keyword, Integer page, Integer pageSize) { + int pageIndex = page == null || page < 1 ? 0 : page - 1; + int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100); + Pageable pageable = PageRequest.of(pageIndex, size, + Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("publishTime"), Sort.Order.desc("id"))); + Page result = itemRepository.findAll((root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), schId)); + predicates.add(cb.equal(root.get("siteCode"), siteCode)); + predicates.add(cb.equal(root.get("status"), ServiceItemStatusEnum.PUBLISHED.getCode())); + if (StringUtils.hasText(objectKey)) { + predicates.add(cb.equal(root.get("objectKey"), objectKey)); + } + if (StringUtils.hasText(categoryKey)) { + predicates.add(cb.equal(root.get("categoryKey"), categoryKey)); + } + if (deptGroupId != null) { + predicates.add(cb.equal(root.get("deptGroupId"), deptGroupId)); + } + if (StringUtils.hasText(keyword)) { + String like = "%" + keyword.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])); + }, pageable); + Map categoryNames = dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY); + OapiModels.ListData data = new OapiModels.ListData(); + data.setTotal(result.getTotalElements()); + data.setPage(pageIndex + 1); + data.setPageSize(pageable.getPageSize()); + data.setTotalPages(result.getTotalPages()); + for (ServiceItem item : result.getContent()) { + OapiModels.ItemRow row = new OapiModels.ItemRow(); + row.setId(item.getId()); + row.setItemCode(item.getItemCode()); + row.setTitle(item.getTitle()); + row.setSummary(item.getSummary()); + row.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage())); + row.setDeptName(item.getDeptName()); + row.setCategoryName(categoryNames.get(item.getCategoryKey())); + row.setHandleMode(item.getHandleMode()); + row.setOnlineFlag(item.getOnlineFlag()); + row.setPublishTime(item.getPublishTime()); + data.getItems().add(row); + } + return data; + } + + public OapiModels.Filters oapiFilters(Long schId, String siteCode) { + OapiModels.Filters filters = new OapiModels.Filters(); + Map objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode)); + Map categoryCounts = countMap(itemRepository.countPublishedGroupByCategoryKey(schId, siteCode)); + for (com.yunhai.svchall.entity.ServiceDict dict : dictService + .enabledDicts(schId, siteCode, ServiceDictTypeEnum.OBJECT)) { + Long count = objectCounts.get(dict.getDictKey()); + if (count != null && count > 0) { + filters.getObjects().add(new OapiModels.FilterOption( + dict.getDictKey(), null, dict.getDictName(), count)); + } + } + for (com.yunhai.svchall.entity.ServiceDict dict : dictService + .enabledDicts(schId, siteCode, ServiceDictTypeEnum.CATEGORY)) { + Long count = categoryCounts.get(dict.getDictKey()); + if (count != null && count > 0) { + filters.getCategories().add(new OapiModels.FilterOption( + dict.getDictKey(), null, dict.getDictName(), count)); + } + } + for (Object[] row : itemRepository.countPublishedGroupByDept(schId, siteCode)) { + filters.getDepts().add(new OapiModels.FilterOption(null, + ((Number) row[0]).intValue(), String.valueOf(row[1]), ((Number) row[2]).longValue())); + } + return filters; + } + + /** 指南页详情(三段 HTML/封面/附件绝对化;关联事项同类别 Top N;不存在/未发布返回 null) */ + public OapiModels.Detail oapiDetail(Long schId, String siteCode, Integer itemId, String itemCode) { + ServiceItem item = itemId != null + ? itemRepository.findByIdAndSchIdAndSiteCode(itemId, schId, siteCode).orElse(null) + : itemRepository.findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null); + if (item == null || item.getStatus() == null + || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + return null; + } + OapiModels.Detail vo = new OapiModels.Detail(); + vo.setId(item.getId()); + vo.setItemCode(item.getItemCode()); + vo.setTitle(item.getTitle()); + vo.setSummary(item.getSummary()); + vo.setObjectKey(item.getObjectKey()); + vo.setObjectName(item.getObjectKey() == null ? null + : dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.OBJECT).get(item.getObjectKey())); + vo.setCategoryKey(item.getCategoryKey()); + vo.setCategoryName(item.getCategoryKey() == null ? null + : dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY).get(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(FileUtil.absolutize(item.getProcessHtml())); + vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml())); + vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml())); + vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage())); + vo.setPublishTime(item.getPublishTime()); + vo.setViewCount(item.getViewCount()); + vo.setFaqCategoryId(item.getFaqCategoryId()); + vo.setNoticeCategoryId(item.getNoticeCategoryId()); + vo.setRuleCategoryId(item.getRuleCategoryId()); + vo.setApplyUrl(item.getApplyUrl()); + vo.setFlowGuid(item.getFlowGuid()); + for (SvcItemAttachmentVo a : attachmentService.guideAttachments(schId, item.getId())) { + OapiModels.AttachmentRow row = new OapiModels.AttachmentRow(); + row.setId(a.getId()); + row.setFileName(a.getFileName()); + row.setFileUrl(a.getFileUrl()); + row.setFileSize(a.getFileSize()); + vo.getAttachments().add(row); + } + if (StringUtils.hasText(item.getCategoryKey())) { + List related = itemRepository + .findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(schId, siteCode, + ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), item.getId(), + PageRequest.of(0, 5, Sort.by(Sort.Order.asc("sortNo"), + Sort.Order.desc("publishTime"), Sort.Order.desc("id")))); + for (ServiceItem other : related) { + OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow(); + row.setId(other.getId()); + row.setItemCode(other.getItemCode()); + row.setTitle(other.getTitle()); + row.setDeptName(other.getDeptName()); + row.setHandleMode(other.getHandleMode()); + vo.getRelatedItems().add(row); + } + } + return vo; + } + + /** 跨服务要点快照(docs/17 §1.1 契约平移;查不到/未发布返回 null,不做全局降级查询) */ + public OapiModels.Snapshot oapiSnapshot(String siteCode, String itemCode) { + ServiceItem item = itemRepository.findBySchIdAndSiteCodeAndItemCode(resolveSchIdByCode(siteCode), + siteCode, itemCode).orElse(null); + if (item == null || item.getStatus() == null + || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + return null; + } + OapiModels.Snapshot vo = new OapiModels.Snapshot(); + 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(item.getApplyUrl()); + vo.setPublishTime(item.getPublishTime()); + return vo; + } + + /** + * 站点解析(snapshot 场景只有 siteCode):经站点目录定 schId。 + * 目录不可达返回 null(oapi 层转 code=2),不做"仅按 itemCode 全局搜"降级。 + */ + private Long resolveSchIdByCode(String siteCode) { + SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode); + return site == null ? null : site.getSchId(); + } + + /** 指南页浏览量异步累加(承袭 CMS 有界队列口径)===== */ + + /** 按 itemId 直取发布态事项(主键全局唯一,无需站点解析;oapi portal-extras 端点用) */ + private ServiceItem requirePublishedById(Integer itemId) { + ServiceItem item = itemRepository.findById(itemId).orElse(null); + if (item == null || item.getStatus() == null + || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + return null; + } + return item; + } + + /** 指南附件(按 itemId 直取;fileUrl 已绝对化) */ + public List attachmentsByItemId(Integer itemId) { + List result = new ArrayList<>(); + ServiceItem item = requirePublishedById(itemId); + if (item == null) { + return result; + } + for (SvcItemAttachmentVo a : attachmentService.guideAttachments(item.getSchId(), itemId)) { + OapiModels.AttachmentRow row = new OapiModels.AttachmentRow(); + row.setId(a.getId()); + row.setFileName(a.getFileName()); + row.setFileUrl(a.getFileUrl()); + row.setFileSize(a.getFileSize()); + result.add(row); + } + return result; + } + + /** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */ + public List relatedByItemId(Integer itemId, Integer limit) { + List result = new ArrayList<>(); + ServiceItem item = requirePublishedById(itemId); + if (item == null || !StringUtils.hasText(item.getCategoryKey())) { + return result; + } + int size = limit == null || limit < 1 ? 5 : Math.min(limit, 20); + List related = itemRepository + .findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(item.getSchId(), item.getSiteCode(), + ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), itemId, + PageRequest.of(0, size, Sort.by(Sort.Order.asc("sortNo"), + Sort.Order.desc("publishTime"), Sort.Order.desc("id")))); + for (ServiceItem other : related) { + OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow(); + row.setId(other.getId()); + row.setItemCode(other.getItemCode()); + row.setTitle(other.getTitle()); + row.setDeptName(other.getDeptName()); + row.setHandleMode(other.getHandleMode()); + result.add(row); + } + return result; + } + + /** 浏览量累加(按 itemId 直取;fire-and-forget) */ + public void countViewByItemId(Integer itemId) { + if (itemId == null || !itemRepository.findById(itemId).isPresent()) { + return; + } + try { + executor.execute(() -> { + try { + transactionTemplate.executeWithoutResult( + status -> itemRepository.incrementViewCount(itemId)); + } catch (Exception e) { + log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e); + } + }); + } catch (RejectedExecutionException e) { + log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId); + } + } + + public void countView(Long schId, String siteCode, String itemCode) { + ServiceItem item = itemRepository + .findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null); + if (item == null) { + return; + } + Integer itemId = item.getId(); + try { + executor.execute(() -> { + try { + transactionTemplate.executeWithoutResult( + status -> itemRepository.incrementViewCount(itemId)); + } catch (Exception e) { + log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e); + } + }); + } catch (RejectedExecutionException e) { + log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId); + } + } + + private final ExecutorService executor = new ThreadPoolExecutor( + 1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(VIEW_QUEUE_CAPACITY), + runnable -> { + Thread thread = new Thread(runnable, "svchall-view-counter"); + thread.setDaemon(true); + return thread; + }, + (runnable, pool) -> log.warn("[svc-hall] 事项浏览量队列已满(上限 {}),丢弃一次计数", VIEW_QUEUE_CAPACITY)); + + @PreDestroy + public void shutdown() { + executor.shutdown(); + try { + executor.awaitTermination(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ===== 校验与装配 ===== + + private void validateItemCode(SiteInfo site, SvcItemSaveRequest req) { + boolean duplicated = req.getId() == null + ? itemRepository.existsBySchIdAndSiteCodeAndItemCode( + site.getSchId(), site.getSiteCode(), req.getItemCode()) + : itemRepository.existsBySchIdAndSiteCodeAndItemCodeAndIdNot( + site.getSchId(), site.getSiteCode(), req.getItemCode(), req.getId()); + if (duplicated) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项编码已存在:" + req.getItemCode() + "(站内唯一)"); + } + } + + private void validateDictKeys(SiteInfo site, SvcItemSaveRequest req) { + if (StringUtils.hasText(req.getObjectKey())) { + dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(), + ServiceDictTypeEnum.OBJECT, req.getObjectKey()); + } + if (StringUtils.hasText(req.getCategoryKey())) { + dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(), + ServiceDictTypeEnum.CATEGORY, req.getCategoryKey()); + } + } + + /** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ flowGuid 非空 */ + private void validateBindings(SiteInfo site, SvcItemSaveRequest req) { + String formCode = emptyToNull(req.getFormCode()); + if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "办理表单不存在:" + formCode); + } + String flowGuid = emptyToNull(req.getFlowGuid()); + if (flowGuid != null && flowClient.enabled()) { + if (flowClient.getFlow(site.getSchId(), flowGuid) == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "办理流程不存在或未发布:" + flowGuid); + } + } + boolean online = req.getOnlineFlag() == null || req.getOnlineFlag() != 0; + if (online && flowGuid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "可在线办理的事项必须绑定流程(flowGuid);线下事项请关闭\"可在线办理\"开关"); + } + } + + /** 部门校验+取权威名:组树不可达时拒绝(防脏快照),id 不在部门集合内拒绝 */ + private String resolveDeptName(SiteInfo site, Integer deptGroupId) { + if (deptGroupId == null) { + return null; + } + Map deptNames = new LinkedHashMap<>(); + collectDeptNodes(deptRootOfSite(site), deptNames); + String name = deptNames.get(deptGroupId); + if (name == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "服务部门不存在或部门服务不可达(id=" + deptGroupId + "),请重新选择"); + } + return name; + } + + private BoaGroup deptRootOfSite(SiteInfo site) { + Integer schId = site.getSchId() == null ? null : site.getSchId().intValue(); + if (schId == null) { + log.warn("[svc-hall] 站点 {} 未配置 sch_id,服务部门回退全平台组树(跨校可见,请补站点学校归属)", + site.getSiteCode()); + return deptSource.getGroupTree(); + } + BoaGroup subtree = deptSource.getGroupSubtree(schId); + if (subtree == null) { + log.warn("[svc-hall] 站点 {} 的学校组子树不可达(schId={}),服务部门按空列表处理", + site.getSiteCode(), schId); + } + return subtree; + } + + private List buildDeptTree(BoaGroup root) { + List top = new ArrayList<>(); + if (root == null) { + return top; + } + for (BoaGroup container : containersOf(root)) { + List children = toDeptVos(container.getChildren()); + if (children.isEmpty()) { + top.add(new SvcDeptVo(container.getId(), container.getName())); + } else { + top.addAll(children); + } + } + return top; + } + + private List containersOf(BoaGroup root) { + List out = new ArrayList<>(); + collectContainers(root, out); + return out; + } + + private void collectContainers(BoaGroup node, List out) { + if (node == null) { + return; + } + if (node.getType() != null && node.getType() == deptGroupType) { + out.add(node); + return; + } + if (node.getChildren() != null) { + for (BoaGroup child : node.getChildren()) { + collectContainers(child, out); + } + } + } + + private List toDeptVos(List nodes) { + List out = new ArrayList<>(); + if (nodes == null) { + return out; + } + for (BoaGroup node : nodes) { + if (node == null || node.getId() == null) { + continue; + } + SvcDeptVo vo = new SvcDeptVo(node.getId(), node.getName()); + vo.setChildren(toDeptVos(node.getChildren())); + out.add(vo); + } + return out; + } + + private void collectDeptNodes(BoaGroup root, Map out) { + if (root == null) { + return; + } + for (BoaGroup container : containersOf(root)) { + if (container.getChildren() == null || container.getChildren().isEmpty()) { + if (container.getId() != null) { + out.putIfAbsent(container.getId(), container.getName()); + } + continue; + } + collectDescendants(container, out); + } + } + + private void collectDescendants(BoaGroup parent, Map out) { + if (parent.getChildren() == null) { + return; + } + for (BoaGroup child : parent.getChildren()) { + if (child.getId() != null) { + out.putIfAbsent(child.getId(), child.getName()); + } + collectDescendants(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; + } + + private ServiceItem requireItem(SiteInfo site, Integer id) { + return itemRepository.findByIdAndSchIdAndSiteCode(id, site.getSchId(), site.getSiteCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在")); + } + + private static String emptyToNull(String value) { + return StringUtils.hasText(value) ? value : null; + } + + /** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径) */ + private static String toRelativeIfLocal(String coverImage) { + if (!StringUtils.hasText(coverImage)) { + return null; + } + int idx = coverImage.indexOf("/upload/"); + return idx >= 0 ? coverImage.substring(idx) : coverImage; + } + + private static Map countMap(List rows) { + Map map = new HashMap<>(); + for (Object[] row : rows) { + map.put(String.valueOf(row[0]), ((Number) row[1]).longValue()); + } + return map; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/YschBaseDeptSource.java b/src/main/java/com/yunhai/svchall/service/impl/YschBaseDeptSource.java new file mode 100644 index 0000000..3c2025c --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/YschBaseDeptSource.java @@ -0,0 +1,38 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.svchall.service.DeptSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 默认部门组树来源:代理 UserBase oapi 静态调用,异常降级为 null(Cms YschBaseSvcDeptSource 原样平移)。 + */ +@Slf4j +@Component +public class YschBaseDeptSource implements DeptSource { + + @Override + public BoaGroup getGroupSubtree(Integer schoolId) { + if (schoolId == null) { + return null; + } + try { + return YschBaseOpenApi.getGroupById(schoolId, true); + } catch (Exception e) { + log.warn("[svc-hall] 获取 UserBase 学校组子树失败(schId={}):{}", schoolId, e.getMessage()); + return null; + } + } + + @Override + public BoaGroup getGroupTree() { + try { + return YschBaseOpenApi.getGroupTree(); + } catch (Exception e) { + log.warn("[svc-hall] 获取 UserBase 组树失败:{}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/utils/FileUtil.java b/src/main/java/com/yunhai/svchall/utils/FileUtil.java new file mode 100644 index 0000000..0521240 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/utils/FileUtil.java @@ -0,0 +1,130 @@ +package com.yunhai.svchall.utils; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * 文件存储工具(照《Notice文件上传统一改造方案.md》§3.1 ①:UserBase FileUtil 精简版)。 + * 磁盘 {@code FILE_PATH/{type}/{uuid}.{ext}},DB/出参存相对 URL {@code /upload/{type}/{uuid}.{ext}}, + * 完整 URL 由 FileStorageConfig.publicBaseUrl 拼接(存储与契约分离:域名/前缀变化不破坏数据)。 + */ +public final class FileUtil { + + /** 14 种危险后缀黑名单(同 UserBase/Notice) */ + private static final Set DANGEROUS_EXT = new HashSet<>(Arrays.asList( + "jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", "php", "asp", "aspx", + "jar", "war", "class", "htaccess")); + + /** 上传子目录白名单:image=富文本图片 / file=附件与表单材料 */ + private static final Set ALLOWED_TYPES = new HashSet<>(Arrays.asList("image", "file")); + + private static String baseStorePath; + private static String publicBaseUrl; + + private FileUtil() { + } + + public static void init(String storePath, String publicBase) { + baseStorePath = storePath == null ? "" : normalize(storePath); + publicBaseUrl = publicBase == null ? "" : normalize(publicBase); + ensureStoreBase(); + } + + /** + * 上传:类型白名单 → 后缀黑名单 → 建目录 → UUID 文件名 → 流式写入。 + * + * @return 完整可访问 URL(publicBaseUrl + /upload/{type}/{uuid}.{ext}) + */ + public static String upload(MultipartFile file, String type) throws IOException { + if (file == null || file.isEmpty()) { + throw new IOException("上传文件为空"); + } + if (type == null || !ALLOWED_TYPES.contains(type)) { + throw new IOException("上传类型非法(仅 image/file):" + type); + } + String original = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename(); + String ext = extOf(original); + if (ext.isEmpty()) { + throw new IOException("文件缺少扩展名:" + original); + } + if (DANGEROUS_EXT.contains(ext)) { + throw new IOException("不允许上传的文件类型:" + ext); + } + String uuid = UUID.randomUUID().toString().replace("-", ""); + Path dir = Paths.get(baseStorePath, type); + Files.createDirectories(dir); + Path target = dir.resolve(uuid + "." + ext); + try (InputStream in = file.getInputStream()) { + Files.copy(in, target); + } + return buildUrl("/upload/" + type + "/" + uuid + "." + ext); + } + + /** 相对 URL → 完整 URL(publicBaseUrl 拼接;已是绝对地址原样返回) */ + public static String buildUrl(String relativeUrl) { + if (relativeUrl == null || relativeUrl.isEmpty()) { + return relativeUrl; + } + if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) { + return relativeUrl; + } + String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/"; + String rel = relativeUrl.startsWith("/") ? relativeUrl.substring(1) : relativeUrl; + return base + rel; + } + + /** + * oapi 富文本/封面绝对化:把内容里相对 {@code /upload/} 的 src/href(与裸相对封面值) + * 补成完整 URL。新内容入库即相对路径(B-1 口径),本方法只在**对外出参**时执行, + * 不污染库内数据。 + */ + public static String absolutize(String html) { + if (html == null || html.isEmpty()) { + return html; + } + String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/"; + return html.replace("src=\"/upload/", "src=\"" + base + "upload/") + .replace("href=\"/upload/", "href=\"" + base + "upload/") + .replace("src='/upload/", "src='" + base + "upload/") + .replace("href='/upload/", "href='" + base + "upload/"); + } + + /** 裸相对封面值 → 完整 URL(空值原样;绝对地址原样) */ + public static String absolutizePlain(String url) { + if (url == null || url.isEmpty() || url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + return buildUrl(url); + } + + private static String extOf(String name) { + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return ""; + } + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private static String normalize(String path) { + String p = path.replace("\\", "/"); + return p.endsWith("/") ? p : p + "/"; + } + + private static void ensureStoreBase() { + try { + Files.createDirectories(Paths.get(baseStorePath)); + } catch (IOException e) { + throw new IllegalStateException("文件存储目录不可创建:" + baseStorePath, e); + } + } +} diff --git a/src/main/java/com/yunhai/svchall/utils/FormValidator.java b/src/main/java/com/yunhai/svchall/utils/FormValidator.java new file mode 100644 index 0000000..fd3519b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/utils/FormValidator.java @@ -0,0 +1,258 @@ +package com.yunhai.svchall.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 表单 schema 契约校验 + 提交数据强校验(V2.0 Part B.3;契约自流程中心概览 v1.2 §4.2 平移)。 + * 纪律:类型用字符串 key(一期 7 类 / 14 类命名冻结);校验只用声明式白名单 + * required/pattern/min/max/maxLength;严禁存库 JS + eval(旧 CustomForm 反面先例)。 + */ +public final class FormValidator { + + /** 14 类命名一次冻结(V2.0 B.3) */ + public static final Set FROZEN_TYPES = new HashSet<>(Arrays.asList( + "text", "textarea", "number", "date", "select", "radio", "upload", + "checkbox", "multiselect", "phone", "address", "image", "user", "users")); + + /** 一期启用 7 类 */ + public static final Set PHASE1_TYPES = new HashSet<>(Arrays.asList( + "text", "textarea", "number", "date", "select", "radio", "upload")); + + /** 声明式校验白名单属性(其余属性一律拒绝,防契约外语义渗入) */ + private static final Set ALLOWED_PROPS = new HashSet<>(Arrays.asList( + "key", "label", "type", "required", "pattern", "min", "max", "maxLength", + "tip", "placeholder", "options", "rows", "defaultValue", "accept", "maxSize", "maxFiles")); + + private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,64}$"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private FormValidator() { + } + + // ===== schema 静态校验(发布期) ===== + + /** 校验 schema_json;非法抛 BizException。返回字段数(信息用) */ + public static int validateSchema(String schemaJson) { + JsonNode root = parse(schemaJson, "表单结构不是合法 JSON"); + if (!root.isObject() || !root.has("groups") || !root.get("groups").isArray()) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单结构必须是 {\"groups\":[…]}"); + } + Set keys = new LinkedHashSet<>(); + int total = 0; + for (JsonNode group : root.get("groups")) { + if (!group.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "分组必须是对象"); + } + if (group.has("title") && !group.get("title").isNull() && group.get("title").asText().length() > 64) { + throw new BizException(ErrorCode.PARAM_ERROR, "分组标题过长(≤64)"); + } + if (!group.has("fields") || !group.get("fields").isArray()) { + throw new BizException(ErrorCode.PARAM_ERROR, "每个分组必须含 fields 数组"); + } + for (JsonNode field : group.get("fields")) { + validateField(field, keys); + total++; + } + } + if (total == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单至少要有一个字段"); + } + return total; + } + + private static void validateField(JsonNode field, Set keys) { + if (!field.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段必须是对象"); + } + Iterator names = field.fieldNames(); + while (names.hasNext()) { + String prop = names.next(); + if (!ALLOWED_PROPS.contains(prop)) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段属性不在契约白名单内:" + prop); + } + } + String key = text(field, "key"); + if (key == null || !KEY_PATTERN.matcher(key).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "字段 key 仅限字母/数字/下划线(≤64 位):" + key); + } + if (!keys.add(key)) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段 key 重复:" + key); + } + String label = text(field, "label"); + if (label == null || label.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段缺少 label:" + key); + } + String type = text(field, "type"); + if (type == null || !FROZEN_TYPES.contains(type)) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段类型未冻结:" + type + "(字段 " + key + ")"); + } + if (!PHASE1_TYPES.contains(type)) { + throw new BizException(ErrorCode.PARAM_ERROR, "该类型二期开放,一期不可用:" + type + + "(字段 " + key + ")"); + } + String pattern = text(field, "pattern"); + if (pattern != null) { + try { + Pattern.compile(pattern); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "字段 pattern 不可编译(字段 " + key + ")"); + } + } + if ("select".equals(type) || "radio".equals(type)) { + JsonNode options = field.get("options"); + if (options == null || !options.isArray() || options.size() == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "select/radio 必须提供 options(字段 " + key + ")"); + } + } + Integer min = intVal(field, "min"); + Integer max = intVal(field, "max"); + if (min != null && max != null && min > max) { + throw new BizException(ErrorCode.PARAM_ERROR, "min 不得大于 max(字段 " + key + ")"); + } + } + + // ===== 提交数据强校验(发起期,按 schema 版本) ===== + + /** + * 按表单结构校验提交数据:未知 key 拒绝、required 必填、type 相关校验 + * (number→min/max、文本→maxLength/pattern、select/radio→options 成员、upload→数组非空)。 + * + * @param formDataJson 提交的 {key:value} JSON 字符串(可为 null) + * @param schemaJson 表单结构 + */ + public static void validateSubmit(String formDataJson, String schemaJson) { + JsonNode schema = parse(schemaJson, "表单结构损坏(请联系管理员)"); + Map fieldMap = new java.util.HashMap<>(); + for (JsonNode group : schema.get("groups")) { + for (JsonNode field : group.get("fields")) { + fieldMap.put(field.get("key").asText(), field); + } + } + JsonNode data = parse(formDataJson == null || formDataJson.isEmpty() ? "{}" : formDataJson, + "表单数据不是合法 JSON 对象"); + if (!data.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单数据必须是 JSON 对象"); + } + for (Map.Entry en : fieldMap.entrySet()) { + JsonNode field = en.getValue(); + String key = en.getKey(); + boolean required = field.has("required") && field.get("required").asBoolean(); + JsonNode value = data.get(key); + if (value == null || value.isNull() + || (value.isTextual() && value.asText().trim().isEmpty()) + || (value.isArray() && value.size() == 0)) { + if (required) { + throw new BizException(ErrorCode.PARAM_ERROR, + "必填项未填写:" + field.get("label").asText()); + } + continue; + } + validateValue(field, key, value); + } + // 未知 key 拒绝(严格契约:提交数据不得夹带 schema 外字段) + Iterator it = data.fieldNames(); + List unknown = new ArrayList<>(); + while (it.hasNext()) { + String k = it.next(); + if (!fieldMap.containsKey(k)) { + unknown.add(k); + } + } + if (!unknown.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单数据含未知字段:" + String.join(",", unknown)); + } + } + + private static void validateValue(JsonNode field, String key, JsonNode value) { + String type = field.get("type").asText(); + switch (type) { + case "number": + if (!value.isNumber()) { + throw fail(field, key, "必须是数字"); + } + Integer min = intVal(field, "min"); + Integer max = intVal(field, "max"); + if (min != null && value.asDouble() < min) { + throw fail(field, key, "不得小于 " + min); + } + if (max != null && value.asDouble() > max) { + throw fail(field, key, "不得大于 " + max); + } + break; + case "select": + case "radio": { + String v = value.asText(); + JsonNode options = field.get("options"); + boolean hit = false; + for (JsonNode option : options) { + if (option.asText().equals(v)) { + hit = true; + break; + } + } + if (!hit) { + throw fail(field, key, "选项值不在候选列表内"); + } + break; + } + case "upload": { + if (!value.isArray()) { + throw fail(field, key, "必须是附件数组"); + } + Integer maxFiles = intVal(field, "maxFiles"); + if (maxFiles != null && value.size() > maxFiles) { + throw fail(field, key, "附件数超过上限 " + maxFiles); + } + break; + } + default: { + String v = value.asText(); + Integer maxLength = intVal(field, "maxLength"); + if (maxLength != null && v.length() > maxLength) { + throw fail(field, key, "长度超过上限 " + maxLength); + } + String pattern = text(field, "pattern"); + if (pattern != null && !Pattern.compile(pattern).matcher(v).matches()) { + throw fail(field, key, "格式不正确"); + } + break; + } + } + } + + private static BizException fail(JsonNode field, String key, String reason) { + return new BizException(ErrorCode.PARAM_ERROR, + "字段「" + field.get("label").asText() + "」" + reason + "(" + key + ")"); + } + + private static JsonNode parse(String json, String message) { + try { + return MAPPER.readTree(json == null ? "{}" : json); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, message); + } + } + + private static String text(JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; + } + + private static Integer intVal(JsonNode node, String field) { + return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null; + } +} diff --git a/src/main/java/com/yunhai/svchall/utils/HtmlSanitizer.java b/src/main/java/com/yunhai/svchall/utils/HtmlSanitizer.java new file mode 100644 index 0000000..5605753 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/utils/HtmlSanitizer.java @@ -0,0 +1,68 @@ +package com.yunhai.svchall.utils; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +import java.util.regex.Pattern; + +/** + * 富文本白名单清洗(Cms HtmlSanitizer 原样平移:jsoup Safelist.relaxed() 基础 + + * figure/figcaption/span + 受限 style + a/img 协议收紧 + preserveRelativeLinks)。 + * script/iframe/on* 一律剥除;列表页纯文本字段 th:text 等价转义,utext 仅限本工具清洗过的内容。 + */ +public final class HtmlSanitizer { + + /** 允许保留的内联样式属性(排版用途),其余 style 值丢弃 */ + private static final Pattern SAFE_STYLE = + Pattern.compile("^(\\s*(text-align|color|background-color|font-size|font-weight" + + "|line-height|width|height|max-width|float|margin|padding)\\s*:\\s*[^;()]+;?)*\\s*$", + Pattern.CASE_INSENSITIVE); + + private static final Safelist WHITELIST = Safelist.relaxed() + .addTags("figure", "figcaption", "span") + .addAttributes(":all", "style") + .addAttributes("img", "style", "alt", "width", "height") + .addProtocols("a", "href", "http", "https", "mailto", "ftp", "#") + .addProtocols("img", "src", "http", "https", "data") + // 相对链接保留(Cms B-9 修复平移):上传地址相对路径入库,编辑器贴图 img src 即相对 URL + .preserveRelativeLinks(true); + + private HtmlSanitizer() { + } + + /** + * 清洗富文本;空入参原样返回。baseUri 用占位 https 域(Cms B-9 根因修复配套, + * 配合 preserveRelativeLinks(true) 回写原始相对值)。 + */ + public static String clean(String html) { + if (html == null || html.isEmpty()) { + return html; + } + String cleaned = Jsoup.clean(html, "https://sanitizer.base.invalid/", WHITELIST); + return stripUnsafeStyle(cleaned); + } + + /** 纯文本提取(摘要兜底/搜索片段用):去标签 + 折叠空白 */ + public static String toText(String html, int maxLength) { + if (html == null || html.isEmpty()) { + return ""; + } + String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); + if (maxLength > 0 && text.length() > maxLength) { + return text.substring(0, maxLength); + } + return text; + } + + /** jsoup 不支持按值过滤属性,二次遍历按 SAFE_STYLE 白名单过滤 style(Cms A-07 修复平移) */ + private static String stripUnsafeStyle(String html) { + org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parseBodyFragment(html); + for (org.jsoup.nodes.Element el : doc.select("[style]")) { + String style = el.attr("style"); + if (style == null || !SAFE_STYLE.matcher(style).matches()) { + el.removeAttr("style"); + } + } + return doc.body().html(); + } +} diff --git a/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java new file mode 100644 index 0000000..00546bc --- /dev/null +++ b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java @@ -0,0 +1,63 @@ +package com.yunhai.svchall.utils; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后, + * 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。 + */ +public class ReReadableRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public ReReadableRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (InputStream in = request.getInputStream()) { + byte[] chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) > 0) { + buffer.write(chunk, 0, read); + } + } + this.body = buffer.toByteArray(); + } + + public String bodyString() { + return new String(body, StandardCharsets.UTF_8); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream source = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return source.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return source.read(); + } + }; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9cbb754 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,110 @@ +server: + port: 8086 + # 本地无 context-path(同 Notice dev);生产经 CONTEXT_PATH=/svc-hall 注入, + # Caddy 只反代 /svc-hall/api/*,页面与上传走前端/静态直出 + +spring: + application: + name: svc-hall + datasource: + url: jdbc:mysql://localhost:3306/bsch_svchall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + jpa: + hibernate: + # 开发期 update 自动建表;生产环境建议 none + 升级脚本(sql/ 目录) + ddl-auto: update + show-sql: false + open-in-view: false + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate: + jdbc: + time_zone: Asia/Shanghai + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + servlet: + # 对齐《Notice文件上传统一改造方案.md》D4:50MB / 200MB + multipart: + max-file-size: 50MB + max-request-size: 200MB + +# ===== 基础平台 yh 配置(Cms/Notice 同款三件套)===== +yh: + auth: + enabled: true + sso: + server-url-prefix: http://sso.yunhaixingtong.com + # 本地 dev 指向前端 dev server(8087):CAS 验票回跳走 vite 代理(Cms 同款约定) + client-host-url: http://localhost:8087 + allow-headers: all + allow-origins: all + # 用户面(发起/我的办件)与管理面全在 /api/* 下;本应用无匿名面。 + # 流程中心回调走 /callback/*(不在 pattern 内,由 FlowCallbackVerifyFilter 验签保护) + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换, + # 生产由 projman/Nacos 注入,禁止明文入库仓库(V2.0 R7) + app-key: 100018 + # 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致); + # 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号 + app-secret: "svc-hall-dev-secret-100018" + # 入站验签白名单:CMS(供数消费方,appKey=100005);流程中心 flow-center 分配 appKey 后在此追加 + trusted-apps: + 100005: 2c7885de594742cc840b8762910b2d2e + open: + enabled: true + base-host: http://base.yunhaixingtong.com + base-path: /userbase + app-key: 100018 + # 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致); + # 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号 + app-secret: "svc-hall-dev-secret-100018" + +# ===== 办事大厅专属配置 ===== +svchall: + # 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入) + public-base-url: http://localhost:8086 + # 办事大厅前端地址(办件 bizUrl 落地页用) + front: + base-url: http://localhost:8087 + item: + # 服务部门"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type) + dept-group-type: 7 + cms: + # CMS 基址(站点目录 /oapi/site/list 数据源);本地直连后端,生产由 projman 注入 + base-url: http://localhost:8072/cms + flow: + # 流程中心 flow-center 基址;2026-09-12 联调置 true(本地 8085 已部署 flow-center M1) + enabled: true + base-url: http://localhost:8085 + # 对账 Job 应急闸(契约冻结"消费方免每日对账":flow-center 持续重试+死信+挂起告警为主链路)。 + # 默认 false 停用;仅大面积漂移事故应急时置 true 开闸(每日 04:00 跑) + reconcile-enabled: false + file: + # 文件对外可达基址(绝对地址):上传返回值、oapi 富文本内链绝对化都用它。 + # 本地=后端直连(无 context-path);生产=https://base.yunhaixingtong.com/svc-hall/upload/ + # (Projman 自动生成静态直出段,prod 由 CONTEXT_PATH 注入应用上下文) + public-base-url: http://localhost:8086/upload/ + +logging: + level: + com.yunhai.svchall: debug + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + +# 文件存储磁盘路径(Notice 三件套约定;Docker 部署时与 volume 挂载一致) +FILE_PATH: D:/bsch/svchall/upload/ diff --git a/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java b/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java new file mode 100644 index 0000000..7155da6 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java @@ -0,0 +1,151 @@ +package com.yunhai.svchall.config; + +import com.yunhai.svchall.service.impl.FlowCallbackService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 回调验签过滤器单测(对齐 Notice FlowCallbackVerifyFilterTest): + * 注册探活 ping 直接 200(探活签名是字面量 "ping" 无法验签,flow-center 实测把 5xx 判不可达); + * 缺头/坏时间戳/±5 分钟窗外/无注册/坏签名 → 401;合法签名放行。 + */ +class FlowCallbackVerifyFilterTest { + + private static final String SECRET = "unit-test-secret"; + private static final String BODY = + "{\"eventId\":\"e-9\",\"event\":\"flow.completed\",\"flowGuid\":\"fg-1\",\"schId\":1," + + "\"instanceId\":9001,\"bizType\":\"svc_item\",\"bizId\":\"42\",\"instanceVersion\":4}"; + + private FlowCallbackService callbackService; + private Filter filter; + + @BeforeEach + void setUp() { + callbackService = mock(FlowCallbackService.class); + FlowCallbackFilterConfiguration configuration = + new FlowCallbackFilterConfiguration(callbackService, new com.fasterxml.jackson.databind.ObjectMapper()); + FilterRegistrationBean bean = configuration.flowCallbackVerifyFilter(); + filter = bean.getFilter(); + when(callbackService.secretOf("fg-1")).thenReturn(SECRET); + } + + @Test + void pingIsAnswered200WithoutVerification() throws Exception { + MockHttpServletResponse response = doFilter( + request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", "1", "n", "bad-signature"), new MockFilterChain()); + assertEquals(200, response.getStatus()); + assertEquals("{\"ok\":true}", response.getContentAsString()); + } + + @Test + void pingWithoutHeadersStill200() throws Exception { + MockHttpServletResponse response = doFilter( + request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", null, null, null), new MockFilterChain()); + assertEquals(200, response.getStatus()); + } + + @Test + void missingHeadersIs401() throws Exception { + MockHttpServletResponse response = doFilter(request(BODY, null, null, null), new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void badTimestampIs401() throws Exception { + MockHttpServletResponse response = doFilter( + request(BODY, "not-a-number", "n", "sig"), new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void staleTimestampBeyondFiveMinutesIs401() throws Exception { + String stale = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L); + MockHttpServletResponse response = doFilter( + request(BODY, stale, "n", "sig"), new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void unknownRegistrationIs401() throws Exception { + when(callbackService.secretOf("fg-1")).thenReturn(null); + MockHttpServletResponse response = doFilter(signedRequest(SECRET), new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void badSignatureIs401() throws Exception { + MockHttpServletResponse response = doFilter(signedRequest(null), new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void validSignaturePassesThrough() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletResponse response = doFilter(signedRequest(SECRET), chain); + assertEquals(200, response.getStatus()); + assertNotNull(chain.getRequest(), "合法签名应放行到后续链路"); + } + + @Test + void nonPostIs405() throws Exception { + MockHttpServletRequest request = request(BODY, "1", "n", "sig"); + request.setMethod("GET"); + MockHttpServletResponse response = doFilter(request, new MockFilterChain()); + assertEquals(405, response.getStatus()); + } + + // ===== helpers ===== + + private MockHttpServletRequest request(String body, String ts, String nonce, String signature) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + if (ts != null) { + request.addHeader("X-Flw-EventId", "e-9"); + request.addHeader("X-Flw-Timestamp", ts); + request.addHeader("X-Flw-Nonce", nonce); + request.addHeader("X-Flw-Signature", signature); + } + return request; + } + + /** 头齐全、时间戳取当前时刻;secret 为 null 时签名置垃圾值 */ + private MockHttpServletRequest signedRequest(String secret) { + String ts = String.valueOf(System.currentTimeMillis()); + String signature; + if (secret == null) { + signature = "garbage-signature"; + } else { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + signature = Base64.getEncoder().encodeToString( + mac.doFinal((ts + ".nonce-1." + BODY).getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + return request(BODY, ts, "nonce-1", signature); + } + + private MockHttpServletResponse doFilter(MockHttpServletRequest request, FilterChain chain) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, chain); + return response; + } +} diff --git a/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java b/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java new file mode 100644 index 0000000..3adfc1f --- /dev/null +++ b/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java @@ -0,0 +1,55 @@ +package com.yunhai.svchall.controller.callback; + +import com.yunhai.svchall.service.impl.FlowCallbackService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +/** + * 回调接收端点返回语义单测(契约冻结 D2,免对账前提): + * 成功/毒消息 → 200(毒消息由 service 静默消化,不抛); + * TransientCallbackException / 意外 RuntimeException → 5xx——flow-center 持续重试直至送达。 + */ +class FlowCallbackControllerTest { + + private FlowCallbackService callbackService; + private FlowCallbackController controller; + + @BeforeEach + void setUp() { + callbackService = mock(FlowCallbackService.class); + controller = new FlowCallbackController(callbackService); + } + + @Test + void successReturns200() { + ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); + assertEquals(HttpStatus.OK, resp.getStatusCode()); + assertEquals("{\"ok\":true}", resp.getBody()); + } + + @Test + void transientFailureReturns5xx() { + doThrow(new FlowCallbackService.TransientCallbackException("db down", new RuntimeException("db down"))) + .when(callbackService).process("{\"event\":\"flow.completed\"}"); + ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); + assertEquals("{\"ok\":false}", resp.getBody()); + } + + @Test + void unexpectedExceptionAlsoReturns5xx() { + // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) + doThrow(new IllegalStateException("boom")) + .when(callbackService).process("{\"event\":\"flow.completed\"}"); + ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); + } +} diff --git a/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java b/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java new file mode 100644 index 0000000..b947a13 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java @@ -0,0 +1,118 @@ +package com.yunhai.svchall.job; + +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.FlowClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 对账兜底 Job 降级单测(契约冻结"消费方免每日对账"): + * reconcile-enabled=false(默认)时整体不跑——flow-center 持续重试+死信+挂起告警为主链路, + * 本 Job 仅作应急闸(如大面积漂移事故时置 true 手工兜底)。开关开启时保留原漂移修正语义。 + */ +class FlowReconcileJobTest { + + private ServiceApplyRepository applyRepository; + private FlowClient flowClient; + + @BeforeEach + void setUp() { + applyRepository = mock(ServiceApplyRepository.class); + flowClient = mock(FlowClient.class); + } + + private ServiceApply apply(int status, Integer lastVersion) { + ServiceApply apply = new ServiceApply(); + ReflectionTestUtils.setField(apply, "id", 42); + apply.setSchId(1L); + apply.setFlowInstanceId(9011L); + apply.setFlowStatus(status); + apply.setLastInstanceVersion(lastVersion); + return apply; + } + + @Test + void disabledByDefaultJobDoesNothing() { + FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false); + job.reconcile(); + verify(flowClient, never()).enabled(); + verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class)); + } + + @Test + void enabledJobFixesDriftWithTerminalFinishTime() { + when(flowClient.enabled()).thenReturn(true); + ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 1); + when(applyRepository.findByFlowStatus(eq(ApplyFlowStatusEnum.APPROVING.getCode()), any(Pageable.class))) + .thenReturn(Collections.singletonList(apply)); + Map remote = new HashMap<>(); + remote.put("status", 20); + remote.put("version", 4); + when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote); + + new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + + assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); + assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion()); + assertNotNull(apply.getFinishTime(), "终态补 finishTime"); + verify(applyRepository).save(apply); + } + + @Test + void enabledJobSkipsWhenNoDrift() { + when(flowClient.enabled()).thenReturn(true); + ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 2); + when(applyRepository.findByFlowStatus(anyInt(), any(Pageable.class))) + .thenReturn(Collections.singletonList(apply)); + Map remote = new HashMap<>(); + remote.put("status", 10); + remote.put("version", 2); + when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote); + + new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + + verify(applyRepository, never()).save(any()); + } + + @Test + void enabledJobSkipsWhenFlowCenterDisabled() { + when(flowClient.enabled()).thenReturn(false); + new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class)); + } + + @Test + void enabledJobToleratesPerApplyFailure() { + when(flowClient.enabled()).thenReturn(true); + ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 1); + List list = Collections.singletonList(apply); + when(applyRepository.findByFlowStatus(anyInt(), any(Pageable.class))).thenReturn(list); + when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())) + .thenThrow(new RuntimeException("boom")); + + new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + + verify(applyRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java new file mode 100644 index 0000000..ab54070 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java @@ -0,0 +1,135 @@ +package com.yunhai.svchall.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.service.impl.FlowCallbackService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 办件详情放行守卫单测(BP-R2-3:原先"同校即放行"越权——任意同校登录者可看他人表单与审批意见)。 + * 三分支放行:提交人本人 / 管理员(type=1 或 roleId∈{2,5,7})/ 办理人(assigneeIds 实时含当前用户, + * 60s 缓存+fail-closed)。其余同校普通用户 403"查不到"。 + */ +@DisplayName("ApplyViewGuard - 办件详情三分支放行(BP-R2-3)") +class ApplyViewGuardTest { + + private FlowClient flowClient; + private ApplyViewGuard guard; + private ServiceApply apply; + + @BeforeEach + void setUp() { + flowClient = mock(FlowClient.class); + guard = new ApplyViewGuard(flowClient); + when(flowClient.enabled()).thenReturn(true); + + apply = new ServiceApply(); + ReflectionTestUtils.setField(apply, "id", 42); + apply.setSchId(1L); + apply.setSiteCode("S1"); + apply.setSubmitterId(9); + apply.setFlowInstanceId(9001L); + } + + private BoaUser user(int id) { + BoaUser u = new BoaUser(); + ReflectionTestUtils.setField(u, "id", id); + return u; + } + + private BoaUser userWithRoles(int id, Integer type, Integer... roleIds) { + BoaUser u = user(id); + if (type != null) { + ReflectionTestUtils.setField(u, "type", type); + } + if (roleIds != null) { + ReflectionTestUtils.setField(u, "roleIds", Arrays.asList(roleIds)); + } + return u; + } + + private void assignees(Integer... ids) { + Map instance = new HashMap<>(); + instance.put("assigneeIds", Arrays.asList(ids)); + when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(instance); + } + + @Test + @DisplayName("提交人本人放行(不调流程中心)") + void ownerPasses_withoutFlowCall() { + assertDoesNotThrow(() -> guard.requireVisible(apply, user(9))); + verify(flowClient, times(0)).getInstanceByBiz(anyLong(), anyString(), anyString()); + } + + @Test + @DisplayName("超管(type=1)放行") + void superAdminPasses() { + assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(77, 1))); + } + + @Test + @DisplayName("校级管理员角色白名单 roleId∈{2,5,7} 放行") + void adminRoleWhitelistPasses() { + assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(11, null, 5))); + assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(12, null, 2, 9))); + } + + @Test + @DisplayName("办理人(assigneeIds 含当前用户)放行") + void assigneePasses() { + assignees(30, 31); + assertDoesNotThrow(() -> guard.requireVisible(apply, user(30))); + verify(flowClient).getInstanceByBiz(eq(1L), eq(FlowCallbackService.BIZ_TYPE), eq("42")); + } + + @Test + @DisplayName("同校普通用户(无管理角色、非办理人)→ 403 查不到语义(越权收紧核心断言)") + void plainSameSchoolUserDenied() { + assignees(30, 31); // 流程中心正常返回,但不含用户 50 + BizException e = assertThrows(BizException.class, () -> guard.requireVisible(apply, userWithRoles(50, null, 9))); + org.junit.jupiter.api.Assertions.assertTrue(e.getMessage().contains("办件不存在或无权查看")); + } + + @Test + @DisplayName("流程中心不可达 → fail-closed 拒绝(不因异常放行)") + void flowCenterDownFailClosed() { + when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())) + .thenThrow(new RuntimeException("connection refused")); + assertThrows(BizException.class, () -> guard.requireVisible(apply, user(50))); + } + + @Test + @DisplayName("assigneeIds 60s 缓存:同一办件第二次判定不再出站") + void assigneeQueryCached() { + assignees(30); + assertDoesNotThrow(() -> guard.requireVisible(apply, user(30))); + assertDoesNotThrow(() -> guard.requireVisible(apply, user(30))); + verify(flowClient, times(1)).getInstanceByBiz(anyLong(), anyString(), anyString()); + } + + @Test + @DisplayName("未提交成功(flowInstanceId 空)非本人/管理员不可见") + void pendingApplyOnlyOwnerAndAdmin() { + apply.setFlowInstanceId(null); + assertThrows(BizException.class, () -> guard.requireVisible(apply, user(30))); + verify(flowClient, times(0)).getInstanceByBiz(anyLong(), anyString(), anyString()); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java new file mode 100644 index 0000000..c58df60 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java @@ -0,0 +1,126 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.TargetSpec; +import com.yunhai.platform.openapi.YschOpenCall; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.service.SiteDirectory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * CMS 站点目录接缝契约测试(P0-1 逃逸根因的回归网:此前两侧契约相反且零测试)。 + * 冻结 CMS {@code GET /oapi/site/list} 出参形态(与 CMS 侧 SiteOapiIT 互为咬合哨兵): + *

    + *
  • resolveSite:不带 schId 拉全量,data[] 元素键=siteCode/name/schId;
  • + *
  • listSites:透传 schId query;
  • + *
  • fail-closed:CMS 不可达 → resolveSite=null / listSites 抛业务异常。
  • + *
+ * 若 CMS 改出参键名/形态,本类与 SiteOapiIT 至少一边红——两仓同步改。 + */ +@DisplayName("CmsSiteDirectoryImpl - CMS /oapi/site/list 契约咬合") +class CmsSiteDirectoryImplTest { + + private static final String PATH = "/oapi/site/list"; + private static final Class VO_TYPE = SvcModels.SiteOptionVo.class; + + private YschOpenCall openCall; + private CmsSiteDirectoryImpl directory; + + @BeforeEach + void setUp() { + openCall = mock(YschOpenCall.class); + directory = new CmsSiteDirectoryImpl(openCall, "http://localhost:8072/cms/"); + } + + private static SvcModels.SiteOptionVo vo(String code, String name, Long schId) { + SvcModels.SiteOptionVo vo = new SvcModels.SiteOptionVo(); + vo.setSiteCode(code); + vo.setName(name); + vo.setSchId(schId); + return vo; + } + + @Test + @DisplayName("resolveSite:全量拉取(不带 schId)+ 出参三键解析命中 siteCode") + void resolveSite_parsesFrozenShape() { + List rows = Arrays.asList(vo("S1", "站点一", 1L), vo("S2", "站点二", 2L)); + when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE))).thenReturn(rows); + + SiteDirectory.SiteInfo site = directory.resolveSite("S1"); + assertEquals("S1", site.getSiteCode()); + assertEquals("站点一", site.getName()); + assertEquals(1L, site.getSchId()); + // 全量结果缓存:第二次解析不再出站 + directory.resolveSite("S2"); + verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)); + } + + @Test + @DisplayName("resolveSite:schId 数字串形态(Long 反序列化)") + void resolveSite_parsesNumericSchId() { + when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE))) + .thenReturn(Collections.singletonList(vo("S1", "站点一", 1L))); + assertEquals(1L, directory.resolveSite("S1").getSchId()); + } + + @Test + @DisplayName("resolveSite:CMS 不可达 → null(fail-closed)") + void resolveSite_failClosedWhenCmsDown() { + when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE))) + .thenThrow(new RuntimeException("connection refused")); + assertNull(directory.resolveSite("S1")); + BizException e = assertThrows(BizException.class, () -> directory.requireSite("S1")); + assertTrue(e.getMessage().contains("站点不存在或站点服务不可达")); + } + + @Test + @DisplayName("resolveSite:全量无命中 code → null;空 siteCode → null 不出站") + void resolveSite_missReturnsNull() { + when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE))) + .thenReturn(Collections.singletonList(vo("S1", "站点一", 1L))); + assertNull(directory.resolveSite("NOPE")); + assertNull(directory.resolveSite(" ")); + verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)); + } + + @Test + @DisplayName("listSites:透传 schId query;不可达抛业务异常") + void listSites_passesSchIdAndFailsClosed() { + when(openCall.getList(any(TargetSpec.class), eq(PATH), + eq(Collections.singletonMap("schId", "1")), eq(VO_TYPE))) + .thenReturn(Collections.singletonList(vo("S1", "站点一", 1L))); + List sites = directory.listSites(1L); + assertEquals(1, sites.size()); + assertEquals("S1", sites.get(0).getSiteCode()); + + YschOpenCall dead = mock(YschOpenCall.class); + when(dead.getList(any(TargetSpec.class), eq(PATH), + eq(Collections.singletonMap("schId", "2")), eq(VO_TYPE))) + .thenThrow(new RuntimeException("timeout")); + assertThrows(BizException.class, + () -> new CmsSiteDirectoryImpl(dead, "http://localhost:8072/cms").listSites(2L)); + } + + @Test + @DisplayName("listSites:schId 为空 → PARAM_ERROR") + void listSites_rejectsNullSchId() { + assertThrows(BizException.class, () -> directory.listSites(null)); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java new file mode 100644 index 0000000..db15a3a --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java @@ -0,0 +1,287 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.entity.ServiceFlowEvent; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceFlowEventRepository; +import com.yunhai.svchall.repository.ServiceFlowRegRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 回调事件处理单测(对齐 Notice FlowCallbackServiceTest,5xx 语义 D2 核心断言): + * 六事件映射;eventId 重复/并发重复跳过;乱序闸门严格小于(同版本事件不丢); + * 按 payload.bizId 定位办件(findByIdForUpdate 行锁);毒 JSON/缺字段/非 svc_item/ping 静默; + * DB 异常 → TransientCallbackException(→ controller 回 5xx 触发 flow-center 持续重试)。 + */ +class FlowCallbackServiceTest { + + private ServiceFlowEventRepository eventRepository; + private ServiceFlowRegRepository regRepository; + private ServiceApplyRepository applyRepository; + private FlowCallbackService service; + private ServiceApply apply; + + @BeforeEach + void setUp() { + eventRepository = mock(ServiceFlowEventRepository.class); + regRepository = mock(ServiceFlowRegRepository.class); + applyRepository = mock(ServiceApplyRepository.class); + service = new FlowCallbackService(eventRepository, regRepository, applyRepository, new ObjectMapper()); + + apply = new ServiceApply(); + ReflectionTestUtils.setField(apply, "id", 42); + apply.setSchId(1L); + apply.setItemId(7); + apply.setItemCode("ITEM-1"); + apply.setItemTitle("测试事项"); + apply.setSubmitterId(9); + apply.setSubmitterName("张三"); + apply.setFlowGuid("fg-1"); + apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); + apply.setLastInstanceVersion(1); + when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(apply)); + } + + // ===== 六事件映射 ===== + + @Test + void submitted_taskCreated_taskHandled_mapToApproving() { + for (String event : new String[]{"flow.submitted", "flow.task.created", "flow.task.handled"}) { + ServiceApply a = freshApply(); + service.process(body(event, 2)); + assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), a.getFlowStatus(), event); + assertNull(a.getFinishTime(), event + " 非终态不应置 finishTime"); + } + } + + @Test + void completed_mapsToCompletedAndStampsFinishTime() { + service.process(body("flow.completed", 4)); + assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); + assertNotNull(apply.getFinishTime()); + } + + @Test + void rejected_mapsToRejectedAndStampsFinishTime() { + service.process(body("flow.rejected", 4)); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); + assertNotNull(apply.getFinishTime()); + } + + @Test + void withdrawn_mapsToWithdrawnAndStampsFinishTime() { + service.process(body("flow.withdrawn", 4)); + assertEquals(ApplyFlowStatusEnum.WITHDRAWN.getCode(), apply.getFlowStatus()); + assertNotNull(apply.getFinishTime()); + } + + // ===== 乱序闸门:严格小于(<),同版本不丢 ===== + + @Test + void sameVersionEventIsProcessed_notDiscarded() { + // flow-center 实测同版本多事件(submitted+task.created 同 v1)——旧 <= 闸门会误杀 + apply.setLastInstanceVersion(1); + service.process(body("flow.task.created", 1)); + assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); + verify(applyRepository).save(apply); + } + + @Test + void olderVersionEventIsDiscarded() { + apply.setLastInstanceVersion(3); + service.process(body("flow.completed", 2)); + assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); + verify(applyRepository, never()).save(any()); + } + + @Test + void missingVersionIsProcessedWithoutInventingVersion() { + // version 缺失放行推进,但不伪造 last+1 + service.process(bodyWithout("instanceVersion")); + verify(applyRepository).save(apply); + assertEquals(Integer.valueOf(1), apply.getLastInstanceVersion()); + } + + // ===== eventId 去重(两道:先查后落,uk 兜底并发)===== + + @Test + void duplicateEventIdIsSkipped() { + when(eventRepository.existsByEventId("e-1")).thenReturn(true); + service.process(body("flow.completed", 4)); + verify(applyRepository, never()).save(any()); + assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); + } + + @Test + void concurrentDuplicateViaUniqueConstraintIsSkipped() { + doThrow(new DataIntegrityViolationException("uk")).when(eventRepository).saveAndFlush(any(ServiceFlowEvent.class)); + service.process(body("flow.completed", 4)); + verify(applyRepository, never()).save(any()); + assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); + } + + // ===== 定位:按 payload.bizId + 行锁;bizType 守卫 ===== + + @Test + void locateByBizIdWithPessimisticLock() { + service.process(body("flow.completed", 4)); + verify(applyRepository).findByIdForUpdate(42); + verify(applyRepository, never()).findBySchIdAndFlowInstanceId(any(), any()); + } + + @Test + void nonSvcItemBizTypeIsSilentlyDropped() { + service.process(body("flow.completed", 4).replace("\"svc_item\"", "\"notice_item\"")); + verify(applyRepository, never()).findByIdForUpdate(any()); + verify(applyRepository, never()).save(any()); + } + + @Test + void unknownBizIdIsSilentlyDropped() { + when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.empty()); + service.process(body("flow.completed", 4)); + verify(applyRepository, never()).save(any()); + } + + @Test + void nonNumericBizIdIsSilentlyDropped() { + service.process(body("flow.completed", 4).replace("\"bizId\":\"42\"", "\"bizId\":\"abc\"")); + verify(applyRepository, never()).findByIdForUpdate(any()); + } + + // ===== 毒消息/未知事件/ping:静默(协议层不可重试,防无限重放)===== + + @Test + void poisonJsonIsSilentlyIgnored() { + service.process("not-json{{"); + verify(applyRepository, never()).save(any()); + } + + @Test + void missingRequiredFieldIsSilentlyIgnored() { + service.process(body("flow.completed", 4).replace("\"bizId\":\"42\",", "")); + verify(applyRepository, never()).save(any()); + } + + @Test + void unknownEventTypeIsSilentlyIgnored() { + service.process(body("flow.handed", 2)); + verify(applyRepository, never()).save(any()); + } + + @Test + void pingEventIsSilentlyIgnored() { + // 过滤器已直通 200,这里兜底防穿透 + service.process(body("ping", 0)); + verify(applyRepository, never()).findByIdForUpdate(any()); + } + + // ===== 5xx 语义:DB 瞬时失败 → TransientCallbackException ===== + + @Test + void transientDbFailureThrowsTransientCallbackException() { + doThrow(new RuntimeException("connection reset")).when(applyRepository).save(any(ServiceApply.class)); + assertThrows(FlowCallbackService.TransientCallbackException.class, + () -> service.process(body("flow.completed", 4))); + } + + // ===== 终态明细快照 ===== + + @Test + void terminalEventWritesResultSnapshot() { + service.process(body("flow.completed", 4)); + assertNotNull(apply.getFlowResultJson()); + assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); + } + + // ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置 ===== + + @Test + void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() { + // completed(v4) 先落库(20 + result),迟到的 task.handled(v4) 不得打回 10、不得覆盖结果 + service.process(body("e-a", "flow.completed", 4)); + assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); + String frozen = apply.getFlowResultJson(); + Mockito.clearInvocations(applyRepository); + + service.process(body("e-b", "flow.task.handled", 4)); // 不抛 → 200 + assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus(), "终态行拒绝非终态事件"); + assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被 handled 明细覆盖"); + verify(applyRepository, never()).save(any()); + } + + @Test + void terminalRow_rejectsNonTerminalEvent_higherVersion() { + service.process(body("e-c", "flow.rejected", 4)); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); + service.process(body("e-d", "flow.task.handled", 5)); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus(), + "终态后任何非终态事件都不回退(重提走 resubmit 先置 10,不冲突)"); + } + + @Test + void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() { + service.process(body("e-e", "flow.rejected", 4)); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); + String frozen = apply.getFlowResultJson(); + service.process(body("e-f", "flow.withdrawn", 4)); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); + assertEquals(frozen, apply.getFlowResultJson(), "已终态不被另一终态改写"); + } + + // ===== helpers ===== + + private ServiceApply freshApply() { + ServiceApply a = new ServiceApply(); + ReflectionTestUtils.setField(a, "id", 42); + a.setSchId(1L); + a.setItemId(7); + a.setItemCode("ITEM-1"); + a.setItemTitle("测试事项"); + a.setSubmitterId(9); + a.setSubmitterName("张三"); + a.setFlowGuid("fg-1"); + a.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); + a.setLastInstanceVersion(1); + when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(a)); + return a; + } + + private String body(String event, Integer version) { + return body("e-1", event, version); + } + + private String body(String eventId, String event, Integer version) { + return "{\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\",\"schId\":1,\"instanceId\":9001," + + "\"bizType\":\"svc_item\",\"bizId\":\"42\",\"flowGuid\":\"fg-1\"," + + "\"instanceVersion\":" + version + "," + + "\"data\":{\"result\":\"同意\",\"opinion\":\"情况属实\"}}"; + } + + private String bodyWithout(String field) { + String b = body("flow.task.created", 2); + String needle = "\"" + field + "\":2,"; + assertEquals(true, b.contains(needle), "测试体应包含待删字段 " + needle); + return b.replace(needle, ""); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java new file mode 100644 index 0000000..4a6c053 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java @@ -0,0 +1,302 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest; +import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 提交链路单测(验收报告 §四.1/§四.2 两大实测坑的回归网): + * 代发起出站不进 DB 事务(AFTER_COMMIT + 独立线程;无活动事务时同步兜底执行); + * 回填走窄列 markApproving(终态不回退由 JPQL 守卫,此处验证调用契约); + * 代发起失败保持 PENDING 可重试;enabled=false 降级;retry 既有规则。 + */ +class ServiceApplyFlowSubmitTest { + + private ServiceApplyRepository applyRepository; + private ServiceItemRepository itemRepository; + private ServiceFormService formService; + private AttachmentService attachmentService; + private FlowClient flowClient; + private SiteGuard siteGuard; + private SiteDirectory siteDirectory; + private ServiceApplyService service; + private ServiceApply apply; + + @BeforeEach + void setUp() { + applyRepository = mock(ServiceApplyRepository.class); + itemRepository = mock(ServiceItemRepository.class); + formService = mock(ServiceFormService.class); + attachmentService = mock(AttachmentService.class); + flowClient = mock(FlowClient.class); + siteGuard = mock(SiteGuard.class); + siteDirectory = mock(SiteDirectory.class); + service = new ServiceApplyService(applyRepository, itemRepository, formService, + attachmentService, flowClient, siteGuard, siteDirectory, + new com.yunhai.svchall.service.ApplyViewGuard(flowClient), new ObjectMapper(), + new org.springframework.transaction.support.TransactionTemplate( + org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class))); + ReflectionTestUtils.setField(service, "frontBaseUrl", "http://localhost:8087"); + + apply = new ServiceApply(); + ReflectionTestUtils.setField(apply, "id", 42); + apply.setSchId(1L); + apply.setSiteCode("S1"); + apply.setItemId(7); + apply.setItemCode("ITEM-1"); + apply.setItemTitle("测试事项"); + apply.setSubmitterId(9); + apply.setSubmitterName("张三"); + apply.setFlowGuid("fg-1"); + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + apply.setLastInstanceVersion(0); + when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> { + ServiceApply a = inv.getArgument(0); + if (a.getId() == null) { + ReflectionTestUtils.setField(a, "id", 42); // 模拟 JPA persist 赋 id + } + return a; + }); + when(applyRepository.findById(42)).thenReturn(Optional.of(apply)); + when(flowClient.enabled()).thenReturn(true); + when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(), + any(), any(), anyInt(), anyString(), any())) + .thenReturn(new FlowClient.ApplyResult(9011L, false)); + when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L)); + ServiceItem item = new ServiceItem(); + item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + item.setItemCode("ITEM-1"); + item.setTitle("测试事项"); + item.setFlowGuid("fg-1"); + when(itemRepository.findBySchIdAndSiteCodeAndItemCode(1L, "S1", "ITEM-1")) + .thenReturn(Optional.of(item)); + when(attachmentService.applyAttachments(anyLong(), anyInt())).thenReturn(new java.util.ArrayList<>()); + } + + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + private BoaUser user(int id, int schId) { + BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(user.getId()).thenReturn(id); + when(user.getCurrentRole().getSchId()).thenReturn(schId); + return user; + } + + private ApplySubmitRequest request() { + ApplySubmitRequest req = new ApplySubmitRequest(); + ReflectionTestUtils.setField(req, "siteCode", "S1"); + ReflectionTestUtils.setField(req, "itemCode", "ITEM-1"); + return req; + } + + // ===== 提交:代发起出站 + 窄列回填 ===== + + @Test + void submitWithoutTransactionAppliesImmediately() { + // 无活动事务(测试/兜底路径)→ 立即执行,语义与异步路径一致 + ApplyVo vo = service.submit(user(9, 1), request()); + assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转"); + ArgumentCaptor bizId = ArgumentCaptor.forClass(String.class); + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), bizId.capture(), + anyString(), any(), any(), eq(9), eq("张三"), any()); + assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)"); + verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + @Test + void submitPassesBizUrlInFrontendHashQueryForm() { + // bizUrl 落地页是 svc-hall 前端的 hash 路由 /apply-detail?id=(routes.ts path + Apply.vue + // router.replace 同形);拼成路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2) + service.submit(user(9, 1), request()); + ArgumentCaptor bizUrl = ArgumentCaptor.forClass(String.class); + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"), + anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any()); + assertEquals("http://localhost:8087/#/apply-detail?id=42", bizUrl.getValue()); + } + + @Test + void submitRegistersAfterCommitAndAppliesOnSeparateThread() { + TransactionSynchronizationManager.initSynchronization(); + service.submit(user(9, 1), request()); + verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), + any(), any(), any(), anyInt(), anyString(), any()); + // 事务提交前不出站;手动触发 afterCommit 模拟提交成功 + for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + verify(flowClient, timeout(3000)).apply(anyLong(), anyString(), anyString(), anyString(), + any(), any(), any(), anyInt(), anyString(), any()); + verify(applyRepository, timeout(3000)).markApproving(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + @Test + void submitKeepsPendingWhenFlowApplyFails() { + when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(), + any(), any(), anyInt(), anyString(), any())) + .thenThrow(new RuntimeException("connection refused")); + ApplyVo vo = service.submit(user(9, 1), request()); + assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "失败静默保持待提交(可重试)"); + verify(applyRepository, never()).markApproving(anyInt(), anyLong(), any()); + } + + @Test + void submitStaysPendingWhenFlowClientDisabled() { + when(flowClient.enabled()).thenReturn(false); + ApplyVo vo = service.submit(user(9, 1), request()); + assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus()); + verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), + any(), any(), any(), anyInt(), anyString(), any()); + } + + @Test + void duplicatedApplyStillMarksApproving() { + when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(), + any(), any(), anyInt(), anyString(), any())) + .thenReturn(new FlowClient.ApplyResult(9011L, true)); + service.submit(user(9, 1), request()); + verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + // ===== 重试:既有规则回归 + 同步代发起 ===== + + @Test + void retryRejectsNonOwner() { + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + BizException e = assertThrows(BizException.class, () -> service.retry(user(8, 1), 42)); + assertEquals("仅可重试自己的办件", e.getMessage()); + } + + @Test + void retryRejectsNonPendingApply() { + apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + assertThrows(BizException.class, () -> service.retry(user(9, 1), 42)); + } + + @Test + void retryAppliesSynchronously() { + apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + service.retry(user(9, 1), 42); + verify(flowClient).apply(anyLong(), anyString(), anyString(), anyString(), anyString(), + any(), any(), anyInt(), anyString(), any()); + verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + // ===== 详情放行收紧(BP-R2-3):同校普通用户不再放行 ===== + + @Test + void detail_ownerSeesOwnApply() { + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + assertEquals(Integer.valueOf(42), service.detail(user(9, 1), "S1", 42).getId()); + } + + @Test + void detail_plainSameSchoolUserDenied() { + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + BoaUser plain = user(8, 1); + when(plain.getType()).thenReturn(null); + when(plain.getRoleIds()).thenReturn(null); + BizException e = assertThrows(BizException.class, () -> service.detail(plain, "S1", 42)); + assertEquals("办件不存在或无权查看", e.getMessage()); + } + + // ===== 一键重提(P2-5/BP-R2-2:30/40 复用同 bizId,R4 引擎重启推进) ===== + + @Test + void resubmit_rejectedApplyReappliesSameBizIdAndMarksApproving() { + apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode()); + apply.setFlowInstanceId(9000L); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + service.resubmit(user(9, 1), 42); + // 同 bizId=42(flow-center R4 命中 30/40 → 复用同一办件行重启推进) + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"), + anyString(), anyString(), any(), eq(9), eq("张三"), any()); + verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + @Test + void resubmit_withdrawnApplyAlsoAllowed() { + apply.setFlowStatus(ApplyFlowStatusEnum.WITHDRAWN.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + service.resubmit(user(9, 1), 42); + verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class)); + } + + @Test + void resubmit_rejectsNonOwner() { + apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + BizException e = assertThrows(BizException.class, () -> service.resubmit(user(8, 1), 42)); + assertEquals("仅可重提自己的办件", e.getMessage()); + verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), + anyString(), anyString(), any(), anyInt(), anyString(), any()); + } + + @Test + void resubmit_rejectsNonTerminalStatus() { + for (ApplyFlowStatusEnum st : ApplyFlowStatusEnum.values()) { + if (st == ApplyFlowStatusEnum.REJECTED || st == ApplyFlowStatusEnum.WITHDRAWN) { + continue; + } + apply.setFlowStatus(st.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42), + "flowStatus=" + st.getCode() + " 不可重提"); + } + verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), + anyString(), anyString(), any(), anyInt(), anyString(), any()); + } + + @Test + void resubmit_flowUnreachable_keepsStateReenterable() { + apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode()); + when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); + when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(), + anyString(), any(), anyInt(), anyString(), any())) + .thenThrow(new RuntimeException("connection refused")); + assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42)); + verify(applyRepository, never()).markResubmitted(anyInt(), anyLong(), any()); + assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus().intValue(), + "出站失败保持原态,可再次重提"); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java new file mode 100644 index 0000000..72aacf2 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java @@ -0,0 +1,126 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.svchall.entity.ServiceFlowReg; +import com.yunhai.svchall.repository.ServiceFlowRegRepository; +import com.yunhai.svchall.repository.ServiceFormRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.DeptSource; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 回调注册防轮换单测(契约冻结:flow-center 重复注册会轮换 secret,导致已存密钥验签失配): + * 仅 reg 缺失/失效(secret 空或 status≠0)时才(重)注册;有效注册绝不重复调用; + * 注册失败 best-effort 不阻断事项保存。 + */ +class ServiceItemFlowRegTest { + + private ServiceFlowRegRepository flowRegRepository; + private FlowClient flowClient; + private ServiceItemService service; + private SiteDirectory.SiteInfo site = new SiteDirectory.SiteInfo("S1", "站点一", 1L); + + @BeforeEach + void setUp() { + flowRegRepository = mock(ServiceFlowRegRepository.class); + flowClient = mock(FlowClient.class); + service = new ServiceItemService( + mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), flowRegRepository, + mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class), + mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class), + flowClient, mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "callbackPublicBase", "http://localhost:8086"); + when(flowClient.enabled()).thenReturn(true); + } + + private ServiceFlowReg reg(Integer status, String secret) { + ServiceFlowReg reg = new ServiceFlowReg(); + reg.setSchId(1L); + reg.setFlowGuid("fg-1"); + reg.setStatus(status); + reg.setSecret(secret); + return reg; + } + + @Test + void validRegistrationIsNeverRepeated() { + when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")) + .thenReturn(Optional.of(reg(0, "stored-secret"))); + service.ensureFlowReg(site, "fg-1"); + verify(flowClient, never()).registerCallback(any(), anyString(), anyString(), any()); + } + + @Test + void missingRegistrationIsCreated() { + when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty()); + when(flowClient.registerCallback(eq(1L), eq("fg-1"), + eq("http://localhost:8086/callback/flow"), isNull())).thenReturn("issued-secret"); + service.ensureFlowReg(site, "fg-1"); + assertEquals("issued-secret", firstSavedReg().getSecret()); + assertEquals(Integer.valueOf(0), firstSavedReg().getStatus()); + assertEquals("http://localhost:8086/callback/flow", firstSavedReg().getCallbackUrl()); + } + + @Test + void registrationWithEmptySecretIsReissued() { + // 失效注册(secret 空)→ 重新注册并原地更新同一行(拿回新 secret) + ServiceFlowReg existing = reg(0, null); + when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing)); + when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull())) + .thenReturn("rotated-secret"); + service.ensureFlowReg(site, "fg-1"); + assertSame(existing, firstSavedReg(), "失效注册应原地更新同一行"); + assertEquals("rotated-secret", firstSavedReg().getSecret()); + } + + @Test + void registrationWithDisabledStatusIsReissued() { + ServiceFlowReg existing = reg(1, "old-secret"); + when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing)); + when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull())) + .thenReturn("rotated-secret"); + service.ensureFlowReg(site, "fg-1"); + verify(flowRegRepository).save(existing); + } + + @Test + void registrationFailureIsBestEffort() { + when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty()); + when(flowClient.registerCallback(any(), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("flow-center down")); + service.ensureFlowReg(site, "fg-1"); // 不抛——事项保存不被注册失败阻断 + verify(flowRegRepository, never()).save(any()); + } + + @Test + void flowClientDisabledSkipsEverything() { + when(flowClient.enabled()).thenReturn(false); + service.ensureFlowReg(site, "fg-1"); + verify(flowRegRepository, never()).findBySchIdAndFlowGuid(any(), anyString()); + } + + private ServiceFlowReg firstSavedReg() { + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class); + verify(flowRegRepository).save(captor.capture()); + return captor.getValue(); + } +} diff --git a/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java b/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java new file mode 100644 index 0000000..6ff1f3b --- /dev/null +++ b/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java @@ -0,0 +1,107 @@ +package com.yunhai.svchall.utils; + +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 7 类/声明式校验白名单/提交强校验)。 + */ +@DisplayName("FormValidator - 表单 schema 契约") +class FormValidatorTest { + + private static final String OK_SCHEMA = "{\"groups\":[{" + + "\"title\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20}," + + "{\"key\":\"idCard\",\"label\":\"身份证\",\"type\":\"text\",\"required\":true," + + "\"pattern\":\"^\\\\d{17}[\\\\dXx]$\"}," + + "{\"key\":\"dept\",\"label\":\"部门\",\"type\":\"select\",\"options\":[\"计财处\",\"教务处\"]}," + + "{\"key\":\"joinDate\",\"label\":\"日期\",\"type\":\"date\"}" + + "]}]}"; + + @Test + @DisplayName("合法 schema 通过校验并返回字段数") + void validSchemaPasses() { + assertThat(FormValidator.validateSchema(OK_SCHEMA)).isEqualTo(4); + } + + @Test + @DisplayName("一期只放行 7 类控件:user 为二期占位 → 拒绝") + void phase2TypeRejected() { + String schema = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"u\",\"label\":\"用户\",\"type\":\"user\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(schema)) + .isInstanceOf(BizException.class) + .hasMessageContaining("二期开放"); + } + + @Test + @DisplayName("未冻结类型拒绝(14 类之外的字符串 key)") + void unfrozenTypeRejected() { + String schema = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"x\",\"label\":\"X\",\"type\":\"richtext\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(schema)) + .isInstanceOf(BizException.class) + .hasMessageContaining("未冻结"); + } + + @Test + @DisplayName("契约白名单外的属性拒绝(防存库 JS/eval 类契约外语义渗入)") + void nonContractPropRejected() { + String schema = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"x\",\"label\":\"X\",\"type\":\"text\",\"dataValidator\":\"return true\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(schema)) + .isInstanceOf(BizException.class) + .hasMessageContaining("dataValidator"); + } + + @Test + @DisplayName("key 重复拒绝;pattern 不可编译拒绝") + void duplicateKeyAndBadPatternRejected() { + String dup = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\"}," + + "{\"key\":\"a\",\"label\":\"A2\",\"type\":\"text\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(dup)) + .isInstanceOf(BizException.class) + .hasMessageContaining("重复"); + + String badPattern = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"p\",\"label\":\"P\",\"type\":\"text\",\"pattern\":\"[a-\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(badPattern)) + .isInstanceOf(BizException.class) + .hasMessageContaining("pattern"); + } + + @Test + @DisplayName("提交校验:必填拦截、pattern 拦截、select 候选成员、未知字段拒绝") + void submitValidation() { + FormValidator.validateSchema(OK_SCHEMA); + + // 缺必填 + assertThatThrownBy(() -> FormValidator.validateSubmit("{\"name\":\"张三\"}", OK_SCHEMA)) + .hasMessageContaining("必填"); + // pattern 不匹配 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{\"name\":\"张三\",\"idCard\":\"123\",\"dept\":\"计财处\"}", OK_SCHEMA)) + .hasMessageContaining("格式不正确"); + // select 候选外 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"食堂\"}", OK_SCHEMA)) + .hasMessageContaining("候选列表"); + // 未知字段 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"教务处\",\"hack\":1}", OK_SCHEMA)) + .hasMessageContaining("未知字段"); + // 合法提交通过 + assertThatCode(() -> FormValidator.validateSubmit( + "{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"教务处\"}", OK_SCHEMA)) + .doesNotThrowAnyException(); + // 无表单数据(null)→ 只按必填口径 + assertThatThrownBy(() -> FormValidator.validateSubmit(null, OK_SCHEMA)) + .hasMessageContaining("必填"); + } +}