feat(svcitem): 事项库+办事大厅 R2 后端落地,并完成交付后独立审计修复
build / build-push (push) Successful in 2m26s

落地(依据《事项库与办事大厅详细设计 v1》)
- 事项域垂直包 com.yunhai.cms.svcitem.*(entity/enums/repository/service+impl/event/config/model dto+query+vo)
  与 controller/{api,oapi}/svcitem;两表 DDL 进 cms_init.sql(表 7/8)+ upgrade_svcitem_r2.sql
- SvcItemQuery 接缝:门户 8 方法 + 管理端 11 方法统一收口,控制器不再注入实现类
- 门户公开层:doServiceList/doGuide 双路由变体 + M0 可选模板整页回退
  (视图名/themeBase/buildUrl/资产主题段四处同源 effectiveTheme,缺模板整包退 default)
- oapi 跨服务快照:与平台 AppAuthFilter 同口径签名(canonical=method\npath?sortedQuery\ntimestamp,
  HmacSHA256/Base64, 5min 窗口)+ /oapi/* 强制验签过滤器
- default 皮肤 service.html/guide.html + main.css 服务中心/指南页样式段

审计修复(发现与处置全表见 docs/18-事项库审计与修复记录.md)
- D1 附件引用链:内容资产(封面+正文内嵌图)独立场景 svc_item_asset,保存时按内容派生全量同步归属
  (AttachmentService.localAssetUrls/localWebUrl/syncAssets:集合内标记、集合外解除)。
  修复根因:upload 带 scene 时只写 ref_type 不写 ref_id,而保存仅按前端提交 id 重绑,
  正文图与封面停在半绑定态 → 既不在孤儿口径内也不受 release 管辖,成永久无主行
- D5 归属校验:绑定前做可认领判定(无引用/半绑定/已属本事项方可绑),他事项引用与跨站附件跳过并告警
- D2 部门:组类型改配置 cms.svc.item.dept-group-type(默认 2=学校组,校内科室改 3);
  deptTree 返回嵌套组树;新增 SvcDeptSource 接缝使静态 getGroupTree 可替身(此前部门链零覆盖)
- D4 缓存:事件 scope 语义化,dict/dept 变更整体失效 cms:svcItem(展示名快照不再滞后 5min)
- 站点解析:findByCode 在 uk(parent_site_id,code) 下命中多条会抛异常变 500,
  4 处生产调用点改 findFirstByCodeOrderByIdAsc(确定性取最早站点)
- 加固:新增 idx_svc_item_portal(含 DESC 列)对症门户排序 filesort;升级脚本改 information_schema
  +PREPARE 幂等索引守卫块(表在缺索引可重跑修复);clear_cms_data.sql 补事项两表;
  view_count 改有界队列+拒绝丢弃告警;SEL_LIST_MAX 截断告警

测试
- 新增 SvcDeptChainIT 7、SvcItemServiceIT 16(含 D1/D5 回归锚点与全方法权限)、
  SvcItemOapiIT 9(时间戳超窗/未知 appKey/畸形头/siteCode 歧义)、SvcItemPortalIT 11
  (嵌套指南路由/关键词/canonical/浏览量异步)、SvcDictServiceIT 8(改名后缓存即时刷新)
- ThemeContractIT 补"缺可选模板主题资产段必退 default"断言并订正七路由表述
- 全量 mvn -o test:388 测试 0 失败;升级脚本 bsch_cms_test 两跑幂等(含缺索引自动补建)
This commit is contained in:
chenxing
2026-09-11 14:12:04 +08:00
parent 39454e4efb
commit 9e28092a8b
61 changed files with 5537 additions and 23 deletions
@@ -27,11 +27,17 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
@@ -46,6 +52,10 @@ public class AttachmentService {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
/** 富文本资产地址(img src / a href,单双引号皆可) */
private static final Pattern ASSET_URL_PATTERN =
Pattern.compile("(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE);
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
private static final List<String> ALLOWED_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
@@ -56,8 +66,14 @@ public class AttachmentService {
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / category=单页栏目贴图 */
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site", "category");
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图 / svc_item=事项指南附件(列表可见) / svc_item_asset=事项封面与正文内嵌资产。
* 事项侧拆两个场景的原因:指南附件查询(refType=svc_item, refId=itemId)必须只返回"用户显式上传的附件",
* 封面与正文图片若同场景会被混进指南页附件列表。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category", "svc_item", "svc_item_asset");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -69,12 +85,13 @@ public class AttachmentService {
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category,B2 初步标记) */
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category/svc_item/svc_item_asset,B2 初步标记) */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site/category)");
throw new BizException(ErrorCode.PARAM_ERROR,
"scene 非法:" + scene + "(仅 banner/link/site/category/svc_item/svc_item_asset)");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
@@ -155,6 +172,64 @@ public class AttachmentService {
});
}
/**
* 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。
*
* <p>兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空
* (外链、data: 等一律不参与场景引用)。
*/
public Optional<String> localWebUrl(String url) {
if (!StringUtils.hasText(url) || !StringUtils.hasText(fileWebPath)) {
return Optional.empty();
}
int start = url.indexOf(fileWebPath);
return start < 0 ? Optional.empty() : Optional.of(url.substring(start));
}
/**
* 抽出 HTML 中指向本站上传目录的资产地址(img src / a href),已归一为 webUrl。
*
* <p>用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。
*/
public Set<String> localAssetUrls(String html) {
Set<String> webUrls = new LinkedHashSet<>();
if (!StringUtils.hasText(html)) {
return webUrls;
}
Matcher matcher = ASSET_URL_PATTERN.matcher(html);
while (matcher.find()) {
localWebUrl(matcher.group(1)).ifPresent(webUrls::add);
}
return webUrls;
}
/**
* 内容派生引用的全量同步(事项库 R2):把 (refType, refId) 下的引用对齐到给定 webUrl 集合——
* 集合内标记归属、集合外解除归属(转回未使用),幂等。
*
* <p>为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步,
* 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。
*/
@Transactional
public int syncAssets(Integer siteId, String refType, Integer refId, Set<String> webUrls) {
Set<String> desired = webUrls == null ? Collections.emptySet() : webUrls;
List<CmsAttachment> stale = new ArrayList<>();
for (CmsAttachment a : attachmentRepository.findByRefTypeAndRefId(refType, refId)) {
if (!desired.contains(a.getWebUrl())) {
a.setRefType(null);
a.setRefId(null);
stale.add(a);
}
}
if (!stale.isEmpty()) {
attachmentRepository.saveAll(stale);
}
for (String webUrl : desired) {
markUsed(siteId, webUrl, refType, refId);
}
return desired.size();
}
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
@Transactional
public void release(String refType, Integer refId) {
@@ -54,7 +54,7 @@ public class PortalQueryService {
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
public PortalSiteSnapshot siteSnapshot(String siteCode) {
CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
return assembleSnapshot(site);
}
@@ -123,7 +123,7 @@ public class SiteService {
}
public CmsSite getByCode(String code) {
return siteRepository.findByCode(code).orElse(null);
return siteRepository.findFirstByCodeOrderByIdAsc(code).orElse(null);
}
public String configValue(CmsSite site, String key) {
@@ -353,6 +353,7 @@ public class SiteService {
}
break;
case "public_base_url":
case "svc_hall_base":
case "social_wechat":
case "social_weibo":
case "social_douyin":