build / build-push (push) Successful in 1m21s
含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
958 lines
46 KiB
Java
958 lines
46 KiB
Java
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.hall.HallModels;
|
||
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;
|
||
|
||
private final ServiceItemRepository itemRepository;
|
||
private final com.yunhai.svchall.repository.ServiceFormRepository formRepository;
|
||
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 com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
|
||
private final TransactionTemplate transactionTemplate;
|
||
|
||
/**
|
||
* 表单中心目录/核验客户端(v3/TASK_1)。
|
||
*
|
||
* <p><b>用 setter 而不是加进 {@code @RequiredArgsConstructor} 的构造参数</b>:本类那 11 参构造
|
||
* 已被 {@code ServiceItemEffectiveFlowTest}/{@code ServiceItemFlowRegTest} 逐位钉住,而那两份测试
|
||
* 不在本任务的可改清单里——多一个入参会让它们直接编译不过。绑定核验必须在
|
||
* {@link #adminSave} 的事务内发生(远端失败=整次保存回滚,不留半个绑定),所以客户端装配在这里,
|
||
* 而不是挪到控制器入口。</p>
|
||
*/
|
||
private FormCenterCatalogClient formCenterClient;
|
||
|
||
@org.springframework.beans.factory.annotation.Autowired
|
||
void setFormCenterClient(FormCenterCatalogClient formCenterClient) {
|
||
this.formCenterClient = formCenterClient;
|
||
}
|
||
|
||
// ===== 管理端 =====
|
||
|
||
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);
|
||
ServiceItem item = requireItem(site, id);
|
||
SvcItemEditVo vo = SvcItemEditVo.of(item);
|
||
// 封面出参绝对化(与他端列表/详情口径一致),否则管理端编辑页 <img> 拿相对路径 404,看着像"封面没保存上";
|
||
// 保存侧 toRelativeIfLocal 会再相对化,往返幂等
|
||
vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||
return vo;
|
||
}
|
||
|
||
/** 保存: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()));
|
||
// 本地 form_code 停写(v3/TASK_1):原样回传=保留历史绑定(不清空存量行),改值即拒;
|
||
// 新表单一律走表单中心坐标,且已在 validateBindings 里经远端核验
|
||
String legacyFormCode = emptyToNull(req.getFormCode());
|
||
if (legacyFormCode != null && !legacyFormCode.equals(item.getFormCode())) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"本地表单定义已停用:不能在事项上写新的 form_code,请到表单中心建表后选择表单");
|
||
}
|
||
item.setFcFormUuid(trimToNull(req.getFcFormUuid()));
|
||
item.setFcFormVersion(req.getFcFormVersion());
|
||
item.setFlowGuid(emptyToNull(req.getFlowGuid()));
|
||
item.setApplyUrl(applyUrl);
|
||
// 服务承诺(P1-1):空串归一为 null,避免库里出现 "" 与 null 两种空值
|
||
item.setCoopDepts(emptyToNull(req.getCoopDepts()));
|
||
item.setHandlerName(emptyToNull(req.getHandlerName()));
|
||
item.setHandleTimeDesc(emptyToNull(req.getHandleTimeDesc()));
|
||
item.setRunCount(req.getRunCount());
|
||
item.setExpressFlag(req.getExpressFlag());
|
||
item.setFeeDesc(emptyToNull(req.getFeeDesc()));
|
||
item.setFeeBasis(emptyToNull(req.getFeeBasis()));
|
||
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||
item = itemRepository.save(item);
|
||
|
||
attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds());
|
||
// 注册以生效流程为准(表单级绑定:事项自身为空时用表单的)
|
||
ensureFlowReg(site, effectiveFlowGuid(site.getSchId(), item.getFlowGuid(), item.getFormCode()));
|
||
return item.getId();
|
||
}
|
||
|
||
/**
|
||
* 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换;2026-09-20 迁 starter
|
||
* {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):绑定 flowGuid 时确保
|
||
* <b>有效</b>回调注册存在(reg 下发 secret 落 starter 镜像表 flow_callback_reg)。
|
||
* 仅镜像行缺失/失效时才注册——flow-center 重复注册会轮换 secret,导致已存密钥验签失配。
|
||
* best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。
|
||
*/
|
||
void ensureFlowReg(SiteInfo site, String flowGuid) {
|
||
if (flowGuid == null || !flowClient.enabled()) {
|
||
return;
|
||
}
|
||
try {
|
||
callbackRegistrar.ensureRegistered(
|
||
site.getSchId() == null ? null : site.getSchId().intValue(), flowGuid);
|
||
} catch (Exception e) {
|
||
log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}",
|
||
site.getSiteCode(), flowGuid, e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生效流程解析(表单级绑定,2026-09-20):事项自身绑定优先,为空回退绑定表单最新版本的
|
||
* flow_guid。返回 null=两侧都没绑(不可在线办理)。
|
||
*/
|
||
String effectiveFlowGuid(Long schId, String itemFlowGuid, String formCode) {
|
||
if (itemFlowGuid != null && !itemFlowGuid.isEmpty()) {
|
||
return itemFlowGuid;
|
||
}
|
||
if (formCode == null || formCode.isEmpty()) {
|
||
return null;
|
||
}
|
||
return formRepository
|
||
.findBySchIdAndCodeOrderByVersionDesc(schId, formCode).stream()
|
||
.findFirst()
|
||
.map(com.yunhai.svchall.entity.ServiceForm::getFlowGuid)
|
||
.filter(guid -> guid != null && !guid.isEmpty())
|
||
.orElse(null);
|
||
}
|
||
|
||
@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());
|
||
// 一期契约 §3.1 追加字段:纯文本/数字,不做地址绝对化(coverImage 才是路径)
|
||
row.setApplyUrl(item.getApplyUrl());
|
||
row.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||
row.setHandleTimeLimit(item.getHandleTimeLimit());
|
||
row.setRunCount(item.getRunCount());
|
||
row.setExpressFlag(item.getExpressFlag());
|
||
row.setCoopDepts(item.getCoopDepts());
|
||
row.setHandlerName(item.getHandlerName());
|
||
row.setHandleTimeDesc(item.getHandleTimeDesc());
|
||
row.setFeeDesc(item.getFeeDesc());
|
||
row.setFeeBasis(item.getFeeBasis());
|
||
data.getItems().add(row);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// ===== 用户面事项指南(/api/hall/item,D7 匿名只读)=====
|
||
|
||
/**
|
||
* 匿名事项指南(一期契约 §3.2):siteCode + itemCode 均必填,只返回已发布事项。
|
||
* 租户由 siteCode 经站点目录解析(游客无 BoaUser,不做成员校验——匿名只读,权限面即 siteCode 本身);
|
||
* 出参刻意不含 formCode/flowGuid 与任何办件人/办件量信息(§1 匿名端点约束)。
|
||
*/
|
||
public HallModels.ItemGuideVo hallItemGuide(String siteCode, String itemCode) {
|
||
if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 与 itemCode 均为必填");
|
||
}
|
||
SiteInfo site = siteDirectory.requireSite(siteCode.trim());
|
||
ServiceItem item = itemRepository
|
||
.findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), itemCode.trim())
|
||
.orElse(null);
|
||
if (item == null || item.getStatus() == null
|
||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
|
||
}
|
||
HallModels.ItemGuideVo vo = new HallModels.ItemGuideVo();
|
||
vo.setId(item.getId());
|
||
vo.setItemCode(item.getItemCode());
|
||
vo.setTitle(item.getTitle());
|
||
vo.setSummary(item.getSummary());
|
||
vo.setObjectKey(item.getObjectKey());
|
||
vo.setCategoryKey(item.getCategoryKey());
|
||
vo.setCategoryName(item.getCategoryKey() == null ? null
|
||
: dictService.nameMap(site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY)
|
||
.get(item.getCategoryKey()));
|
||
vo.setDeptName(item.getDeptName());
|
||
vo.setHandleMode(item.getHandleMode());
|
||
vo.setOnlineFlag(item.getOnlineFlag());
|
||
vo.setApplyUrl(item.getApplyUrl());
|
||
vo.setHandlePlace(item.getHandlePlace());
|
||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||
vo.setContactPhone(item.getContactPhone());
|
||
vo.setSupervisePhone(item.getSupervisePhone());
|
||
vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||
vo.setViewCount(item.getViewCount());
|
||
vo.setCoopDepts(item.getCoopDepts());
|
||
vo.setHandlerName(item.getHandlerName());
|
||
vo.setHandleTimeDesc(item.getHandleTimeDesc());
|
||
vo.setRunCount(item.getRunCount());
|
||
vo.setExpressFlag(item.getExpressFlag());
|
||
vo.setFeeDesc(item.getFeeDesc());
|
||
vo.setFeeBasis(item.getFeeBasis());
|
||
vo.setProcessHtml(FileUtil.absolutize(item.getProcessHtml()));
|
||
vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml()));
|
||
vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml()));
|
||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(site.getSchId(), item.getId())) {
|
||
HallModels.Attachment attachment = new HallModels.Attachment();
|
||
attachment.setFileName(a.getFileName());
|
||
attachment.setFileUrl(FileUtil.absolutizePlain(a.getFileUrl()));
|
||
attachment.setFileSize(a.getFileSize());
|
||
vo.getAttachments().add(attachment);
|
||
}
|
||
return vo;
|
||
}
|
||
|
||
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 ⇒ 生效流程(事项自身或绑定表单的)非空。
|
||
* <br>v3/TASK_1 追加:表单中心绑定必须<b>成对</b>提交,且必须在写库前经远端核验
|
||
* (核验失败=抛错=整次 {@code adminSave} 回滚,不存在"uuid 落了、版本没落"的半个绑定)。
|
||
* 这里刻意不做"没有表单中心坐标时回落本地 form_code"的第二套口径:本地轨只读不写。 */
|
||
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 fcFormUuid = trimToNull(req.getFcFormUuid());
|
||
Integer fcFormVersion = req.getFcFormVersion();
|
||
if ((fcFormUuid == null) != (fcFormVersion == null)) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"表单中心绑定必须成对提交(fcFormUuid 与 fcFormVersion 同传或都不传;"
|
||
+ "当前 uuid=" + fcFormUuid + ",version=" + fcFormVersion + ")");
|
||
}
|
||
if (fcFormUuid != null) {
|
||
if (formCenterClient == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心客户端未装配,无法核验绑定坐标");
|
||
}
|
||
formCenterClient.requireBindable(site.getSchId(), fcFormUuid, fcFormVersion);
|
||
}
|
||
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 && effectiveFlowGuid(site.getSchId(), flowGuid, formCode) == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"可在线办理的事项必须绑定流程(自身或所选表单二选一);线下事项请关闭\"可在线办理\"开关");
|
||
}
|
||
}
|
||
|
||
/** 部门校验+取权威名:组树不可达时拒绝(防脏快照),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;
|
||
}
|
||
|
||
/**
|
||
* 跨服务坐标用(表单中心 uuid):去空白后入库,空串归 null。
|
||
* {@link #emptyToNull} 不去空白——历史列里"首尾带空格的 code"从来不是问题,
|
||
* 但 uuid 要和中心逐字等值比对,留着空格会让核验通过后存进一个查不回的值。
|
||
*/
|
||
private static String trimToNull(String value) {
|
||
return StringUtils.hasText(value) ? value.trim() : null;
|
||
}
|
||
|
||
/** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径);折叠历史叠加段自愈 */
|
||
private static String toRelativeIfLocal(String coverImage) {
|
||
if (!StringUtils.hasText(coverImage)) {
|
||
return null;
|
||
}
|
||
int idx = coverImage.indexOf("/upload/");
|
||
return idx >= 0 ? FileUtil.squashDuplicateUploadSeg(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;
|
||
}
|
||
}
|