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)。 * *

用 setter 而不是加进 {@code @RequiredArgsConstructor} 的构造参数:本类那 11 参构造 * 已被 {@code ServiceItemEffectiveFlowTest}/{@code ServiceItemFlowRegTest} 逐位钉住,而那两份测试 * 不在本任务的可改清单里——多一个入参会让它们直接编译不过。绑定核验必须在 * {@link #adminSave} 的事务内发生(远端失败=整次保存回滚,不留半个绑定),所以客户端装配在这里, * 而不是挪到控制器入口。

*/ private FormCenterCatalogClient formCenterClient; @org.springframework.beans.factory.annotation.Autowired void setFormCenterClient(FormCenterCatalogClient formCenterClient) { this.formCenterClient = formCenterClient; } // ===== 管理端 ===== public Map 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 spec = (root, cq, cb) -> { List 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 objectNames = dictService.nameMap( site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.OBJECT); Map categoryNames = dictService.nameMap( site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY); Page page = itemRepository.findAll(spec, pageable); List items = new ArrayList<>(); for (ServiceItem item : page.getContent()) { items.add(SvcItemPageItem.of(item, objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey()))); } Map 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); // 封面出参绝对化(与他端列表/详情口径一致),否则管理端编辑页 拿相对路径 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 时确保 * 有效回调注册存在(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 adminSelList(BoaUser user, String siteCode) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); List 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 result = new ArrayList<>(); for (ServiceItem item : items) { result.add(SvcItemSimpleVo.of(item)); } return result; } @Transactional public void adminSort(BoaUser user, String siteCode, List items) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); for (IdSortItem pair : items == null ? new ArrayList() : items) { ServiceItem item = requireItem(site, pair.getId()); item.setSortNo(pair.getSortNo()); itemRepository.save(item); } } /** 同步部门名快照(承袭 CMS syncDeptName):按组树刷新全站 dept_name,返回更新条数 */ @Transactional public Map syncDeptName(BoaUser user, String siteCode) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); Map deptNames = new LinkedHashMap<>(); collectDeptNodes(deptRootOfSite(site), deptNames); if (deptNames.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名"); } int updated = 0; List 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 result = new HashMap<>(); result.put("updated", updated); return result; } /** 服务部门选项(本站所属学校子树内、容器组下的全部子节点;平台不可达降级空列表) */ public List deptTree(BoaUser user, String siteCode) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); return buildDeptTree(deptRootOfSite(site)); } /** 管理端:事项当前绑定的指南附件(编辑回显) */ public List 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 result = itemRepository.findAll((root, cq, cb) -> { List 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 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 objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode)); Map 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 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 attachmentsByItemId(Integer itemId) { List 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 relatedByItemId(Integer itemId, Integer limit) { List 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 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 ⇒ 生效流程(事项自身或绑定表单的)非空。 *
v3/TASK_1 追加:表单中心绑定必须成对提交,且必须在写库前经远端核验 * (核验失败=抛错=整次 {@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 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 buildDeptTree(BoaGroup root) { List top = new ArrayList<>(); if (root == null) { return top; } for (BoaGroup container : containersOf(root)) { List children = toDeptVos(container.getChildren()); if (children.isEmpty()) { top.add(new SvcDeptVo(container.getId(), container.getName())); } else { top.addAll(children); } } return top; } private List containersOf(BoaGroup root) { List out = new ArrayList<>(); collectContainers(root, out); return out; } private void collectContainers(BoaGroup node, List 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 toDeptVos(List nodes) { List 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 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 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 countMap(List rows) { Map map = new HashMap<>(); for (Object[] row : rows) { map.put(String.valueOf(row[0]), ((Number) row[1]).longValue()); } return map; } }