This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.svchall;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 办事大厅(V2.0 独立应用版):事项库 + 自定义表单 + 办件记录。
|
||||
* 职责边界见《办事大厅独立应用与对接方案V2.0.md》Part A:
|
||||
* 向 CMS 门户供数(/oapi/svc/**,appKey 签名)、以 Notice 同款托管模式接流程中心
|
||||
* (代发起 + 回调 + 对账);CMS 只保留门户主题化展示。
|
||||
*/
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
public class SvcHallApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SvcHallApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yunhai.svchall.common;
|
||||
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.svchall.entity.BaseEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.PreUpdate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审计字段自动填充(CmsAuditListener/NoticeAuditListener 同款)。
|
||||
* 账号取 ThreadLocal 中的 CAS 会话用户;定时任务等无会话场景为 null。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SvcHallAuditListener {
|
||||
|
||||
@PrePersist
|
||||
public void prePersist(BaseEntity entity) {
|
||||
String account = currentAccount();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setCreateBy(account);
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateBy(account);
|
||||
entity.setUpdateTime(now);
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate(BaseEntity entity) {
|
||||
entity.setUpdateBy(currentAccount());
|
||||
entity.setUpdateTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private String currentAccount() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
/**
|
||||
* 文件存储配置(Notice FileStorageConfig 同款三件套之一):
|
||||
* FILE_PATH 磁盘落盘根 + svchall.file.public-base-url 对外可达基址 → FileUtil.init()。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class FileStorageConfig {
|
||||
|
||||
@Value("${FILE_PATH}")
|
||||
private String filePath;
|
||||
|
||||
@Value("${svchall.file.public-base-url}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void init() {
|
||||
FileUtil.init(filePath, publicBaseUrl);
|
||||
log.info("[svc-hall] 文件存储就绪:FILE_PATH={},publicBaseUrl={}", filePath, publicBaseUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import com.yunhai.svchall.utils.ReReadableRequestWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 流程中心回调验签(验收报告 §四.6,Notice FlowCallbackVerifyFilter 同构):
|
||||
* 只拦 /callback/*(CAS url-pattern /api/* 之外,独立于 oapi 的 appKey 签名体系)。
|
||||
* 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/
|
||||
* X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)),
|
||||
* secret 为 reg 接口按 flowGuid 下发的回调密钥(FlowCallbackService.secretOf 查找,须 status=0 且非空)。
|
||||
* <b>注册探活 ping 直接 200</b>——flow-center 探活的签名是字面量 "ping" 不可能验签通过,且实测把
|
||||
* 5xx 判为不可达(§四.6),以 ping 200 保证注册顺畅;探活只判连接级可达,无状态语义。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackFilterConfiguration {
|
||||
|
||||
/** ±5 分钟防重放窗口(对齐概览 §8.4) */
|
||||
private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L;
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<FlowCallbackVerifyFilter> flowCallbackVerifyFilter() {
|
||||
FilterRegistrationBean<FlowCallbackVerifyFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new FlowCallbackVerifyFilter());
|
||||
bean.addUrlPatterns("/callback/*");
|
||||
// 先于业务过滤器链(AppAuthFilter +10 与签名强制 +11 均在 HIGHEST_PRECEDENCE 之上)
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
|
||||
return bean;
|
||||
}
|
||||
|
||||
class FlowCallbackVerifyFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (!"POST".equalsIgnoreCase(req.getMethod())) {
|
||||
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
|
||||
return;
|
||||
}
|
||||
ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req);
|
||||
String body = wrapper.bodyString();
|
||||
// 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过)
|
||||
if (isPing(body)) {
|
||||
byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
res.setContentType("application/json");
|
||||
res.setContentLength(ok.length);
|
||||
res.setStatus(HttpServletResponse.SC_OK);
|
||||
res.getOutputStream().write(ok);
|
||||
return;
|
||||
}
|
||||
String eventId = req.getHeader("X-Flw-EventId");
|
||||
String timestamp = req.getHeader("X-Flw-Timestamp");
|
||||
String nonce = req.getHeader("X-Flw-Nonce");
|
||||
String signature = req.getHeader("X-Flw-Signature");
|
||||
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp)
|
||||
|| !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long ts = Long.parseLong(timestamp.trim());
|
||||
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp");
|
||||
return;
|
||||
}
|
||||
String flowGuid = extractFlowGuid(body);
|
||||
String secret = flowGuid == null ? null : callbackService.secretOf(flowGuid);
|
||||
if (secret == null) {
|
||||
log.warn("[svc-hall] 回调验签失败:无注册或无 secret,flowGuid={}", flowGuid);
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration");
|
||||
return;
|
||||
}
|
||||
String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body);
|
||||
if (!constantTimeEquals(expected, signature.trim())) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(wrapper, response);
|
||||
}
|
||||
|
||||
/** 注册探活判定:event=ping(flow-center 注册时发送,签名头是字面量 "ping") */
|
||||
private boolean isPing(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload != null && "ping".equals(payload.path("event").asText(null));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从推送体提取 flowGuid(验签必需的最小解析) */
|
||||
private String extractFlowGuid(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload.has("flowGuid") && !payload.get("flowGuid").isNull()
|
||||
? payload.get("flowGuid").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String hmacSha256Base64(String secret, String content) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HmacSHA256 计算失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String a, String b) {
|
||||
return java.security.MessageDigest.isEqual(
|
||||
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 本地上传目录静态映射(Notice MyWebAppConfigurer 同款;仅本地开发生效——
|
||||
* 生产走 Projman 生成的 Caddy 静态段 /svc-hall/upload/* 直出,不进应用)。
|
||||
* 有 context-path 时该映射自动变成 /svc-hall/upload/**。
|
||||
*/
|
||||
@Configuration
|
||||
public class MyWebAppConfigurer implements WebMvcConfigurer {
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${FILE_PATH}")
|
||||
private String basePath;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String root = Paths.get(basePath.replace("\\", "/")).toUri().toString();
|
||||
registry.addResourceHandler("/upload/image/**").addResourceLocations(root + "image/");
|
||||
registry.addResourceHandler("/upload/file/**").addResourceLocations(root + "file/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 开放接口签名强制(照 Cms OpenApiAuthEnforceConfiguration 范式):yh-app-auth 的
|
||||
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
|
||||
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
|
||||
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用
|
||||
* X-Flw-* 四头验签,两种签名体系互不干扰。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户面办件(/api/apply/**,CAS 登录):发起/重试/我的办件/详情。
|
||||
* 审批动作(撤回/催办/轨迹)由前端直调流程中心 /api/flow/*(同主域 CAS cookie),
|
||||
* 本控制器只管办事大厅侧的申请记录。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/apply")
|
||||
@RequiredArgsConstructor
|
||||
public class ApplyUserController {
|
||||
|
||||
private final ServiceApplyService applyService;
|
||||
|
||||
/** 发起(表单数据强校验 → 快照落库 → 代发起;流程中心未接入时落待提交态) */
|
||||
@PostMapping
|
||||
public ApplyVo submit(BoaUser authUserWithDept, @Valid @RequestBody ApplySubmitRequest request) {
|
||||
return applyService.submit(authUserWithDept, request);
|
||||
}
|
||||
|
||||
/** 重试(仅待提交态;复用同一 bizId,流程中心 uk 幂等) */
|
||||
@PostMapping("/{id}/retry")
|
||||
public ApplyVo retry(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
|
||||
return applyService.retry(authUserWithDept, id);
|
||||
}
|
||||
|
||||
/** 一键重提(仅驳回/撤回态;复用同一 bizId,flow-center R4 重启推进,轨迹跨轮连续) */
|
||||
@PostMapping("/{id}/resubmit")
|
||||
public ApplyVo resubmit(BoaUser authUserWithDept, @PathVariable("id") Integer id) {
|
||||
return applyService.resubmit(authUserWithDept, id);
|
||||
}
|
||||
|
||||
/** 我的办件 */
|
||||
@GetMapping("/my")
|
||||
public Map<String, Object> my(BoaUser authUserWithDept,
|
||||
@RequestParam(value = "flowStatus", required = false) Integer flowStatus,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return applyService.myApplies(authUserWithDept, flowStatus, page, pageSize);
|
||||
}
|
||||
|
||||
/** 详情(提交人本人或站点管理面) */
|
||||
@GetMapping("/{id}")
|
||||
public ApplyVo detail(BoaUser authUserWithDept,
|
||||
@PathVariable("id") Integer id,
|
||||
@RequestParam(value = "siteCode", required = false) String siteCode) {
|
||||
return applyService.detail(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起页数据(CAS,本校用户):事项发布态要点 + 绑定表单的已发布 schema(前端渲染动态表单)。
|
||||
* 未发布/不存在报业务错;未绑流程报"未开通在线办理"。
|
||||
*/
|
||||
@GetMapping("/item")
|
||||
public java.util.Map<String, Object> applyItem(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("itemCode") String itemCode) {
|
||||
return applyService.applyItem(authUserWithDept, siteCode, itemCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 平台代理域接口(Notice/CMS BaseApiController 同款契约):GetUserInfo / GetUserMod。
|
||||
* GetUserInfo 直接返回 CAS 会话注入的 BoaUser;GetUserMod 经 Tier1 SDK 取当前用户在
|
||||
* 本应用(appKey 按 yh.open 身份)下已授权模块并组树,是前端左侧菜单与页头用户信息的数据源。
|
||||
* 返回裸对象,由统一响应包装为 {code, msg, data}。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/base")
|
||||
public class BaseApiController {
|
||||
|
||||
/** 当前登录用户信息(页头用户名/头像) */
|
||||
@GetMapping("GetUserInfo")
|
||||
public BoaUser getUserInfo(BoaUser authUserWithDept) {
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
/** 当前用户已授权模块列表(左侧菜单树) */
|
||||
@GetMapping("GetUserMod")
|
||||
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId);
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return buildModuleTree(modules);
|
||||
}
|
||||
|
||||
/** 扁平模块列表组树(Notice UserCenterService.buildModuleTree 同构:modId/page/children) */
|
||||
private static List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
||||
Map<Integer, BoaModule> moduleMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
moduleMap.put(m.getId(), m);
|
||||
}
|
||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", m.getId());
|
||||
node.put("modId", m.getId());
|
||||
node.put("name", m.getName());
|
||||
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
|
||||
node.put("page", m.getPath() != null ? m.getPath() : "");
|
||||
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
|
||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||
node.put("usable", true);
|
||||
node.put("children", new ArrayList<>());
|
||||
nodeMap.put(m.getId(), node);
|
||||
}
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = nodeMap.get(m.getId());
|
||||
int parentId = m.getParentId() != null ? m.getParentId() : 0;
|
||||
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children =
|
||||
(List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
||||
children.add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceAttachment;
|
||||
import com.yunhai.svchall.service.impl.AttachmentService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 文件上传(/api/file/upload,Notice 上传通道契约):完整 URL 直接返回(直连访问),
|
||||
* DB 存相对段;业务绑定(指南附件/办件材料)由保存与提交动作完成。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/file")
|
||||
@RequiredArgsConstructor
|
||||
public class FileController {
|
||||
|
||||
private final AttachmentService attachmentService;
|
||||
|
||||
@PostMapping("/upload")
|
||||
public UploadVo upload(BoaUser authUserWithDept,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "type", defaultValue = "file") String type,
|
||||
@RequestParam(value = "siteCode", required = false) String siteCode) {
|
||||
ServiceAttachment attachment = attachmentService.upload(authUserWithDept, siteCode, file, type);
|
||||
UploadVo vo = new UploadVo();
|
||||
vo.setId(attachment.getId());
|
||||
vo.setFileName(attachment.getFileName());
|
||||
vo.setFileUrl(com.yunhai.svchall.utils.FileUtil.buildUrl(attachment.getFileUrl()));
|
||||
vo.setFileSize(attachment.getFileSize());
|
||||
vo.setMimeType(attachment.getMimeType());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 上传返回体(fileUrl 为完整可达地址) */
|
||||
@Data
|
||||
public static class UploadVo {
|
||||
private Integer id;
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private Long fileSize;
|
||||
private String mimeType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.service.impl.ServiceFormService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 站点选项(/api/svc/site/options):管理端站点选择器数据源,代理 CMS 站点目录
|
||||
* (/oapi/site/list,本地缓存 5 分钟)。当前用户角色学校口径;超管同样按其角色学校下发
|
||||
* (跨校站点管理走超管在该校的角色,与 CMS requireManageable 的使用方式一致)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/svc/site")
|
||||
@RequiredArgsConstructor
|
||||
public class SiteOptionsController {
|
||||
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final SiteGuard siteGuard;
|
||||
private final ServiceFormService formService;
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<com.yunhai.svchall.model.svc.SvcModels.SiteOptionVo> options(BoaUser authUserWithDept) {
|
||||
if (authUserWithDept == null || authUserWithDept.getCurrentRole() == null
|
||||
|| authUserWithDept.getCurrentRole().getSchId() == null) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "当前用户无学校归属");
|
||||
}
|
||||
Long schId = authUserWithDept.getCurrentRole().getSchId().longValue();
|
||||
return siteDirectory.listSites(schId);
|
||||
}
|
||||
|
||||
/** 站点解析校验(编辑页初始化时前端可调,确保站点可达且有权) */
|
||||
@GetMapping("/check")
|
||||
public SiteInfo check(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) {
|
||||
return siteGuard.requireSiteManager(authUserWithDept, siteCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yunhai.svchall.controller.api.svc;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplyPageQuery;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** 办件记录管理面(/api/svc/apply/**):本校在途与历史办件分页。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/svc/apply")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcApplyAdminController {
|
||||
|
||||
private final ServiceApplyService applyService;
|
||||
|
||||
@GetMapping("/page")
|
||||
public Map<String, Object> page(BoaUser authUserWithDept, ApplyPageQuery query) {
|
||||
return applyService.adminPage(authUserWithDept, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.svchall.controller.api.svc;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDeptVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDictSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDictVo;
|
||||
import com.yunhai.svchall.service.impl.ServiceDictService;
|
||||
import com.yunhai.svchall.service.impl.ServiceItemService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree,迁移自 CMS SvcDictController)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/svc")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcDictController {
|
||||
|
||||
private final ServiceDictService dictService;
|
||||
private final ServiceItemService itemService;
|
||||
|
||||
@GetMapping("/dict/list")
|
||||
public List<SvcDictVo> list(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam(value = "dictType", required = false) String dictType) {
|
||||
return dictService.list(authUserWithDept, siteCode, dictType);
|
||||
}
|
||||
|
||||
@PostMapping("/dict/save")
|
||||
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcDictSaveRequest request) {
|
||||
return dictService.save(authUserWithDept, request);
|
||||
}
|
||||
|
||||
@PostMapping("/dict/delete")
|
||||
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
|
||||
dictService.delete(authUserWithDept, request);
|
||||
}
|
||||
|
||||
/** 服务部门选项(UserBase 组树代理;平台不可达降级空列表) */
|
||||
@GetMapping("/dept/tree")
|
||||
public List<SvcDeptVo> deptTree(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.deptTree(authUserWithDept, siteCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yunhai.svchall.controller.api.svc;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.FlowSummaryVo;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程选择器(/api/svc/flow/**):事项编辑页"办理流程"数据源,代理流程中心
|
||||
* /oapi/flow/list。流程中心未接入(svchall.flow.enabled=false)时抛业务异常,
|
||||
* 前端据此降级提示(事项只能配为线下/纯指南)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/svc/flow")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcFlowController {
|
||||
|
||||
private final FlowClient flowClient;
|
||||
private final SiteGuard siteGuard;
|
||||
private final SiteDirectory siteDirectory;
|
||||
|
||||
@GetMapping("/list")
|
||||
public List<FlowSummaryVo> list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) {
|
||||
siteGuard.requireSiteManager(authUserWithDept, siteCode);
|
||||
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
return flowClient.listFlows(site.getSchId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.svchall.controller.api.svc;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo;
|
||||
import com.yunhai.svchall.service.impl.ServiceFormService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 自定义表单管理(/api/svc/form/**,V2.0 Part B.3):列表/版本轨迹/保存(发布版自动升版)/
|
||||
* 发布(静态校验)/删除(被绑定即拦)。表单为 sch 级,端点仍以 siteCode 定位做权限收口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/svc/form")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcFormController {
|
||||
|
||||
private final ServiceFormService formService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public List<SvcFormVo> list(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode) {
|
||||
return formService.list(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
@GetMapping("/versions")
|
||||
public List<SvcFormVo> versions(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("code") String code) {
|
||||
return formService.versions(authUserWithDept, siteCode, code);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
public SvcFormVo get(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("id") Integer id) {
|
||||
return formService.get(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
public Integer save(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@Valid @RequestBody SvcFormSaveRequest request) {
|
||||
return formService.save(authUserWithDept, siteCode, request);
|
||||
}
|
||||
|
||||
@PostMapping("/publish/{id}")
|
||||
public void publish(BoaUser authUserWithDept,
|
||||
@PathVariable("id") Integer id,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
formService.publish(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
public void delete(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("code") String code) {
|
||||
formService.delete(authUserWithDept, siteCode, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.yunhai.svchall.controller.api.svc;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.IdSortItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageQuery;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSimpleVo;
|
||||
import com.yunhai.svchall.service.impl.ServiceItemService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 事项管理(/api/svc/item/**,迁移自 CMS SvcItemController;siteId → siteCode)。
|
||||
* 权限:站点管理面(SiteGuard:超管或本校角色,站点经 CMS 目录解析 fail-closed)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/svc/item")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcItemController {
|
||||
|
||||
private final ServiceItemService itemService;
|
||||
|
||||
@GetMapping("/page")
|
||||
public Map<String, Object> page(BoaUser authUserWithDept, SvcItemPageQuery query) {
|
||||
return itemService.adminPage(authUserWithDept, query);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
public SvcItemEditVo get(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("id") Integer id) {
|
||||
return itemService.adminGet(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
/** 保存(新建草稿/编辑;富文本 jsoup 清洗;flowGuid 绑定校验:onlineFlag=1 ⇒ 必绑流程) */
|
||||
@PostMapping("/save")
|
||||
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcItemSaveRequest request) {
|
||||
return itemService.adminSave(authUserWithDept, request);
|
||||
}
|
||||
|
||||
@PostMapping("/publish/{id}")
|
||||
public void publish(BoaUser authUserWithDept,
|
||||
@PathVariable("id") Integer id,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
itemService.adminPublish(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
@PostMapping("/offline/{id}")
|
||||
public void offline(BoaUser authUserWithDept,
|
||||
@PathVariable("id") Integer id,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
itemService.adminOffline(authUserWithDept, siteCode, id);
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
|
||||
itemService.adminDelete(authUserWithDept, request);
|
||||
}
|
||||
|
||||
@PostMapping("/sort")
|
||||
public void sort(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestBody List<IdSortItem> items) {
|
||||
itemService.adminSort(authUserWithDept, siteCode, items);
|
||||
}
|
||||
|
||||
/** 同步部门名快照(UserBase 组树改名后刷新 dept_name) */
|
||||
@PostMapping("/syncDeptName")
|
||||
public Map<String, Object> syncDeptName(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.syncDeptName(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
@GetMapping("/selList")
|
||||
public List<SvcItemSimpleVo> selList(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
return itemService.adminSelList(authUserWithDept, siteCode);
|
||||
}
|
||||
|
||||
/** 指南附件回显(编辑页列表) */
|
||||
@GetMapping("/attList")
|
||||
public List<SvcItemAttachmentVo> attList(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@RequestParam("id") Integer id) {
|
||||
return itemService.itemAttachments(authUserWithDept, siteCode, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.svchall.controller.callback;
|
||||
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 流程中心回调接收端点(/callback/flow;不在 CAS pattern(/api/*)内、也不在 /oapi/*
|
||||
* (appKey 签名)内——独立走 FlowCallbackVerifyFilter 的 X-Flw-* 四头验签后才到达这里)。
|
||||
* <p>
|
||||
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
||||
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
||||
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
|
||||
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平
|
||||
* (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/callback")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackController {
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
|
||||
@PostMapping("/flow")
|
||||
public ResponseEntity<String> flow(@RequestBody byte[] body) {
|
||||
try {
|
||||
callbackService.process(new String(body, StandardCharsets.UTF_8));
|
||||
return ResponseEntity.ok("{\"ok\":true}");
|
||||
} catch (FlowCallbackService.TransientCallbackException e) {
|
||||
log.error("[svc-hall] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
} catch (Exception e) {
|
||||
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
|
||||
log.error("[svc-hall] 回调意外异常(回 5xx 触发流程中心重试)", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.yunhai.svchall.controller.oapi.svc;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.model.oapi.OapiModels;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.impl.ServiceItemService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 门户供数开放接口(/oapi/svc/**,appKey 签名访问,消费方=CMS;V2.0 Part C.2 契约冻结件)。
|
||||
* 错误纪律:未签名 401(RequireAppSignFilter);缺参/站点或事项不存在/未发布 →
|
||||
* HTTP 200 + 信封 code=2(BizException);detail 支持 itemId/itemCode 双键。
|
||||
* 站点解析 fail-closed:站点目录不可达按"站点不存在"报 code=2,不降级全局查询。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oapi/svc")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcOapiController {
|
||||
|
||||
private final ServiceItemService itemService;
|
||||
private final SiteDirectory siteDirectory;
|
||||
|
||||
/** 服务中心列表(分页 + 三维筛选 + 关键词;URL 由 CMS 生成,接口不返回 url) */
|
||||
@GetMapping("/items")
|
||||
public OapiModels.ListData items(@RequestParam(value = "siteCode", required = false) String siteCode,
|
||||
@RequestParam(value = "objectKey", required = false) String objectKey,
|
||||
@RequestParam(value = "categoryKey", required = false) String categoryKey,
|
||||
@RequestParam(value = "deptGroupId", required = false) Integer deptGroupId,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
SiteDirectory.SiteInfo site = requireSite(siteCode);
|
||||
return itemService.oapiItems(site.getSchId(), site.getSiteCode(), emptyToNull(objectKey),
|
||||
emptyToNull(categoryKey), deptGroupId, emptyToNull(keyword), page, pageSize);
|
||||
}
|
||||
|
||||
/** 三组筛选项 + 计数(已发布口径) */
|
||||
@GetMapping("/filters")
|
||||
public OapiModels.Filters filters(@RequestParam(value = "siteCode", required = false) String siteCode) {
|
||||
SiteDirectory.SiteInfo site = requireSite(siteCode);
|
||||
return itemService.oapiFilters(site.getSchId(), site.getSiteCode());
|
||||
}
|
||||
|
||||
/** 指南页详情(itemId 或 itemCode 二选一;三段 HTML/封面/附件已绝对化;含关联事项与 applyUrl) */
|
||||
@GetMapping("/item/detail")
|
||||
public OapiModels.Detail detail(@RequestParam(value = "siteCode", required = false) String siteCode,
|
||||
@RequestParam(value = "itemId", required = false) Integer itemId,
|
||||
@RequestParam(value = "itemCode", required = false) String itemCode) {
|
||||
SiteDirectory.SiteInfo site = requireSite(siteCode);
|
||||
if (itemId == null && !StringUtils.hasText(itemCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "itemId/itemCode 不得同时为空");
|
||||
}
|
||||
OapiModels.Detail detail = itemService.oapiDetail(site.getSchId(), site.getSiteCode(),
|
||||
itemId, emptyToNull(itemCode));
|
||||
if (detail == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 浏览量累加(CMS fire-and-forget;支持 itemId 直取;找不到事项静默忽略,保持信封 code=0) */
|
||||
@PostMapping("/item/view")
|
||||
public void view(@RequestParam(value = "siteCode", required = false) String siteCode,
|
||||
@RequestParam(value = "itemCode", required = false) String itemCode,
|
||||
@RequestParam(value = "itemId", required = false) Integer itemId) {
|
||||
if (itemId != null) {
|
||||
itemService.countViewByItemId(itemId);
|
||||
return;
|
||||
}
|
||||
SiteDirectory.SiteInfo site = requireSite(siteCode);
|
||||
if (!StringUtils.hasText(itemCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "itemCode/itemId 不得同时为空");
|
||||
}
|
||||
itemService.countView(site.getSchId(), site.getSiteCode(), itemCode.trim());
|
||||
}
|
||||
|
||||
/** 指南附件(按 itemId 直取;fileUrl 已绝对化) */
|
||||
@GetMapping("/item/attachments")
|
||||
public java.util.List<OapiModels.AttachmentRow> attachments(
|
||||
@RequestParam(value = "itemId", required = false) Integer itemId) {
|
||||
if (itemId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空");
|
||||
}
|
||||
return itemService.attachmentsByItemId(itemId);
|
||||
}
|
||||
|
||||
/** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */
|
||||
@GetMapping("/item/related")
|
||||
public java.util.List<OapiModels.RelatedItemRow> related(
|
||||
@RequestParam(value = "itemId", required = false) Integer itemId,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
if (itemId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "itemId 不得为空");
|
||||
}
|
||||
return itemService.relatedByItemId(itemId, limit);
|
||||
}
|
||||
|
||||
/** 跨服务要点快照(docs/17 §1.1 契约平移;原消费方流程中心已取消,保留为对外标准口) */
|
||||
@GetMapping("/item/snapshot")
|
||||
public OapiModels.Snapshot snapshot(@RequestParam(value = "siteCode", required = false) String siteCode,
|
||||
@RequestParam(value = "itemCode", required = false) String itemCode) {
|
||||
if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode/itemCode 不得为空");
|
||||
}
|
||||
OapiModels.Snapshot vo = itemService.oapiSnapshot(siteCode.trim(), itemCode.trim());
|
||||
if (vo == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 站点解析 fail-closed:不可达或不存在一律 code=2(门户侧不应渲染未知站点) */
|
||||
private SiteDirectory.SiteInfo requireSite(String siteCode) {
|
||||
if (!StringUtils.hasText(siteCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 不得为空");
|
||||
}
|
||||
SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode.trim());
|
||||
if (site == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:" + siteCode);
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import com.yunhai.svchall.common.SvcHallAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 所有实体的公共基类:自增主键 + 审计四件套(SvcHallAuditListener 自动填充,CmsAuditListener 同款)。
|
||||
* 多租户键 sch_id 不在基类——各业务实体自带列(Notice 先例,语义不同不混入)。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(SvcHallAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "create_by", updatable = false)
|
||||
private String createBy;
|
||||
|
||||
@Column(name = "create_time", updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_by")
|
||||
private String updateBy;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 办件申请记录(V2.0 Part B.2.4)——办事大厅侧业务对象,对应 Notice 首接的 notice 行加列形态。
|
||||
* 与流程中心的隔离约定:本表保存提交快照(事项要点/表单数据/附件清单),事项后续改名/下架不影响
|
||||
* 在途与历史办件(跨库不 join);审批唯一真相在流程中心 flw_instance,本表 flow_status 为
|
||||
* 回调驱动的镜像,按 lastInstanceVersion 单调丢弃乱序旧事件,每日对账兜底。
|
||||
*
|
||||
* <p>幂等键:flow-center 侧 uk(sch_id, biz_type, biz_id),本表 bizType 固定 "svc_item"、bizId=id——
|
||||
* 重试/重提复用同一行即自动幂等。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_apply",
|
||||
indexes = {
|
||||
@Index(name = "idx_svc_apply_submitter", columnList = "sch_id, submitter_id, flow_status"),
|
||||
@Index(name = "idx_svc_apply_site", columnList = "sch_id, site_code, flow_status"),
|
||||
@Index(name = "idx_svc_apply_instance", columnList = "flow_instance_id"),
|
||||
@Index(name = "idx_svc_apply_item", columnList = "sch_id, item_id")})
|
||||
public class ServiceApply extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 站点编码(冗余,管理端筛选) */
|
||||
@Column(name = "site_code", nullable = false, length = 64)
|
||||
private String siteCode;
|
||||
|
||||
/** 归属事项(逻辑外键 → service_item.id) */
|
||||
@Column(name = "item_id", nullable = false)
|
||||
private Integer itemId;
|
||||
|
||||
/** 事项编码(冗余) */
|
||||
@Column(name = "item_code", nullable = false, length = 64)
|
||||
private String itemCode;
|
||||
|
||||
/** 事项标题快照(发起时) */
|
||||
@Column(name = "item_title", nullable = false, length = 200)
|
||||
private String itemTitle;
|
||||
|
||||
/** 事项要点快照 JSON(= biz_snapshot 内容源:title/summary/deptName/handleTimeLimit/contactPhone/attachmentNames) */
|
||||
@Column(name = "item_snapshot_json", columnDefinition = "LONGTEXT")
|
||||
private String itemSnapshotJson;
|
||||
|
||||
/** 提交时表单版本(form_code 的已发布版本号;无表单事项为 null) */
|
||||
@Column(name = "form_version")
|
||||
private Integer formVersion;
|
||||
|
||||
/** 表单提交快照 JSON({key:value};无表单模式 NULL) */
|
||||
@Column(name = "form_data_json", columnDefinition = "LONGTEXT")
|
||||
private String formDataJson;
|
||||
|
||||
/** 申请级材料清单 JSON(无表单事项的附件;[{fieldKey,fileUrl,fileName,fileSize,mimeType}]) */
|
||||
@Column(name = "attachments_json", columnDefinition = "LONGTEXT")
|
||||
private String attachmentsJson;
|
||||
|
||||
/** 提交人 id(CAS 当前登录人快照,传给 flow-center 作 submitterId) */
|
||||
@Column(name = "submitter_id", nullable = false)
|
||||
private Integer submitterId;
|
||||
|
||||
/** 提交人姓名快照 */
|
||||
@Column(name = "submitter_name", nullable = false, length = 128)
|
||||
private String submitterName;
|
||||
|
||||
/** 提交时的流程绑定 */
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程中心办件实例 id(代发起成功后回填) */
|
||||
@Column(name = "flow_instance_id")
|
||||
private Long flowInstanceId;
|
||||
|
||||
/** 镜像状态(ApplyFlowStatusEnum:0 待提交/10 审批中/20 完成/30 拒绝/40 撤回) */
|
||||
@Column(name = "flow_status", nullable = false)
|
||||
private Integer flowStatus = ApplyFlowStatusEnum.PENDING.getCode();
|
||||
|
||||
/** 已消费的最大回调 instanceVersion(乱序防护:小于等于它的回调丢弃) */
|
||||
@Column(name = "last_instance_version")
|
||||
private Integer lastInstanceVersion = 0;
|
||||
|
||||
/** 回调明细 JSON(终态事件 result/opinion、拒绝节点等,详情页展示) */
|
||||
@Column(name = "flow_result_json", columnDefinition = "LONGTEXT")
|
||||
private String flowResultJson;
|
||||
|
||||
/** 提交时间(首次代发起成功时间) */
|
||||
@Column(name = "submit_time")
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
/** 终态时间 */
|
||||
@Column(name = "finish_time")
|
||||
private LocalDateTime finishTime;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 附件(Notice up_file + 业务绑定形态,V2.0 Part B.2.5)。
|
||||
* 文件本体走 FILE_PATH 上传通道(FileUtil),本表存相对 URL 与可选业务绑定:
|
||||
* topic_type=svc_item_guide(指南附件,topic_id=itemId)/ svc_apply_field(办件材料,topic_id=applyId);
|
||||
* 封面与富文本内嵌图只存 URL 不建行(无"半绑定"归属问题——docs/19 平移教训 1 的取简解)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_attachment",
|
||||
indexes = {
|
||||
@Index(name = "idx_svc_att_topic", columnList = "sch_id, topic_type, topic_id")})
|
||||
public class ServiceAttachment extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 业务绑定类型(空=未绑定,仅上传) */
|
||||
@Column(name = "topic_type", length = 32)
|
||||
private String topicType;
|
||||
|
||||
/** 业务绑定 id(事项 id 或办件 id) */
|
||||
@Column(name = "topic_id")
|
||||
private Integer topicId;
|
||||
|
||||
/** 表单字段 key(svc_apply_field 场景:材料归属的表单字段) */
|
||||
@Column(name = "field_key", length = 64)
|
||||
private String fieldKey;
|
||||
|
||||
/** 展示文件名 */
|
||||
@Column(name = "file_name", nullable = false, length = 255)
|
||||
private String fileName;
|
||||
|
||||
/** 相对 URL(/upload/{type}/{uuid}.{ext};出参经 FileUtil.buildUrl 拼完整地址) */
|
||||
@Column(name = "file_url", nullable = false, length = 255)
|
||||
private String fileUrl;
|
||||
|
||||
/** 字节大小 */
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
/** MIME 类型 */
|
||||
@Column(name = "mime_type", length = 128)
|
||||
private String mimeType;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 事项字典表(承袭 CMS cms_service_dict):dict_key 为稳定键——被事项引用后不可改,只允许改名。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_dict",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_svc_dict",
|
||||
columnNames = {"sch_id", "site_code", "dict_type", "dict_key"}),
|
||||
indexes = @Index(name = "idx_svc_dict_list", columnList = "sch_id, site_code, dict_type, status, sort_no"))
|
||||
public class ServiceDict extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 所属站点编码 */
|
||||
@Column(name = "site_code", nullable = false, length = 64)
|
||||
private String siteCode;
|
||||
|
||||
/** object=服务对象 / category=事项类别(ServiceDictTypeEnum 收口) */
|
||||
@Column(name = "dict_type", nullable = false, length = 32)
|
||||
private String dictType;
|
||||
|
||||
/** 稳定键(站内同类型唯一;被事项引用后不可改) */
|
||||
@Column(name = "dict_key", nullable = false, length = 64)
|
||||
private String dictKey;
|
||||
|
||||
/** 展示名 */
|
||||
@Column(name = "dict_name", nullable = false, length = 128)
|
||||
private String dictName;
|
||||
|
||||
/** 排序(升序) */
|
||||
@Column(name = "sort_no", nullable = false)
|
||||
private Integer sortNo = 0;
|
||||
|
||||
/** 0 停用 / 1 启用(停用后不出现在筛选与选择器,存量引用仍可渲染) */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
/** 预留扩展 */
|
||||
@Column(name = "ext_json", columnDefinition = "JSON")
|
||||
private String extJson;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 回调事件接收日志(eventId 去重表,Notice 方案 §8.6"eventId 去重表"选型;
|
||||
* 本应用无 Redis,落库去重 + 事件留档排查)。处理幂等另有 service_apply.last_instance_version
|
||||
* 单调兜底,本表是第一道闸:同 eventId 重投直接 200 跳过。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_flow_event",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_event", columnNames = "event_id"),
|
||||
indexes = @Index(name = "idx_svc_flow_event_instance", columnList = "instance_id"))
|
||||
public class ServiceFlowEvent extends BaseEntity {
|
||||
|
||||
/** 事件唯一键(32 位 uuid,重试重放不变) */
|
||||
@Column(name = "event_id", nullable = false, length = 32)
|
||||
private String eventId;
|
||||
|
||||
/** 事件类型(flow.completed 等) */
|
||||
@Column(name = "event", nullable = false, length = 32)
|
||||
private String event;
|
||||
|
||||
/** 租户键(payload.schId) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 流程中心办件实例 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 接收时间 */
|
||||
@Column(name = "receive_time", nullable = false)
|
||||
private LocalDateTime receiveTime;
|
||||
|
||||
/** 完整推送体(排障留档) */
|
||||
@Column(name = "payload_json", columnDefinition = "LONGTEXT")
|
||||
private String payloadJson;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 流程回调注册(V2.0 Part B.2.6):管理端在事项绑定 flow_guid 时经
|
||||
* POST /oapi/flow/callback/reg 向流程中心注册并保存下发的 secret(回调签名密钥,
|
||||
* 与应用级 appSecret 分离、粒度到"应用×流程"——流程中心概览 §4.8)。
|
||||
* 同 flow_guid 多事项共享一条注册。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_flow_reg",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_svc_flow_reg", columnNames = {"sch_id", "flow_guid"}),
|
||||
indexes = @Index(name = "idx_svc_flow_reg_guid", columnList = "flow_guid"))
|
||||
public class ServiceFlowReg extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 订阅的流程 */
|
||||
@Column(name = "flow_guid", nullable = false, length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 回调接收端点(= 本应用对外可达地址 + /callback/flow) */
|
||||
@Column(name = "callback_url", nullable = false, length = 500)
|
||||
private String callbackUrl;
|
||||
|
||||
/** 订阅事件逗号分隔(空=全部;flow.submitted/task.created/task.handled/completed/rejected/withdrawn) */
|
||||
@Column(name = "event_types", length = 200)
|
||||
private String eventTypes;
|
||||
|
||||
/** reg 接口下发的回调签名密钥(HmacSHA256;不回显给前端) */
|
||||
@Column(name = "secret", nullable = false, length = 64)
|
||||
private String secret;
|
||||
|
||||
/** 0 启用 / 1 停用 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 自定义表单定义(V2.0 Part B.2.3,自流程中心 flw_form 平移——表单能力归办事大厅)。
|
||||
* 行不可变原则:已发布版本不可改,编辑已发布表单 = 克隆出新草稿版本(version+1)。
|
||||
* 事项经 form_code 绑定,发起时取**最新已发布版本**并快照 form_version。
|
||||
* schema_json 契约见 FormSchemaValidator(14 类命名冻结、声明式校验白名单、groups 分组)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_form",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_svc_form", columnNames = {"sch_id", "code", "version"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_svc_form_code", columnList = "sch_id, code, status, version"),
|
||||
@Index(name = "idx_svc_form_status", columnList = "sch_id, status")})
|
||||
public class ServiceForm extends BaseEntity {
|
||||
|
||||
/** 租户键(表单为 sch_id 级:校内跨站点共享) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 表单编码(稳定键,事项经它绑定) */
|
||||
@Column(name = "code", nullable = false, length = 64)
|
||||
private String code;
|
||||
|
||||
/** 表单名 */
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
/** 版本:发布后不可改,改定义=升版本 */
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version = 1;
|
||||
|
||||
/** 0 草稿 / 1 已发布 / 2 停用 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
|
||||
/** 表单结构 JSON({"groups":[{title,fields:[…]}]};M1 仅放行 7 类控件) */
|
||||
@Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String schemaJson;
|
||||
|
||||
/** 发布时间 */
|
||||
@Column(name = "publish_time")
|
||||
private LocalDateTime publishTime;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.yunhai.svchall.entity;
|
||||
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 事项主表(承袭 CMS cms_service_item 全字段,V2.0 Part B.2.1)。
|
||||
* 变化点:site_id → site_code(跨应用后 CMS 自增 id 无意义,sch_id 定租户);
|
||||
* 新增 form_code(绑定自定义表单)/ flow_guid(绑定流程中心流程,NULL=纯指南事项);
|
||||
* apply_url 由 ext_json 提升为独立列。
|
||||
*
|
||||
* <p>主键 id 语义(V2.0 C.2"id 语义"):CMS 迁移时**原值 identity insert**——CMS 门户指南页
|
||||
* 路由 /sv/{itemId} 直接携带本表主键;本应用自增起点须大于迁移 max(id)。
|
||||
*
|
||||
* <p>索引声明口径:带排序方向的门户列表索引(sch_id, site_code, status, sort_no ASC,
|
||||
* publish_time DESC, id DESC)含 DESC 列,Hibernate {@code @Index} 无法表达,由 sql/ 升级脚本补建。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "service_item",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_svc_item_code",
|
||||
columnNames = {"sch_id", "site_code", "item_code"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_svc_item_list", columnList = "sch_id, site_code, status, online_flag, sort_no"),
|
||||
@Index(name = "idx_svc_item_obj", columnList = "sch_id, site_code, status, object_key"),
|
||||
@Index(name = "idx_svc_item_cat", columnList = "sch_id, site_code, status, category_key"),
|
||||
@Index(name = "idx_svc_item_dept", columnList = "sch_id, site_code, status, dept_group_id"),
|
||||
@Index(name = "idx_svc_item_flow", columnList = "flow_guid"),
|
||||
@Index(name = "idx_svc_item_form", columnList = "form_code")})
|
||||
public class ServiceItem extends BaseEntity {
|
||||
|
||||
/** 租户键(= UserBase 学校 id,承袭 CMS site.sch_id 口径) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 站点编码(CMS cms_site.code 引用;办事大厅不建 site 表,站点目录经 CMS oapi 解析) */
|
||||
@Column(name = "site_code", nullable = false, length = 64)
|
||||
private String siteCode;
|
||||
|
||||
/** 事项编码,站点内唯一;跨服务键的一半(另一半是 siteCode) */
|
||||
@Column(name = "item_code", nullable = false, length = 64)
|
||||
private String itemCode;
|
||||
|
||||
/** 事项名称 */
|
||||
@Column(name = "title", nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
/** 一句话简介(列表卡片副标题、办件 biz_snapshot 摘要) */
|
||||
@Column(name = "summary", length = 500)
|
||||
private String summary;
|
||||
|
||||
/** 服务对象,指向 service_dict(dict_type=object) 的 dict_key */
|
||||
@Column(name = "object_key", length = 64)
|
||||
private String objectKey;
|
||||
|
||||
/** 事项类别,指向 service_dict(dict_type=category) 的 dict_key */
|
||||
@Column(name = "category_key", length = 64)
|
||||
private String categoryKey;
|
||||
|
||||
/** 服务部门 = UserBase 组树节点 id 快照(不建字典表) */
|
||||
@Column(name = "dept_group_id")
|
||||
private Integer deptGroupId;
|
||||
|
||||
/** 部门名快照(列表/指南免跨服务查名;改名不自动同步,靠 syncDeptName) */
|
||||
@Column(name = "dept_name", length = 128)
|
||||
private String deptName;
|
||||
|
||||
/** 办理方式:1 线上 / 2 线下 / 3 线上线上线下(HandleModeEnum) */
|
||||
@Column(name = "handle_mode", nullable = false)
|
||||
private Integer handleMode = 1;
|
||||
|
||||
/** 是否可在线办理:1 是 / 0 否。一致性校验:=1 时 flowGuid 必须非空(V2.0 B.2.1) */
|
||||
@Column(name = "online_flag", nullable = false)
|
||||
private Integer onlineFlag = 1;
|
||||
|
||||
/** 办理地点 */
|
||||
@Column(name = "handle_place", length = 200)
|
||||
private String handlePlace;
|
||||
|
||||
/** 办理时限(文本,如"3 个工作日") */
|
||||
@Column(name = "handle_time_limit", length = 64)
|
||||
private String handleTimeLimit;
|
||||
|
||||
/** 承诺时限(文本) */
|
||||
@Column(name = "promise_time_limit", length = 64)
|
||||
private String promiseTimeLimit;
|
||||
|
||||
/** 咨询电话 */
|
||||
@Column(name = "contact_phone", length = 64)
|
||||
private String contactPhone;
|
||||
|
||||
/** 监督投诉电话 */
|
||||
@Column(name = "supervise_phone", length = 64)
|
||||
private String supervisePhone;
|
||||
|
||||
/** 办理流程(保存时 jsoup 白名单清洗;库内相对 /upload/ 路径,oapi 出参绝对化) */
|
||||
@Column(name = "process_html", columnDefinition = "LONGTEXT")
|
||||
private String processHtml;
|
||||
|
||||
/** 所需材料(同上) */
|
||||
@Column(name = "material_html", columnDefinition = "LONGTEXT")
|
||||
private String materialHtml;
|
||||
|
||||
/** 注意事项(同上) */
|
||||
@Column(name = "notice_html", columnDefinition = "LONGTEXT")
|
||||
private String noticeHtml;
|
||||
|
||||
/**
|
||||
* 关联 CMS 栏目:常见问题(**不透明引用** → cms_category.id,本应用不校验存在性,
|
||||
* CMS detail 转调时解析为 URL;V2.0 Part C.2/R2)
|
||||
*/
|
||||
@Column(name = "faq_category_id")
|
||||
private Integer faqCategoryId;
|
||||
|
||||
/** 关联 CMS 栏目:公告信息 */
|
||||
@Column(name = "notice_category_id")
|
||||
private Integer noticeCategoryId;
|
||||
|
||||
/** 关联 CMS 栏目:文件规定 */
|
||||
@Column(name = "rule_category_id")
|
||||
private Integer ruleCategoryId;
|
||||
|
||||
/** 事项封面(库内相对 /upload/ 路径,出参绝对化) */
|
||||
@Column(name = "cover_image", length = 500)
|
||||
private String coverImage;
|
||||
|
||||
/** 绑定自定义表单 code(service_form.code;NULL=发起无需表单);发起时取最新已发布版本 */
|
||||
@Column(name = "form_code", length = 64)
|
||||
private String formCode;
|
||||
|
||||
/** 绑定流程中心流程(flw_def.flow_guid;NULL=纯指南事项不可在线办理) */
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** "我要办理"手填外链(http(s)://;三态优先级最高,承袭 CMS ext_json.apply_url 语义) */
|
||||
@Column(name = "apply_url", length = 500)
|
||||
private String applyUrl;
|
||||
|
||||
/** 管理端自定义排序(列表默认排序第一优先级,升序) */
|
||||
@Column(name = "sort_no", nullable = false)
|
||||
private Integer sortNo = 0;
|
||||
|
||||
/** 状态:0 草稿 / 1 已发布 / 2 已下架(ServiceItemStatusEnum) */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = ServiceItemStatusEnum.DRAFT.getCode();
|
||||
|
||||
/** 发布时间 */
|
||||
@Column(name = "publish_time")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/** 下架时间 */
|
||||
@Column(name = "offline_time")
|
||||
private LocalDateTime offlineTime;
|
||||
|
||||
/** 指南页浏览量(CMS 经 /oapi/svc/item/view 异步累加,不承诺精确) */
|
||||
@Column(name = "view_count", nullable = false)
|
||||
private Integer viewCount = 0;
|
||||
|
||||
/** 扩展字段 JSON(预留,一期不写) */
|
||||
@Column(name = "ext_json", columnDefinition = "JSON")
|
||||
private String extJson;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.svchall.enums;
|
||||
|
||||
/**
|
||||
* 办件在办事大厅侧的镜像状态(V2.0 Part B.2.4)。
|
||||
* 由流程中心回调驱动**单向推进**(按 payload.instanceVersion 丢弃乱序旧事件);
|
||||
* 数值对齐 flw_instance.status:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回,
|
||||
* 另有本地态 0=待提交(已落库但代发起尚未成功,可重试)。
|
||||
*/
|
||||
public enum ApplyFlowStatusEnum {
|
||||
|
||||
/** 待提交(本地落库,FlowClient 代发起未成功;重试复用同一 bizId) */
|
||||
PENDING(0),
|
||||
/** 审批中 */
|
||||
APPROVING(10),
|
||||
/** 已完成 */
|
||||
COMPLETED(20),
|
||||
/** 已拒绝 */
|
||||
REJECTED(30),
|
||||
/** 已撤回 */
|
||||
WITHDRAWN(40);
|
||||
|
||||
private final int code;
|
||||
|
||||
ApplyFlowStatusEnum(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static ApplyFlowStatusEnum byCode(Integer code) {
|
||||
if (code != null) {
|
||||
for (ApplyFlowStatusEnum e : values()) {
|
||||
if (e.code == code) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return PENDING;
|
||||
}
|
||||
|
||||
/** 是否为终态(终态只接受例外性回退——对账以 flow-center 为准修正) */
|
||||
public boolean terminal() {
|
||||
return this == COMPLETED || this == REJECTED || this == WITHDRAWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.svchall.enums;
|
||||
|
||||
/**
|
||||
* 办理方式(承袭 CMS HandleModeEnum)。与 onlineFlag 语义不同、允许不一致:
|
||||
* 如「线上线下」但本期未开通在线入口时 online_flag=0。
|
||||
*/
|
||||
public enum HandleModeEnum {
|
||||
|
||||
/** 线上办理 */
|
||||
ONLINE(1),
|
||||
/** 线下办理 */
|
||||
OFFLINE(2),
|
||||
/** 线上线下均可 */
|
||||
BOTH(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
HandleModeEnum(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static boolean isValid(Integer code) {
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
for (HandleModeEnum e : values()) {
|
||||
if (e.code == code) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.svchall.enums;
|
||||
|
||||
/**
|
||||
* 事项字典类型(承袭 CMS ServiceDictTypeEnum):object=服务对象、category=事项类别。
|
||||
*/
|
||||
public enum ServiceDictTypeEnum {
|
||||
|
||||
/** 服务对象 */
|
||||
OBJECT("object"),
|
||||
/** 事项类别 */
|
||||
CATEGORY("category");
|
||||
|
||||
private final String key;
|
||||
|
||||
ServiceDictTypeEnum(String key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 非法值(含 null)返回 null,由调用方决定报错口径 */
|
||||
public static ServiceDictTypeEnum byKey(String key) {
|
||||
if (key != null) {
|
||||
for (ServiceDictTypeEnum e : values()) {
|
||||
if (e.key.equals(key)) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yunhai.svchall.enums;
|
||||
|
||||
/**
|
||||
* 事项状态(承袭 CMS ServiceItemStatusEnum):草稿→发布→下架;
|
||||
* 门户可见性:仅已发布。
|
||||
*/
|
||||
public enum ServiceItemStatusEnum {
|
||||
|
||||
/** 草稿 */
|
||||
DRAFT(0),
|
||||
/** 已发布 */
|
||||
PUBLISHED(1),
|
||||
/** 已下架 */
|
||||
OFFLINE(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
ServiceItemStatusEnum(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static ServiceItemStatusEnum byCode(Integer code) {
|
||||
if (code != null) {
|
||||
for (ServiceItemStatusEnum e : values()) {
|
||||
if (e.code == code) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DRAFT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.yunhai.svchall.job;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 对账兜底 Job(降级为应急闸,V2.0 Part B.4#5 → 契约冻结 v1.4):
|
||||
* 升级后 flow-center 回调为<b>持续重试</b>(1m→5m→30m→2h→6h→60m 封顶)+ retryCount=6 挂起并
|
||||
* UserMsg 告警恰一次 + 死信管理端手工处理——消费方免每日对账(本应用 5xx 语义已对齐),
|
||||
* 故本 Job 默认停用(svchall.flow.reconcile-enabled=false);仅在大面积漂移事故应急时置 true
|
||||
* 手工开闸。开启时语义不变:拉在途办件(审批中)→ 按 biz 键查 flow-center 真实状态 →
|
||||
* 漂移以 flow-center 为准修正(对账直改状态,不写 service_flow_event 去重表与结果明细——
|
||||
* 与回调链路的差异,仅应急用不混淆镜像口径)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FlowReconcileJob {
|
||||
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final FlowClient flowClient;
|
||||
private final boolean reconcileEnabled;
|
||||
|
||||
public FlowReconcileJob(ServiceApplyRepository applyRepository, FlowClient flowClient,
|
||||
@Value("${svchall.flow.reconcile-enabled:false}") boolean reconcileEnabled) {
|
||||
this.applyRepository = applyRepository;
|
||||
this.flowClient = flowClient;
|
||||
this.reconcileEnabled = reconcileEnabled;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 4 * * ?")
|
||||
public void reconcile() {
|
||||
if (!reconcileEnabled) {
|
||||
return; // 默认停用:免对账契约下推拉主链路由回调持续重试承担
|
||||
}
|
||||
if (!flowClient.enabled()) {
|
||||
return;
|
||||
}
|
||||
// 全租户口径:审批中的在途办件(分页上限 500,量级评估:全校审批 << 千/周)
|
||||
List<ServiceApply> pending = applyRepository.findByFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode(),
|
||||
org.springframework.data.domain.PageRequest.of(0, 500));
|
||||
int fixed = 0;
|
||||
for (ServiceApply apply : pending) {
|
||||
if (apply.getFlowInstanceId() == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> remote = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (remote == null) {
|
||||
continue;
|
||||
}
|
||||
Integer remoteStatus = toInt(remote.get("status"));
|
||||
Integer remoteVersion = toInt(remote.get("version"));
|
||||
if (remoteStatus != null && !remoteStatus.equals(apply.getFlowStatus())
|
||||
&& (remoteVersion == null || remoteVersion > apply.getLastInstanceVersion())) {
|
||||
apply.setFlowStatus(remoteStatus);
|
||||
if (remoteVersion != null) {
|
||||
apply.setLastInstanceVersion(remoteVersion);
|
||||
}
|
||||
if (remoteStatus.equals(ApplyFlowStatusEnum.COMPLETED.getCode())
|
||||
|| remoteStatus.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||
|| remoteStatus.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
apply.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
applyRepository.save(apply);
|
||||
fixed++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 对账跳过 applyId={}:{}", apply.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
if (fixed > 0 || !pending.isEmpty()) {
|
||||
log.info("[svc-hall] 对账完成(应急闸开启中):在途 {} 条,修正 {} 条", pending.size(), fixed);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer toInt(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.yunhai.svchall.model.oapi;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 开放接口(供 CMS)出参模型收口(V2.0 Part C.2 契约冻结件)。
|
||||
* 平台统一响应信封 {code,msg,data} 由 starter 包装,本类只定义 data 部分。
|
||||
* 签名/信封纪律:未签名 401(RequireAppSignFilter);缺参/不存在/未发布 → HTTP 200 + code=2。
|
||||
*/
|
||||
public final class OapiModels {
|
||||
|
||||
private OapiModels() {
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/items 行(id 为办事大厅主键——CMS 门户 /sv/{id} 路由直接携带) */
|
||||
@Data
|
||||
public static class ItemRow {
|
||||
private Integer id;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String coverImage;
|
||||
private String deptName;
|
||||
private String categoryName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private LocalDateTime publishTime;
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/items 出参 */
|
||||
@Data
|
||||
public static class ListData {
|
||||
private Long total = 0L;
|
||||
private Integer page = 1;
|
||||
private Integer pageSize = 10;
|
||||
private Integer totalPages = 0;
|
||||
private List<ItemRow> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 筛选项(key 或 id 二选一:object/category 用 key,dept 用组 id) */
|
||||
@Data
|
||||
public static class FilterOption {
|
||||
private final String key;
|
||||
private final Integer id;
|
||||
private final String name;
|
||||
private final long count;
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/filters 出参 */
|
||||
@Data
|
||||
public static class Filters {
|
||||
private List<FilterOption> objects = new ArrayList<>();
|
||||
private List<FilterOption> categories = new ArrayList<>();
|
||||
private List<FilterOption> depts = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 指南附件(fileUrl 为完整可达地址) */
|
||||
@Data
|
||||
public static class AttachmentRow {
|
||||
private Integer id;
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private Long fileSize;
|
||||
}
|
||||
|
||||
/** 关联事项行 */
|
||||
@Data
|
||||
public static class RelatedItemRow {
|
||||
private Integer id;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/item/detail 出参(三段 HTML 与封面已绝对化) */
|
||||
@Data
|
||||
public static class Detail {
|
||||
private Integer id;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String objectKey;
|
||||
private String objectName;
|
||||
private String categoryKey;
|
||||
private String categoryName;
|
||||
private Integer deptGroupId;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private String handlePlace;
|
||||
private String handleTimeLimit;
|
||||
private String promiseTimeLimit;
|
||||
private String contactPhone;
|
||||
private String supervisePhone;
|
||||
private String processHtml;
|
||||
private String materialHtml;
|
||||
private String noticeHtml;
|
||||
private String coverImage;
|
||||
private LocalDateTime publishTime;
|
||||
private Integer viewCount;
|
||||
/** 关联 CMS 栏目(不透明引用,CMS 侧解析为 URL) */
|
||||
private Integer faqCategoryId;
|
||||
private Integer noticeCategoryId;
|
||||
private Integer ruleCategoryId;
|
||||
private List<AttachmentRow> attachments = new ArrayList<>();
|
||||
private List<RelatedItemRow> relatedItems = new ArrayList<>();
|
||||
/** 办事大厅发起页地址({front-base}/#/apply?siteCode=&itemCode=;CMS 三态兜底用) */
|
||||
private String applyUrl;
|
||||
/** 绑定流程 GUID(§3.7:CMS"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空;null=未绑流程) */
|
||||
private String flowGuid;
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/item/snapshot 出参(docs/17 §1.1 契约原样平移,11 字段) */
|
||||
@Data
|
||||
public static class Snapshot {
|
||||
private String siteCode;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private String handleTimeLimit;
|
||||
private String contactPhone;
|
||||
private String applyUrl;
|
||||
private LocalDateTime publishTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.yunhai.svchall.model.svc;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 办事大厅业务模型收口(管理端/用户面 DTO 与 VO;嵌套静态类收口减少文件数)。
|
||||
* oapi(供 CMS)模型另见 model/oapi/OapiModels。
|
||||
*/
|
||||
public final class SvcModels {
|
||||
|
||||
private SvcModels() {
|
||||
}
|
||||
|
||||
// ===== 通用 =====
|
||||
|
||||
/** 删除入参(siteCode 定位站点 + id) */
|
||||
@Data
|
||||
public static class DeleteRequest {
|
||||
@NotBlank(message = "siteCode 不得为空")
|
||||
private String siteCode;
|
||||
@NotNull(message = "id 不得为空")
|
||||
private Integer id;
|
||||
}
|
||||
|
||||
/** 排序项 */
|
||||
@Data
|
||||
public static class IdSortItem {
|
||||
@NotNull(message = "id 不得为空")
|
||||
private Integer id;
|
||||
@NotNull(message = "sortNo 不得为空")
|
||||
private Integer sortNo;
|
||||
}
|
||||
|
||||
/** 站点选项(CMS 站点目录代理,管理端站点选择器) */
|
||||
@Data
|
||||
public static class SiteOptionVo {
|
||||
private String siteCode;
|
||||
private String name;
|
||||
private Long schId;
|
||||
}
|
||||
|
||||
// ===== 字典 =====
|
||||
|
||||
@Data
|
||||
public static class SvcDictSaveRequest {
|
||||
private Integer id;
|
||||
@NotBlank(message = "siteCode 不得为空")
|
||||
private String siteCode;
|
||||
@NotBlank(message = "字典类型不得为空")
|
||||
private String dictType;
|
||||
@NotBlank(message = "字典键不得为空")
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]{1,64}$", message = "字典键仅限字母/数字/下划线/中划线")
|
||||
private String dictKey;
|
||||
@NotBlank(message = "字典名称不得为空")
|
||||
@Size(max = 128, message = "字典名称过长")
|
||||
private String dictName;
|
||||
private Integer sortNo;
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcDictVo {
|
||||
private Integer id;
|
||||
private String siteCode;
|
||||
private String dictType;
|
||||
private String dictKey;
|
||||
private String dictName;
|
||||
private Integer sortNo;
|
||||
private Integer status;
|
||||
private Long referenced;
|
||||
|
||||
public static SvcDictVo of(com.yunhai.svchall.entity.ServiceDict d, Long referenced) {
|
||||
SvcDictVo vo = new SvcDictVo();
|
||||
vo.setId(d.getId());
|
||||
vo.setSiteCode(d.getSiteCode());
|
||||
vo.setDictType(d.getDictType());
|
||||
vo.setDictKey(d.getDictKey());
|
||||
vo.setDictName(d.getDictName());
|
||||
vo.setSortNo(d.getSortNo());
|
||||
vo.setStatus(d.getStatus());
|
||||
vo.setReferenced(referenced == null ? 0L : referenced);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务部门选项(UserBase 组树嵌套下发,承袭 CMS SvcDeptVo) */
|
||||
@Data
|
||||
public static class SvcDeptVo {
|
||||
private Integer id;
|
||||
private String name;
|
||||
private List<SvcDeptVo> children = new ArrayList<>();
|
||||
|
||||
public SvcDeptVo() {
|
||||
}
|
||||
|
||||
public SvcDeptVo(Integer id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 事项 =====
|
||||
|
||||
@Data
|
||||
public static class SvcItemPageQuery {
|
||||
@NotBlank(message = "siteCode 不得为空")
|
||||
private String siteCode;
|
||||
private Integer status;
|
||||
private String keyword;
|
||||
private String objectKey;
|
||||
private String categoryKey;
|
||||
private Integer deptGroupId;
|
||||
private Integer page = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcItemSaveRequest {
|
||||
private Integer id;
|
||||
@NotBlank(message = "siteCode 不得为空")
|
||||
private String siteCode;
|
||||
@NotBlank(message = "事项编码不得为空")
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$", message = "事项编码仅限字母/数字/下划线/中划线,2~64 位")
|
||||
private String itemCode;
|
||||
@NotBlank(message = "事项名称不得为空")
|
||||
@Size(max = 200, message = "事项名称过长")
|
||||
private String title;
|
||||
@Size(max = 500, message = "简介过长")
|
||||
private String summary;
|
||||
@Size(max = 64)
|
||||
private String objectKey;
|
||||
@Size(max = 64)
|
||||
private String categoryKey;
|
||||
private Integer deptGroupId;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
@Size(max = 200)
|
||||
private String handlePlace;
|
||||
@Size(max = 64)
|
||||
private String handleTimeLimit;
|
||||
@Size(max = 64)
|
||||
private String promiseTimeLimit;
|
||||
@Size(max = 64)
|
||||
private String contactPhone;
|
||||
@Size(max = 64)
|
||||
private String supervisePhone;
|
||||
private String processHtml;
|
||||
private String materialHtml;
|
||||
private String noticeHtml;
|
||||
private Integer faqCategoryId;
|
||||
private Integer noticeCategoryId;
|
||||
private Integer ruleCategoryId;
|
||||
@Size(max = 500)
|
||||
private String coverImage;
|
||||
/** 绑定自定义表单 code(可空;发起无需表单) */
|
||||
@Size(max = 64)
|
||||
private String formCode;
|
||||
/** 绑定流程中心流程 flow_guid(可空;onlineFlag=1 时必填) */
|
||||
@Size(max = 32)
|
||||
private String flowGuid;
|
||||
@Size(max = 500)
|
||||
private String applyUrl;
|
||||
private Integer sortNo;
|
||||
/** 指南附件 id 列表(保存时全量重绑 topic_type=svc_item_guide) */
|
||||
private List<Integer> attachmentIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcItemEditVo {
|
||||
private Integer id;
|
||||
private String siteCode;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String objectKey;
|
||||
private String categoryKey;
|
||||
private Integer deptGroupId;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private String handlePlace;
|
||||
private String handleTimeLimit;
|
||||
private String promiseTimeLimit;
|
||||
private String contactPhone;
|
||||
private String supervisePhone;
|
||||
private String processHtml;
|
||||
private String materialHtml;
|
||||
private String noticeHtml;
|
||||
private Integer faqCategoryId;
|
||||
private Integer noticeCategoryId;
|
||||
private Integer ruleCategoryId;
|
||||
private String coverImage;
|
||||
private String formCode;
|
||||
private String flowGuid;
|
||||
private String applyUrl;
|
||||
private Integer sortNo;
|
||||
private Integer status;
|
||||
private LocalDateTime publishTime;
|
||||
private LocalDateTime offlineTime;
|
||||
private Integer viewCount;
|
||||
|
||||
public static SvcItemEditVo of(com.yunhai.svchall.entity.ServiceItem item) {
|
||||
SvcItemEditVo vo = new SvcItemEditVo();
|
||||
vo.setId(item.getId());
|
||||
vo.setSiteCode(item.getSiteCode());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setObjectKey(item.getObjectKey());
|
||||
vo.setCategoryKey(item.getCategoryKey());
|
||||
vo.setDeptGroupId(item.getDeptGroupId());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setHandlePlace(item.getHandlePlace());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setSupervisePhone(item.getSupervisePhone());
|
||||
vo.setProcessHtml(item.getProcessHtml());
|
||||
vo.setMaterialHtml(item.getMaterialHtml());
|
||||
vo.setNoticeHtml(item.getNoticeHtml());
|
||||
vo.setFaqCategoryId(item.getFaqCategoryId());
|
||||
vo.setNoticeCategoryId(item.getNoticeCategoryId());
|
||||
vo.setRuleCategoryId(item.getRuleCategoryId());
|
||||
vo.setCoverImage(item.getCoverImage());
|
||||
vo.setFormCode(item.getFormCode());
|
||||
vo.setFlowGuid(item.getFlowGuid());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setSortNo(item.getSortNo());
|
||||
vo.setStatus(item.getStatus());
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
vo.setOfflineTime(item.getOfflineTime());
|
||||
vo.setViewCount(item.getViewCount());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcItemPageItem {
|
||||
private Integer id;
|
||||
private String siteCode;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String objectKey;
|
||||
private String objectName;
|
||||
private String categoryKey;
|
||||
private String categoryName;
|
||||
private Integer deptGroupId;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private String formCode;
|
||||
private String flowGuid;
|
||||
private Integer status;
|
||||
private Integer sortNo;
|
||||
private LocalDateTime publishTime;
|
||||
private Integer viewCount;
|
||||
private LocalDateTime createTime;
|
||||
private String createBy;
|
||||
|
||||
public static SvcItemPageItem of(com.yunhai.svchall.entity.ServiceItem item, String objectName,
|
||||
String categoryName) {
|
||||
SvcItemPageItem vo = new SvcItemPageItem();
|
||||
vo.setId(item.getId());
|
||||
vo.setSiteCode(item.getSiteCode());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setObjectKey(item.getObjectKey());
|
||||
vo.setObjectName(objectName);
|
||||
vo.setCategoryKey(item.getCategoryKey());
|
||||
vo.setCategoryName(categoryName);
|
||||
vo.setDeptGroupId(item.getDeptGroupId());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setFormCode(item.getFormCode());
|
||||
vo.setFlowGuid(item.getFlowGuid());
|
||||
vo.setStatus(item.getStatus());
|
||||
vo.setSortNo(item.getSortNo());
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
vo.setViewCount(item.getViewCount());
|
||||
vo.setCreateTime(item.getCreateTime());
|
||||
vo.setCreateBy(item.getCreateBy());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcItemSimpleVo {
|
||||
private Integer id;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private Integer status;
|
||||
|
||||
public static SvcItemSimpleVo of(com.yunhai.svchall.entity.ServiceItem item) {
|
||||
SvcItemSimpleVo vo = new SvcItemSimpleVo();
|
||||
vo.setId(item.getId());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setStatus(item.getStatus());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcItemAttachmentVo {
|
||||
private Integer id;
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private Long fileSize;
|
||||
|
||||
public static SvcItemAttachmentVo of(com.yunhai.svchall.entity.ServiceAttachment a) {
|
||||
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
|
||||
vo.setId(a.getId());
|
||||
vo.setFileName(a.getFileName());
|
||||
vo.setFileUrl(com.yunhai.svchall.utils.FileUtil.buildUrl(a.getFileUrl()));
|
||||
vo.setFileSize(a.getFileSize());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 表单 =====
|
||||
|
||||
@Data
|
||||
public static class SvcFormSaveRequest {
|
||||
private Integer id;
|
||||
@NotBlank(message = "表单编码不得为空")
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]{2,64}$", message = "表单编码仅限字母/数字/下划线/中划线,2~64 位")
|
||||
private String code;
|
||||
@NotBlank(message = "表单名称不得为空")
|
||||
@Size(max = 128, message = "表单名称过长")
|
||||
private String name;
|
||||
@NotBlank(message = "表单结构不得为空")
|
||||
private String schemaJson;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SvcFormVo {
|
||||
private Integer id;
|
||||
private String code;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
private String schemaJson;
|
||||
private LocalDateTime publishTime;
|
||||
private Boolean referenced;
|
||||
|
||||
public static SvcFormVo of(com.yunhai.svchall.entity.ServiceForm form, Boolean referenced) {
|
||||
SvcFormVo vo = new SvcFormVo();
|
||||
vo.setId(form.getId());
|
||||
vo.setCode(form.getCode());
|
||||
vo.setName(form.getName());
|
||||
vo.setVersion(form.getVersion());
|
||||
vo.setStatus(form.getStatus());
|
||||
vo.setSchemaJson(form.getSchemaJson());
|
||||
vo.setPublishTime(form.getPublishTime());
|
||||
vo.setReferenced(referenced);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 办件 =====
|
||||
|
||||
/** 申请级材料项(无表单事项的附件;先经 /api/file/upload 上传,再携 attachmentId 绑定) */
|
||||
@Data
|
||||
public static class ApplyAttachmentItem {
|
||||
/** 表单/材料字段标识(可空) */
|
||||
private String fieldKey;
|
||||
/** 上传接口返回的附件行 id */
|
||||
@NotNull(message = "attachmentId 不得为空")
|
||||
private Integer attachmentId;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ApplySubmitRequest {
|
||||
@NotBlank(message = "siteCode 不得为空")
|
||||
private String siteCode;
|
||||
@NotBlank(message = "itemCode 不得为空")
|
||||
private String itemCode;
|
||||
/** 表单数据 JSON 字符串({key:value};无表单事项传空) */
|
||||
private String formData;
|
||||
/** 申请级材料(可空) */
|
||||
private List<ApplyAttachmentItem> attachments;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ApplyVo {
|
||||
private Integer id;
|
||||
private String siteCode;
|
||||
private Integer itemId;
|
||||
private String itemCode;
|
||||
private String itemTitle;
|
||||
private String itemSnapshotJson;
|
||||
private Integer formVersion;
|
||||
private String formDataJson;
|
||||
private String attachmentsJson;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String flowGuid;
|
||||
private Long flowInstanceId;
|
||||
private Integer flowStatus;
|
||||
private String flowResultJson;
|
||||
private LocalDateTime submitTime;
|
||||
private LocalDateTime finishTime;
|
||||
private LocalDateTime createTime;
|
||||
|
||||
public static ApplyVo of(com.yunhai.svchall.entity.ServiceApply a) {
|
||||
ApplyVo vo = new ApplyVo();
|
||||
vo.setId(a.getId());
|
||||
vo.setSiteCode(a.getSiteCode());
|
||||
vo.setItemId(a.getItemId());
|
||||
vo.setItemCode(a.getItemCode());
|
||||
vo.setItemTitle(a.getItemTitle());
|
||||
vo.setItemSnapshotJson(a.getItemSnapshotJson());
|
||||
vo.setFormVersion(a.getFormVersion());
|
||||
vo.setFormDataJson(a.getFormDataJson());
|
||||
vo.setAttachmentsJson(a.getAttachmentsJson());
|
||||
vo.setSubmitterId(a.getSubmitterId());
|
||||
vo.setSubmitterName(a.getSubmitterName());
|
||||
vo.setFlowGuid(a.getFlowGuid());
|
||||
vo.setFlowInstanceId(a.getFlowInstanceId());
|
||||
vo.setFlowStatus(a.getFlowStatus());
|
||||
vo.setFlowResultJson(a.getFlowResultJson());
|
||||
vo.setSubmitTime(a.getSubmitTime());
|
||||
vo.setFinishTime(a.getFinishTime());
|
||||
vo.setCreateTime(a.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ApplyPageQuery {
|
||||
private String siteCode;
|
||||
private String itemCode;
|
||||
private Integer flowStatus;
|
||||
private String keyword;
|
||||
private Integer page = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
|
||||
// ===== 流程(管理端回显) =====
|
||||
|
||||
@Data
|
||||
public static class FlowSummaryVo {
|
||||
private String flowGuid;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import javax.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceApplyRepository extends JpaRepository<ServiceApply, Integer>,
|
||||
JpaSpecificationExecutor<ServiceApply> {
|
||||
|
||||
List<ServiceApply> findBySchIdAndSubmitterIdOrderByCreateTimeDesc(Long schId, Integer submitterId, Pageable pageable);
|
||||
|
||||
Optional<ServiceApply> findByIdAndSchId(Integer id, Long schId);
|
||||
|
||||
Optional<ServiceApply> findBySchIdAndFlowInstanceId(Long schId, Long flowInstanceId);
|
||||
|
||||
/** 回调侧行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新 */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select a from ServiceApply a where a.id = :id")
|
||||
Optional<ServiceApply> findByIdForUpdate(@Param("id") Integer id);
|
||||
|
||||
/** 对账:在途办件(待提交/审批中) */
|
||||
List<ServiceApply> findBySchIdAndFlowStatusIn(Long schId, List<Integer> statuses);
|
||||
|
||||
/** 全租户对账:审批中的在途办件(分页上限防御) */
|
||||
List<ServiceApply> findByFlowStatus(Integer flowStatus, Pageable pageable);
|
||||
|
||||
/** 待提交且已产生 flowInstanceId 之外的挂起单(代发起未返回的重试候选) */
|
||||
List<ServiceApply> findBySchIdAndFlowStatus(Long schId, Integer flowStatus, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 代发起成功回填(窄列原子更新,Notice markApproving 同构):
|
||||
* 置 flow_instance_id + flow_status=10;若回调已并发先行落定终态(20 完成/30 拒绝/40 撤回,
|
||||
* code 对齐 ApplyFlowStatusEnum)则保持终态不回退(验收报告 §四.2:旧快照盲写会把状态打回);
|
||||
* submitTime 仅首次回填(coalesce 保留既有值)。
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update ServiceApply a set a.flowInstanceId = :instanceId,"
|
||||
+ " a.flowStatus = (case when a.flowStatus in (20, 30, 40) then a.flowStatus else 10 end),"
|
||||
+ " a.submitTime = coalesce(a.submitTime, :submitTime)"
|
||||
+ " where a.id = :id")
|
||||
int markApproving(@Param("id") Integer id, @Param("instanceId") Long instanceId,
|
||||
@Param("submitTime") java.time.LocalDateTime submitTime);
|
||||
|
||||
/**
|
||||
* 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。
|
||||
* 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫
|
||||
* 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作,
|
||||
* submitTime 覆盖为当下、finishTime 清空。WHERE 限定 30/40,并发窗口内若状态已被推进
|
||||
* (如新一轮回调先置 10)则本次为幂等空更新。
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10,"
|
||||
+ " a.submitTime = :submitTime, a.finishTime = null"
|
||||
+ " where a.id = :id and a.flowStatus in (30, 40)")
|
||||
int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId,
|
||||
@Param("submitTime") java.time.LocalDateTime submitTime);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceAttachment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ServiceAttachmentRepository extends JpaRepository<ServiceAttachment, Integer> {
|
||||
|
||||
List<ServiceAttachment> findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(Long schId, String topicType,
|
||||
Integer topicId);
|
||||
|
||||
List<ServiceAttachment> findBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
|
||||
|
||||
List<ServiceAttachment> findAllById(Iterable<Integer> ids);
|
||||
|
||||
void deleteBySchIdAndTopicTypeAndTopicId(Long schId, String topicType, Integer topicId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceDict;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceDictRepository extends JpaRepository<ServiceDict, Integer> {
|
||||
|
||||
List<ServiceDict> findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(Long schId, String siteCode,
|
||||
String dictType);
|
||||
|
||||
List<ServiceDict> findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceDict> findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceDict> findBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType,
|
||||
String dictKey);
|
||||
|
||||
boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKey(Long schId, String siteCode, String dictType,
|
||||
String dictKey);
|
||||
|
||||
boolean existsBySchIdAndSiteCodeAndDictTypeAndDictKeyAndIdNot(Long schId, String siteCode, String dictType,
|
||||
String dictKey, Integer id);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceFlowEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ServiceFlowEventRepository extends JpaRepository<ServiceFlowEvent, Integer> {
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceFlowReg;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceFlowRegRepository extends JpaRepository<ServiceFlowReg, Integer> {
|
||||
|
||||
Optional<ServiceFlowReg> findBySchIdAndFlowGuid(Long schId, String flowGuid);
|
||||
|
||||
Optional<ServiceFlowReg> findByFlowGuid(String flowGuid);
|
||||
|
||||
void deleteBySchIdAndFlowGuid(Long schId, String flowGuid);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceForm;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceFormRepository extends JpaRepository<ServiceForm, Integer> {
|
||||
|
||||
List<ServiceForm> findBySchIdOrderByCodeAscVersionDescIdAsc(Long schId);
|
||||
|
||||
List<ServiceForm> findBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
|
||||
|
||||
Optional<ServiceForm> findByIdAndSchId(Integer id, Long schId);
|
||||
|
||||
Optional<ServiceForm> findBySchIdAndCodeAndVersion(Long schId, String code, Integer version);
|
||||
|
||||
/** 事项绑定校验/发起取数:code 的最新已发布版本 */
|
||||
Optional<ServiceForm> findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(Long schId, String code, Integer status);
|
||||
|
||||
boolean existsBySchIdAndCode(Long schId, String code);
|
||||
|
||||
/** 事项引用计数(删除守护:任一版本被绑定即算被引用,按 code 判) */
|
||||
long countBySchIdAndCode(Long schId, String code);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.yunhai.svchall.repository;
|
||||
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceItemRepository extends JpaRepository<ServiceItem, Integer>,
|
||||
JpaSpecificationExecutor<ServiceItem> {
|
||||
|
||||
Optional<ServiceItem> findByIdAndSchIdAndSiteCode(Integer id, Long schId, String siteCode);
|
||||
|
||||
Optional<ServiceItem> findBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode);
|
||||
|
||||
boolean existsBySchIdAndSiteCodeAndItemCode(Long schId, String siteCode, String itemCode);
|
||||
|
||||
boolean existsBySchIdAndSiteCodeAndItemCodeAndIdNot(Long schId, String siteCode, String itemCode, Integer id);
|
||||
|
||||
/** 字典键被事项引用计数(字典改键/停用守护用) */
|
||||
long countBySchIdAndSiteCodeAndObjectKey(Long schId, String siteCode, String objectKey);
|
||||
|
||||
long countBySchIdAndSiteCodeAndCategoryKey(Long schId, String siteCode, String categoryKey);
|
||||
|
||||
/** 关联事项:同类别已发布,排除自身(Pageable 控制条数与排序) */
|
||||
List<ServiceItem> findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(Long schId, String siteCode,
|
||||
Integer status, String categoryKey,
|
||||
Integer id, Pageable pageable);
|
||||
|
||||
/** 管理端选择器/部门名同步:全状态按排序取(条数由 Pageable 控制) */
|
||||
List<ServiceItem> findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(Long schId, String siteCode, Pageable pageable);
|
||||
|
||||
List<ServiceItem> findBySchIdAndFlowGuid(Long schId, String flowGuid);
|
||||
|
||||
/** 表单被事项绑定计数(表单删除守护,按 form_code 判) */
|
||||
long countBySchIdAndFormCode(Long schId, String formCode);
|
||||
|
||||
// ===== 门户筛选项计数(已发布口径,group by 一次往返/维度,承袭 CMS 口径)=====
|
||||
|
||||
@Query("select i.objectKey, count(i) from ServiceItem i"
|
||||
+ " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.objectKey is not null"
|
||||
+ " group by i.objectKey")
|
||||
List<Object[]> countPublishedGroupByObjectKey(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
|
||||
@Query("select i.categoryKey, count(i) from ServiceItem i"
|
||||
+ " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.categoryKey is not null"
|
||||
+ " group by i.categoryKey")
|
||||
List<Object[]> countPublishedGroupByCategoryKey(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
|
||||
@Query("select i.deptGroupId, max(i.deptName), count(i) from ServiceItem i"
|
||||
+ " where i.schId = :schId and i.siteCode = :siteCode and i.status = 1 and i.deptGroupId is not null"
|
||||
+ " group by i.deptGroupId")
|
||||
List<Object[]> countPublishedGroupByDept(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
|
||||
/** 指南页浏览量异步累加(fire-and-forget,失败忽略) */
|
||||
@Modifying
|
||||
@Query("update ServiceItem i set i.viewCount = i.viewCount + 1 where i.id = :id")
|
||||
int incrementViewCount(@Param("id") Integer id);
|
||||
|
||||
// ===== 字典引用计数(全状态口径,字典守护与列表"被引用"展示用)=====
|
||||
|
||||
@Query("select i.objectKey, count(i) from ServiceItem i"
|
||||
+ " where i.schId = :schId and i.siteCode = :siteCode and i.objectKey is not null group by i.objectKey")
|
||||
List<Object[]> countGroupByObjectKey(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
|
||||
@Query("select i.categoryKey, count(i) from ServiceItem i"
|
||||
+ " where i.schId = :schId and i.siteCode = :siteCode and i.categoryKey is not null group by i.categoryKey")
|
||||
List<Object[]> countGroupByCategoryKey(@Param("schId") Long schId, @Param("siteCode") String siteCode);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 办件详情放行守卫(BP-R2-3 修复,对齐 Notice NoticeFlowGuard 三分支口径):
|
||||
* 仅三类人可看非本人办件——管理员(type=1 或 roleId∈{2,5,7} 白名单)/ 办理人
|
||||
* (flow-center getInstanceByBiz 的 assigneeIds 含当前用户,60s 短缓存+fail-closed——
|
||||
* 审批中途换人不实时,可接受)。提交人本人在调用方先行判定(owner 短路)。
|
||||
* 其余同校普通用户一律"办件不存在或无权查看"(查不到语义,不泄露他人表单与审批意见)。
|
||||
* 注意:SiteGuard.requireSiteManager 的"同校即过"管理面口径本轮维持不动(影响 5 个管理页,
|
||||
* 已登记);本守卫是详情页专用收紧,不回退该口径。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApplyViewGuard {
|
||||
|
||||
/** 管理员角色白名单(与 Notice NoticeFlowGuard 对齐:校级管理员/分管领导等) */
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private static final long ASSIGNEE_CACHE_MS = 60_000L;
|
||||
|
||||
private final FlowClient flowClient;
|
||||
|
||||
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CachedAssignees {
|
||||
final List<Integer> userIds;
|
||||
final long expireAt;
|
||||
|
||||
CachedAssignees(List<Integer> userIds) {
|
||||
this.userIds = userIds;
|
||||
this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户可查看该办件(调用方已按 schId 定位行,即本校上下文成立)。
|
||||
* 不可见抛 FORBIDDEN(文案=查不到语义)。
|
||||
*/
|
||||
public void requireVisible(ServiceApply apply, BoaUser user) {
|
||||
if (apply == null || user == null || user.getId() == null) {
|
||||
throw notFound();
|
||||
}
|
||||
// 1) 提交人本人
|
||||
if (user.getId().equals(apply.getSubmitterId())) {
|
||||
return;
|
||||
}
|
||||
// 2) 管理员(超管 type=1 或校级管理员角色白名单——Notice NoticeFlowGuard 同款口径)
|
||||
if (isAdmin(user)) {
|
||||
return;
|
||||
}
|
||||
// 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见)
|
||||
if (apply.getFlowInstanceId() != null && flowClient.enabled()
|
||||
&& currentAssignees(apply).contains(user.getId())) {
|
||||
return;
|
||||
}
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
private boolean isAdmin(BoaUser user) {
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return true;
|
||||
}
|
||||
if (user.getRoleIds() != null) {
|
||||
for (Integer roleId : user.getRoleIds()) {
|
||||
if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Integer> currentAssignees(ServiceApply apply) {
|
||||
long now = System.currentTimeMillis();
|
||||
CachedAssignees cached = assigneeCache.get(apply.getFlowInstanceId());
|
||||
if (cached != null && cached.expireAt > now) {
|
||||
return cached.userIds;
|
||||
}
|
||||
List<Integer> assignees = Collections.emptyList();
|
||||
try {
|
||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||
assignees = (List<Integer>) instance.get("assigneeIds");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 流程中心不可达:fail-closed(不放行),日志留痕
|
||||
log.warn("[svc-hall] 办理人查询失败(fail-closed):applyId={} {}", apply.getId(), e.toString());
|
||||
}
|
||||
assigneeCache.put(apply.getFlowInstanceId(), new CachedAssignees(assignees));
|
||||
return assignees;
|
||||
}
|
||||
|
||||
private BizException notFound() {
|
||||
return new BizException(ErrorCode.FORBIDDEN, "办件不存在或无权查看");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
|
||||
/**
|
||||
* 部门组树来源接缝(Cms SvcDeptSource 原样平移):默认实现代理 UserBase oapi 静态调用
|
||||
* {@code YschBaseOpenApi.getGroupById/getGroupTree}。包一层 bean 的理由:静态方法在测试中
|
||||
* 无法替身;且隔离了外部依赖,未来平台调整只换实现。
|
||||
*/
|
||||
public interface DeptSource {
|
||||
|
||||
/**
|
||||
* 学校组子树(含该学校节点自身,children 递归全部后代)。
|
||||
* 服务部门"跨校隔离"的唯一来源:部门只在**本校**子树内取。
|
||||
*
|
||||
* @param schoolId UserBase 组 id(= 事项所属学校的 schId)
|
||||
* @return 子树根;入参为空、组不存在或平台不可达时返回 {@code null}(不抛出,降级口径由消费方决定)
|
||||
*/
|
||||
BoaGroup getGroupSubtree(Integer schoolId);
|
||||
|
||||
/**
|
||||
* 全平台组树根节点。仅用于本地未挂学校(schId 缺失)的兼容回退;正常路径不应使用。
|
||||
*
|
||||
* @return 组树根;平台不可达或异常时返回 {@code null}
|
||||
*/
|
||||
BoaGroup getGroupTree();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程中心客户端接缝(V2.0 Part B.4):Notice 同款托管模式的出站面。
|
||||
* 默认实现经共享 IDHttp(出站自动签名,yh.app-auth 身份)调 flow-center /oapi/flow/**。
|
||||
* enabled=false(本地未部署 flow-center)时所有方法抛业务异常,消费方按需降级
|
||||
* (事项不绑流程=纯指南,不经过本接口)。
|
||||
*/
|
||||
public interface FlowClient {
|
||||
|
||||
/** 流程中心是否已接入(未接入时管理端选择器降级提示,事项绑 flow_guid 校验跳过) */
|
||||
boolean enabled();
|
||||
|
||||
/** 已发布流程列表(事项编辑页"办理流程"选择器数据源) */
|
||||
List<SvcModels.FlowSummaryVo> listFlows(Long schId);
|
||||
|
||||
/** 流程详情回显(节点与办理科室摘要) */
|
||||
SvcModels.FlowSummaryVo getFlow(Long schId, String flowGuid);
|
||||
|
||||
/**
|
||||
* 代发起(POST /oapi/flow/apply,V2.0 B.4#3)。
|
||||
*
|
||||
* @return {instanceId, duplicated}(duplicated=true 表示命中 uk 幂等返回既有办件)
|
||||
*/
|
||||
ApplyResult apply(Long schId, String flowGuid, String bizType, String bizId, String bizTitle,
|
||||
String bizUrl, String bizSnapshot, Integer submitterId, String submitterName,
|
||||
String formDataJson);
|
||||
|
||||
/** 按业务键查办件(对账兜底;返回 {instanceId,status,version,nodeSummary,traceJson},查不到返回 null) */
|
||||
Map<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId);
|
||||
|
||||
/** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */
|
||||
String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes);
|
||||
|
||||
/** 代发起结果 */
|
||||
class ApplyResult {
|
||||
private final Long instanceId;
|
||||
private final boolean duplicated;
|
||||
|
||||
public ApplyResult(Long instanceId, boolean duplicated) {
|
||||
this.instanceId = instanceId;
|
||||
this.duplicated = duplicated;
|
||||
}
|
||||
|
||||
public Long getInstanceId() {
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public boolean isDuplicated() {
|
||||
return duplicated;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* CMS 站点目录接缝(V2.0 Part B):办事大厅不建 site 表,站点的 schId/名称经 CMS
|
||||
* {@code GET /oapi/site/list}(签名)解析,本地 Caffeine 缓存。用于:
|
||||
* ① 管理端权限校验(siteCode → schId 对比 currentRole.schId);
|
||||
* ② 数据落库时确定 schId;③ 管理端站点选择器。
|
||||
*/
|
||||
public interface SiteDirectory {
|
||||
|
||||
/** 站点信息(不可达/不存在返回 null——消费方 fail-closed) */
|
||||
SiteInfo resolveSite(String siteCode);
|
||||
|
||||
/** 本校可选站点列表(管理端站点选择器;不可达抛业务异常) */
|
||||
List<SvcModels.SiteOptionVo> listSites(Long schId);
|
||||
|
||||
/** fail-closed 语义:解析不到站点(或 CMS 不可达)时抛业务异常 */
|
||||
SiteInfo requireSite(String siteCode);
|
||||
|
||||
/** 站点信息 */
|
||||
class SiteInfo {
|
||||
private final String siteCode;
|
||||
private final String name;
|
||||
private final Long schId;
|
||||
|
||||
public SiteInfo(String siteCode, String name, Long schId) {
|
||||
this.siteCode = siteCode;
|
||||
this.name = name;
|
||||
this.schId = schId;
|
||||
}
|
||||
|
||||
public String getSiteCode() {
|
||||
return siteCode;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Long getSchId() {
|
||||
return schId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 站点级权限守卫(承袭 CMS SiteService.requireManageable 语义,跨应用版):
|
||||
* 超管判定 {@code BoaUser.type == 1}(UserBase 平台约定);校级按
|
||||
* {@code currentRole.schId} 与站点 schId(CMS 站点目录解析)比对。
|
||||
* 站点解析不到时 fail-closed(SiteDirectory.requireSite 抛异常),绝不静默放行。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SiteGuard {
|
||||
|
||||
/** 超管类型(UserBase sys_user.type=1 平台约定) */
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
|
||||
private final SiteDirectory siteDirectory;
|
||||
|
||||
/** 管理面:可管理该校站点(超管或本校角色),返回解析后的站点信息(含权威 schId) */
|
||||
public SiteInfo requireSiteManager(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
if (isSuperAdmin(user)) {
|
||||
return site;
|
||||
}
|
||||
Long schId = currentSchId(user);
|
||||
if (schId != null && schId.equals(site.getSchId())) {
|
||||
return site;
|
||||
}
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无该站点管理权限:" + siteCode);
|
||||
}
|
||||
|
||||
/** 用户面:本校用户才可办本校事项(超管豁免;跨校办件属脏数据,V2.0 B.4 权限细节) */
|
||||
public void requireSchoolMember(BoaUser user, Long schId) {
|
||||
if (isSuperAdmin(user)) {
|
||||
return;
|
||||
}
|
||||
Long userSchId = currentSchId(user);
|
||||
if (userSchId == null || !userSchId.equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅本校用户可办理本校事项");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSuperAdmin(BoaUser user) {
|
||||
return user != null && user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
|
||||
}
|
||||
|
||||
private static Long currentSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null) {
|
||||
return null;
|
||||
}
|
||||
Integer schId = user.getCurrentRole().getSchId();
|
||||
return schId == null ? null : schId.longValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceAttachment;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
import com.yunhai.svchall.repository.ServiceAttachmentRepository;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 附件服务(Notice 上传通道 + 业务绑定,V2.0 Part B.2.5)。
|
||||
* 与 CMS svc 场景的差异(docs/19 平移教训 1 的取简解):封面与富文本内嵌图只存 URL 不建行,
|
||||
* 不做"内容派生归属"——附件行只有两类显式绑定:指南附件(svc_item_guide)与办件材料(svc_apply_field),
|
||||
* 绑定即授权,不绑定不产生无主引用问题。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AttachmentService {
|
||||
|
||||
/** 指南附件(事项编辑页显式列表) */
|
||||
public static final String TOPIC_GUIDE = "svc_item_guide";
|
||||
/** 办件材料(发起提交时绑定) */
|
||||
public static final String TOPIC_APPLY = "svc_apply_field";
|
||||
|
||||
private final ServiceAttachmentRepository attachmentRepository;
|
||||
private final SiteDirectory siteDirectory;
|
||||
|
||||
/** 上传:落盘 + 建行(topic 未绑定;schId 取站点或用户角色,二者必知其一) */
|
||||
@Transactional
|
||||
public ServiceAttachment upload(BoaUser user, String siteCode, MultipartFile file, String type) {
|
||||
String url;
|
||||
try {
|
||||
url = FileUtil.upload(file, type);
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传失败:" + e.getMessage());
|
||||
}
|
||||
Long schId = resolveSchId(user, siteCode);
|
||||
ServiceAttachment attachment = new ServiceAttachment();
|
||||
attachment.setSchId(schId);
|
||||
attachment.setFileName(file.getOriginalFilename() == null ? "file" : file.getOriginalFilename());
|
||||
attachment.setFileUrl(toRelative(url));
|
||||
attachment.setFileSize(file.getSize());
|
||||
attachment.setMimeType(file.getContentType());
|
||||
return attachmentRepository.save(attachment);
|
||||
}
|
||||
|
||||
/** 指南页附件(ref:topic_type=svc_item_guide, topic_id=itemId) */
|
||||
public List<SvcItemAttachmentVo> guideAttachments(Long schId, Integer itemId) {
|
||||
List<SvcItemAttachmentVo> result = new ArrayList<>();
|
||||
for (ServiceAttachment a : attachmentRepository
|
||||
.findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(schId, TOPIC_GUIDE, itemId)) {
|
||||
result.add(SvcItemAttachmentVo.of(a));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 指南附件全量重绑(Cms bindAttachments 的显式部分平移:移除即解除;认领防抢占) */
|
||||
@Transactional
|
||||
public void bindGuide(Long schId, Integer itemId, List<Integer> attachmentIds) {
|
||||
Set<Integer> keep = attachmentIds == null ? new HashSet<>() : new HashSet<>(attachmentIds);
|
||||
List<ServiceAttachment> bound = attachmentRepository
|
||||
.findBySchIdAndTopicTypeAndTopicId(schId, TOPIC_GUIDE, itemId);
|
||||
List<ServiceAttachment> released = new ArrayList<>();
|
||||
for (ServiceAttachment a : bound) {
|
||||
if (!keep.contains(a.getId())) {
|
||||
a.setTopicType(null);
|
||||
a.setTopicId(null);
|
||||
released.add(a);
|
||||
}
|
||||
}
|
||||
if (!released.isEmpty()) {
|
||||
attachmentRepository.saveAll(released);
|
||||
}
|
||||
if (!keep.isEmpty()) {
|
||||
List<ServiceAttachment> claiming = new ArrayList<>();
|
||||
for (ServiceAttachment a : attachmentRepository.findAllById(keep)) {
|
||||
if (!schId.equals(a.getSchId())) {
|
||||
log.warn("[svc-hall] 指南附件跳过:非本校附件 itemId={} attachmentId={}", itemId, a.getId());
|
||||
continue;
|
||||
}
|
||||
boolean claimable = a.getTopicType() == null
|
||||
|| (TOPIC_GUIDE.equals(a.getTopicType())
|
||||
&& (a.getTopicId() == null || itemId.equals(a.getTopicId())));
|
||||
if (!claimable) {
|
||||
log.warn("[svc-hall] 指南附件跳过:已被其他业务占用 attachmentId={} topicType={}",
|
||||
a.getId(), a.getTopicType());
|
||||
continue;
|
||||
}
|
||||
a.setTopicType(TOPIC_GUIDE);
|
||||
a.setTopicId(itemId);
|
||||
claiming.add(a);
|
||||
}
|
||||
if (!claiming.isEmpty()) {
|
||||
attachmentRepository.saveAll(claiming);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 办件材料绑定(发起提交时;行必须未绑定且属本校) */
|
||||
@Transactional
|
||||
public void bindApply(Long schId, Integer applyId,
|
||||
List<com.yunhai.svchall.model.svc.SvcModels.ApplyAttachmentItem> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ServiceAttachment> claiming = new ArrayList<>();
|
||||
for (com.yunhai.svchall.model.svc.SvcModels.ApplyAttachmentItem item : items) {
|
||||
ServiceAttachment a = attachmentRepository.findById(item.getAttachmentId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"附件不存在:" + item.getAttachmentId()));
|
||||
if (!schId.equals(a.getSchId())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件不属于本校:" + item.getAttachmentId());
|
||||
}
|
||||
if (a.getTopicType() != null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件已被其他业务绑定:" + item.getAttachmentId());
|
||||
}
|
||||
a.setTopicType(TOPIC_APPLY);
|
||||
a.setTopicId(applyId);
|
||||
a.setFieldKey(item.getFieldKey());
|
||||
claiming.add(a);
|
||||
}
|
||||
attachmentRepository.saveAll(claiming);
|
||||
}
|
||||
|
||||
/** 办件材料清单 */
|
||||
public List<SvcItemAttachmentVo> applyAttachments(Long schId, Integer applyId) {
|
||||
List<SvcItemAttachmentVo> result = new ArrayList<>();
|
||||
for (ServiceAttachment a : attachmentRepository
|
||||
.findBySchIdAndTopicTypeAndTopicIdOrderByIdAsc(schId, TOPIC_APPLY, applyId)) {
|
||||
result.add(SvcItemAttachmentVo.of(a));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 事项删除时释放指南附件引用(行保留为未绑定,物理文件不删) */
|
||||
@Transactional
|
||||
public void releaseGuide(Long schId, Integer itemId) {
|
||||
attachmentRepository.deleteBySchIdAndTopicTypeAndTopicId(schId, TOPIC_GUIDE, itemId);
|
||||
}
|
||||
|
||||
/** 上传时确定租户:站点目录优先(管理端场景),退回用户角色学校 */
|
||||
private Long resolveSchId(BoaUser user, String siteCode) {
|
||||
if (siteCode != null && !siteCode.trim().isEmpty()) {
|
||||
SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode.trim());
|
||||
if (site != null && site.getSchId() != null) {
|
||||
return site.getSchId();
|
||||
}
|
||||
}
|
||||
if (user != null && user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null) {
|
||||
return user.getCurrentRole().getSchId().longValue();
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无法确定附件所属学校(站点不可达且当前角色无学校归属)");
|
||||
}
|
||||
|
||||
/** 完整 URL → 相对(/upload/…),DB 只存相对段(存储与契约分离) */
|
||||
private String toRelative(String fullUrl) {
|
||||
if (fullUrl == null) {
|
||||
return null;
|
||||
}
|
||||
int idx = fullUrl.indexOf("/upload/");
|
||||
return idx >= 0 ? fullUrl.substring(idx) : fullUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.TargetSpec;
|
||||
import com.yunhai.platform.openapi.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* CMS 站点目录默认实现:经 Tier 0 {@link YschOpenCall}(ad-hoc TargetSpec)调 CMS
|
||||
* {@code GET /oapi/site/list},结果按 schId 缓存 5 分钟(CMS 侧缓存纪律的镜像:站点列表
|
||||
* 低频变化,失效走 TTL)。解析不到/fail 时 fail-closed(权限校验绝不放行)。
|
||||
*
|
||||
* <p>必须走 YschOpenCall 而非共享 IDHttp:CMS 部署带 context-path(/cms),共享拦截器的
|
||||
* basePath 取自 {@code yh.open.base-path}(本应用=/userbase),剥不掉 /cms 前缀,签名
|
||||
* target 与服务端不一致会 401;YschOpenCall 的 per-target base-path 按提供方 context-path
|
||||
* 剥离,与 {@code AppAuthFilter} 对齐(同 Notice FlowCenterClient 范式)。
|
||||
* {@code svchall.cms.base-url} 可配带/不带 context-path 两种形态,构造时拆成 host+basePath。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CmsSiteDirectoryImpl implements SiteDirectory {
|
||||
|
||||
/** schId → 该校站点列表(TTL 5min;站点低频变化,失效靠 TTL) */
|
||||
private final Cache<String, List<SvcModels.SiteOptionVo>> siteCache = Caffeine.newBuilder()
|
||||
.maximumSize(200)
|
||||
.expireAfterWrite(Duration.ofMinutes(5))
|
||||
.build();
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final TargetSpec cmsSpec;
|
||||
|
||||
public CmsSiteDirectoryImpl(YschOpenCall openCall,
|
||||
@Value("${svchall.cms.base-url}") String cmsBaseUrl) {
|
||||
this.openCall = openCall;
|
||||
String base = trimTrailing(cmsBaseUrl == null ? "" : cmsBaseUrl.trim());
|
||||
URI uri = URI.create(base);
|
||||
String host = uri.getScheme() + "://" + uri.getRawAuthority();
|
||||
String path = uri.getRawPath() == null ? "" : uri.getRawPath();
|
||||
this.cmsSpec = TargetSpec.of(host, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SiteInfo resolveSite(String siteCode) {
|
||||
if (siteCode == null || siteCode.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 站点列表按 schId 下发,siteCode → schId 需要反向索引:本地缓存"siteCode → 站点"二级缓存
|
||||
List<SvcModels.SiteOptionVo> sites = siteCache.getIfPresent(ALL_SITES_KEY);
|
||||
if (sites == null) {
|
||||
try {
|
||||
// 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId,
|
||||
// 由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解,权限判定仍走 requireSchoolMember。
|
||||
sites = fetchSites(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] CMS 站点目录不可达:{}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
if (sites != null) {
|
||||
siteCache.put(ALL_SITES_KEY, sites);
|
||||
}
|
||||
}
|
||||
if (sites == null) {
|
||||
return null;
|
||||
}
|
||||
String code = siteCode.trim();
|
||||
for (SvcModels.SiteOptionVo site : sites) {
|
||||
if (code.equals(site.getSiteCode())) {
|
||||
return new SiteInfo(site.getSiteCode(), site.getName(), site.getSchId());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SvcModels.SiteOptionVo> listSites(Long schId) {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "schId 不得为空");
|
||||
}
|
||||
List<SvcModels.SiteOptionVo> sites = siteCache.get(String.valueOf(schId), k -> {
|
||||
try {
|
||||
return fetchSites(schId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] CMS 站点列表获取失败(schId={}):{}", schId, e.getMessage());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "站点服务不可达,请稍后重试");
|
||||
}
|
||||
});
|
||||
return sites == null ? java.util.Collections.emptyList() : sites;
|
||||
}
|
||||
|
||||
private List<SvcModels.SiteOptionVo> fetchSites(Long schId) {
|
||||
Map<String, String> query = schId == null
|
||||
? Collections.emptyMap()
|
||||
: Collections.singletonMap("schId", String.valueOf(schId));
|
||||
return openCall.getList(cmsSpec, "/oapi/site/list", query, SvcModels.SiteOptionVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SiteInfo requireSite(String siteCode) {
|
||||
SiteInfo site = resolveSite(siteCode);
|
||||
if (site == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"站点不存在或站点服务不可达:" + siteCode);
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private static String trimTrailing(String s) {
|
||||
while (s.endsWith("/")) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** resolveSite 的全量缓存键(与真实 schId 不冲突的哨兵) */
|
||||
private static final String ALL_SITES_KEY = "-1";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.entity.ServiceFlowEvent;
|
||||
import com.yunhai.svchall.entity.ServiceFlowReg;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.repository.ServiceFlowEventRepository;
|
||||
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程中心回调事件处理(验收报告 §五遗留批次"回调语义修订",Notice FlowCallbackService D2 同构)。
|
||||
* <b>返回语义(契约冻结,免对账核心)</b>:瞬时失败(DB 异常/锁冲突)抛 {@link TransientCallbackException}
|
||||
* → controller 回 5xx → flow-center 持续重试直至送达;毒 JSON/缺字段/eventId 去重命中/
|
||||
* 乱序丢弃/非本应用办件 → 正常返回(HTTP 200,防无限重放毒消息)。
|
||||
* 定位:按 payload.bizId(本应用行已先于代发起提交,竞态闭合)+ {@code findByIdForUpdate}
|
||||
* 行锁串行化并发推送;事件映射:submitted/task.created/task.handled → 10;completed → 20;
|
||||
* rejected → 30;withdrawn → 40(对齐流程中心概览 §8.3)。
|
||||
* 乱序闸门:instanceVersion <b>严格小于</b>已消费版本才丢弃——flow-center 存在同版本多事件
|
||||
* (submitted+task.created 同 v1,两会签 handled 同 v3),≤ 会误杀合法事件(验收报告 §四.3)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackService {
|
||||
|
||||
/** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */
|
||||
public static final String BIZ_TYPE = "svc_item";
|
||||
|
||||
/** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */
|
||||
public static class TransientCallbackException extends RuntimeException {
|
||||
public TransientCallbackException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private final ServiceFlowEventRepository eventRepository;
|
||||
private final ServiceFlowRegRepository regRepository;
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 处理一条回调推送体(已通过 FlowCallbackVerifyFilter 验签)。
|
||||
* 毒消息静默 200;瞬时失败抛 TransientCallbackException 且事务回滚(含去重留档行),重试从头再来。
|
||||
*/
|
||||
@Transactional
|
||||
public void process(String body) {
|
||||
JsonNode payload;
|
||||
try {
|
||||
payload = objectMapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
// 协议毒消息:不可重试,静默 200 防无限重放
|
||||
log.warn("[svc-hall] 回调体不是合法 JSON(丢弃)");
|
||||
return;
|
||||
}
|
||||
String eventId = text(payload, "eventId");
|
||||
String event = text(payload, "event");
|
||||
String bizType = text(payload, "bizType");
|
||||
String bizId = text(payload, "bizId");
|
||||
Long schId = longVal(payload, "schId");
|
||||
Long instanceId = longVal(payload, "instanceId");
|
||||
if (eventId == null || event == null || bizType == null || bizId == null
|
||||
|| schId == null || instanceId == null) {
|
||||
log.warn("[svc-hall] 回调缺少必要字段 eventId/event/bizType/bizId/schId/instanceId(丢弃):event={}", event);
|
||||
return;
|
||||
}
|
||||
if ("ping".equals(event)) {
|
||||
return; // 注册探活回声(过滤器已直接 200,防御性兜底)
|
||||
}
|
||||
if (!BIZ_TYPE.equals(bizType)) {
|
||||
log.warn("[svc-hall] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
|
||||
return;
|
||||
}
|
||||
// 第一道闸:eventId 去重(唯一约束兜底并发);重复投递在持续重试语义下必然发生
|
||||
if (eventRepository.existsByEventId(eventId)) {
|
||||
log.info("[svc-hall] 回调 eventId={} 重复投递,跳过", eventId);
|
||||
return;
|
||||
}
|
||||
ServiceFlowEvent record = new ServiceFlowEvent();
|
||||
record.setEventId(eventId);
|
||||
record.setEvent(event);
|
||||
record.setSchId(schId);
|
||||
record.setInstanceId(instanceId);
|
||||
record.setReceiveTime(LocalDateTime.now());
|
||||
record.setPayloadJson(body);
|
||||
try {
|
||||
eventRepository.saveAndFlush(record);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.info("[svc-hall] 回调 eventId={} 并发重复(uk 命中),跳过", eventId);
|
||||
return; // 事务回滚无副作用(尚未写办件)
|
||||
}
|
||||
|
||||
// 第二道闸前置:按 bizId 行锁定位(并发推送对同一办件排队,防丢更新)
|
||||
Integer applyId;
|
||||
try {
|
||||
applyId = Integer.valueOf(bizId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", bizId);
|
||||
return;
|
||||
}
|
||||
ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null);
|
||||
if (apply == null) {
|
||||
log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)", bizId, eventId);
|
||||
return;
|
||||
}
|
||||
Integer version = intVal(payload, "instanceVersion");
|
||||
// 乱序闸门:严格小于已消费版本才丢弃(同版本多事件合法)
|
||||
if (version != null && apply.getLastInstanceVersion() != null
|
||||
&& version < apply.getLastInstanceVersion()) {
|
||||
log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}",
|
||||
instanceId, version, apply.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
advance(apply, event, version, payload);
|
||||
} catch (TransientCallbackException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 持续重试追平(免对账核心语义)
|
||||
throw new TransientCallbackException("回调状态推进失败:apply=" + applyId + " event=" + event, e);
|
||||
}
|
||||
log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}",
|
||||
applyId, instanceId, event, apply.getFlowStatus());
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
private void advance(ServiceApply apply, String event, Integer version, JsonNode payload) {
|
||||
Integer status = mapStatus(event);
|
||||
if (status == null) {
|
||||
log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event);
|
||||
return;
|
||||
}
|
||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||
// 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10
|
||||
// 并用 handled 明细覆盖已落定的 flow_result_json。命中守卫记 log 后正常返回(200,
|
||||
// 不触发重试)。重提不冲突:resubmit 成功后先以窄列原子置 10,新一轮事件到达时行已非终态。
|
||||
if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) {
|
||||
if (!ApplyFlowStatusEnum.byCode(status).terminal()) {
|
||||
log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})",
|
||||
apply.getId(), apply.getFlowStatus(), event, version);
|
||||
return;
|
||||
}
|
||||
if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) {
|
||||
log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})",
|
||||
apply.getId(), apply.getFlowStatus(), event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("event", event);
|
||||
JsonNode data = payload.get("data");
|
||||
if (data != null && data.isObject()) {
|
||||
if (data.has("result") && !data.get("result").isNull()) {
|
||||
result.put("result", data.get("result").asText());
|
||||
}
|
||||
if (data.has("opinion") && !data.get("opinion").isNull()) {
|
||||
result.put("opinion", data.get("opinion").asText());
|
||||
}
|
||||
if (data.has("rejectNode") && !data.get("rejectNode").isNull()) {
|
||||
result.put("rejectNode", data.get("rejectNode").asText());
|
||||
}
|
||||
}
|
||||
try {
|
||||
apply.setFlowResultJson(objectMapper.writeValueAsString(result));
|
||||
} catch (Exception ignored) {
|
||||
// 明细序列化失败不影响状态推进
|
||||
}
|
||||
apply.setFlowStatus(status);
|
||||
if (version != null) {
|
||||
apply.setLastInstanceVersion(version);
|
||||
}
|
||||
if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
apply.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
applyRepository.save(apply);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调验签密钥查找(FlowCallbackVerifyFilter 用;按 flowGuid 定注册)。
|
||||
* 仅有效注册(status=0 且 secret 非空)返回——失效注册按无注册处理(401),
|
||||
* 修复路径=管理端重新保存绑定触发重注册(重复注册会轮换 secret,绝不能静默轮换)。
|
||||
*/
|
||||
public String secretOf(String flowGuid) {
|
||||
ServiceFlowReg reg = regRepository.findByFlowGuid(flowGuid).orElse(null);
|
||||
boolean valid = reg != null && reg.getStatus() != null && reg.getStatus() == 0
|
||||
&& reg.getSecret() != null && !reg.getSecret().isEmpty();
|
||||
return valid ? reg.getSecret() : null;
|
||||
}
|
||||
|
||||
private static Integer mapStatus(String event) {
|
||||
switch (event) {
|
||||
case "flow.submitted":
|
||||
case "flow.task.created":
|
||||
case "flow.task.handled":
|
||||
return ApplyFlowStatusEnum.APPROVING.getCode();
|
||||
case "flow.completed":
|
||||
return ApplyFlowStatusEnum.COMPLETED.getCode();
|
||||
case "flow.rejected":
|
||||
return ApplyFlowStatusEnum.REJECTED.getCode();
|
||||
case "flow.withdrawn":
|
||||
return ApplyFlowStatusEnum.WITHDRAWN.getCode();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
private static Long longVal(JsonNode node, String field) {
|
||||
return node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null;
|
||||
}
|
||||
|
||||
private static Integer intVal(JsonNode node, String field) {
|
||||
return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.TargetSpec;
|
||||
import com.yunhai.platform.openapi.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程中心客户端默认实现:经 Tier 0 {@link YschOpenCall}(ad-hoc TargetSpec)调 flow-center
|
||||
* /oapi/flow/**(flow-center 的 RequireAppSignFilter 验本应用 appKey)。flow-center 未部署时
|
||||
* enabled=false,方法全部抛"流程中心未接入"。
|
||||
*
|
||||
* <p>必须走 YschOpenCall 而非共享 IDHttp:flow-center 生产部署带 context-path(/flow-center),
|
||||
* 共享拦截器的 basePath 取自 {@code yh.open.base-path}(本应用=/userbase),剥不掉该前缀,签名
|
||||
* target 与服务端不一致会 401;YschOpenCall 的 per-target base-path 按提供方 context-path 剥离
|
||||
* (同 {@link CmsSiteDirectoryImpl})。{@code svchall.flow.base-url} 可配带/不带 context-path
|
||||
* 两种形态,构造时拆成 host+basePath。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FlowCenterClient implements FlowClient {
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final boolean enabled;
|
||||
private final TargetSpec flowSpec;
|
||||
|
||||
public FlowCenterClient(YschOpenCall openCall, ObjectMapper objectMapper,
|
||||
@Value("${svchall.flow.enabled:false}") boolean enabled,
|
||||
@Value("${svchall.flow.base-url:http://localhost:8085}") String baseUrl) {
|
||||
this.openCall = openCall;
|
||||
this.objectMapper = objectMapper;
|
||||
this.enabled = enabled;
|
||||
String base = trimTrailing(baseUrl == null ? "" : baseUrl.trim());
|
||||
URI uri = URI.create(base);
|
||||
String host = uri.getScheme() + "://" + uri.getRawAuthority();
|
||||
String path = uri.getRawPath() == null ? "" : uri.getRawPath();
|
||||
this.flowSpec = TargetSpec.of(host, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!enabled) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未接入(svchall.flow.enabled=false),"
|
||||
+ "该操作需要流程中心支持");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SvcModels.FlowSummaryVo> listFlows(Long schId) {
|
||||
requireEnabled();
|
||||
return openCall.getList(flowSpec, "/oapi/flow/list",
|
||||
Collections.singletonMap("schId", String.valueOf(schId)), SvcModels.FlowSummaryVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SvcModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
|
||||
requireEnabled();
|
||||
return openCall.get(flowSpec, "/oapi/flow/" + flowGuid,
|
||||
Collections.singletonMap("schId", String.valueOf(schId)), SvcModels.FlowSummaryVo.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApplyResult apply(Long schId, String flowGuid, String bizType, String bizId, String bizTitle,
|
||||
String bizUrl, String bizSnapshot, Integer submitterId, String submitterName,
|
||||
String formDataJson) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("flowGuid", flowGuid);
|
||||
body.put("bizType", bizType);
|
||||
body.put("bizId", bizId);
|
||||
body.put("bizTitle", bizTitle);
|
||||
if (bizUrl != null) {
|
||||
body.put("bizUrl", bizUrl);
|
||||
}
|
||||
if (bizSnapshot != null) {
|
||||
body.put("bizSnapshot", bizSnapshot);
|
||||
}
|
||||
body.put("submitterId", submitterId);
|
||||
body.put("submitterName", submitterName);
|
||||
if (formDataJson != null && !formDataJson.isEmpty()) {
|
||||
try {
|
||||
body.put("formData", objectMapper.readValue(formDataJson, Map.class));
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单数据不是合法 JSON 对象");
|
||||
}
|
||||
}
|
||||
JsonNode data = openCall.post(flowSpec, "/oapi/flow/apply",
|
||||
Collections.singletonMap("schId", String.valueOf(schId)), body, JsonNode.class);
|
||||
if (data == null || data.get("instanceId") == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心代发起返回异常(缺少 instanceId)");
|
||||
}
|
||||
return new ApplyResult(data.get("instanceId").asLong(),
|
||||
data.has("duplicated") && data.get("duplicated").asBoolean());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", bizType);
|
||||
query.put("bizId", bizId);
|
||||
return openCall.get(flowSpec, "/oapi/flow/instance", query, Map.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("flowGuid", flowGuid);
|
||||
body.put("callbackUrl", callbackUrl);
|
||||
if (eventTypes != null && !eventTypes.isEmpty()) {
|
||||
body.put("eventTypes", eventTypes);
|
||||
}
|
||||
Map<?, ?> data = openCall.post(flowSpec, "/oapi/flow/callback/reg",
|
||||
Collections.singletonMap("schId", String.valueOf(schId)), body, Map.class);
|
||||
Object secret = data == null ? null : data.get("secret");
|
||||
if (secret == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心回调注册返回异常(缺少 secret)");
|
||||
}
|
||||
return String.valueOf(secret);
|
||||
}
|
||||
|
||||
private static String trimTrailing(String s) {
|
||||
while (s.endsWith("/")) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.entity.ServiceForm;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplyPageQuery;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FormValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 办件服务(V2.0 Part B.4 + 验收报告 §四 D9/§四.2 范式对齐):用户发起(事项/表单快照落库 →
|
||||
* AFTER_COMMIT 后独立线程 FlowClient 代发起 → 回调驱动镜像状态)、我的办件/详情、管理端分页、
|
||||
* 重试(复用同一 bizId 命中 flow-center uk 幂等)。提交纪律:快照自包含(item_snapshot_json/
|
||||
* form_data_json),事项后续改名/下架不影响在途与历史办件;代发起出站不进 DB 事务(HTTP 长调用
|
||||
* 拖住连接 + 回调先于提交到达找不到行),成功回填走窄列 markApproving(终态不回退)。
|
||||
* 用户面撤回/催办/审批轨迹由前端直调 flow-center CAS 接口(同主域 cookie),本服务不代理。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceApplyService {
|
||||
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final ServiceFormService formService;
|
||||
private final AttachmentService attachmentService;
|
||||
private final FlowClient flowClient;
|
||||
private final SiteGuard siteGuard;
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final com.yunhai.svchall.service.ApplyViewGuard applyViewGuard;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||||
|
||||
/** 办事大厅前端对外地址(bizUrl 落地页用;projman 注入,本地默认 dev server) */
|
||||
@Value("${svchall.front.base-url:http://localhost:8087}")
|
||||
private String frontBaseUrl;
|
||||
|
||||
// ===== 用户面(CAS)=====
|
||||
|
||||
/**
|
||||
* 发起:本校成员 + 事项发布态 + 表单数据强校验 → 落 service_apply(PENDING, bizId=id)
|
||||
* → AFTER_COMMIT 后独立线程代发起(失败保留 PENDING 行,重试复用同一 id)。
|
||||
* 返回办件 VO(PENDING 态——代发起异步执行,状态秒级翻转,前端以"待提交/审批中"刷新可见)。
|
||||
*/
|
||||
@Transactional
|
||||
public ApplyVo submit(BoaUser user, ApplySubmitRequest req) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "未获取到登录用户");
|
||||
}
|
||||
SiteInfo site = siteDirectory.requireSite(req.getSiteCode());
|
||||
siteGuard.requireSchoolMember(user, site.getSchId());
|
||||
ServiceItem item = itemRepository
|
||||
.findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), req.getItemCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
|
||||
if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理");
|
||||
}
|
||||
if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
// 表单解析 + 提交数据强校验(按最新已发布版本;发起时快照 form_version)
|
||||
ServiceForm form = null;
|
||||
if (item.getFormCode() != null && !item.getFormCode().isEmpty()) {
|
||||
form = formService.latestPublished(site.getSchId(), item.getFormCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"办理表单未发布,请联系管理员(事项 " + item.getItemCode() + ")"));
|
||||
FormValidator.validateSubmit(req.getFormData(), form.getSchemaJson());
|
||||
} else if (StringUtils.hasText(req.getFormData())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项无办理表单,不应提交表单数据");
|
||||
}
|
||||
|
||||
ServiceApply apply = new ServiceApply();
|
||||
apply.setSchId(site.getSchId());
|
||||
apply.setSiteCode(site.getSiteCode());
|
||||
apply.setItemId(item.getId());
|
||||
apply.setItemCode(item.getItemCode());
|
||||
apply.setItemTitle(item.getTitle());
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item));
|
||||
apply.setFormVersion(form == null ? null : form.getVersion());
|
||||
apply.setFormDataJson(req.getFormData());
|
||||
apply.setSubmitterId(user.getId());
|
||||
apply.setSubmitterName(user.getName());
|
||||
apply.setFlowGuid(item.getFlowGuid());
|
||||
apply = applyRepository.save(apply);
|
||||
|
||||
// 办件材料绑定(必须落库拿到 applyId 之后再绑)
|
||||
attachmentService.bindApply(site.getSchId(), apply.getId(), req.getAttachments());
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item, attachmentService
|
||||
.applyAttachments(site.getSchId(), apply.getId())));
|
||||
apply = applyRepository.save(apply);
|
||||
|
||||
flowSubmitAfterCommit(apply.getId());
|
||||
return ApplyVo.of(apply);
|
||||
}
|
||||
|
||||
/** 重试:PENDING 行复用同一 bizId 再次代发起(幂等);仅提交人本人可重试自己的办件(同步路径,无长事务) */
|
||||
public ApplyVo retry(BoaUser user, Integer applyId) {
|
||||
ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user))
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
if (!user.getId().equals(apply.getSubmitterId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅可重试自己的办件");
|
||||
}
|
||||
if (apply.getFlowStatus() == null || apply.getFlowStatus() != ApplyFlowStatusEnum.PENDING.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该办件不在待提交状态,无需重试");
|
||||
}
|
||||
doFlowApply(applyId, false);
|
||||
return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键重提(P2-5/BP-R2-2):驳回(30)/撤回(40)后复用同一办件行重新发起——flow-center R4
|
||||
* 按 bizType+bizId 命中 30/40 → 重启推进(定义刷新、快照覆盖、version 自增、轨迹跨轮连续)。
|
||||
* 校验:提交人本人 + 终态 30/40 + flowGuid 非空;表单数据沿用原 formData
|
||||
* ("改后重提"属后续产品项,登记于验收报告)。出站失败保持原态可重入。
|
||||
*/
|
||||
public ApplyVo resubmit(BoaUser user, Integer applyId) {
|
||||
ServiceApply apply = applyRepository.findByIdAndSchId(applyId, userSchId(user))
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
if (!user.getId().equals(apply.getSubmitterId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅可重提自己的办件");
|
||||
}
|
||||
Integer status = apply.getFlowStatus();
|
||||
if (status == null || (status != ApplyFlowStatusEnum.REJECTED.getCode()
|
||||
&& status != ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅驳回/已撤回状态的办件可重新提交");
|
||||
}
|
||||
if (apply.getFlowGuid() == null || apply.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该办件未绑定流程,不可重提");
|
||||
}
|
||||
doFlowApply(applyId, true);
|
||||
return applyRepository.findById(applyId).map(ApplyVo::of).orElse(ApplyVo.of(apply));
|
||||
}
|
||||
|
||||
/** 我的办件(提交人视角,flowStatus 可选筛选) */
|
||||
public Map<String, Object> myApplies(BoaUser user, Integer flowStatus, Integer page, Integer pageSize) {
|
||||
int pageIndex = page == null || page < 1 ? 0 : page - 1;
|
||||
int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
|
||||
Pageable pageable = PageRequest.of(pageIndex, size,
|
||||
Sort.by(Sort.Order.desc("createTime"), Sort.Order.desc("id")));
|
||||
Specification<ServiceApply> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), userSchId(user)));
|
||||
predicates.add(cb.equal(root.get("submitterId"), user.getId()));
|
||||
if (flowStatus != null) {
|
||||
predicates.add(cb.equal(root.get("flowStatus"), flowStatus));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Page<ServiceApply> result = applyRepository.findAll(spec, pageable);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", result.getTotalElements());
|
||||
List<ApplyVo> list = new ArrayList<>();
|
||||
for (ServiceApply apply : result.getContent()) {
|
||||
list.add(ApplyVo.of(apply));
|
||||
}
|
||||
out.put("list", list);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情:提交人本人 / 管理员(type=1 或 roleId∈{2,5,7})/ 办理人(assigneeIds 实时含当前用户)
|
||||
* 三分支放行(BP-R2-3 修复,ApplyViewGuard=NoticeFlowGuard 同构;原先"同校即放行"越权已收紧)。
|
||||
*/
|
||||
public ApplyVo detail(BoaUser user, String siteCode, Integer id) {
|
||||
ServiceApply apply;
|
||||
if (isSuperAdmin(user)) {
|
||||
apply = applyRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
} else {
|
||||
Long schId = userSchId(user);
|
||||
apply = applyRepository.findByIdAndSchId(id, schId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
applyViewGuard.requireVisible(apply, user);
|
||||
}
|
||||
return ApplyVo.of(apply);
|
||||
}
|
||||
|
||||
// ===== 管理面 =====
|
||||
|
||||
/**
|
||||
* 发起页数据(本校用户):事项发布态要点 + 绑定表单的已发布 schema。
|
||||
* 校验链与 submit 一致(同校成员/发布态/已绑流程),前端据此渲染动态表单。
|
||||
*/
|
||||
public Map<String, Object> applyItem(BoaUser user, String siteCode, String itemCode) {
|
||||
SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
siteGuard.requireSchoolMember(user, site.getSchId());
|
||||
ServiceItem item = itemRepository
|
||||
.findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), itemCode)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
|
||||
if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理");
|
||||
}
|
||||
if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("siteCode", site.getSiteCode());
|
||||
out.put("itemCode", item.getItemCode());
|
||||
out.put("title", item.getTitle());
|
||||
out.put("summary", item.getSummary());
|
||||
out.put("deptName", item.getDeptName());
|
||||
out.put("handleMode", item.getHandleMode());
|
||||
out.put("handleTimeLimit", item.getHandleTimeLimit());
|
||||
out.put("promiseTimeLimit", item.getPromiseTimeLimit());
|
||||
out.put("contactPhone", item.getContactPhone());
|
||||
out.put("coverImage", com.yunhai.svchall.utils.FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
out.put("flowGuid", item.getFlowGuid());
|
||||
if (item.getFormCode() != null && !item.getFormCode().isEmpty()) {
|
||||
ServiceForm form = formService.latestPublished(site.getSchId(), item.getFormCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"办理表单未发布,请联系管理员"));
|
||||
out.put("formVersion", form.getVersion());
|
||||
out.put("schemaJson", form.getSchemaJson());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public Map<String, Object> adminPage(BoaUser user, ApplyPageQuery query) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, query.getSiteCode());
|
||||
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
|
||||
int size = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : Math.min(query.getPageSize(), 100);
|
||||
Pageable pageable = PageRequest.of(pageIndex, size,
|
||||
Sort.by(Sort.Order.desc("createTime"), Sort.Order.desc("id")));
|
||||
Specification<ServiceApply> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), site.getSchId()));
|
||||
predicates.add(cb.equal(root.get("siteCode"), site.getSiteCode()));
|
||||
if (StringUtils.hasText(query.getItemCode())) {
|
||||
predicates.add(cb.equal(root.get("itemCode"), query.getItemCode().trim()));
|
||||
}
|
||||
if (query.getFlowStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("flowStatus"), query.getFlowStatus()));
|
||||
}
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String like = "%" + query.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(cb.like(root.get("itemTitle"), like),
|
||||
cb.like(root.get("submitterName"), like)));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Page<ServiceApply> result = applyRepository.findAll(spec, pageable);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", result.getTotalElements());
|
||||
List<ApplyVo> list = new ArrayList<>();
|
||||
for (ServiceApply apply : result.getContent()) {
|
||||
list.add(ApplyVo.of(apply));
|
||||
}
|
||||
out.put("list", list);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===== 内部:代发起(D9 范式,Notice NoticeFlowService 同构)=====
|
||||
|
||||
/**
|
||||
* 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底)。
|
||||
* E2E 实测教训(验收报告 §四.1):afterCommit 回调若留在原请求线程执行,OSIV 绑定的
|
||||
* EntityManager 已随事务提交关闭,后续 repository 写会话静默丢失——提交动作一律切独立线程。
|
||||
*/
|
||||
private static final ExecutorService APPLY_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "svchall-flow-submit");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* 事务提交后再代发起(出站 HTTP 不进 DB 事务,防长调用拖住连接;且 flow-center 收到 apply
|
||||
* 后会立即推送回调,须保证办件行已提交可见)。无活动事务时立即执行(测试/兜底)。
|
||||
*/
|
||||
private void flowSubmitAfterCommit(Integer applyId) {
|
||||
Runnable task = () -> tryApplyQuietly(applyId);
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
APPLY_EXECUTOR.submit(task);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** 异步路径提交失败静默保持 PENDING(行已落库不回滚;列表"待提交"态即提示,可重试) */
|
||||
private void tryApplyQuietly(Integer applyId) {
|
||||
try {
|
||||
doFlowApply(applyId, false);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 代发起失败(保持待提交,可重试)applyId={}:{}", applyId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代发起(幂等:bizId=apply.id,flow-center uk 防重复建件)。
|
||||
* 成功回填走窄列 markApproving——整实体 save 是旧快照盲写,会覆盖出站期间并发送达的回调状态
|
||||
* (验收报告 §四.2);BizException 透传(retry 同步路径给用户提示,异步路径由静默包装消化)。
|
||||
* resubmit=true(一键重提):R4 命中 30/40 重启推进后改用 markResubmitted 回开为 10。
|
||||
*/
|
||||
private void doFlowApply(Integer applyId, boolean resubmit) {
|
||||
if (!flowClient.enabled()) {
|
||||
log.warn("[svc-hall] 流程中心未接入,办件保留待提交状态 applyId={}", applyId);
|
||||
return;
|
||||
}
|
||||
ServiceApply apply = applyRepository.findById(applyId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
try {
|
||||
FlowClient.ApplyResult result = flowClient.apply(
|
||||
apply.getSchId(),
|
||||
apply.getFlowGuid(),
|
||||
FlowCallbackService.BIZ_TYPE,
|
||||
String.valueOf(apply.getId()),
|
||||
apply.getSubmitterName() + "的" + apply.getItemTitle(),
|
||||
// 前端详情页是 hash 路由 + query 传参(routes.ts path="/apply-detail"、
|
||||
// Apply.vue router.replace("/apply-detail?id=")):路径参数形态会 no-match 空白页
|
||||
frontBaseUrl + "/#/apply-detail?id=" + apply.getId(),
|
||||
apply.getItemSnapshotJson(),
|
||||
apply.getSubmitterId(),
|
||||
apply.getSubmitterName(),
|
||||
apply.getFormDataJson());
|
||||
// @Modifying 更新须在事务内执行;本方法出站 HTTP 后运行在 AFTER_COMMIT 线程,
|
||||
// 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted)
|
||||
final Integer applyIdFinal = apply.getId();
|
||||
final Long instanceId = result.getInstanceId();
|
||||
transactionTemplate.executeWithoutResult(tx -> {
|
||||
if (resubmit) {
|
||||
applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now());
|
||||
} else {
|
||||
applyRepository.markApproving(applyIdFinal, instanceId, LocalDateTime.now());
|
||||
}
|
||||
});
|
||||
if (result.isDuplicated()) {
|
||||
log.info("[svc-hall] 代发起命中幂等(flow-center 返回既有办件)applyId={} instanceId={}",
|
||||
apply.getId(), result.getInstanceId());
|
||||
}
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// PENDING 行保留:用户可重试(复用同一 bizId,flow-center uk 幂等防重复建件)
|
||||
log.error("[svc-hall] 代发起失败,办件保留待提交 applyId={}", applyId, e);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程中心提交失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 内部:快照与工具 =====
|
||||
|
||||
/** 事项要点快照(= biz_snapshot 内容源;附件清单仅文件名——flow-center biz_snapshot 内容边界) */
|
||||
private String itemSnapshotJson(ServiceItem item) {
|
||||
return itemSnapshotJson(item, new ArrayList<>());
|
||||
}
|
||||
|
||||
private String itemSnapshotJson(ServiceItem item, List<com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo> attachments) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("itemTitle", item.getTitle());
|
||||
snapshot.put("summary", item.getSummary());
|
||||
snapshot.put("deptName", item.getDeptName());
|
||||
snapshot.put("handleTimeLimit", item.getHandleTimeLimit());
|
||||
snapshot.put("contactPhone", item.getContactPhone());
|
||||
List<String> names = new ArrayList<>();
|
||||
for (com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo a : attachments) {
|
||||
names.add(a.getFileName());
|
||||
}
|
||||
snapshot.put("attachmentNames", names);
|
||||
try {
|
||||
return objectMapper.writeValueAsString(snapshot);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 事项快照序列化失败(存 null)itemCode={}", item.getItemCode());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long userSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前用户无学校归属,无法操作办件");
|
||||
}
|
||||
return user.getCurrentRole().getSchId().longValue();
|
||||
}
|
||||
|
||||
private static boolean isSuperAdmin(BoaUser user) {
|
||||
return user != null && user.getType() != null && user.getType() == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceDict;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDictSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDictVo;
|
||||
import com.yunhai.svchall.repository.ServiceDictRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 事项字典服务(Cms SvcDictService 原样平移,siteId → schId+siteCode 双键)。
|
||||
* 纪律:dict_key 稳定键——被事项引用后不可改(只能改名)、被引用不可删除(只能停用)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceDictService {
|
||||
|
||||
private final ServiceDictRepository dictRepository;
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final SiteGuard siteGuard;
|
||||
|
||||
/** 字典列表(管理端;referenced=被事项引用条数,全状态口径) */
|
||||
public List<SvcDictVo> list(BoaUser user, String siteCode, String dictType) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
if (dictType != null && !dictType.trim().isEmpty() && ServiceDictTypeEnum.byKey(dictType) == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + dictType + "(仅 object/category)");
|
||||
}
|
||||
boolean filterType = dictType != null && !dictType.trim().isEmpty();
|
||||
List<ServiceDict> dicts = filterType
|
||||
? dictRepository.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), siteCode, dictType.trim())
|
||||
: dictRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(site.getSchId(), siteCode);
|
||||
Map<String, Long> objectRefs = countMap(itemRepository.countGroupByObjectKey(site.getSchId(), siteCode));
|
||||
Map<String, Long> categoryRefs = countMap(itemRepository.countGroupByCategoryKey(site.getSchId(), siteCode));
|
||||
List<SvcDictVo> result = new ArrayList<>();
|
||||
for (ServiceDict dict : dicts) {
|
||||
Map<String, Long> refs = ServiceDictTypeEnum.CATEGORY.getKey().equals(dict.getDictType())
|
||||
? categoryRefs : objectRefs;
|
||||
result.add(SvcDictVo.of(dict, refs.get(dict.getDictKey())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Integer save(BoaUser user, SvcDictSaveRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(req.getDictType());
|
||||
if (type == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字典类型非法:" + req.getDictType() + "(仅 object/category)");
|
||||
}
|
||||
ServiceDict dict;
|
||||
if (req.getId() == null) {
|
||||
if (dictRepository.existsBySchIdAndSiteCodeAndDictTypeAndDictKey(
|
||||
site.getSchId(), site.getSiteCode(), type.getKey(), req.getDictKey())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
|
||||
}
|
||||
dict = new ServiceDict();
|
||||
dict.setSchId(site.getSchId());
|
||||
dict.setSiteCode(site.getSiteCode());
|
||||
dict.setDictType(type.getKey());
|
||||
dict.setDictKey(req.getDictKey());
|
||||
} else {
|
||||
dict = dictRepository.findByIdAndSchIdAndSiteCode(req.getId(), site.getSchId(), site.getSiteCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
|
||||
if (!dict.getDictKey().equals(req.getDictKey())) {
|
||||
long referenced = referencedCount(site, type, dict.getDictKey());
|
||||
if (referenced > 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"字典键已被 " + referenced + " 个事项引用,不可修改;只能修改名称");
|
||||
}
|
||||
if (dictRepository.existsBySchIdAndSiteCodeAndDictTypeAndDictKeyAndIdNot(
|
||||
site.getSchId(), site.getSiteCode(), type.getKey(), req.getDictKey(), req.getId())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字典键已存在:" + req.getDictKey());
|
||||
}
|
||||
dict.setDictKey(req.getDictKey());
|
||||
}
|
||||
}
|
||||
dict.setDictName(req.getDictName());
|
||||
dict.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||
dict.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
return dictRepository.save(dict).getId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(BoaUser user, DeleteRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
ServiceDict dict = dictRepository.findByIdAndSchIdAndSiteCode(req.getId(), site.getSchId(), site.getSiteCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "字典不存在"));
|
||||
ServiceDictTypeEnum type = ServiceDictTypeEnum.byKey(dict.getDictType());
|
||||
long referenced = referencedCount(site, type, dict.getDictKey());
|
||||
if (referenced > 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"字典已被 " + referenced + " 个事项引用,不能删除;可改为停用(存量引用仍可渲染)");
|
||||
}
|
||||
dictRepository.delete(dict);
|
||||
}
|
||||
|
||||
/** 键 → 展示名(全状态口径:停用字典的存量引用仍可渲染) */
|
||||
public Map<String, String> nameMap(Long schId, String siteCode, ServiceDictTypeEnum type) {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
for (ServiceDict dict : dictRepository
|
||||
.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(schId, siteCode, type.getKey())) {
|
||||
names.put(dict.getDictKey(), dict.getDictName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 启用字典(oapi 筛选/选择器口径:停用不出现在筛选与选择器) */
|
||||
public List<ServiceDict> enabledDicts(Long schId, String siteCode, ServiceDictTypeEnum type) {
|
||||
List<ServiceDict> result = new ArrayList<>();
|
||||
for (ServiceDict dict : dictRepository
|
||||
.findBySchIdAndSiteCodeAndDictTypeOrderBySortNoAscIdAsc(schId, siteCode, type.getKey())) {
|
||||
if (dict.getStatus() != null && dict.getStatus() == 1) {
|
||||
result.add(dict);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 键必须存在于启用字典(保存事项时的归属校验) */
|
||||
public void requireEnabledKey(Long schId, String siteCode, ServiceDictTypeEnum type, String dictKey) {
|
||||
ServiceDict dict = dictRepository
|
||||
.findBySchIdAndSiteCodeAndDictTypeAndDictKey(schId, siteCode, type.getKey(), dictKey)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别") + "不存在:" + dictKey));
|
||||
if (dict.getStatus() == null || dict.getStatus() != 1) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
(type == ServiceDictTypeEnum.OBJECT ? "服务对象" : "事项类别")
|
||||
+ "已停用,请先启用或更换:" + dictKey);
|
||||
}
|
||||
}
|
||||
|
||||
private long referencedCount(SiteDirectory.SiteInfo site, ServiceDictTypeEnum type, String dictKey) {
|
||||
return type == ServiceDictTypeEnum.CATEGORY
|
||||
? itemRepository.countBySchIdAndSiteCodeAndCategoryKey(site.getSchId(), site.getSiteCode(), dictKey)
|
||||
: itemRepository.countBySchIdAndSiteCodeAndObjectKey(site.getSchId(), site.getSiteCode(), dictKey);
|
||||
}
|
||||
|
||||
private static Map<String, Long> countMap(List<Object[]> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceForm;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo;
|
||||
import com.yunhai.svchall.repository.ServiceFormRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FormValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 自定义表单服务(V2.0 Part B.3):行不可变原则——已发布版本不可改,编辑已发布表单自动
|
||||
* 克隆出新草稿版本(version+1);发布跑 FormValidator 静态校验;事项绑定 code、发起取最新已发布版本。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceFormService {
|
||||
|
||||
private final ServiceFormRepository formRepository;
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final SiteGuard siteGuard;
|
||||
|
||||
/** 表单列表(sch 级;同 code 多版本聚合成一行最新版,referenced=是否被事项绑定) */
|
||||
public List<SvcFormVo> list(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<ServiceForm> forms = formRepository.findBySchIdOrderByCodeAscVersionDescIdAsc(site.getSchId());
|
||||
List<SvcFormVo> result = new ArrayList<>();
|
||||
String lastCode = null;
|
||||
for (ServiceForm form : forms) {
|
||||
if (form.getCode().equals(lastCode)) {
|
||||
continue;
|
||||
}
|
||||
lastCode = form.getCode();
|
||||
result.add(SvcFormVo.of(form, itemRepository.countBySchIdAndFormCode(site.getSchId(), form.getCode()) > 0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 版本轨迹(同 code 全部版本) */
|
||||
public List<SvcFormVo> versions(BoaUser user, String siteCode, String code) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<SvcFormVo> result = new ArrayList<>();
|
||||
for (ServiceForm form : formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), code)) {
|
||||
result.add(SvcFormVo.of(form, null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public SvcFormVo get(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceForm form = formRepository.findByIdAndSchId(id, site.getSchId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在"));
|
||||
return SvcFormVo.of(form, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存:新建时若 code 已有发布/停用版本 → 自动克隆新草稿版本(升版本);已有草稿 → 就地更新;
|
||||
* 已存在行编辑仅限草稿态。schema 服务端静态校验(FormValidator.validateSchema)。
|
||||
*/
|
||||
@Transactional
|
||||
public Integer save(BoaUser user, String siteCode, SvcFormSaveRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
FormValidator.validateSchema(req.getSchemaJson());
|
||||
ServiceForm form;
|
||||
if (req.getId() == null) {
|
||||
List<ServiceForm> versions =
|
||||
formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), req.getCode());
|
||||
if (versions.isEmpty()) {
|
||||
form = new ServiceForm();
|
||||
form.setSchId(site.getSchId());
|
||||
form.setCode(req.getCode());
|
||||
form.setVersion(1);
|
||||
form.setStatus(0);
|
||||
} else if (versions.get(0).getStatus() != null && versions.get(0).getStatus() == 0) {
|
||||
form = versions.get(0);
|
||||
} else {
|
||||
ServiceForm latest = versions.get(0);
|
||||
form = new ServiceForm();
|
||||
form.setSchId(site.getSchId());
|
||||
form.setCode(latest.getCode());
|
||||
form.setVersion(latest.getVersion() + 1);
|
||||
form.setStatus(0);
|
||||
}
|
||||
} else {
|
||||
form = formRepository.findByIdAndSchId(req.getId(), site.getSchId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在"));
|
||||
if (form.getStatus() == null || form.getStatus() != 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"该版本已发布不可修改;保存将自动创建新草稿版本(去掉 id 重新提交)");
|
||||
}
|
||||
if (!form.getCode().equals(req.getCode())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单编码不可修改:" + form.getCode());
|
||||
}
|
||||
}
|
||||
form.setName(req.getName());
|
||||
form.setSchemaJson(req.getSchemaJson());
|
||||
return formRepository.save(form).getId();
|
||||
}
|
||||
|
||||
/** 发布:草稿 → 已发布(版本固化;事项侧经 code 取到本版本) */
|
||||
@Transactional
|
||||
public void publish(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceForm form = formRepository.findByIdAndSchId(id, site.getSchId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单不存在"));
|
||||
if (form.getStatus() == null || form.getStatus() != 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅草稿可发布");
|
||||
}
|
||||
// 发布前重跑静态校验(防草稿期间契约调整)
|
||||
FormValidator.validateSchema(form.getSchemaJson());
|
||||
form.setStatus(1);
|
||||
form.setPublishTime(LocalDateTime.now());
|
||||
formRepository.save(form);
|
||||
}
|
||||
|
||||
/** 删除:整 code 删除(全部版本);被事项绑定即拦(可绑定空版本表单会造成发起 500) */
|
||||
@Transactional
|
||||
public void delete(BoaUser user, String siteCode, String code) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
if (!formRepository.existsBySchIdAndCode(site.getSchId(), code)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在");
|
||||
}
|
||||
long referenced = itemRepository.countBySchIdAndFormCode(site.getSchId(), code);
|
||||
if (referenced > 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"表单已被 " + referenced + " 个事项绑定,不能删除;可先解除事项绑定");
|
||||
}
|
||||
List<ServiceForm> versions = formRepository.findBySchIdAndCodeOrderByVersionDesc(site.getSchId(), code);
|
||||
formRepository.deleteAll(versions);
|
||||
}
|
||||
|
||||
/** 发起取数:code 的最新已发布版本(无 → Optional.empty) */
|
||||
public Optional<ServiceForm> latestPublished(Long schId, String code) {
|
||||
return formRepository.findFirstBySchIdAndCodeAndStatusOrderByVersionDesc(schId, code, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.HandleModeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.oapi.OapiModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.IdSortItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDeptVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageQuery;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSimpleVo;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.DeptSource;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import com.yunhai.svchall.utils.HtmlSanitizer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.annotation.PreDestroy;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事项服务(Cms SvcItemService 迁移版,V2.0 Part B):
|
||||
* 管理端 CRUD/发布/下架/删除/排序/部门名同步 + oapi 供数查询(items/filters/detail/view/snapshot)。
|
||||
* 迁移差异:① 权限由 siteCode + CMS 站点目录解析(SiteGuard);② 关联 CMS 栏目为不透明引用不校验;
|
||||
* ③ 附件为显式绑定(无内容派生归属);④ 新增 formCode/flowGuid 绑定校验(onlineFlag=1 ⇒ flowGuid);
|
||||
* ⑤ oapi 出参 HTML/封面/附件绝对化(FileUtil)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceItemService {
|
||||
|
||||
/** applyUrl 形态(三态优先级最高的一态) */
|
||||
private static final Pattern APPLY_URL_PATTERN = Pattern.compile("^https?://\\S+$");
|
||||
|
||||
/** 选择器/同步部门名的全量扫描上限(防御异常库容) */
|
||||
private static final int SEL_LIST_MAX = 500;
|
||||
|
||||
/** 浏览量异步队列容量(满则丢弃计数并告警,不反压请求线程) */
|
||||
private static final int VIEW_QUEUE_CAPACITY = 1000;
|
||||
|
||||
/** 部门"容器组"类型(默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type) */
|
||||
@Value("${svchall.item.dept-group-type:7}")
|
||||
private int deptGroupType = 7;
|
||||
|
||||
/** 本应用对外可达基址(回调注册 callbackUrl 前缀;projman 注入,本地默认 dev) */
|
||||
@Value("${svchall.public-base-url:http://localhost:8086}")
|
||||
private String callbackPublicBase;
|
||||
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFormRepository formRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFlowRegRepository flowRegRepository;
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final ServiceDictService dictService;
|
||||
private final ServiceFormService formService;
|
||||
private final AttachmentService attachmentService;
|
||||
private final SiteGuard siteGuard;
|
||||
private final DeptSource deptSource;
|
||||
private final FlowClient flowClient;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
// ===== 管理端 =====
|
||||
|
||||
public Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, query.getSiteCode());
|
||||
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
|
||||
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
|
||||
Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100),
|
||||
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("createTime"), Sort.Order.desc("id")));
|
||||
Specification<ServiceItem> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), site.getSchId()));
|
||||
predicates.add(cb.equal(root.get("siteCode"), site.getSiteCode()));
|
||||
if (query.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), query.getStatus()));
|
||||
}
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String like = "%" + query.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||
cb.like(root.get("itemCode"), like)));
|
||||
}
|
||||
if (StringUtils.hasText(query.getObjectKey())) {
|
||||
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
|
||||
}
|
||||
if (StringUtils.hasText(query.getCategoryKey())) {
|
||||
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
|
||||
}
|
||||
if (query.getDeptGroupId() != null) {
|
||||
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Map<String, String> objectNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.OBJECT);
|
||||
Map<String, String> categoryNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY);
|
||||
Page<ServiceItem> page = itemRepository.findAll(spec, pageable);
|
||||
List<SvcItemPageItem> items = new ArrayList<>();
|
||||
for (ServiceItem item : page.getContent()) {
|
||||
items.add(SvcItemPageItem.of(item,
|
||||
objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey())));
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", page.getTotalElements());
|
||||
result.put("list", items);
|
||||
return result;
|
||||
}
|
||||
|
||||
public SvcItemEditVo adminGet(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
return SvcItemEditVo.of(requireItem(site, id));
|
||||
}
|
||||
|
||||
/** 保存:itemCode 唯一/字典键归属/部门快照/applyUrl 白名单/表单与流程绑定校验;指南附件全量重绑 */
|
||||
@Transactional
|
||||
public Integer adminSave(BoaUser user, SvcItemSaveRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
validateItemCode(site, req);
|
||||
validateDictKeys(site, req);
|
||||
validateBindings(site, req);
|
||||
String deptName = resolveDeptName(site, req.getDeptGroupId());
|
||||
String applyUrl = validateApplyUrl(req.getApplyUrl());
|
||||
|
||||
ServiceItem item;
|
||||
if (req.getId() == null) {
|
||||
item = new ServiceItem();
|
||||
item.setSchId(site.getSchId());
|
||||
item.setSiteCode(site.getSiteCode());
|
||||
item.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
|
||||
item.setViewCount(0);
|
||||
} else {
|
||||
item = requireItem(site, req.getId());
|
||||
}
|
||||
item.setItemCode(req.getItemCode());
|
||||
item.setTitle(req.getTitle());
|
||||
item.setSummary(req.getSummary());
|
||||
item.setObjectKey(emptyToNull(req.getObjectKey()));
|
||||
item.setCategoryKey(emptyToNull(req.getCategoryKey()));
|
||||
item.setDeptGroupId(req.getDeptGroupId());
|
||||
item.setDeptName(deptName);
|
||||
item.setHandleMode(req.getHandleMode() == null ? HandleModeEnum.ONLINE.getCode() : req.getHandleMode());
|
||||
if (!HandleModeEnum.isValid(item.getHandleMode())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理方式非法:" + item.getHandleMode());
|
||||
}
|
||||
item.setOnlineFlag(req.getOnlineFlag() == null || req.getOnlineFlag() != 0 ? 1 : 0);
|
||||
item.setHandlePlace(req.getHandlePlace());
|
||||
item.setHandleTimeLimit(req.getHandleTimeLimit());
|
||||
item.setPromiseTimeLimit(req.getPromiseTimeLimit());
|
||||
item.setContactPhone(req.getContactPhone());
|
||||
item.setSupervisePhone(req.getSupervisePhone());
|
||||
item.setProcessHtml(HtmlSanitizer.clean(req.getProcessHtml()));
|
||||
item.setMaterialHtml(HtmlSanitizer.clean(req.getMaterialHtml()));
|
||||
item.setNoticeHtml(HtmlSanitizer.clean(req.getNoticeHtml()));
|
||||
// 关联 CMS 栏目:不透明引用(V2.0 Part C.2/R2),不校验存在性
|
||||
item.setFaqCategoryId(req.getFaqCategoryId());
|
||||
item.setNoticeCategoryId(req.getNoticeCategoryId());
|
||||
item.setRuleCategoryId(req.getRuleCategoryId());
|
||||
item.setCoverImage(toRelativeIfLocal(req.getCoverImage()));
|
||||
item.setFormCode(emptyToNull(req.getFormCode()));
|
||||
item.setFlowGuid(emptyToNull(req.getFlowGuid()));
|
||||
item.setApplyUrl(applyUrl);
|
||||
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||
item = itemRepository.save(item);
|
||||
|
||||
attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds());
|
||||
ensureFlowReg(site, item.getFlowGuid());
|
||||
return item.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换):事项绑 flow_guid 时确保<b>有效</b>回调注册存在
|
||||
* (reg 下发 secret 落 service_flow_reg)。仅 reg 缺失/失效(secret 空或 status≠0)时才注册——
|
||||
* flow-center 重复注册会轮换 secret,导致已存密钥验签失配;失效注册原地更新同一行拿回新密钥。
|
||||
* best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。
|
||||
*/
|
||||
void ensureFlowReg(SiteInfo site, String flowGuid) {
|
||||
if (flowGuid == null || !flowClient.enabled()) {
|
||||
return;
|
||||
}
|
||||
com.yunhai.svchall.entity.ServiceFlowReg reg = flowRegRepository
|
||||
.findBySchIdAndFlowGuid(site.getSchId(), flowGuid).orElse(null);
|
||||
if (reg != null && reg.getStatus() != null && reg.getStatus() == 0
|
||||
&& reg.getSecret() != null && !reg.getSecret().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String callbackUrl = callbackPublicBase + "/callback/flow";
|
||||
try {
|
||||
String secret = flowClient.registerCallback(site.getSchId(), flowGuid, callbackUrl, null);
|
||||
if (reg == null) {
|
||||
reg = new com.yunhai.svchall.entity.ServiceFlowReg();
|
||||
reg.setSchId(site.getSchId());
|
||||
reg.setFlowGuid(flowGuid);
|
||||
}
|
||||
reg.setCallbackUrl(callbackUrl);
|
||||
reg.setSecret(secret);
|
||||
reg.setStatus(0);
|
||||
flowRegRepository.save(reg);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}",
|
||||
site.getSiteCode(), flowGuid, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminPublish(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceItem item = requireItem(site, id);
|
||||
if (!StringUtils.hasText(item.getTitle())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项名称缺失,不可发布");
|
||||
}
|
||||
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||
item.setPublishTime(LocalDateTime.now());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
|
||||
/** 下架:已发布 → 已下架(门户经 oapi 即刻不可见;在途办件不受影响——快照隔离) */
|
||||
@Transactional
|
||||
public void adminOffline(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceItem item = requireItem(site, id);
|
||||
item.setStatus(ServiceItemStatusEnum.OFFLINE.getCode());
|
||||
item.setOfflineTime(LocalDateTime.now());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
|
||||
/** 删除:物理删除 + 指南附件解绑(行保留未绑定态);办件侧靠快照隔离不受影响 */
|
||||
@Transactional
|
||||
public void adminDelete(BoaUser user, DeleteRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
ServiceItem item = requireItem(site, req.getId());
|
||||
attachmentService.releaseGuide(site.getSchId(), item.getId());
|
||||
itemRepository.delete(item);
|
||||
}
|
||||
|
||||
public List<SvcItemSimpleVo> adminSelList(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<ServiceItem> items = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX));
|
||||
if (items.size() >= SEL_LIST_MAX) {
|
||||
log.warn("[svc-hall] 事项选择器命中上限 {} 条(siteCode={}),结果已截断", SEL_LIST_MAX, siteCode);
|
||||
}
|
||||
List<SvcItemSimpleVo> result = new ArrayList<>();
|
||||
for (ServiceItem item : items) {
|
||||
result.add(SvcItemSimpleVo.of(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminSort(BoaUser user, String siteCode, List<IdSortItem> items) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
for (IdSortItem pair : items == null ? new ArrayList<IdSortItem>() : items) {
|
||||
ServiceItem item = requireItem(site, pair.getId());
|
||||
item.setSortNo(pair.getSortNo());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步部门名快照(承袭 CMS syncDeptName):按组树刷新全站 dept_name,返回更新条数 */
|
||||
@Transactional
|
||||
public Map<String, Object> syncDeptName(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||
collectDeptNodes(deptRootOfSite(site), deptNames);
|
||||
if (deptNames.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名");
|
||||
}
|
||||
int updated = 0;
|
||||
List<ServiceItem> candidates = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX));
|
||||
for (ServiceItem item : candidates) {
|
||||
if (item.getDeptGroupId() == null) {
|
||||
continue;
|
||||
}
|
||||
String fresh = deptNames.get(item.getDeptGroupId());
|
||||
if (fresh != null && !fresh.equals(item.getDeptName())) {
|
||||
item.setDeptName(fresh);
|
||||
itemRepository.save(item);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", updated);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 服务部门选项(本站所属学校子树内、容器组下的全部子节点;平台不可达降级空列表) */
|
||||
public List<SvcDeptVo> deptTree(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
return buildDeptTree(deptRootOfSite(site));
|
||||
}
|
||||
|
||||
/** 管理端:事项当前绑定的指南附件(编辑回显) */
|
||||
public List<SvcItemAttachmentVo> itemAttachments(BoaUser user, String siteCode, Integer itemId) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
requireItem(site, itemId);
|
||||
return attachmentService.guideAttachments(site.getSchId(), itemId);
|
||||
}
|
||||
|
||||
// ===== oapi 供数(V2.0 Part C.2 契约;oapi 控制器负责信封与缺参报错)=====
|
||||
|
||||
public OapiModels.ListData oapiItems(Long schId, String siteCode, String objectKey, String categoryKey,
|
||||
Integer deptGroupId, String keyword, Integer page, Integer pageSize) {
|
||||
int pageIndex = page == null || page < 1 ? 0 : page - 1;
|
||||
int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
|
||||
Pageable pageable = PageRequest.of(pageIndex, size,
|
||||
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("publishTime"), Sort.Order.desc("id")));
|
||||
Page<ServiceItem> result = itemRepository.findAll((root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), schId));
|
||||
predicates.add(cb.equal(root.get("siteCode"), siteCode));
|
||||
predicates.add(cb.equal(root.get("status"), ServiceItemStatusEnum.PUBLISHED.getCode()));
|
||||
if (StringUtils.hasText(objectKey)) {
|
||||
predicates.add(cb.equal(root.get("objectKey"), objectKey));
|
||||
}
|
||||
if (StringUtils.hasText(categoryKey)) {
|
||||
predicates.add(cb.equal(root.get("categoryKey"), categoryKey));
|
||||
}
|
||||
if (deptGroupId != null) {
|
||||
predicates.add(cb.equal(root.get("deptGroupId"), deptGroupId));
|
||||
}
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
String like = "%" + keyword.trim() + "%";
|
||||
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||
cb.like(root.get("summary"), like)));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
}, pageable);
|
||||
Map<String, String> categoryNames = dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY);
|
||||
OapiModels.ListData data = new OapiModels.ListData();
|
||||
data.setTotal(result.getTotalElements());
|
||||
data.setPage(pageIndex + 1);
|
||||
data.setPageSize(pageable.getPageSize());
|
||||
data.setTotalPages(result.getTotalPages());
|
||||
for (ServiceItem item : result.getContent()) {
|
||||
OapiModels.ItemRow row = new OapiModels.ItemRow();
|
||||
row.setId(item.getId());
|
||||
row.setItemCode(item.getItemCode());
|
||||
row.setTitle(item.getTitle());
|
||||
row.setSummary(item.getSummary());
|
||||
row.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
row.setDeptName(item.getDeptName());
|
||||
row.setCategoryName(categoryNames.get(item.getCategoryKey()));
|
||||
row.setHandleMode(item.getHandleMode());
|
||||
row.setOnlineFlag(item.getOnlineFlag());
|
||||
row.setPublishTime(item.getPublishTime());
|
||||
data.getItems().add(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public OapiModels.Filters oapiFilters(Long schId, String siteCode) {
|
||||
OapiModels.Filters filters = new OapiModels.Filters();
|
||||
Map<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode));
|
||||
Map<String, Long> categoryCounts = countMap(itemRepository.countPublishedGroupByCategoryKey(schId, siteCode));
|
||||
for (com.yunhai.svchall.entity.ServiceDict dict : dictService
|
||||
.enabledDicts(schId, siteCode, ServiceDictTypeEnum.OBJECT)) {
|
||||
Long count = objectCounts.get(dict.getDictKey());
|
||||
if (count != null && count > 0) {
|
||||
filters.getObjects().add(new OapiModels.FilterOption(
|
||||
dict.getDictKey(), null, dict.getDictName(), count));
|
||||
}
|
||||
}
|
||||
for (com.yunhai.svchall.entity.ServiceDict dict : dictService
|
||||
.enabledDicts(schId, siteCode, ServiceDictTypeEnum.CATEGORY)) {
|
||||
Long count = categoryCounts.get(dict.getDictKey());
|
||||
if (count != null && count > 0) {
|
||||
filters.getCategories().add(new OapiModels.FilterOption(
|
||||
dict.getDictKey(), null, dict.getDictName(), count));
|
||||
}
|
||||
}
|
||||
for (Object[] row : itemRepository.countPublishedGroupByDept(schId, siteCode)) {
|
||||
filters.getDepts().add(new OapiModels.FilterOption(null,
|
||||
((Number) row[0]).intValue(), String.valueOf(row[1]), ((Number) row[2]).longValue()));
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/** 指南页详情(三段 HTML/封面/附件绝对化;关联事项同类别 Top N;不存在/未发布返回 null) */
|
||||
public OapiModels.Detail oapiDetail(Long schId, String siteCode, Integer itemId, String itemCode) {
|
||||
ServiceItem item = itemId != null
|
||||
? itemRepository.findByIdAndSchIdAndSiteCode(itemId, schId, siteCode).orElse(null)
|
||||
: itemRepository.findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
OapiModels.Detail vo = new OapiModels.Detail();
|
||||
vo.setId(item.getId());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setObjectKey(item.getObjectKey());
|
||||
vo.setObjectName(item.getObjectKey() == null ? null
|
||||
: dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.OBJECT).get(item.getObjectKey()));
|
||||
vo.setCategoryKey(item.getCategoryKey());
|
||||
vo.setCategoryName(item.getCategoryKey() == null ? null
|
||||
: dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY).get(item.getCategoryKey()));
|
||||
vo.setDeptGroupId(item.getDeptGroupId());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setHandlePlace(item.getHandlePlace());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setSupervisePhone(item.getSupervisePhone());
|
||||
vo.setProcessHtml(FileUtil.absolutize(item.getProcessHtml()));
|
||||
vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml()));
|
||||
vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml()));
|
||||
vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
vo.setViewCount(item.getViewCount());
|
||||
vo.setFaqCategoryId(item.getFaqCategoryId());
|
||||
vo.setNoticeCategoryId(item.getNoticeCategoryId());
|
||||
vo.setRuleCategoryId(item.getRuleCategoryId());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setFlowGuid(item.getFlowGuid());
|
||||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(schId, item.getId())) {
|
||||
OapiModels.AttachmentRow row = new OapiModels.AttachmentRow();
|
||||
row.setId(a.getId());
|
||||
row.setFileName(a.getFileName());
|
||||
row.setFileUrl(a.getFileUrl());
|
||||
row.setFileSize(a.getFileSize());
|
||||
vo.getAttachments().add(row);
|
||||
}
|
||||
if (StringUtils.hasText(item.getCategoryKey())) {
|
||||
List<ServiceItem> related = itemRepository
|
||||
.findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(schId, siteCode,
|
||||
ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), item.getId(),
|
||||
PageRequest.of(0, 5, Sort.by(Sort.Order.asc("sortNo"),
|
||||
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
|
||||
for (ServiceItem other : related) {
|
||||
OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow();
|
||||
row.setId(other.getId());
|
||||
row.setItemCode(other.getItemCode());
|
||||
row.setTitle(other.getTitle());
|
||||
row.setDeptName(other.getDeptName());
|
||||
row.setHandleMode(other.getHandleMode());
|
||||
vo.getRelatedItems().add(row);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 跨服务要点快照(docs/17 §1.1 契约平移;查不到/未发布返回 null,不做全局降级查询) */
|
||||
public OapiModels.Snapshot oapiSnapshot(String siteCode, String itemCode) {
|
||||
ServiceItem item = itemRepository.findBySchIdAndSiteCodeAndItemCode(resolveSchIdByCode(siteCode),
|
||||
siteCode, itemCode).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
OapiModels.Snapshot vo = new OapiModels.Snapshot();
|
||||
vo.setSiteCode(siteCode);
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点解析(snapshot 场景只有 siteCode):经站点目录定 schId。
|
||||
* 目录不可达返回 null(oapi 层转 code=2),不做"仅按 itemCode 全局搜"降级。
|
||||
*/
|
||||
private Long resolveSchIdByCode(String siteCode) {
|
||||
SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode);
|
||||
return site == null ? null : site.getSchId();
|
||||
}
|
||||
|
||||
/** 指南页浏览量异步累加(承袭 CMS 有界队列口径)===== */
|
||||
|
||||
/** 按 itemId 直取发布态事项(主键全局唯一,无需站点解析;oapi portal-extras 端点用) */
|
||||
private ServiceItem requirePublishedById(Integer itemId) {
|
||||
ServiceItem item = itemRepository.findById(itemId).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 指南附件(按 itemId 直取;fileUrl 已绝对化) */
|
||||
public List<OapiModels.AttachmentRow> attachmentsByItemId(Integer itemId) {
|
||||
List<OapiModels.AttachmentRow> result = new ArrayList<>();
|
||||
ServiceItem item = requirePublishedById(itemId);
|
||||
if (item == null) {
|
||||
return result;
|
||||
}
|
||||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(item.getSchId(), itemId)) {
|
||||
OapiModels.AttachmentRow row = new OapiModels.AttachmentRow();
|
||||
row.setId(a.getId());
|
||||
row.setFileName(a.getFileName());
|
||||
row.setFileUrl(a.getFileUrl());
|
||||
row.setFileSize(a.getFileSize());
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */
|
||||
public List<OapiModels.RelatedItemRow> relatedByItemId(Integer itemId, Integer limit) {
|
||||
List<OapiModels.RelatedItemRow> result = new ArrayList<>();
|
||||
ServiceItem item = requirePublishedById(itemId);
|
||||
if (item == null || !StringUtils.hasText(item.getCategoryKey())) {
|
||||
return result;
|
||||
}
|
||||
int size = limit == null || limit < 1 ? 5 : Math.min(limit, 20);
|
||||
List<ServiceItem> related = itemRepository
|
||||
.findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(item.getSchId(), item.getSiteCode(),
|
||||
ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), itemId,
|
||||
PageRequest.of(0, size, Sort.by(Sort.Order.asc("sortNo"),
|
||||
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
|
||||
for (ServiceItem other : related) {
|
||||
OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow();
|
||||
row.setId(other.getId());
|
||||
row.setItemCode(other.getItemCode());
|
||||
row.setTitle(other.getTitle());
|
||||
row.setDeptName(other.getDeptName());
|
||||
row.setHandleMode(other.getHandleMode());
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 浏览量累加(按 itemId 直取;fire-and-forget) */
|
||||
public void countViewByItemId(Integer itemId) {
|
||||
if (itemId == null || !itemRepository.findById(itemId).isPresent()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(
|
||||
status -> itemRepository.incrementViewCount(itemId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
public void countView(Long schId, String siteCode, String itemCode) {
|
||||
ServiceItem item = itemRepository
|
||||
.findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null);
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
Integer itemId = item.getId();
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(
|
||||
status -> itemRepository.incrementViewCount(itemId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
private final ExecutorService executor = new ThreadPoolExecutor(
|
||||
1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(VIEW_QUEUE_CAPACITY),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "svchall-view-counter");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
(runnable, pool) -> log.warn("[svc-hall] 事项浏览量队列已满(上限 {}),丢弃一次计数", VIEW_QUEUE_CAPACITY));
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
executor.shutdown();
|
||||
try {
|
||||
executor.awaitTermination(3, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 校验与装配 =====
|
||||
|
||||
private void validateItemCode(SiteInfo site, SvcItemSaveRequest req) {
|
||||
boolean duplicated = req.getId() == null
|
||||
? itemRepository.existsBySchIdAndSiteCodeAndItemCode(
|
||||
site.getSchId(), site.getSiteCode(), req.getItemCode())
|
||||
: itemRepository.existsBySchIdAndSiteCodeAndItemCodeAndIdNot(
|
||||
site.getSchId(), site.getSiteCode(), req.getItemCode(), req.getId());
|
||||
if (duplicated) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项编码已存在:" + req.getItemCode() + "(站内唯一)");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDictKeys(SiteInfo site, SvcItemSaveRequest req) {
|
||||
if (StringUtils.hasText(req.getObjectKey())) {
|
||||
dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(),
|
||||
ServiceDictTypeEnum.OBJECT, req.getObjectKey());
|
||||
}
|
||||
if (StringUtils.hasText(req.getCategoryKey())) {
|
||||
dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(),
|
||||
ServiceDictTypeEnum.CATEGORY, req.getCategoryKey());
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ flowGuid 非空 */
|
||||
private void validateBindings(SiteInfo site, SvcItemSaveRequest req) {
|
||||
String formCode = emptyToNull(req.getFormCode());
|
||||
if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理表单不存在:" + formCode);
|
||||
}
|
||||
String flowGuid = emptyToNull(req.getFlowGuid());
|
||||
if (flowGuid != null && flowClient.enabled()) {
|
||||
if (flowClient.getFlow(site.getSchId(), flowGuid) == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理流程不存在或未发布:" + flowGuid);
|
||||
}
|
||||
}
|
||||
boolean online = req.getOnlineFlag() == null || req.getOnlineFlag() != 0;
|
||||
if (online && flowGuid == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"可在线办理的事项必须绑定流程(flowGuid);线下事项请关闭\"可在线办理\"开关");
|
||||
}
|
||||
}
|
||||
|
||||
/** 部门校验+取权威名:组树不可达时拒绝(防脏快照),id 不在部门集合内拒绝 */
|
||||
private String resolveDeptName(SiteInfo site, Integer deptGroupId) {
|
||||
if (deptGroupId == null) {
|
||||
return null;
|
||||
}
|
||||
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||
collectDeptNodes(deptRootOfSite(site), deptNames);
|
||||
String name = deptNames.get(deptGroupId);
|
||||
if (name == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"服务部门不存在或部门服务不可达(id=" + deptGroupId + "),请重新选择");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private BoaGroup deptRootOfSite(SiteInfo site) {
|
||||
Integer schId = site.getSchId() == null ? null : site.getSchId().intValue();
|
||||
if (schId == null) {
|
||||
log.warn("[svc-hall] 站点 {} 未配置 sch_id,服务部门回退全平台组树(跨校可见,请补站点学校归属)",
|
||||
site.getSiteCode());
|
||||
return deptSource.getGroupTree();
|
||||
}
|
||||
BoaGroup subtree = deptSource.getGroupSubtree(schId);
|
||||
if (subtree == null) {
|
||||
log.warn("[svc-hall] 站点 {} 的学校组子树不可达(schId={}),服务部门按空列表处理",
|
||||
site.getSiteCode(), schId);
|
||||
}
|
||||
return subtree;
|
||||
}
|
||||
|
||||
private List<SvcDeptVo> buildDeptTree(BoaGroup root) {
|
||||
List<SvcDeptVo> top = new ArrayList<>();
|
||||
if (root == null) {
|
||||
return top;
|
||||
}
|
||||
for (BoaGroup container : containersOf(root)) {
|
||||
List<SvcDeptVo> children = toDeptVos(container.getChildren());
|
||||
if (children.isEmpty()) {
|
||||
top.add(new SvcDeptVo(container.getId(), container.getName()));
|
||||
} else {
|
||||
top.addAll(children);
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private List<BoaGroup> containersOf(BoaGroup root) {
|
||||
List<BoaGroup> out = new ArrayList<>();
|
||||
collectContainers(root, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectContainers(BoaGroup node, List<BoaGroup> out) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
if (node.getType() != null && node.getType() == deptGroupType) {
|
||||
out.add(node);
|
||||
return;
|
||||
}
|
||||
if (node.getChildren() != null) {
|
||||
for (BoaGroup child : node.getChildren()) {
|
||||
collectContainers(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SvcDeptVo> toDeptVos(List<BoaGroup> nodes) {
|
||||
List<SvcDeptVo> out = new ArrayList<>();
|
||||
if (nodes == null) {
|
||||
return out;
|
||||
}
|
||||
for (BoaGroup node : nodes) {
|
||||
if (node == null || node.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
SvcDeptVo vo = new SvcDeptVo(node.getId(), node.getName());
|
||||
vo.setChildren(toDeptVos(node.getChildren()));
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectDeptNodes(BoaGroup root, Map<Integer, String> out) {
|
||||
if (root == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaGroup container : containersOf(root)) {
|
||||
if (container.getChildren() == null || container.getChildren().isEmpty()) {
|
||||
if (container.getId() != null) {
|
||||
out.putIfAbsent(container.getId(), container.getName());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
collectDescendants(container, out);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectDescendants(BoaGroup parent, Map<Integer, String> out) {
|
||||
if (parent.getChildren() == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaGroup child : parent.getChildren()) {
|
||||
if (child.getId() != null) {
|
||||
out.putIfAbsent(child.getId(), child.getName());
|
||||
}
|
||||
collectDescendants(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
private String validateApplyUrl(String applyUrl) {
|
||||
if (!StringUtils.hasText(applyUrl)) {
|
||||
return null;
|
||||
}
|
||||
String url = applyUrl.trim();
|
||||
if (!APPLY_URL_PATTERN.matcher(url).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "我要办理外链必须是完整的 http(s):// 地址");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private ServiceItem requireItem(SiteInfo site, Integer id) {
|
||||
return itemRepository.findByIdAndSchIdAndSiteCode(id, site.getSchId(), site.getSiteCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径) */
|
||||
private static String toRelativeIfLocal(String coverImage) {
|
||||
if (!StringUtils.hasText(coverImage)) {
|
||||
return null;
|
||||
}
|
||||
int idx = coverImage.indexOf("/upload/");
|
||||
return idx >= 0 ? coverImage.substring(idx) : coverImage;
|
||||
}
|
||||
|
||||
private static Map<String, Long> countMap(List<Object[]> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.svchall.service.DeptSource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 默认部门组树来源:代理 UserBase oapi 静态调用,异常降级为 null(Cms YschBaseSvcDeptSource 原样平移)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class YschBaseDeptSource implements DeptSource {
|
||||
|
||||
@Override
|
||||
public BoaGroup getGroupSubtree(Integer schoolId) {
|
||||
if (schoolId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return YschBaseOpenApi.getGroupById(schoolId, true);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 获取 UserBase 学校组子树失败(schId={}):{}", schoolId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaGroup getGroupTree() {
|
||||
try {
|
||||
return YschBaseOpenApi.getGroupTree();
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 获取 UserBase 组树失败:{}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.yunhai.svchall.utils;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 文件存储工具(照《Notice文件上传统一改造方案.md》§3.1 ①:UserBase FileUtil 精简版)。
|
||||
* 磁盘 {@code FILE_PATH/{type}/{uuid}.{ext}},DB/出参存相对 URL {@code /upload/{type}/{uuid}.{ext}},
|
||||
* 完整 URL 由 FileStorageConfig.publicBaseUrl 拼接(存储与契约分离:域名/前缀变化不破坏数据)。
|
||||
*/
|
||||
public final class FileUtil {
|
||||
|
||||
/** 14 种危险后缀黑名单(同 UserBase/Notice) */
|
||||
private static final Set<String> DANGEROUS_EXT = new HashSet<>(Arrays.asList(
|
||||
"jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", "php", "asp", "aspx",
|
||||
"jar", "war", "class", "htaccess"));
|
||||
|
||||
/** 上传子目录白名单:image=富文本图片 / file=附件与表单材料 */
|
||||
private static final Set<String> ALLOWED_TYPES = new HashSet<>(Arrays.asList("image", "file"));
|
||||
|
||||
private static String baseStorePath;
|
||||
private static String publicBaseUrl;
|
||||
|
||||
private FileUtil() {
|
||||
}
|
||||
|
||||
public static void init(String storePath, String publicBase) {
|
||||
baseStorePath = storePath == null ? "" : normalize(storePath);
|
||||
publicBaseUrl = publicBase == null ? "" : normalize(publicBase);
|
||||
ensureStoreBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传:类型白名单 → 后缀黑名单 → 建目录 → UUID 文件名 → 流式写入。
|
||||
*
|
||||
* @return 完整可访问 URL(publicBaseUrl + /upload/{type}/{uuid}.{ext})
|
||||
*/
|
||||
public static String upload(MultipartFile file, String type) throws IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IOException("上传文件为空");
|
||||
}
|
||||
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
||||
throw new IOException("上传类型非法(仅 image/file):" + type);
|
||||
}
|
||||
String original = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
|
||||
String ext = extOf(original);
|
||||
if (ext.isEmpty()) {
|
||||
throw new IOException("文件缺少扩展名:" + original);
|
||||
}
|
||||
if (DANGEROUS_EXT.contains(ext)) {
|
||||
throw new IOException("不允许上传的文件类型:" + ext);
|
||||
}
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
Path dir = Paths.get(baseStorePath, type);
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(uuid + "." + ext);
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
Files.copy(in, target);
|
||||
}
|
||||
return buildUrl("/upload/" + type + "/" + uuid + "." + ext);
|
||||
}
|
||||
|
||||
/** 相对 URL → 完整 URL(publicBaseUrl 拼接;已是绝对地址原样返回) */
|
||||
public static String buildUrl(String relativeUrl) {
|
||||
if (relativeUrl == null || relativeUrl.isEmpty()) {
|
||||
return relativeUrl;
|
||||
}
|
||||
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) {
|
||||
return relativeUrl;
|
||||
}
|
||||
String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/";
|
||||
String rel = relativeUrl.startsWith("/") ? relativeUrl.substring(1) : relativeUrl;
|
||||
return base + rel;
|
||||
}
|
||||
|
||||
/**
|
||||
* oapi 富文本/封面绝对化:把内容里相对 {@code /upload/} 的 src/href(与裸相对封面值)
|
||||
* 补成完整 URL。新内容入库即相对路径(B-1 口径),本方法只在**对外出参**时执行,
|
||||
* 不污染库内数据。
|
||||
*/
|
||||
public static String absolutize(String html) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return html;
|
||||
}
|
||||
String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/";
|
||||
return html.replace("src=\"/upload/", "src=\"" + base + "upload/")
|
||||
.replace("href=\"/upload/", "href=\"" + base + "upload/")
|
||||
.replace("src='/upload/", "src='" + base + "upload/")
|
||||
.replace("href='/upload/", "href='" + base + "upload/");
|
||||
}
|
||||
|
||||
/** 裸相对封面值 → 完整 URL(空值原样;绝对地址原样) */
|
||||
public static String absolutizePlain(String url) {
|
||||
if (url == null || url.isEmpty() || url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
return buildUrl(url);
|
||||
}
|
||||
|
||||
private static String extOf(String name) {
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot < 0 || dot == name.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalize(String path) {
|
||||
String p = path.replace("\\", "/");
|
||||
return p.endsWith("/") ? p : p + "/";
|
||||
}
|
||||
|
||||
private static void ensureStoreBase() {
|
||||
try {
|
||||
Files.createDirectories(Paths.get(baseStorePath));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("文件存储目录不可创建:" + baseStorePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.yunhai.svchall.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 表单 schema 契约校验 + 提交数据强校验(V2.0 Part B.3;契约自流程中心概览 v1.2 §4.2 平移)。
|
||||
* 纪律:类型用字符串 key(一期 7 类 / 14 类命名冻结);校验只用声明式白名单
|
||||
* required/pattern/min/max/maxLength;严禁存库 JS + eval(旧 CustomForm 反面先例)。
|
||||
*/
|
||||
public final class FormValidator {
|
||||
|
||||
/** 14 类命名一次冻结(V2.0 B.3) */
|
||||
public static final Set<String> FROZEN_TYPES = new HashSet<>(Arrays.asList(
|
||||
"text", "textarea", "number", "date", "select", "radio", "upload",
|
||||
"checkbox", "multiselect", "phone", "address", "image", "user", "users"));
|
||||
|
||||
/** 一期启用 7 类 */
|
||||
public static final Set<String> PHASE1_TYPES = new HashSet<>(Arrays.asList(
|
||||
"text", "textarea", "number", "date", "select", "radio", "upload"));
|
||||
|
||||
/** 声明式校验白名单属性(其余属性一律拒绝,防契约外语义渗入) */
|
||||
private static final Set<String> ALLOWED_PROPS = new HashSet<>(Arrays.asList(
|
||||
"key", "label", "type", "required", "pattern", "min", "max", "maxLength",
|
||||
"tip", "placeholder", "options", "rows", "defaultValue", "accept", "maxSize", "maxFiles"));
|
||||
|
||||
private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,64}$");
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private FormValidator() {
|
||||
}
|
||||
|
||||
// ===== schema 静态校验(发布期) =====
|
||||
|
||||
/** 校验 schema_json;非法抛 BizException。返回字段数(信息用) */
|
||||
public static int validateSchema(String schemaJson) {
|
||||
JsonNode root = parse(schemaJson, "表单结构不是合法 JSON");
|
||||
if (!root.isObject() || !root.has("groups") || !root.get("groups").isArray()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单结构必须是 {\"groups\":[…]}");
|
||||
}
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
int total = 0;
|
||||
for (JsonNode group : root.get("groups")) {
|
||||
if (!group.isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "分组必须是对象");
|
||||
}
|
||||
if (group.has("title") && !group.get("title").isNull() && group.get("title").asText().length() > 64) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "分组标题过长(≤64)");
|
||||
}
|
||||
if (!group.has("fields") || !group.get("fields").isArray()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "每个分组必须含 fields 数组");
|
||||
}
|
||||
for (JsonNode field : group.get("fields")) {
|
||||
validateField(field, keys);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
if (total == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单至少要有一个字段");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void validateField(JsonNode field, Set<String> keys) {
|
||||
if (!field.isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段必须是对象");
|
||||
}
|
||||
Iterator<String> names = field.fieldNames();
|
||||
while (names.hasNext()) {
|
||||
String prop = names.next();
|
||||
if (!ALLOWED_PROPS.contains(prop)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段属性不在契约白名单内:" + prop);
|
||||
}
|
||||
}
|
||||
String key = text(field, "key");
|
||||
if (key == null || !KEY_PATTERN.matcher(key).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"字段 key 仅限字母/数字/下划线(≤64 位):" + key);
|
||||
}
|
||||
if (!keys.add(key)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段 key 重复:" + key);
|
||||
}
|
||||
String label = text(field, "label");
|
||||
if (label == null || label.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段缺少 label:" + key);
|
||||
}
|
||||
String type = text(field, "type");
|
||||
if (type == null || !FROZEN_TYPES.contains(type)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段类型未冻结:" + type + "(字段 " + key + ")");
|
||||
}
|
||||
if (!PHASE1_TYPES.contains(type)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该类型二期开放,一期不可用:" + type
|
||||
+ "(字段 " + key + ")");
|
||||
}
|
||||
String pattern = text(field, "pattern");
|
||||
if (pattern != null) {
|
||||
try {
|
||||
Pattern.compile(pattern);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "字段 pattern 不可编译(字段 " + key + ")");
|
||||
}
|
||||
}
|
||||
if ("select".equals(type) || "radio".equals(type)) {
|
||||
JsonNode options = field.get("options");
|
||||
if (options == null || !options.isArray() || options.size() == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "select/radio 必须提供 options(字段 " + key + ")");
|
||||
}
|
||||
}
|
||||
Integer min = intVal(field, "min");
|
||||
Integer max = intVal(field, "max");
|
||||
if (min != null && max != null && min > max) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "min 不得大于 max(字段 " + key + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 提交数据强校验(发起期,按 schema 版本) =====
|
||||
|
||||
/**
|
||||
* 按表单结构校验提交数据:未知 key 拒绝、required 必填、type 相关校验
|
||||
* (number→min/max、文本→maxLength/pattern、select/radio→options 成员、upload→数组非空)。
|
||||
*
|
||||
* @param formDataJson 提交的 {key:value} JSON 字符串(可为 null)
|
||||
* @param schemaJson 表单结构
|
||||
*/
|
||||
public static void validateSubmit(String formDataJson, String schemaJson) {
|
||||
JsonNode schema = parse(schemaJson, "表单结构损坏(请联系管理员)");
|
||||
Map<String, JsonNode> fieldMap = new java.util.HashMap<>();
|
||||
for (JsonNode group : schema.get("groups")) {
|
||||
for (JsonNode field : group.get("fields")) {
|
||||
fieldMap.put(field.get("key").asText(), field);
|
||||
}
|
||||
}
|
||||
JsonNode data = parse(formDataJson == null || formDataJson.isEmpty() ? "{}" : formDataJson,
|
||||
"表单数据不是合法 JSON 对象");
|
||||
if (!data.isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单数据必须是 JSON 对象");
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> en : fieldMap.entrySet()) {
|
||||
JsonNode field = en.getValue();
|
||||
String key = en.getKey();
|
||||
boolean required = field.has("required") && field.get("required").asBoolean();
|
||||
JsonNode value = data.get(key);
|
||||
if (value == null || value.isNull()
|
||||
|| (value.isTextual() && value.asText().trim().isEmpty())
|
||||
|| (value.isArray() && value.size() == 0)) {
|
||||
if (required) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"必填项未填写:" + field.get("label").asText());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
validateValue(field, key, value);
|
||||
}
|
||||
// 未知 key 拒绝(严格契约:提交数据不得夹带 schema 外字段)
|
||||
Iterator<String> it = data.fieldNames();
|
||||
List<String> unknown = new ArrayList<>();
|
||||
while (it.hasNext()) {
|
||||
String k = it.next();
|
||||
if (!fieldMap.containsKey(k)) {
|
||||
unknown.add(k);
|
||||
}
|
||||
}
|
||||
if (!unknown.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单数据含未知字段:" + String.join(",", unknown));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateValue(JsonNode field, String key, JsonNode value) {
|
||||
String type = field.get("type").asText();
|
||||
switch (type) {
|
||||
case "number":
|
||||
if (!value.isNumber()) {
|
||||
throw fail(field, key, "必须是数字");
|
||||
}
|
||||
Integer min = intVal(field, "min");
|
||||
Integer max = intVal(field, "max");
|
||||
if (min != null && value.asDouble() < min) {
|
||||
throw fail(field, key, "不得小于 " + min);
|
||||
}
|
||||
if (max != null && value.asDouble() > max) {
|
||||
throw fail(field, key, "不得大于 " + max);
|
||||
}
|
||||
break;
|
||||
case "select":
|
||||
case "radio": {
|
||||
String v = value.asText();
|
||||
JsonNode options = field.get("options");
|
||||
boolean hit = false;
|
||||
for (JsonNode option : options) {
|
||||
if (option.asText().equals(v)) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
throw fail(field, key, "选项值不在候选列表内");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "upload": {
|
||||
if (!value.isArray()) {
|
||||
throw fail(field, key, "必须是附件数组");
|
||||
}
|
||||
Integer maxFiles = intVal(field, "maxFiles");
|
||||
if (maxFiles != null && value.size() > maxFiles) {
|
||||
throw fail(field, key, "附件数超过上限 " + maxFiles);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
String v = value.asText();
|
||||
Integer maxLength = intVal(field, "maxLength");
|
||||
if (maxLength != null && v.length() > maxLength) {
|
||||
throw fail(field, key, "长度超过上限 " + maxLength);
|
||||
}
|
||||
String pattern = text(field, "pattern");
|
||||
if (pattern != null && !Pattern.compile(pattern).matcher(v).matches()) {
|
||||
throw fail(field, key, "格式不正确");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException fail(JsonNode field, String key, String reason) {
|
||||
return new BizException(ErrorCode.PARAM_ERROR,
|
||||
"字段「" + field.get("label").asText() + "」" + reason + "(" + key + ")");
|
||||
}
|
||||
|
||||
private static JsonNode parse(String json, String message) {
|
||||
try {
|
||||
return MAPPER.readTree(json == null ? "{}" : json);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
private static Integer intVal(JsonNode node, String field) {
|
||||
return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.svchall.utils;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 富文本白名单清洗(Cms HtmlSanitizer 原样平移:jsoup Safelist.relaxed() 基础 +
|
||||
* figure/figcaption/span + 受限 style + a/img 协议收紧 + preserveRelativeLinks)。
|
||||
* script/iframe/on* 一律剥除;列表页纯文本字段 th:text 等价转义,utext 仅限本工具清洗过的内容。
|
||||
*/
|
||||
public final class HtmlSanitizer {
|
||||
|
||||
/** 允许保留的内联样式属性(排版用途),其余 style 值丢弃 */
|
||||
private static final Pattern SAFE_STYLE =
|
||||
Pattern.compile("^(\\s*(text-align|color|background-color|font-size|font-weight"
|
||||
+ "|line-height|width|height|max-width|float|margin|padding)\\s*:\\s*[^;()]+;?)*\\s*$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Safelist WHITELIST = Safelist.relaxed()
|
||||
.addTags("figure", "figcaption", "span")
|
||||
.addAttributes(":all", "style")
|
||||
.addAttributes("img", "style", "alt", "width", "height")
|
||||
.addProtocols("a", "href", "http", "https", "mailto", "ftp", "#")
|
||||
.addProtocols("img", "src", "http", "https", "data")
|
||||
// 相对链接保留(Cms B-9 修复平移):上传地址相对路径入库,编辑器贴图 img src 即相对 URL
|
||||
.preserveRelativeLinks(true);
|
||||
|
||||
private HtmlSanitizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗富文本;空入参原样返回。baseUri 用占位 https 域(Cms B-9 根因修复配套,
|
||||
* 配合 preserveRelativeLinks(true) 回写原始相对值)。
|
||||
*/
|
||||
public static String clean(String html) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return html;
|
||||
}
|
||||
String cleaned = Jsoup.clean(html, "https://sanitizer.base.invalid/", WHITELIST);
|
||||
return stripUnsafeStyle(cleaned);
|
||||
}
|
||||
|
||||
/** 纯文本提取(摘要兜底/搜索片段用):去标签 + 折叠空白 */
|
||||
public static String toText(String html, int maxLength) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
||||
if (maxLength > 0 && text.length() > maxLength) {
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** jsoup 不支持按值过滤属性,二次遍历按 SAFE_STYLE 白名单过滤 style(Cms A-07 修复平移) */
|
||||
private static String stripUnsafeStyle(String html) {
|
||||
org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parseBodyFragment(html);
|
||||
for (org.jsoup.nodes.Element el : doc.select("[style]")) {
|
||||
String style = el.attr("style");
|
||||
if (style == null || !SAFE_STYLE.matcher(style).matches()) {
|
||||
el.removeAttr("style");
|
||||
}
|
||||
}
|
||||
return doc.body().html();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yunhai.svchall.utils;
|
||||
|
||||
import javax.servlet.ReadListener;
|
||||
import javax.servlet.ServletInputStream;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后,
|
||||
* 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。
|
||||
*/
|
||||
public class ReReadableRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final byte[] body;
|
||||
|
||||
public ReReadableRequestWrapper(HttpServletRequest request) throws IOException {
|
||||
super(request);
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (InputStream in = request.getInputStream()) {
|
||||
byte[] chunk = new byte[4096];
|
||||
int read;
|
||||
while ((read = in.read(chunk)) > 0) {
|
||||
buffer.write(chunk, 0, read);
|
||||
}
|
||||
}
|
||||
this.body = buffer.toByteArray();
|
||||
}
|
||||
|
||||
public String bodyString() {
|
||||
return new String(body, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream source = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return source.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener listener) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return source.read();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
server:
|
||||
port: 8086
|
||||
# 本地无 context-path(同 Notice dev);生产经 CONTEXT_PATH=/svc-hall 注入,
|
||||
# Caddy 只反代 /svc-hall/api/*,页面与上传走前端/静态直出
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: svc-hall
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/bsch_svchall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: 123456
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
jpa:
|
||||
hibernate:
|
||||
# 开发期 update 自动建表;生产环境建议 none + 升级脚本(sql/ 目录)
|
||||
ddl-auto: update
|
||||
show-sql: false
|
||||
open-in-view: false
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc:
|
||||
time_zone: Asia/Shanghai
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
servlet:
|
||||
# 对齐《Notice文件上传统一改造方案.md》D4:50MB / 200MB
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 200MB
|
||||
|
||||
# ===== 基础平台 yh 配置(Cms/Notice 同款三件套)=====
|
||||
yh:
|
||||
auth:
|
||||
enabled: true
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com
|
||||
# 本地 dev 指向前端 dev server(8087):CAS 验票回跳走 vite 代理(Cms 同款约定)
|
||||
client-host-url: http://localhost:8087
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
# 用户面(发起/我的办件)与管理面全在 /api/* 下;本应用无匿名面。
|
||||
# 流程中心回调走 /callback/*(不在 pattern 内,由 FlowCallbackVerifyFilter 验签保护)
|
||||
url-pattern: /api/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换,
|
||||
# 生产由 projman/Nacos 注入,禁止明文入库仓库(V2.0 R7)
|
||||
app-key: 100018
|
||||
# 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致);
|
||||
# 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号
|
||||
app-secret: "svc-hall-dev-secret-100018"
|
||||
# 入站验签白名单:CMS(供数消费方,appKey=100005);流程中心 flow-center 分配 appKey 后在此追加
|
||||
trusted-apps:
|
||||
100005: 2c7885de594742cc840b8762910b2d2e
|
||||
open:
|
||||
enabled: true
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
base-path: /userbase
|
||||
app-key: 100018
|
||||
# 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致);
|
||||
# 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号
|
||||
app-secret: "svc-hall-dev-secret-100018"
|
||||
|
||||
# ===== 办事大厅专属配置 =====
|
||||
svchall:
|
||||
# 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入)
|
||||
public-base-url: http://localhost:8086
|
||||
# 办事大厅前端地址(办件 bizUrl 落地页用)
|
||||
front:
|
||||
base-url: http://localhost:8087
|
||||
item:
|
||||
# 服务部门"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type)
|
||||
dept-group-type: 7
|
||||
cms:
|
||||
# CMS 基址(站点目录 /oapi/site/list 数据源);本地直连后端,生产由 projman 注入
|
||||
base-url: http://localhost:8072/cms
|
||||
flow:
|
||||
# 流程中心 flow-center 基址;2026-09-12 联调置 true(本地 8085 已部署 flow-center M1)
|
||||
enabled: true
|
||||
base-url: http://localhost:8085
|
||||
# 对账 Job 应急闸(契约冻结"消费方免每日对账":flow-center 持续重试+死信+挂起告警为主链路)。
|
||||
# 默认 false 停用;仅大面积漂移事故应急时置 true 开闸(每日 04:00 跑)
|
||||
reconcile-enabled: false
|
||||
file:
|
||||
# 文件对外可达基址(绝对地址):上传返回值、oapi 富文本内链绝对化都用它。
|
||||
# 本地=后端直连(无 context-path);生产=https://base.yunhaixingtong.com/svc-hall/upload/
|
||||
# (Projman 自动生成静态直出段,prod 由 CONTEXT_PATH 注入应用上下文)
|
||||
public-base-url: http://localhost:8086/upload/
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.yunhai.svchall: debug
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
|
||||
# 文件存储磁盘路径(Notice 三件套约定;Docker 部署时与 volume 挂载一致)
|
||||
FILE_PATH: D:/bsch/svchall/upload/
|
||||
Reference in New Issue
Block a user