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

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