落地(依据《事项库与办事大厅详细设计 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:
@@ -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);未编排布局时
|
||||
|
||||
Reference in New Issue
Block a user