This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user