落地(依据《事项库与办事大厅详细设计 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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user