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