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
@@ -0,0 +1,57 @@
package com.yunhai.cms.controller.api.svcitem;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.model.dto.SvcDictSaveRequest;
import com.yunhai.cms.svcitem.model.vo.SvcDeptVo;
import com.yunhai.cms.svcitem.model.vo.SvcDictVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.svcitem.service.impl.SvcDictService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/**
* 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree,事项库详细设计 §3.3)。
*/
@RestController
@RequestMapping("/api/svc")
@RequiredArgsConstructor
public class SvcDictController {
private final SvcDictService svcDictService;
private final SvcItemQuery svcItemQuery;
/** 字典列表(dictType 空=全部;referenced=被引用条数) */
@GetMapping("/dict/list")
public List<SvcDictVo> list(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "dictType", required = false) String dictType) {
return svcDictService.list(authUserWithDept, siteId, dictType);
}
/** 保存(新建/改名;稳定键被引用后不可改、被引用不可删——服务端校验) */
@PostMapping("/dict/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcDictSaveRequest request) {
return svcDictService.save(authUserWithDept, request);
}
@PostMapping("/dict/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
svcDictService.delete(authUserWithDept, request);
}
/** 服务部门选项(UserBase 组树代理;平台不可达降级空列表) */
@GetMapping("/dept/tree")
public List<SvcDeptVo> deptTree(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.deptTree(authUserWithDept, siteId);
}
}
@@ -0,0 +1,105 @@
package com.yunhai.cms.controller.api.svcitem;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.svcitem.model.dto.IdSortItem;
import com.yunhai.cms.svcitem.model.dto.SvcItemSaveRequest;
import com.yunhai.cms.svcitem.model.query.SvcItemPageQuery;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemEditVo;
import com.yunhai.cms.svcitem.model.vo.SvcItemSimpleVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* 事项管理(/api/svc/item/**,事项库详细设计 §3.3)。
* 包必须落 controller.api 之下以获得 ApiParamExceptionHandler 的参数绑定异常翻译(§3.1 修正)。
* 消费方纪律:只依赖 {@link SvcItemQuery} 接缝,不注入实现类(拆分时替换实现即可整体切换)。
*/
@RestController
@RequestMapping("/api/svc/item")
@RequiredArgsConstructor
public class SvcItemController {
private final SvcItemQuery svcItemQuery;
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept, SvcItemPageQuery query) {
return svcItemQuery.adminPage(authUserWithDept, query);
}
/** 编辑回显(ext_json.apply_url 已提取) */
@GetMapping("/get")
public SvcItemEditVo get(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return svcItemQuery.adminGet(authUserWithDept, siteId, id);
}
/** 保存(新建草稿/编辑;富文本 jsoup 清洗,apply_url 写 ext_json) */
@PostMapping("/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody SvcItemSaveRequest request) {
return svcItemQuery.adminSave(authUserWithDept, request);
}
@PostMapping("/publish/{id}")
public void publish(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
svcItemQuery.adminPublish(authUserWithDept, siteId, id);
}
@PostMapping("/offline/{id}")
public void offline(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
svcItemQuery.adminOffline(authUserWithDept, siteId, id);
}
/** 删除(附件 svc_item 引用同步解引用) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
svcItemQuery.adminDelete(authUserWithDept, request);
}
/** 自定义排序(按传入 (id, sortNo) 落库) */
@PostMapping("/sort")
public void sort(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestBody List<IdSortItem> items) {
svcItemQuery.adminSort(authUserWithDept, siteId, items);
}
/** 同步部门名快照(可选动作:UserBase 组树改名后刷新 dept_name) */
@PostMapping("/syncDeptName")
public Map<String, Object> syncDeptName(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.syncDeptName(authUserWithDept, siteId);
}
/** 选择器精简列表 */
@GetMapping("/selList")
public List<SvcItemSimpleVo> selList(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return svcItemQuery.adminSelList(authUserWithDept, siteId);
}
/** 指南附件回显(ref_type=svc_item, ref_id=id,编辑页列表) */
@GetMapping("/attList")
public List<SvcItemAttachmentVo> attList(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return svcItemQuery.itemAttachments(authUserWithDept, siteId, id);
}
}
@@ -0,0 +1,40 @@
package com.yunhai.cms.controller.oapi.svcitem;
import com.yunhai.cms.svcitem.model.vo.SvcItemSnapshotVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 事项跨服务快照(/oapi/**,appKey 签名访问;事项库详细设计 §3.4 冻结契约)。
* 错误一律走 BizException+信封 code(平台口径 HTTP 恒 200),未签名由签名过滤器返回 401;
* 不做"仅按 itemCode 全局搜"的降级查询——跨服务必须 (siteCode,itemCode) 成对。
* 接口不缓存(调用频次=办件量级,直查 DB)。
*/
@RestController
@RequestMapping("/oapi/svc/item")
@RequiredArgsConstructor
public class SvcItemOapiController {
private final SvcItemQuery svcItemQuery;
/** 发布态要点快照:供 svhall 发起办件时落 flw_instance(跨库不 join,办件不受后续变更影响) */
@GetMapping("/snapshot")
public SvcItemSnapshotVo snapshot(@RequestParam(value = "siteCode", required = false) String siteCode,
@RequestParam(value = "itemCode", required = false) String itemCode) {
if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode/itemCode 不得为空");
}
SvcItemSnapshotVo vo = svcItemQuery.snapshot(siteCode.trim(), itemCode.trim());
if (vo == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
}
return vo;
}
}
@@ -13,6 +13,11 @@ import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -30,6 +35,7 @@ import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -47,12 +53,15 @@ public class PortalController {
private static final int HOME_BLOCK_SIZE = 8;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
/** 指南页"相关事项"取 N 条(事项库 §5.3) */
private static final int RELATED_ITEMS_SIZE = 6;
private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository;
private final CmsSiteRepository siteRepository;
private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer;
private final SvcItemQuery svcItemQuery;
@Value("${cms.portal.detail-max-age:300}")
private int detailMaxAge;
@@ -276,6 +285,270 @@ public class PortalController {
// ===== 内部装配 =====
/**
* 服务中心列表(事项库 §5.1/§5.2):三维筛选 obj/cat/dept + 关键词 q + 分页。
* 筛选/分页 URL 全部后端预生成(C-2:模板禁止手拼 query);service.html 为可选模板,
* 走 M0 整页回退(缺模板时视图名/themeBase/资产主题段统一退 default)。
*/
@GetMapping("/portal/{siteCode}/service")
public String service(@PathVariable("siteCode") String siteCode,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(siteCode, obj, cat, dept, q, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/service */
@GetMapping("/portal/{p}/{s}/service")
public String serviceNested(@PathVariable("p") String p, @PathVariable("s") String s,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(p + "/" + s, obj, cat, dept, q, page, request, response, model);
}
/** 事项指南页(§5.3):富文本已清洗(保存期 jsoup)+ 渲染期补基准;applyUrl 三态优先级 */
@GetMapping("/portal/{siteCode}/sv/{itemId}")
public String guide(@PathVariable("siteCode") String siteCode,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(siteCode, itemId, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/sv/{itemId} */
@GetMapping("/portal/{p}/{s}/sv/{itemId}")
public String guideNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(p + "/" + s, itemId, request, response, model);
}
private String doServiceList(String sitePath, String obj, String cat, Integer dept, String q,
Integer page, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "service", request, response);
if (site == null) {
return null;
}
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "service.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
SvcItemPortalQuery query = new SvcItemPortalQuery();
query.setSiteId(site.getId());
query.setObjectKey(trimToNull(obj));
query.setCategoryKey(trimToNull(cat));
query.setDeptGroupId(dept);
query.setKeyword(trimToNull(q));
query.setPage(page == null || page < 1 ? 1 : page);
PortalServiceListData serviceList = svcItemQuery.portalPage(query);
// 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1
Map<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
model.addAttribute("serviceFilters",
buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters));
Map<String, Object> activeFilters = new HashMap<>();
activeFilters.put("obj", trimToNull(obj));
activeFilters.put("cat", trimToNull(cat));
activeFilters.put("dept", dept);
activeFilters.put("q", q == null ? "" : q);
activeFilters.put("clearUrl", url.serviceList());
activeFilters.put("hasAny", trimToNull(obj) != null || trimToNull(cat) != null
|| dept != null || (q != null && !q.trim().isEmpty()));
model.addAttribute("activeFilters", activeFilters);
for (int p = 1; p <= serviceList.getTotalPages(); p++) {
Map<String, Object> pageQuery = new LinkedHashMap<>(currentFilters);
if (p > 1) {
pageQuery.put("page", p);
}
serviceList.getPageUrls().add(new PortalServiceListData.PageUrl(
p, url.serviceList(pageQuery), p == serviceList.getPage()));
}
model.addAttribute("serviceList", serviceList);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q == null ? "" : q);
model.addAttribute("pageTitle", "服务中心 - " + site.getName());
model.addAttribute("seoDescription", null);
Map<String, Object> canonicalQuery = new LinkedHashMap<>(currentFilters);
if (serviceList.getPage() != null && serviceList.getPage() > 1) {
canonicalQuery.put("page", serviceList.getPage());
}
model.addAttribute("canonical", url.serviceList(canonicalQuery));
return "themes/" + effectiveTheme + "/service";
}
private String doGuide(String sitePath, Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "sv/" + itemId, request, response);
if (site == null) {
return null;
}
PortalServiceItem item = svcItemQuery.portalItem(itemId, site.getId());
if (item == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
svcItemQuery.countView(itemId);
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "guide.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
// 富文本渲染期补基准(copy-on-write,不污染缓存体;内容已在保存期过 jsoup 白名单)
model.addAttribute("item", item.withRewrittenHtml(
url.rewriteUploadUrls(item.getProcessHtml()),
url.rewriteUploadUrls(item.getMaterialHtml()),
url.rewriteUploadUrls(item.getNoticeHtml())));
model.addAttribute("attachments", svcItemQuery.attachments(itemId));
List<Map<String, Object>> related = new ArrayList<>();
for (PortalServiceItem rel : svcItemQuery.relatedItems(site.getId(), itemId,
item.getCategoryKey(), RELATED_ITEMS_SIZE)) {
Map<String, Object> row = new HashMap<>();
row.put("id", rel.getId());
row.put("title", rel.getTitle());
row.put("deptName", rel.getDeptName());
row.put("handleMode", rel.getHandleMode());
row.put("url", url.serviceItem(rel.getId()));
related.add(row);
}
model.addAttribute("relatedItems", related);
if (item.getFaqCategoryId() != null) {
model.addAttribute("faqCategoryUrl", url.category(item.getFaqCategoryId()));
}
if (item.getNoticeCategoryId() != null) {
model.addAttribute("noticeCategoryUrl", url.category(item.getNoticeCategoryId()));
}
if (item.getRuleCategoryId() != null) {
model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId()));
}
// "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先)
// ② 未填且站点配了 svc_hall_base → 生成 svhall 发起页 URL ③ 都没有 → 不注入(模板不渲染按钮)
String applyUrl = item.getApplyUrl();
if ((applyUrl == null || applyUrl.trim().isEmpty())
&& site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) {
String base = site.getSvcHallBase().trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
applyUrl = base + "/#/apply?siteCode=" + urlEncodeValue(site.getCode())
+ "&itemCode=" + urlEncodeValue(item.getItemCode());
}
if (applyUrl != null && !applyUrl.trim().isEmpty()) {
model.addAttribute("applyUrl", applyUrl);
}
if (item.getCoverImage() != null && !item.getCoverImage().trim().isEmpty()) {
model.addAttribute("ogImage", url.upload(item.getCoverImage()));
}
model.addAttribute("pageTitle", item.getTitle() + " - " + site.getName());
model.addAttribute("seoDescription", item.getSummary());
model.addAttribute("canonical", url.serviceItem(itemId));
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
return "themes/" + effectiveTheme + "/guide";
}
/** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */
private Map<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
Map<String, Object> filters = new LinkedHashMap<>();
if (trimToNull(obj) != null) {
filters.put("obj", obj.trim());
}
if (trimToNull(cat) != null) {
filters.put("cat", cat.trim());
}
if (dept != null) {
filters.put("dept", dept);
}
if (q != null && !q.trim().isEmpty()) {
filters.put("q", q.trim());
}
return filters;
}
/** 三组筛选项 → 模板 Facet({code|id, name, count, active, url},url 直用于 th:href) */
private Map<String, Object> buildServiceFilters(Integer siteId, PortalUrlHelper url,
String obj, String cat, Integer dept,
Map<String, Object> currentFilters) {
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
Map<String, Object> model = new HashMap<>();
List<PortalServiceFilters.Facet> objects = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getObjects()) {
boolean active = option.getKey().equals(trimToNull(obj));
objects.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "obj", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> categories = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getCategories()) {
boolean active = option.getKey().equals(trimToNull(cat));
categories.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "cat", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> depts = new ArrayList<>();
for (PortalServiceFilters.DeptOption option : filters.getDepts()) {
boolean active = option.getId() != null && option.getId().equals(dept);
depts.add(PortalServiceFilters.Facet.ofId(option.getId(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "dept",
active ? null : String.valueOf(option.getId())))));
}
model.put("objects", objects);
model.put("categories", categories);
model.put("depts", depts);
return model;
}
/** 复制当前筛选并替换/移除某一维(其余维度保留,page 永不进入——选项切换即归第 1 页) */
private Map<String, Object> toggleFilter(Map<String, Object> current, String key, String value) {
Map<String, Object> next = new LinkedHashMap<>(current);
next.remove("page");
if (value == null) {
next.remove(key);
} else {
next.put(key, value);
}
return next;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String urlEncodeValue(String value) {
try {
return java.net.URLEncoder.encode(value == null ? "" : value, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return value;
}
}
/**
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
@@ -290,7 +563,7 @@ public class PortalController {
return site;
}
if (sitePath != null && !sitePath.contains("/")) {
CmsSite legacy = siteRepository.findByCode(sitePath).orElse(null);
CmsSite legacy = siteRepository.findFirstByCodeOrderByIdAsc(sitePath).orElse(null);
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
&& !legacy.getPath().equals(sitePath)) {
StringBuilder location = new StringBuilder(request.getContextPath())
@@ -320,13 +593,23 @@ public class PortalController {
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
return buildUrl(site, request, null);
}
/**
* M0 可选模板整页回退配套(事项库 §4.2):effectiveTheme 非空时以它构造 URL 生成器——
* 资产 URL 主题段(asset())必须与视图名/themeBase 同主题,否则 default 的 HTML 配别家皮肤的 CSS。
* 三级基准决策不变(public_base_url → base-origin → 当前请求派生)。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request, String effectiveTheme) {
String theme = effectiveTheme == null ? site.getTheme() : effectiveTheme;
if (site.getPublicBaseUrl() != null) {
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), site.getTheme(), baseOrigin);
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), theme, baseOrigin);
}
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), site.getTheme());
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), theme);
}
return PortalUrlHelper.fromRequest(request, site.getPath(), site.getTheme());
return PortalUrlHelper.fromRequest(request, site.getPath(), theme);
}
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
@@ -8,8 +8,10 @@ import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSideNav;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.theme.ThemeRegistry;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
@@ -26,6 +28,7 @@ import java.util.Set;
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PortalPageRenderer {
@@ -37,6 +40,24 @@ public class PortalPageRenderer {
private static final int HOME_TOP_SIZE = 6;
private final CmsArticleRepository articleRepository;
private final ThemeRegistry themeRegistry;
/**
* M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html
* 这类 REQUIRED_TEMPLATES 之外的可选页)时,整页回退 default,而非仅换模板。
* 返回的 effectiveTheme 必须同时用于视图名、themeBase 与 URL 生成器(asset() 的
* 资产主题段)——只换模板不换主题 = default 的 HTML 配别家皮肤的 CSS。
* 必备四页由 ThemePackageValidator 注册期保证,不走本判定。
*/
public String resolveEffectiveTheme(PortalSiteSnapshot site, String requiredTemplate) {
String theme = site.getTheme();
if (themeRegistry.resource(theme, requiredTemplate).exists()) {
return theme;
}
log.warn("主题 {} 缺可选模板 {}(site={}),整页回退 {}", theme, requiredTemplate,
site.getCode(), ThemeRegistry.DEFAULT_THEME);
return ThemeRegistry.DEFAULT_THEME;
}
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时