This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.HandleModeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.oapi.OapiModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.IdSortItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcDeptVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemPageQuery;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSimpleVo;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.DeptSource;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FileUtil;
|
||||
import com.yunhai.svchall.utils.HtmlSanitizer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.annotation.PreDestroy;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事项服务(Cms SvcItemService 迁移版,V2.0 Part B):
|
||||
* 管理端 CRUD/发布/下架/删除/排序/部门名同步 + oapi 供数查询(items/filters/detail/view/snapshot)。
|
||||
* 迁移差异:① 权限由 siteCode + CMS 站点目录解析(SiteGuard);② 关联 CMS 栏目为不透明引用不校验;
|
||||
* ③ 附件为显式绑定(无内容派生归属);④ 新增 formCode/flowGuid 绑定校验(onlineFlag=1 ⇒ flowGuid);
|
||||
* ⑤ oapi 出参 HTML/封面/附件绝对化(FileUtil)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceItemService {
|
||||
|
||||
/** applyUrl 形态(三态优先级最高的一态) */
|
||||
private static final Pattern APPLY_URL_PATTERN = Pattern.compile("^https?://\\S+$");
|
||||
|
||||
/** 选择器/同步部门名的全量扫描上限(防御异常库容) */
|
||||
private static final int SEL_LIST_MAX = 500;
|
||||
|
||||
/** 浏览量异步队列容量(满则丢弃计数并告警,不反压请求线程) */
|
||||
private static final int VIEW_QUEUE_CAPACITY = 1000;
|
||||
|
||||
/** 部门"容器组"类型(默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type) */
|
||||
@Value("${svchall.item.dept-group-type:7}")
|
||||
private int deptGroupType = 7;
|
||||
|
||||
/** 本应用对外可达基址(回调注册 callbackUrl 前缀;projman 注入,本地默认 dev) */
|
||||
@Value("${svchall.public-base-url:http://localhost:8086}")
|
||||
private String callbackPublicBase;
|
||||
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFormRepository formRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFlowRegRepository flowRegRepository;
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final ServiceDictService dictService;
|
||||
private final ServiceFormService formService;
|
||||
private final AttachmentService attachmentService;
|
||||
private final SiteGuard siteGuard;
|
||||
private final DeptSource deptSource;
|
||||
private final FlowClient flowClient;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
// ===== 管理端 =====
|
||||
|
||||
public Map<String, Object> adminPage(BoaUser user, SvcItemPageQuery query) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, query.getSiteCode());
|
||||
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
|
||||
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
|
||||
Pageable pageable = PageRequest.of(pageIndex, Math.min(pageSize, 100),
|
||||
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("createTime"), Sort.Order.desc("id")));
|
||||
Specification<ServiceItem> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), site.getSchId()));
|
||||
predicates.add(cb.equal(root.get("siteCode"), site.getSiteCode()));
|
||||
if (query.getStatus() != null) {
|
||||
predicates.add(cb.equal(root.get("status"), query.getStatus()));
|
||||
}
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String like = "%" + query.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||
cb.like(root.get("itemCode"), like)));
|
||||
}
|
||||
if (StringUtils.hasText(query.getObjectKey())) {
|
||||
predicates.add(cb.equal(root.get("objectKey"), query.getObjectKey()));
|
||||
}
|
||||
if (StringUtils.hasText(query.getCategoryKey())) {
|
||||
predicates.add(cb.equal(root.get("categoryKey"), query.getCategoryKey()));
|
||||
}
|
||||
if (query.getDeptGroupId() != null) {
|
||||
predicates.add(cb.equal(root.get("deptGroupId"), query.getDeptGroupId()));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Map<String, String> objectNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.OBJECT);
|
||||
Map<String, String> categoryNames = dictService.nameMap(
|
||||
site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY);
|
||||
Page<ServiceItem> page = itemRepository.findAll(spec, pageable);
|
||||
List<SvcItemPageItem> items = new ArrayList<>();
|
||||
for (ServiceItem item : page.getContent()) {
|
||||
items.add(SvcItemPageItem.of(item,
|
||||
objectNames.get(item.getObjectKey()), categoryNames.get(item.getCategoryKey())));
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", page.getTotalElements());
|
||||
result.put("list", items);
|
||||
return result;
|
||||
}
|
||||
|
||||
public SvcItemEditVo adminGet(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
return SvcItemEditVo.of(requireItem(site, id));
|
||||
}
|
||||
|
||||
/** 保存:itemCode 唯一/字典键归属/部门快照/applyUrl 白名单/表单与流程绑定校验;指南附件全量重绑 */
|
||||
@Transactional
|
||||
public Integer adminSave(BoaUser user, SvcItemSaveRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
validateItemCode(site, req);
|
||||
validateDictKeys(site, req);
|
||||
validateBindings(site, req);
|
||||
String deptName = resolveDeptName(site, req.getDeptGroupId());
|
||||
String applyUrl = validateApplyUrl(req.getApplyUrl());
|
||||
|
||||
ServiceItem item;
|
||||
if (req.getId() == null) {
|
||||
item = new ServiceItem();
|
||||
item.setSchId(site.getSchId());
|
||||
item.setSiteCode(site.getSiteCode());
|
||||
item.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
|
||||
item.setViewCount(0);
|
||||
} else {
|
||||
item = requireItem(site, req.getId());
|
||||
}
|
||||
item.setItemCode(req.getItemCode());
|
||||
item.setTitle(req.getTitle());
|
||||
item.setSummary(req.getSummary());
|
||||
item.setObjectKey(emptyToNull(req.getObjectKey()));
|
||||
item.setCategoryKey(emptyToNull(req.getCategoryKey()));
|
||||
item.setDeptGroupId(req.getDeptGroupId());
|
||||
item.setDeptName(deptName);
|
||||
item.setHandleMode(req.getHandleMode() == null ? HandleModeEnum.ONLINE.getCode() : req.getHandleMode());
|
||||
if (!HandleModeEnum.isValid(item.getHandleMode())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理方式非法:" + item.getHandleMode());
|
||||
}
|
||||
item.setOnlineFlag(req.getOnlineFlag() == null || req.getOnlineFlag() != 0 ? 1 : 0);
|
||||
item.setHandlePlace(req.getHandlePlace());
|
||||
item.setHandleTimeLimit(req.getHandleTimeLimit());
|
||||
item.setPromiseTimeLimit(req.getPromiseTimeLimit());
|
||||
item.setContactPhone(req.getContactPhone());
|
||||
item.setSupervisePhone(req.getSupervisePhone());
|
||||
item.setProcessHtml(HtmlSanitizer.clean(req.getProcessHtml()));
|
||||
item.setMaterialHtml(HtmlSanitizer.clean(req.getMaterialHtml()));
|
||||
item.setNoticeHtml(HtmlSanitizer.clean(req.getNoticeHtml()));
|
||||
// 关联 CMS 栏目:不透明引用(V2.0 Part C.2/R2),不校验存在性
|
||||
item.setFaqCategoryId(req.getFaqCategoryId());
|
||||
item.setNoticeCategoryId(req.getNoticeCategoryId());
|
||||
item.setRuleCategoryId(req.getRuleCategoryId());
|
||||
item.setCoverImage(toRelativeIfLocal(req.getCoverImage()));
|
||||
item.setFormCode(emptyToNull(req.getFormCode()));
|
||||
item.setFlowGuid(emptyToNull(req.getFlowGuid()));
|
||||
item.setApplyUrl(applyUrl);
|
||||
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||
item = itemRepository.save(item);
|
||||
|
||||
attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds());
|
||||
ensureFlowReg(site, item.getFlowGuid());
|
||||
return item.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换):事项绑 flow_guid 时确保<b>有效</b>回调注册存在
|
||||
* (reg 下发 secret 落 service_flow_reg)。仅 reg 缺失/失效(secret 空或 status≠0)时才注册——
|
||||
* flow-center 重复注册会轮换 secret,导致已存密钥验签失配;失效注册原地更新同一行拿回新密钥。
|
||||
* best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。
|
||||
*/
|
||||
void ensureFlowReg(SiteInfo site, String flowGuid) {
|
||||
if (flowGuid == null || !flowClient.enabled()) {
|
||||
return;
|
||||
}
|
||||
com.yunhai.svchall.entity.ServiceFlowReg reg = flowRegRepository
|
||||
.findBySchIdAndFlowGuid(site.getSchId(), flowGuid).orElse(null);
|
||||
if (reg != null && reg.getStatus() != null && reg.getStatus() == 0
|
||||
&& reg.getSecret() != null && !reg.getSecret().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String callbackUrl = callbackPublicBase + "/callback/flow";
|
||||
try {
|
||||
String secret = flowClient.registerCallback(site.getSchId(), flowGuid, callbackUrl, null);
|
||||
if (reg == null) {
|
||||
reg = new com.yunhai.svchall.entity.ServiceFlowReg();
|
||||
reg.setSchId(site.getSchId());
|
||||
reg.setFlowGuid(flowGuid);
|
||||
}
|
||||
reg.setCallbackUrl(callbackUrl);
|
||||
reg.setSecret(secret);
|
||||
reg.setStatus(0);
|
||||
flowRegRepository.save(reg);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}",
|
||||
site.getSiteCode(), flowGuid, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminPublish(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceItem item = requireItem(site, id);
|
||||
if (!StringUtils.hasText(item.getTitle())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项名称缺失,不可发布");
|
||||
}
|
||||
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||
item.setPublishTime(LocalDateTime.now());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
|
||||
/** 下架:已发布 → 已下架(门户经 oapi 即刻不可见;在途办件不受影响——快照隔离) */
|
||||
@Transactional
|
||||
public void adminOffline(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
ServiceItem item = requireItem(site, id);
|
||||
item.setStatus(ServiceItemStatusEnum.OFFLINE.getCode());
|
||||
item.setOfflineTime(LocalDateTime.now());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
|
||||
/** 删除:物理删除 + 指南附件解绑(行保留未绑定态);办件侧靠快照隔离不受影响 */
|
||||
@Transactional
|
||||
public void adminDelete(BoaUser user, DeleteRequest req) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, req.getSiteCode());
|
||||
ServiceItem item = requireItem(site, req.getId());
|
||||
attachmentService.releaseGuide(site.getSchId(), item.getId());
|
||||
itemRepository.delete(item);
|
||||
}
|
||||
|
||||
public List<SvcItemSimpleVo> adminSelList(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
List<ServiceItem> items = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX));
|
||||
if (items.size() >= SEL_LIST_MAX) {
|
||||
log.warn("[svc-hall] 事项选择器命中上限 {} 条(siteCode={}),结果已截断", SEL_LIST_MAX, siteCode);
|
||||
}
|
||||
List<SvcItemSimpleVo> result = new ArrayList<>();
|
||||
for (ServiceItem item : items) {
|
||||
result.add(SvcItemSimpleVo.of(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminSort(BoaUser user, String siteCode, List<IdSortItem> items) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
for (IdSortItem pair : items == null ? new ArrayList<IdSortItem>() : items) {
|
||||
ServiceItem item = requireItem(site, pair.getId());
|
||||
item.setSortNo(pair.getSortNo());
|
||||
itemRepository.save(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步部门名快照(承袭 CMS syncDeptName):按组树刷新全站 dept_name,返回更新条数 */
|
||||
@Transactional
|
||||
public Map<String, Object> syncDeptName(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||
collectDeptNodes(deptRootOfSite(site), deptNames);
|
||||
if (deptNames.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "部门组树不可达或为空,未同步任何部门名");
|
||||
}
|
||||
int updated = 0;
|
||||
List<ServiceItem> candidates = itemRepository.findBySchIdAndSiteCodeOrderBySortNoAscIdAsc(
|
||||
site.getSchId(), site.getSiteCode(), PageRequest.of(0, SEL_LIST_MAX));
|
||||
for (ServiceItem item : candidates) {
|
||||
if (item.getDeptGroupId() == null) {
|
||||
continue;
|
||||
}
|
||||
String fresh = deptNames.get(item.getDeptGroupId());
|
||||
if (fresh != null && !fresh.equals(item.getDeptName())) {
|
||||
item.setDeptName(fresh);
|
||||
itemRepository.save(item);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", updated);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 服务部门选项(本站所属学校子树内、容器组下的全部子节点;平台不可达降级空列表) */
|
||||
public List<SvcDeptVo> deptTree(BoaUser user, String siteCode) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
return buildDeptTree(deptRootOfSite(site));
|
||||
}
|
||||
|
||||
/** 管理端:事项当前绑定的指南附件(编辑回显) */
|
||||
public List<SvcItemAttachmentVo> itemAttachments(BoaUser user, String siteCode, Integer itemId) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
requireItem(site, itemId);
|
||||
return attachmentService.guideAttachments(site.getSchId(), itemId);
|
||||
}
|
||||
|
||||
// ===== oapi 供数(V2.0 Part C.2 契约;oapi 控制器负责信封与缺参报错)=====
|
||||
|
||||
public OapiModels.ListData oapiItems(Long schId, String siteCode, String objectKey, String categoryKey,
|
||||
Integer deptGroupId, String keyword, Integer page, Integer pageSize) {
|
||||
int pageIndex = page == null || page < 1 ? 0 : page - 1;
|
||||
int size = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
|
||||
Pageable pageable = PageRequest.of(pageIndex, size,
|
||||
Sort.by(Sort.Order.asc("sortNo"), Sort.Order.desc("publishTime"), Sort.Order.desc("id")));
|
||||
Page<ServiceItem> result = itemRepository.findAll((root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), schId));
|
||||
predicates.add(cb.equal(root.get("siteCode"), siteCode));
|
||||
predicates.add(cb.equal(root.get("status"), ServiceItemStatusEnum.PUBLISHED.getCode()));
|
||||
if (StringUtils.hasText(objectKey)) {
|
||||
predicates.add(cb.equal(root.get("objectKey"), objectKey));
|
||||
}
|
||||
if (StringUtils.hasText(categoryKey)) {
|
||||
predicates.add(cb.equal(root.get("categoryKey"), categoryKey));
|
||||
}
|
||||
if (deptGroupId != null) {
|
||||
predicates.add(cb.equal(root.get("deptGroupId"), deptGroupId));
|
||||
}
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
String like = "%" + keyword.trim() + "%";
|
||||
predicates.add(cb.or(cb.like(root.get("title"), like),
|
||||
cb.like(root.get("summary"), like)));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
}, pageable);
|
||||
Map<String, String> categoryNames = dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY);
|
||||
OapiModels.ListData data = new OapiModels.ListData();
|
||||
data.setTotal(result.getTotalElements());
|
||||
data.setPage(pageIndex + 1);
|
||||
data.setPageSize(pageable.getPageSize());
|
||||
data.setTotalPages(result.getTotalPages());
|
||||
for (ServiceItem item : result.getContent()) {
|
||||
OapiModels.ItemRow row = new OapiModels.ItemRow();
|
||||
row.setId(item.getId());
|
||||
row.setItemCode(item.getItemCode());
|
||||
row.setTitle(item.getTitle());
|
||||
row.setSummary(item.getSummary());
|
||||
row.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
row.setDeptName(item.getDeptName());
|
||||
row.setCategoryName(categoryNames.get(item.getCategoryKey()));
|
||||
row.setHandleMode(item.getHandleMode());
|
||||
row.setOnlineFlag(item.getOnlineFlag());
|
||||
row.setPublishTime(item.getPublishTime());
|
||||
data.getItems().add(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public OapiModels.Filters oapiFilters(Long schId, String siteCode) {
|
||||
OapiModels.Filters filters = new OapiModels.Filters();
|
||||
Map<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode));
|
||||
Map<String, Long> categoryCounts = countMap(itemRepository.countPublishedGroupByCategoryKey(schId, siteCode));
|
||||
for (com.yunhai.svchall.entity.ServiceDict dict : dictService
|
||||
.enabledDicts(schId, siteCode, ServiceDictTypeEnum.OBJECT)) {
|
||||
Long count = objectCounts.get(dict.getDictKey());
|
||||
if (count != null && count > 0) {
|
||||
filters.getObjects().add(new OapiModels.FilterOption(
|
||||
dict.getDictKey(), null, dict.getDictName(), count));
|
||||
}
|
||||
}
|
||||
for (com.yunhai.svchall.entity.ServiceDict dict : dictService
|
||||
.enabledDicts(schId, siteCode, ServiceDictTypeEnum.CATEGORY)) {
|
||||
Long count = categoryCounts.get(dict.getDictKey());
|
||||
if (count != null && count > 0) {
|
||||
filters.getCategories().add(new OapiModels.FilterOption(
|
||||
dict.getDictKey(), null, dict.getDictName(), count));
|
||||
}
|
||||
}
|
||||
for (Object[] row : itemRepository.countPublishedGroupByDept(schId, siteCode)) {
|
||||
filters.getDepts().add(new OapiModels.FilterOption(null,
|
||||
((Number) row[0]).intValue(), String.valueOf(row[1]), ((Number) row[2]).longValue()));
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/** 指南页详情(三段 HTML/封面/附件绝对化;关联事项同类别 Top N;不存在/未发布返回 null) */
|
||||
public OapiModels.Detail oapiDetail(Long schId, String siteCode, Integer itemId, String itemCode) {
|
||||
ServiceItem item = itemId != null
|
||||
? itemRepository.findByIdAndSchIdAndSiteCode(itemId, schId, siteCode).orElse(null)
|
||||
: itemRepository.findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
OapiModels.Detail vo = new OapiModels.Detail();
|
||||
vo.setId(item.getId());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setObjectKey(item.getObjectKey());
|
||||
vo.setObjectName(item.getObjectKey() == null ? null
|
||||
: dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.OBJECT).get(item.getObjectKey()));
|
||||
vo.setCategoryKey(item.getCategoryKey());
|
||||
vo.setCategoryName(item.getCategoryKey() == null ? null
|
||||
: dictService.nameMap(schId, siteCode, ServiceDictTypeEnum.CATEGORY).get(item.getCategoryKey()));
|
||||
vo.setDeptGroupId(item.getDeptGroupId());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setHandlePlace(item.getHandlePlace());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setSupervisePhone(item.getSupervisePhone());
|
||||
vo.setProcessHtml(FileUtil.absolutize(item.getProcessHtml()));
|
||||
vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml()));
|
||||
vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml()));
|
||||
vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
vo.setViewCount(item.getViewCount());
|
||||
vo.setFaqCategoryId(item.getFaqCategoryId());
|
||||
vo.setNoticeCategoryId(item.getNoticeCategoryId());
|
||||
vo.setRuleCategoryId(item.getRuleCategoryId());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setFlowGuid(item.getFlowGuid());
|
||||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(schId, item.getId())) {
|
||||
OapiModels.AttachmentRow row = new OapiModels.AttachmentRow();
|
||||
row.setId(a.getId());
|
||||
row.setFileName(a.getFileName());
|
||||
row.setFileUrl(a.getFileUrl());
|
||||
row.setFileSize(a.getFileSize());
|
||||
vo.getAttachments().add(row);
|
||||
}
|
||||
if (StringUtils.hasText(item.getCategoryKey())) {
|
||||
List<ServiceItem> related = itemRepository
|
||||
.findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(schId, siteCode,
|
||||
ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), item.getId(),
|
||||
PageRequest.of(0, 5, Sort.by(Sort.Order.asc("sortNo"),
|
||||
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
|
||||
for (ServiceItem other : related) {
|
||||
OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow();
|
||||
row.setId(other.getId());
|
||||
row.setItemCode(other.getItemCode());
|
||||
row.setTitle(other.getTitle());
|
||||
row.setDeptName(other.getDeptName());
|
||||
row.setHandleMode(other.getHandleMode());
|
||||
vo.getRelatedItems().add(row);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 跨服务要点快照(docs/17 §1.1 契约平移;查不到/未发布返回 null,不做全局降级查询) */
|
||||
public OapiModels.Snapshot oapiSnapshot(String siteCode, String itemCode) {
|
||||
ServiceItem item = itemRepository.findBySchIdAndSiteCodeAndItemCode(resolveSchIdByCode(siteCode),
|
||||
siteCode, itemCode).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
OapiModels.Snapshot vo = new OapiModels.Snapshot();
|
||||
vo.setSiteCode(siteCode);
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点解析(snapshot 场景只有 siteCode):经站点目录定 schId。
|
||||
* 目录不可达返回 null(oapi 层转 code=2),不做"仅按 itemCode 全局搜"降级。
|
||||
*/
|
||||
private Long resolveSchIdByCode(String siteCode) {
|
||||
SiteDirectory.SiteInfo site = siteDirectory.resolveSite(siteCode);
|
||||
return site == null ? null : site.getSchId();
|
||||
}
|
||||
|
||||
/** 指南页浏览量异步累加(承袭 CMS 有界队列口径)===== */
|
||||
|
||||
/** 按 itemId 直取发布态事项(主键全局唯一,无需站点解析;oapi portal-extras 端点用) */
|
||||
private ServiceItem requirePublishedById(Integer itemId) {
|
||||
ServiceItem item = itemRepository.findById(itemId).orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 指南附件(按 itemId 直取;fileUrl 已绝对化) */
|
||||
public List<OapiModels.AttachmentRow> attachmentsByItemId(Integer itemId) {
|
||||
List<OapiModels.AttachmentRow> result = new ArrayList<>();
|
||||
ServiceItem item = requirePublishedById(itemId);
|
||||
if (item == null) {
|
||||
return result;
|
||||
}
|
||||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(item.getSchId(), itemId)) {
|
||||
OapiModels.AttachmentRow row = new OapiModels.AttachmentRow();
|
||||
row.setId(a.getId());
|
||||
row.setFileName(a.getFileName());
|
||||
row.setFileUrl(a.getFileUrl());
|
||||
row.setFileSize(a.getFileSize());
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 关联事项(按 itemId 直取:同类别已发布 Top N,排除自身) */
|
||||
public List<OapiModels.RelatedItemRow> relatedByItemId(Integer itemId, Integer limit) {
|
||||
List<OapiModels.RelatedItemRow> result = new ArrayList<>();
|
||||
ServiceItem item = requirePublishedById(itemId);
|
||||
if (item == null || !StringUtils.hasText(item.getCategoryKey())) {
|
||||
return result;
|
||||
}
|
||||
int size = limit == null || limit < 1 ? 5 : Math.min(limit, 20);
|
||||
List<ServiceItem> related = itemRepository
|
||||
.findBySchIdAndSiteCodeAndStatusAndCategoryKeyAndIdNot(item.getSchId(), item.getSiteCode(),
|
||||
ServiceItemStatusEnum.PUBLISHED.getCode(), item.getCategoryKey(), itemId,
|
||||
PageRequest.of(0, size, Sort.by(Sort.Order.asc("sortNo"),
|
||||
Sort.Order.desc("publishTime"), Sort.Order.desc("id"))));
|
||||
for (ServiceItem other : related) {
|
||||
OapiModels.RelatedItemRow row = new OapiModels.RelatedItemRow();
|
||||
row.setId(other.getId());
|
||||
row.setItemCode(other.getItemCode());
|
||||
row.setTitle(other.getTitle());
|
||||
row.setDeptName(other.getDeptName());
|
||||
row.setHandleMode(other.getHandleMode());
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 浏览量累加(按 itemId 直取;fire-and-forget) */
|
||||
public void countViewByItemId(Integer itemId) {
|
||||
if (itemId == null || !itemRepository.findById(itemId).isPresent()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(
|
||||
status -> itemRepository.incrementViewCount(itemId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
public void countView(Long schId, String siteCode, String itemCode) {
|
||||
ServiceItem item = itemRepository
|
||||
.findBySchIdAndSiteCodeAndItemCode(schId, siteCode, itemCode).orElse(null);
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
Integer itemId = item.getId();
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(
|
||||
status -> itemRepository.incrementViewCount(itemId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 事项浏览量累加失败 itemId={}(忽略)", itemId, e);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
log.warn("[svc-hall] 事项浏览量任务提交被拒 itemId={}(队列已满或正在关闭,忽略)", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
private final ExecutorService executor = new ThreadPoolExecutor(
|
||||
1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(VIEW_QUEUE_CAPACITY),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "svchall-view-counter");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
(runnable, pool) -> log.warn("[svc-hall] 事项浏览量队列已满(上限 {}),丢弃一次计数", VIEW_QUEUE_CAPACITY));
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
executor.shutdown();
|
||||
try {
|
||||
executor.awaitTermination(3, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 校验与装配 =====
|
||||
|
||||
private void validateItemCode(SiteInfo site, SvcItemSaveRequest req) {
|
||||
boolean duplicated = req.getId() == null
|
||||
? itemRepository.existsBySchIdAndSiteCodeAndItemCode(
|
||||
site.getSchId(), site.getSiteCode(), req.getItemCode())
|
||||
: itemRepository.existsBySchIdAndSiteCodeAndItemCodeAndIdNot(
|
||||
site.getSchId(), site.getSiteCode(), req.getItemCode(), req.getId());
|
||||
if (duplicated) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项编码已存在:" + req.getItemCode() + "(站内唯一)");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDictKeys(SiteInfo site, SvcItemSaveRequest req) {
|
||||
if (StringUtils.hasText(req.getObjectKey())) {
|
||||
dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(),
|
||||
ServiceDictTypeEnum.OBJECT, req.getObjectKey());
|
||||
}
|
||||
if (StringUtils.hasText(req.getCategoryKey())) {
|
||||
dictService.requireEnabledKey(site.getSchId(), site.getSiteCode(),
|
||||
ServiceDictTypeEnum.CATEGORY, req.getCategoryKey());
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ flowGuid 非空 */
|
||||
private void validateBindings(SiteInfo site, SvcItemSaveRequest req) {
|
||||
String formCode = emptyToNull(req.getFormCode());
|
||||
if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理表单不存在:" + formCode);
|
||||
}
|
||||
String flowGuid = emptyToNull(req.getFlowGuid());
|
||||
if (flowGuid != null && flowClient.enabled()) {
|
||||
if (flowClient.getFlow(site.getSchId(), flowGuid) == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理流程不存在或未发布:" + flowGuid);
|
||||
}
|
||||
}
|
||||
boolean online = req.getOnlineFlag() == null || req.getOnlineFlag() != 0;
|
||||
if (online && flowGuid == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"可在线办理的事项必须绑定流程(flowGuid);线下事项请关闭\"可在线办理\"开关");
|
||||
}
|
||||
}
|
||||
|
||||
/** 部门校验+取权威名:组树不可达时拒绝(防脏快照),id 不在部门集合内拒绝 */
|
||||
private String resolveDeptName(SiteInfo site, Integer deptGroupId) {
|
||||
if (deptGroupId == null) {
|
||||
return null;
|
||||
}
|
||||
Map<Integer, String> deptNames = new LinkedHashMap<>();
|
||||
collectDeptNodes(deptRootOfSite(site), deptNames);
|
||||
String name = deptNames.get(deptGroupId);
|
||||
if (name == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"服务部门不存在或部门服务不可达(id=" + deptGroupId + "),请重新选择");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private BoaGroup deptRootOfSite(SiteInfo site) {
|
||||
Integer schId = site.getSchId() == null ? null : site.getSchId().intValue();
|
||||
if (schId == null) {
|
||||
log.warn("[svc-hall] 站点 {} 未配置 sch_id,服务部门回退全平台组树(跨校可见,请补站点学校归属)",
|
||||
site.getSiteCode());
|
||||
return deptSource.getGroupTree();
|
||||
}
|
||||
BoaGroup subtree = deptSource.getGroupSubtree(schId);
|
||||
if (subtree == null) {
|
||||
log.warn("[svc-hall] 站点 {} 的学校组子树不可达(schId={}),服务部门按空列表处理",
|
||||
site.getSiteCode(), schId);
|
||||
}
|
||||
return subtree;
|
||||
}
|
||||
|
||||
private List<SvcDeptVo> buildDeptTree(BoaGroup root) {
|
||||
List<SvcDeptVo> top = new ArrayList<>();
|
||||
if (root == null) {
|
||||
return top;
|
||||
}
|
||||
for (BoaGroup container : containersOf(root)) {
|
||||
List<SvcDeptVo> children = toDeptVos(container.getChildren());
|
||||
if (children.isEmpty()) {
|
||||
top.add(new SvcDeptVo(container.getId(), container.getName()));
|
||||
} else {
|
||||
top.addAll(children);
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private List<BoaGroup> containersOf(BoaGroup root) {
|
||||
List<BoaGroup> out = new ArrayList<>();
|
||||
collectContainers(root, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectContainers(BoaGroup node, List<BoaGroup> out) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
if (node.getType() != null && node.getType() == deptGroupType) {
|
||||
out.add(node);
|
||||
return;
|
||||
}
|
||||
if (node.getChildren() != null) {
|
||||
for (BoaGroup child : node.getChildren()) {
|
||||
collectContainers(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SvcDeptVo> toDeptVos(List<BoaGroup> nodes) {
|
||||
List<SvcDeptVo> out = new ArrayList<>();
|
||||
if (nodes == null) {
|
||||
return out;
|
||||
}
|
||||
for (BoaGroup node : nodes) {
|
||||
if (node == null || node.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
SvcDeptVo vo = new SvcDeptVo(node.getId(), node.getName());
|
||||
vo.setChildren(toDeptVos(node.getChildren()));
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void collectDeptNodes(BoaGroup root, Map<Integer, String> out) {
|
||||
if (root == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaGroup container : containersOf(root)) {
|
||||
if (container.getChildren() == null || container.getChildren().isEmpty()) {
|
||||
if (container.getId() != null) {
|
||||
out.putIfAbsent(container.getId(), container.getName());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
collectDescendants(container, out);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectDescendants(BoaGroup parent, Map<Integer, String> out) {
|
||||
if (parent.getChildren() == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaGroup child : parent.getChildren()) {
|
||||
if (child.getId() != null) {
|
||||
out.putIfAbsent(child.getId(), child.getName());
|
||||
}
|
||||
collectDescendants(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
private String validateApplyUrl(String applyUrl) {
|
||||
if (!StringUtils.hasText(applyUrl)) {
|
||||
return null;
|
||||
}
|
||||
String url = applyUrl.trim();
|
||||
if (!APPLY_URL_PATTERN.matcher(url).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "我要办理外链必须是完整的 http(s):// 地址");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private ServiceItem requireItem(SiteInfo site, Integer id) {
|
||||
return itemRepository.findByIdAndSchIdAndSiteCode(id, site.getSchId(), site.getSiteCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在"));
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径) */
|
||||
private static String toRelativeIfLocal(String coverImage) {
|
||||
if (!StringUtils.hasText(coverImage)) {
|
||||
return null;
|
||||
}
|
||||
int idx = coverImage.indexOf("/upload/");
|
||||
return idx >= 0 ? coverImage.substring(idx) : coverImage;
|
||||
}
|
||||
|
||||
private static Map<String, Long> countMap(List<Object[]> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(String.valueOf(row[0]), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user