This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/target/
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.yunhai</groupId>
|
||||
<artifactId>svc-hall</artifactId>
|
||||
<version>0.1</version>
|
||||
<name>svc-hall</name>
|
||||
<description>办事大厅(事项库 + 自定义表单 + 办件记录;CMS 门户供数与流程中心托管接入)</description>
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-platform-dependencies</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
|
||||
actuator 健康探活 + 共享 IDHttp(出站自动签名),一行带齐(同 UserHome/Notice/Cms 约定) -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- CAS 客户端(UserHome/Cms 同款显式声明) -->
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
<artifactId>cas-client-core</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 富文本白名单清洗(jsoup,Cms HtmlSanitizer 同款 1.17.2) -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
<!-- 站点目录缓存(CMS /oapi/site/list 解析结果本地缓存) -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>svc-hall</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.yunhai.svchall.SvcHallApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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 均为绝对地址且可访问。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 且非空)。
|
||||
* <b>注册探活 ping 直接 200</b>——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> flowCallbackVerifyFilter() {
|
||||
FilterRegistrationBean<FlowCallbackVerifyFilter> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -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> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<String, Object> 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<String, Object> applyItem(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("itemCode") String itemCode) {
|
||||
return applyService.applyItem(authUserWithDept, siteCode, itemCode);
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
List<BoaModule> 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<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
||||
Map<Integer, BoaModule> moduleMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
moduleMap.put(m.getId(), m);
|
||||
}
|
||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> 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<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> 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<Map<String, Object>> children =
|
||||
(List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
||||
children.add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<com.yunhai.svchall.model.svc.SvcModels.SiteOptionVo> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> page(BoaUser authUserWithDept, ApplyPageQuery query) {
|
||||
return applyService.adminPage(authUserWithDept, query);
|
||||
}
|
||||
}
|
||||
@@ -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<SvcDictVo> 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<SvcDeptVo> deptTree(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.deptTree(authUserWithDept, siteCode);
|
||||
}
|
||||
}
|
||||
@@ -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<FlowSummaryVo> list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) {
|
||||
siteGuard.requireSiteManager(authUserWithDept, siteCode);
|
||||
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
return flowClient.listFlows(site.getSchId());
|
||||
}
|
||||
}
|
||||
@@ -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<SvcFormVo> list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) {
|
||||
return formService.list(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
@GetMapping("/versions")
|
||||
public List<SvcFormVo> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<IdSortItem> items) {
|
||||
itemService.adminSort(authUserWithDept, siteCode, items);
|
||||
}
|
||||
|
||||
/** 同步部门名快照(UserBase 组树改名后刷新 dept_name) */
|
||||
@PostMapping("/syncDeptName")
|
||||
public Map<String, Object> syncDeptName(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.syncDeptName(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
@GetMapping("/selList")
|
||||
public List<SvcItemSimpleVo> selList(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.adminSelList(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
/** 指南附件回显(编辑页列表) */
|
||||
@GetMapping("/attList")
|
||||
public List<SvcItemAttachmentVo> attList(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("id") Integer id) {
|
||||
return itemService.itemAttachments(authUserWithDept, siteCode, id);
|
||||
}
|
||||
}
|
||||
@@ -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-* 四头验签后才到达这里)。
|
||||
* <p>
|
||||
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
||||
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
||||
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
|
||||
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平
|
||||
* (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/callback")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackController {
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
|
||||
@PostMapping("/flow")
|
||||
public ResponseEntity<String> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OapiModels.AttachmentRow> 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<OapiModels.RelatedItemRow> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 单调丢弃乱序旧事件,每日对账兜底。
|
||||
*
|
||||
* <p>幂等键: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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 提升为独立列。
|
||||
*
|
||||
* <p>主键 id 语义(V2.0 C.2"id 语义"):CMS 迁移时**原值 identity insert**——CMS 门户指南页
|
||||
* 路由 /sv/{itemId} 直接携带本表主键;本应用自增起点须大于迁移 max(id)。
|
||||
*
|
||||
* <p>索引声明口径:带排序方向的门户列表索引(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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 回调为<b>持续重试</b>(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<ServiceApply> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ItemRow> 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<FilterOption> objects = new ArrayList<>();
|
||||
private List<FilterOption> categories = new ArrayList<>();
|
||||
private List<FilterOption> 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<AttachmentRow> attachments = new ArrayList<>();
|
||||
private List<RelatedItemRow> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SvcDeptVo> 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<Integer> 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<ApplyAttachmentItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<ServiceApply, Integer>,
|
||||
JpaSpecificationExecutor<ServiceApply> {
|
||||
|
||||
List<ServiceApply> findBySchIdAndSubmitterIdOrderByCreateTimeDesc(Long schId, Integer submitterId, Pageable pageable);
|
||||
|
||||
Optional<ServiceApply> findByIdAndSchId(Integer id, Long schId);
|
||||
|
||||
Optional<ServiceApply> findBySchIdAndFlowInstanceId(Long schId, Long flowInstanceId);
|
||||
|
||||
/** 回调侧行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新 */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select a from ServiceApply a where a.id = :id")
|
||||
Optional<ServiceApply> findByIdForUpdate(@Param("id") Integer id);
|
||||
|
||||
/** 对账:在途办件(待提交/审批中) */
|
||||
List<ServiceApply> findBySchIdAndFlowStatusIn(Long schId, List<Integer> statuses);
|
||||
|
||||
/** 全租户对账:审批中的在途办件(分页上限防御) */
|
||||
List<ServiceApply> findByFlowStatus(Integer flowStatus, Pageable pageable);
|
||||
|
||||
/** 待提交且已产生 flowInstanceId 之外的挂起单(代发起未返回的重试候选) */
|
||||
List<ServiceApply> 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);
|
||||
}
|
||||
@@ -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<ServiceAttachment, Integer> {
|
||||
|
||||
List<ServiceAttachment> findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(Long schId, String topicType,
|
||||
Integer topicId);
|
||||
|
||||
List<ServiceAttachment> findBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
|
||||
|
||||
List<ServiceAttachment> findAllById(Iterable<Integer> ids);
|
||||
|
||||
void deleteBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
|
||||
}
|
||||
@@ -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<ServiceDict, Integer> {
|
||||
|
||||
List<ServiceDict> findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(Long schId, String siteCode,
|
||||
String dictType);
|
||||
|
||||
List<ServiceDict> findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceDict> findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceDict> 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);
|
||||
}
|
||||
@@ -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<ServiceFlowEvent, Integer> {
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@@ -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<ServiceFlowReg, Integer> {
|
||||
|
||||
Optional<ServiceFlowReg> findBySchIdAndFlowGuid(Long schId, String flowGuid);
|
||||
|
||||
Optional<ServiceFlowReg> findByFlowGuid(String flowGuid);
|
||||
|
||||
void deleteBySchIdAndFlowGuid(Long schId, String flowGuid);
|
||||
}
|
||||
@@ -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<ServiceForm, Integer> {
|
||||
|
||||
List<ServiceForm> findBySchIdOrderByCodeAscVersionDescIdAsc(Long schId);
|
||||
|
||||
List<ServiceForm> findBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
|
||||
|
||||
Optional<ServiceForm> findByIdAndSchId(Integer id, Long schId);
|
||||
|
||||
Optional<ServiceForm> findBySchIdAndCodeAndVersion(Long schId, String code, Integer version);
|
||||
|
||||
/** 事项绑定校验/发起取数:code 的最新已发布版本 */
|
||||
Optional<ServiceForm> findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(Long schId, String code, Integer status);
|
||||
|
||||
boolean existsBySchIdAndCode(Long schId, String code);
|
||||
|
||||
/** 事项引用计数(删除守护:任一版本被绑定即算被引用,按 code 判) */
|
||||
long countBySchIdAndCode(Long schId, String code);
|
||||
}
|
||||
@@ -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<ServiceItem, Integer>,
|
||||
JpaSpecificationExecutor<ServiceItem> {
|
||||
|
||||
Optional<ServiceItem> findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceItem> 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<ServiceItem> findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(Long schId, String siteCode,
|
||||
Integer status, String categoryKey,
|
||||
Integer id, Pageable pageable);
|
||||
|
||||
/** 管理端选择器/部门名同步:全状态按排序取(条数由 Pageable 控制) */
|
||||
List<ServiceItem> findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode, Pageable pageable);
|
||||
|
||||
List<ServiceItem> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> countGroupByCategoryKey(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
}
|
||||
@@ -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<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private static final long ASSIGNEE_CACHE_MS = 60_000L;
|
||||
|
||||
private final FlowClient flowClient;
|
||||
|
||||
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CachedAssignees {
|
||||
final List<Integer> userIds;
|
||||
final long expireAt;
|
||||
|
||||
CachedAssignees(List<Integer> 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<Integer> currentAssignees(ServiceApply apply) {
|
||||
long now = System.currentTimeMillis();
|
||||
CachedAssignees cached = assigneeCache.get(apply.getFlowInstanceId());
|
||||
if (cached != null && cached.expireAt > now) {
|
||||
return cached.userIds;
|
||||
}
|
||||
List<Integer> assignees = Collections.emptyList();
|
||||
try {
|
||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||
assignees = (List<Integer>) 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, "办件不存在或无权查看");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<SvcModels.FlowSummaryVo> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SvcModels.SiteOptionVo> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<SvcItemAttachmentVo> guideAttachments(Long schId, Integer itemId) {
|
||||
List<SvcItemAttachmentVo> 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<Integer> attachmentIds) {
|
||||
Set<Integer> keep = attachmentIds == null ? new HashSet<>() : new HashSet<>(attachmentIds);
|
||||
List<ServiceAttachment> bound = attachmentRepository
|
||||
.findBySchIdAndTopicTypeAndTopicId(schId, TOPIC_GUIDE, itemId);
|
||||
List<ServiceAttachment> 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<ServiceAttachment> 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<com.yunhai.svchall.model.svc.SvcModels.ApplyAttachmentItem> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ServiceAttachment> 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<SvcItemAttachmentVo> applyAttachments(Long schId, Integer applyId) {
|
||||
List<SvcItemAttachmentVo> 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;
|
||||
}
|
||||
}
|
||||
@@ -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(权限校验绝不放行)。
|
||||
*
|
||||
* <p>必须走 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<String, List<SvcModels.SiteOptionVo>> 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<SvcModels.SiteOptionVo> 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<SvcModels.SiteOptionVo> listSites(Long schId) {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "schId 不得为空");
|
||||
}
|
||||
List<SvcModels.SiteOptionVo> 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<SvcModels.SiteOptionVo> fetchSites(Long schId) {
|
||||
Map<String, String> 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";
|
||||
}
|
||||
@@ -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 同构)。
|
||||
* <b>返回语义(契约冻结,免对账核心)</b>:瞬时失败(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 <b>严格小于</b>已消费版本才丢弃——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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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,方法全部抛"流程中心未接入"。
|
||||
*
|
||||
* <p>必须走 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<SvcModels.FlowSummaryVo> 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<String, Object> 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<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId) {
|
||||
requireEnabled();
|
||||
Map<String, String> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<ServiceApply> spec = (root, cq, cb) -> {
|
||||
List<Predicate> 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<ServiceApply> result = applyRepository.findAll(spec, pageable);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", result.getTotalElements());
|
||||
List<ApplyVo> 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<String, Object> 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<String, Object> 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<String, Object> 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<ServiceApply> spec = (root, cq, cb) -> {
|
||||
List<Predicate> 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<ServiceApply> result = applyRepository.findAll(spec, pageable);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", result.getTotalElements());
|
||||
List<ApplyVo> 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<com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo> attachments) {
|
||||
Map<String, Object> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SvcDictVo> 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<ServiceDict> dicts = filterType
|
||||
? dictRepository.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), siteCode, dictType.trim())
|
||||
: dictRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(site.getSchId(), siteCode);
|
||||
Map<String, Long> objectRefs = countMap(itemRepository.countGroupByObjectKey(site.getSchId(), siteCode));
|
||||
Map<String, Long> categoryRefs = countMap(itemRepository.countGroupByCategoryKey(site.getSchId(), siteCode));
|
||||
List<SvcDictVo> result = new ArrayList<>();
|
||||
for (ServiceDict dict : dicts) {
|
||||
Map<String, Long> refs = ServiceDictTypeEnum.CATEGORY.getKey().equals(dict.getDictType())
|
||||
? categoryRefs : objectRefs;
|
||||
result.add(SvcDictVo.of(dict, refs.get(dict.getDictKey())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@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<String, String> nameMap(Long schId, String siteCode, ServiceDictTypeEnum type) {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
for (ServiceDict dict : dictRepository
|
||||
.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(schId, siteCode, type.getKey())) {
|
||||
names.put(dict.getDictKey(), dict.getDictName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 启用字典(oapi 筛选/选择器口径:停用不出现在筛选与选择器) */
|
||||
public List<ServiceDict> enabledDicts(Long schId, String siteCode, ServiceDictTypeEnum type) {
|
||||
List<ServiceDict> 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<String, Long> countMap(List<Object[]> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<SvcFormVo> list(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<ServiceForm> forms = formRepository.findBySchIdOrderByCodeAscVersionDescIdAsc(site.getSchId());
|
||||
List<SvcFormVo> 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<SvcFormVo> versions(BoaUser user, String siteCode, String code) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<SvcFormVo> 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<ServiceForm> 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<ServiceForm> versions = formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), code);
|
||||
formRepository.deleteAll(versions);
|
||||
}
|
||||
|
||||
/** 发起取数:code 的最新已发布版本(无 → Optional.empty) */
|
||||
public Optional<ServiceForm> latestPublished(Long schId, String code) {
|
||||
return formRepository.findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(schId, code, 1);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<ServiceItem> spec = (root, cq, cb) -> {
|
||||
List<Predicate> 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<String, String> objectNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.OBJECT);
|
||||
Map<String, String> categoryNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY);
|
||||
Page<ServiceItem> page = itemRepository.findAll(spec, pageable);
|
||||
List<SvcItemPageItem> items = new ArrayList<>();
|
||||
for (ServiceItem item : page.getContent()) {
|
||||
items.add(SvcItemPageItem.of(item,
|
||||
objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey())));
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", page.getTotalElements());
|
||||
result.put("list", items);
|
||||
return result;
|
||||
}
|
||||
|
||||
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 时确保<b>有效</b>回调注册存在
|
||||
* (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<SvcItemSimpleVo> adminSelList(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<ServiceItem> 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<SvcItemSimpleVo> result = new ArrayList<>();
|
||||
for (ServiceItem item : items) {
|
||||
result.add(SvcItemSimpleVo.of(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminSort(BoaUser user, String siteCode, List<IdSortItem> items) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
for (IdSortItem pair : items == null ? new ArrayList<IdSortItem>() : items) {
|
||||
ServiceItem item = requireItem(site, pair.getId());
|
||||
item.setSortNo(pair.getSortNo());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步部门名快照(承袭 CMS syncDeptName):按组树刷新全站 dept_name,返回更新条数 */
|
||||
@Transactional
|
||||
public Map<String, Object> syncDeptName(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||
collectDeptNodes(deptRootOfSite(site), deptNames);
|
||||
if (deptNames.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名");
|
||||
}
|
||||
int updated = 0;
|
||||
List<ServiceItem> 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<String, Object> result = new HashMap<>();
|
||||
result.put("updated", updated);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 服务部门选项(本站所属学校子树内、容器组下的全部子节点;平台不可达降级空列表) */
|
||||
public List<SvcDeptVo> deptTree(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
return buildDeptTree(deptRootOfSite(site));
|
||||
}
|
||||
|
||||
/** 管理端:事项当前绑定的指南附件(编辑回显) */
|
||||
public List<SvcItemAttachmentVo> 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<ServiceItem> result = itemRepository.findAll((root, cq, cb) -> {
|
||||
List<Predicate> 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<String, String> 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<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode));
|
||||
Map<String, Long> 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<ServiceItem> 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<OapiModels.AttachmentRow> attachmentsByItemId(Integer itemId) {
|
||||
List<OapiModels.AttachmentRow> 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<OapiModels.RelatedItemRow> relatedByItemId(Integer itemId, Integer limit) {
|
||||
List<OapiModels.RelatedItemRow> 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<ServiceItem> 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<Integer, String> 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<SvcDeptVo> buildDeptTree(BoaGroup root) {
|
||||
List<SvcDeptVo> top = new ArrayList<>();
|
||||
if (root == null) {
|
||||
return top;
|
||||
}
|
||||
for (BoaGroup container : containersOf(root)) {
|
||||
List<SvcDeptVo> children = toDeptVos(container.getChildren());
|
||||
if (children.isEmpty()) {
|
||||
top.add(new SvcDeptVo(container.getId(), container.getName()));
|
||||
} else {
|
||||
top.addAll(children);
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private List<BoaGroup> containersOf(BoaGroup root) {
|
||||
List<BoaGroup> out = new ArrayList<>();
|
||||
collectContainers(root, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectContainers(BoaGroup node, List<BoaGroup> 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<SvcDeptVo> toDeptVos(List<BoaGroup> nodes) {
|
||||
List<SvcDeptVo> 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<Integer, String> 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<Integer, String> 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<String, Long> countMap(List<Object[]> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> PHASE1_TYPES = new HashSet<>(Arrays.asList(
|
||||
"text", "textarea", "number", "date", "select", "radio", "upload"));
|
||||
|
||||
/** 声明式校验白名单属性(其余属性一律拒绝,防契约外语义渗入) */
|
||||
private static final Set<String> 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<String> 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<String> keys) {
|
||||
if (!field.isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段必须是对象");
|
||||
}
|
||||
Iterator<String> 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<String, JsonNode> 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<String, JsonNode> 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<String> it = data.fieldNames();
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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):
|
||||
* <b>注册探活 ping 直接 200</b>(探活签名是字面量 "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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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<ServiceApply> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
@@ -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 互为咬合哨兵):
|
||||
* <ul>
|
||||
* <li>resolveSite:不带 schId 拉全量,data[] 元素键=siteCode/name/schId;</li>
|
||||
* <li>listSites:透传 schId query;</li>
|
||||
* <li>fail-closed:CMS 不可达 → resolveSite=null / listSites 抛业务异常。</li>
|
||||
* </ul>
|
||||
* 若 CMS 改出参键名/形态,本类与 SiteOapiIT 至少一边红——两仓同步改。
|
||||
*/
|
||||
@DisplayName("CmsSiteDirectoryImpl - CMS /oapi/site/list 契约咬合")
|
||||
class CmsSiteDirectoryImplTest {
|
||||
|
||||
private static final String PATH = "/oapi/site/list";
|
||||
private static final Class<SvcModels.SiteOptionVo> 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<SvcModels.SiteOptionVo> 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<SvcModels.SiteOptionVo> 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));
|
||||
}
|
||||
}
|
||||
@@ -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 重复/并发重复跳过;<b>乱序闸门严格小于(同版本事件不丢)</b>;
|
||||
* 按 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, "");
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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(),
|
||||
"出站失败保持原态,可再次重提");
|
||||
}
|
||||
}
|
||||
@@ -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<ServiceFlowReg> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class);
|
||||
verify(flowRegRepository).save(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
}
|
||||
@@ -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("必填");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user