Files
svc-hall-service/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java
T
chenxing 391281582a
build / build-push (push) Successful in 1m21s
t1~t3+精修: 事项FC绑定/可信建件(意图+投影+恢复)/答卷对账Worker
含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
2026-10-22 10:03:07 +08:00

958 lines
46 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}