feat: 办事大厅后端源码首次入库(事项/字典/表单/申报 + 流程回调)
build / build-push (push) Failing after 39s

This commit is contained in:
chenxing
2026-09-15 11:00:00 +08:00
parent 1eeb7a4276
commit d47951abd7
70 changed files with 7569 additions and 0 deletions
@@ -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();
}
};
}
}
+110
View File
@@ -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("必填");
}
}