init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.yunhai.cms.controller.api;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 管理端参数级异常翻译(体检 H-1/H-4 根因修复)。
*
* <p>平台 GlobalExceptionHandler 只兜 @Valid 校验/BizException/全局 Exception,
* 三类 Spring MVC 参数绑定异常(缺参/类型不匹配/请求体不可读)落入兜底分支被
* 报成 code=6 "内部服务错误"。本 advice 以更高优先级把它们翻译回 code=2 参数错误,
* 前端可提示、日志不再噪音;真正的业务/系统异常不受影响(仍走平台处理器,堆栈完整)。
*/
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.yunhai.cms.controller.api")
public class ApiParamExceptionHandler {
@ExceptionHandler(MissingServletRequestParameterException.class)
public ApiResponse<Object> missingParam(MissingServletRequestParameterException e) {
log.warn("管理端缺必填参数: {}", e.getParameterName());
return ApiResponse.fail(ErrorCode.PARAM_ERROR, "缺少必填参数:" + e.getParameterName());
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Object> typeMismatch(MethodArgumentTypeMismatchException e) {
String expected = e.getRequiredType() == null ? "" : "(应为 " + e.getRequiredType().getSimpleName() + ")";
log.warn("管理端参数类型不匹配: {}={}", e.getName(), e.getValue());
return ApiResponse.fail(ErrorCode.PARAM_ERROR, "参数格式不正确:" + e.getName() + expected);
}
/** JSON 解析失败:编码/语法/时间格式(jackson date-format yyyy-MM-dd HH:mm:ss) */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Object> unreadable(HttpMessageNotReadableException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
String hint = cause.getMessage() == null ? "" : ": " + firstLine(cause.getMessage());
log.warn("管理端请求体不可读 {}", firstLine(hint));
return ApiResponse.fail(ErrorCode.PARAM_ERROR,
"请求体格式错误(JSON/UTF-8/时间格式,时间需 yyyy-MM-dd HH:mm:ss)");
}
private static String firstLine(String s) {
int i = s.indexOf('\n');
String line = i < 0 ? s : s.substring(0, i);
return line.length() > 120 ? line.substring(0, 120) : line;
}
}
@@ -0,0 +1,78 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.ArticleSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.query.ArticlePageQuery;
import com.yunhai.cms.model.vo.ArticleEditVo;
import com.yunhai.cms.service.impl.ArticleService;
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.Map;
/**
* 文章管理(方案 §9.2):分页/保存(jsoup 清洗)/发布/下架/置顶/删除。
*/
@RestController
@RequestMapping("/api/article")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept, ArticlePageQuery query) {
return articleService.page(authUserWithDept, query);
}
/** 编辑回显(正文/扩展列) */
@GetMapping("/get")
public ArticleEditVo get(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return articleService.getEdit(authUserWithDept, siteId, id);
}
/** 保存草稿(content 先过 jsoup 白名单) */
@PostMapping("/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody ArticleSaveRequest request) {
return articleService.save(authUserWithDept, request);
}
/** 发布(publish_time 为未来时间即定时发布;作者自动从 CAS 会话填充) */
@PostMapping("/publish/{id}")
public void publish(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
articleService.publish(authUserWithDept, siteId, id);
}
@PostMapping("/offline/{id}")
public void offline(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
articleService.offline(authUserWithDept, siteId, id);
}
@PostMapping("/topmost/{id}")
public void topmost(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId,
@RequestParam("enable") boolean enable) {
articleService.topmost(authUserWithDept, siteId, id, enable);
}
/** 删除(附件转孤儿,附件管理页清理) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
articleService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,56 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.AttachmentVo;
import com.yunhai.cms.service.impl.AttachmentService;
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 org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
import java.util.Map;
/**
* 附件管理(方案 §9.2):编辑器图片/附件/轮播图/友链 Logo 复用同一上传接口。
*/
@RestController
@RequestMapping("/api/attachment")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
/** 上传(multipart:file 必填;articleId 可空) */
@PostMapping("/upload")
public AttachmentVo upload(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "articleId", required = false) Integer articleId,
@RequestParam("file") MultipartFile file) {
return attachmentService.upload(authUserWithDept, siteId, articleId, file);
}
/** 附件列表(orphanOnly=true 仅孤儿) */
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "orphanOnly", required = false,
defaultValue = "false") boolean orphanOnly,
@RequestParam(value = "page", required = false,
defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", required = false,
defaultValue = "10") Integer pageSize) {
return attachmentService.page(authUserWithDept, siteId, orphanOnly, page, pageSize);
}
/** 删除附件记录(物理文件运维定期清理) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
attachmentService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.config.CmsAuthProperties;
import com.yunhai.cms.model.vo.AuthUserVo;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 管理端登录态(CAS 会话用户镜像,方案 §7.2)。
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final SiteService siteService;
private final CmsAuthProperties authProperties;
@GetMapping("/user")
public AuthUserVo user(BoaUser authUserWithDept) {
boolean superAdmin = authProperties.isSuperAdmin(authUserWithDept);
List<SiteVo> sites = siteService.listFor(authUserWithDept);
return AuthUserVo.of(authUserWithDept, superAdmin, sites);
}
}
@@ -0,0 +1,46 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.BannerSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.BannerVo;
import com.yunhai.cms.service.impl.BannerService;
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;
/**
* 轮播/推荐位管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/banner")
@RequiredArgsConstructor
public class BannerController {
private final BannerService bannerService;
@GetMapping("/list")
public List<BannerVo> list(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "position", required = false) String position) {
return bannerService.list(authUserWithDept, siteId, position);
}
/** 保存(校验 link_type/link_value 匹配且属本站) */
@PostMapping("/save")
public BannerVo save(BoaUser authUserWithDept, @Valid @RequestBody BannerSaveRequest request) {
return bannerService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
bannerService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,41 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.service.impl.CmsUserCenterService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(Notice BaseApiController 同款契约):GetUserInfo / GetUserMod。
* 返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping("/api/base")
@RequiredArgsConstructor
public class BaseApiController {
private final CmsUserCenterService userCenterService;
/**
* 获取当前登录用户信息(CAS 会话注入;前端 layout 取姓名/头像)。
*/
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
}
@@ -0,0 +1,53 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.CategorySaveRequest;
import com.yunhai.cms.model.dto.CategorySortRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.CategoryTreeNode;
import com.yunhai.cms.service.impl.CategoryService;
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 java.util.List;
import javax.validation.Valid;
/**
* 栏目管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/category")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@GetMapping("/tree")
public List<CategoryTreeNode> tree(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return categoryService.tree(authUserWithDept, siteId);
}
@PostMapping("/save")
public CategoryTreeNode save(BoaUser authUserWithDept,
@Valid @RequestBody CategorySaveRequest request) {
return categoryService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
categoryService.delete(authUserWithDept, request);
}
/** 子栏目排序(UserBase SortChildren 同语义:按传入顺序落库 sortNo=1..n) */
@PostMapping("/sort")
public void sort(BoaUser authUserWithDept, @Valid @RequestBody CategorySortRequest request) {
categoryService.sort(authUserWithDept, request);
}
}
@@ -0,0 +1,43 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.LinkSaveRequest;
import com.yunhai.cms.model.vo.LinkVo;
import com.yunhai.cms.service.impl.LinkService;
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;
/**
* 友情链接管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/link")
@RequiredArgsConstructor
public class LinkController {
private final LinkService linkService;
@GetMapping("/list")
public List<LinkVo> list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) {
return linkService.list(authUserWithDept, siteId);
}
@PostMapping("/save")
public LinkVo save(BoaUser authUserWithDept, @Valid @RequestBody LinkSaveRequest request) {
return linkService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
linkService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,35 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.vo.SchoolVo;
import com.yunhai.cms.service.impl.SchoolService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 学校工作台(docs/09 §3.3):一级页面=学校列表,二级 /sch/{code}/** 为学校专属管理壳。
* 权限语义与 /api/site/list 一致;写操作仍走 /api/site/save(添加学校=初始化主站)。
*/
@RestController
@RequestMapping("/api/school")
@RequiredArgsConstructor
public class SchoolController {
private final SchoolService schoolService;
/** 学校工作台列表(超管=全部顶层站点;校级=本校;无角色=空) */
@GetMapping("/list")
public List<SchoolVo> list(BoaUser authUserWithDept) {
return schoolService.listFor(authUserWithDept);
}
/** UserBase 学校选项(添加学校绑定用,仅超管;平台不可达降级空数组) */
@GetMapping("/options")
public List<SchoolVo.Option> options(BoaUser authUserWithDept) {
return schoolService.schoolOptions(authUserWithDept);
}
}
@@ -0,0 +1,39 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.SiteSaveRequest;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.service.impl.SiteService;
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.RestController;
import java.util.List;
import javax.validation.Valid;
/**
* 站点管理(超管,方案 §9.1/§9.2)。
*/
@RestController
@RequestMapping("/api/site")
@RequiredArgsConstructor
public class SiteController {
private final SiteService siteService;
/** 站点列表(超管=全部;校级=本校站点) */
@GetMapping("/list")
public List<SiteVo> list(BoaUser authUserWithDept) {
return siteService.listFor(authUserWithDept);
}
/** 站点保存(仅超管) */
@PostMapping("/save")
public SiteVo save(BoaUser authUserWithDept, @Valid @RequestBody SiteSaveRequest request) {
return siteService.save(authUserWithDept, request);
}
}
@@ -0,0 +1,83 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.time.Duration;
import java.util.List;
/**
* 主题管理接口(方案 §5.2,路径对齐 /api/{domain} 约定):
* /api/theme/list 主题列表;/api/theme/{name}/asset/** 主题资产代理
* (screenshot 与预览资产共用,防穿越同 PortalAssetController,name 先过 Registry 白名单)。
* 走 /api/* CAS 鉴权(现状边界)。
*/
@RestController
@RequestMapping("/api/theme")
@RequiredArgsConstructor
public class ThemeController {
private static final Duration ASSET_CACHE = Duration.ofHours(1);
private final ThemeService themeService;
private final ThemeRegistry themeRegistry;
@GetMapping("/list")
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
return themeService.list();
}
@GetMapping("/{name}/asset/**")
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
HttpServletRequest request) {
if (!themeRegistry.contains(name)) {
return ResponseEntity.notFound().build();
}
String path = extractRelativePath(request, name);
if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build();
}
Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
MediaType mediaType = MediaTypeFactory.getMediaType(path)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic())
.body(resource);
}
/**
* 从请求 URI 截取 /asset/ 之后的相对路径(同 PortalAssetController:
* 直接解析 URL-decode 后的 URI,兼容两种路径匹配器语义)。
*/
private String extractRelativePath(HttpServletRequest request, String name) {
String uri = request.getRequestURI();
String prefix = request.getContextPath() + "/api/theme/" + name + "/asset/";
int index = uri.indexOf(prefix);
if (index < 0) {
return null;
}
String relative = uri.substring(index + prefix.length());
try {
relative = java.net.URLDecoder.decode(relative, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
return relative.replace('\\', '/');
}
}
@@ -0,0 +1,71 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.theme.ThemeInfo;
import com.yunhai.cms.theme.ThemeVarsCss;
import com.yunhai.cms.controller.portal.PortalPageRenderer;
import com.yunhai.cms.utils.PortalUrlHelper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* 主题预览(方案 §5.5):管理端鉴权的代理渲染接口,iframe 同源加载。
* 预览渲染用站点快照的浅拷贝覆盖 theme/themeParams,资产 URL 切换到管理端代理
* (防预览资产串包,R1);不在匿名门户 URL 开任何覆盖参数。
* B-6:page 白名单(P1 仅 home,非法值明确报错);内容源站点过可管理校验(requireManageable)。
*/
@Controller
@RequestMapping("/api/theme")
@RequiredArgsConstructor
public class ThemePreviewController {
private final ThemeService themeService;
private final PortalQueryService portalQueryService;
private final PortalPageRenderer pageRenderer;
private final SiteService siteService;
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表) */
@GetMapping("/preview")
public String preview(@RequestParam("siteCode") String siteCode,
@RequestParam("theme") String theme,
@RequestParam(value = "page", required = false, defaultValue = "home") String page,
BoaUser authUserWithDept,
HttpServletRequest request, HttpServletResponse response, Model model) {
response.setHeader("Cache-Control", "no-store");
// B-6①:page 白名单——非法值明确报错,不静默当 home 渲染(接口语义误导)
if (!"home".equals(page)) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
}
ThemeInfo info = themeService.requireRegistered(theme);
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
if (site == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"内容源站点不存在或已停用:" + siteCode);
}
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
if (authUserWithDept == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话失效,无法预览");
}
siteService.requireManageable(authUserWithDept, site.getId());
PortalSiteSnapshot previewSite = site.shallowCopy();
previewSite.setTheme(theme);
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
previewSite.setThemeParams(params);
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params));
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, siteCode, theme), model);
}
}
@@ -0,0 +1,87 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.Duration;
/**
* 门户主题资产(方案 §8.2/§8.6):/portal/{siteCode}/assets/{theme}/** 解析到
* classpath:/templates/themes/{theme}/assets/**。URL 由 PortalUrlHelper 以
* public_base_url 为基准生成并携带主题段(B-3),学校域名形态同样命中本控制器。
*
* <p>主题段取自 URL 而非站点当前皮肤:换肤后 URL 即变,浏览器/CDN 对旧 URL 的
* max-age 缓存天然失效;旧 URL 本身仍可服务(URL 与内容一一对应,缓存语义安全)。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class PortalAssetController {
private static final Duration ASSET_CACHE = Duration.ofHours(1);
private final PortalQueryService portalQueryService;
private final ThemeRegistry themeRegistry;
@GetMapping("/portal/{siteCode}/assets/{theme}/**")
public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
@PathVariable("theme") String theme,
HttpServletRequest request) {
// 主题段必须已注册(兼防路径注入:未注册名一律 404)
if (!themeRegistry.contains(theme)) {
return ResponseEntity.notFound().build();
}
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
if (site == null) {
return ResponseEntity.notFound().build();
}
String path = extractRelativePath(request, siteCode, theme);
if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build();
}
Resource resource = new ClassPathResource(
"templates/themes/" + theme + "/assets/" + path);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
MediaType mediaType = MediaTypeFactory.getMediaType(path)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic())
.body(resource);
}
/**
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。
*/
private String extractRelativePath(HttpServletRequest request, String siteCode, String theme) {
String uri = request.getRequestURI();
String prefix = request.getContextPath() + "/portal/" + siteCode + "/assets/" + theme + "/";
int index = uri.indexOf(prefix);
if (index < 0) {
return null;
}
String relative = uri.substring(index + prefix.length());
try {
relative = java.net.URLDecoder.decode(relative, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
return relative.replace('\\', '/');
}
}
@@ -0,0 +1,295 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 门户 SSR(方案 §8,匿名可访问——CAS url-pattern 只配 /api/*,勿改)。
* 站点身份来自路径不依赖 Host(方案 §8.6);所有链接经 PortalUrlHelper 按
* public_base_url 生成,base 域子路径与学校独立域名两种暴露形态共用渲染。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class PortalController {
/** 首页每个一级栏目取 N 条 */
private static final int HOME_BLOCK_SIZE = 8;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository;
private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer;
@Value("${cms.portal.detail-max-age:300}")
private int detailMaxAge;
/** base 域对外基地址(C-6:public_base_url 未配置时的规范派生基准;空=按请求派生) */
@Value("${cms.portal.base-origin:}")
private String baseOrigin;
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5) */
@GetMapping("/portal/{siteCode}/")
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
if (site == null) {
return null;
}
return pageRenderer.renderHome(site, buildUrl(site, request), model);
}
/** 栏目页(列表/单页);外链栏目 302(方案 §8.1) */
@GetMapping("/portal/{siteCode}/c/{categoryId}")
public String category(@PathVariable("siteCode") String siteCode,
@PathVariable("categoryId") Integer categoryId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
if (site == null) {
return null;
}
PortalCategory category = findCategory(site, categoryId);
if (category == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
if (category.getType() != null && category.getType() == CategoryTypeEnum.LINK.getCode()) {
PortalUrlHelper fallback = buildUrl(site, request);
response.sendRedirect(category.getLinkUrl() == null ? fallback.home() : category.getLinkUrl());
return null;
}
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
model.addAttribute("category", category);
model.addAttribute("pageTitle", seoTitle(category, site));
model.addAttribute("seoDescription", category.getSeoDescription());
model.addAttribute("canonical", url.category(categoryId));
if (category.getType() != null && category.getType() == CategoryTypeEnum.SINGLE.getCode()) {
// 单页正文上传地址渲染期补基准(B-1):拷贝后重写,不污染快照缓存体
model.addAttribute("category", category.withContent(
url.rewriteUploadUrls(category.getContent())));
return "themes/" + site.getTheme() + "/single";
}
PortalListData listData = portalQueryService.categoryPage(categoryId, page, 10);
if (listData == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
model.addAttribute("listData", listData);
model.addAttribute("searchMode", false);
return "themes/" + site.getTheme() + "/list";
}
/** 文章详情:正文 th:utext + 上下篇 + 浏览量异步累加 + Cache-Control(方案 §8.3) */
@GetMapping("/portal/{siteCode}/a/{articleId}")
public String article(@PathVariable("siteCode") String siteCode,
@PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
if (site == null) {
return null;
}
com.yunhai.cms.model.portal.PortalArticleDetail detail =
portalQueryService.articleDetail(articleId);
if (detail == null || !detail.getSiteId().equals(site.getId())) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
viewCounterService.increment(articleId);
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", detail.getCategoryId());
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
model.addAttribute("article", detail.withContent(
url.rewriteUploadUrls(detail.getContent())));
// og:image(C-7):文章封面(无封面则不输出该标签)
model.addAttribute("ogImage", url.upload(detail.getCoverImage()));
model.addAttribute("pageTitle", detail.getTitle() + " - " + site.getName());
model.addAttribute("seoDescription", detail.getSummary());
model.addAttribute("canonical", url.article(articleId));
model.addAttribute("prevArticle", prevArticle(site.getId(), articleId, detail.getPublishTime()));
model.addAttribute("nextArticle", nextArticle(site.getId(), articleId, detail.getPublishTime()));
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
return "themes/" + site.getTheme() + "/detail";
}
/** 站内搜索:title/summary LIKE(实时查询不缓存) */
@GetMapping("/portal/{siteCode}/search")
public String search(@PathVariable("siteCode") String siteCode,
@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 {
PortalSiteSnapshot site = requireSite(siteCode, response);
if (site == null) {
return null;
}
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q);
model.addAttribute("pageTitle", "搜索:" + q + " - " + site.getName());
model.addAttribute("seoDescription", null);
model.addAttribute("canonical", url.search(q, page));
model.addAttribute("searchMode", true);
if (q.trim().isEmpty()) {
model.addAttribute("listData", emptyList(page));
return "themes/" + site.getTheme() + "/list";
}
org.springframework.data.domain.Page<PortalListData.Item> result =
portalQueryService.search(site.getId(), q, page, 10);
PortalListData listData = new PortalListData();
listData.setSiteId(site.getId());
listData.setItems(result.getContent());
listData.setTotal(result.getTotalElements());
listData.setPage(page);
listData.setPageSize(10);
listData.setTotalPages(result.getTotalPages());
model.addAttribute("listData", listData);
return "themes/" + site.getTheme() + "/list";
}
// ===== 内部装配 =====
private PortalSiteSnapshot requireSite(String siteCode, HttpServletResponse response) throws Exception {
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
if (site == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
return site;
}
/**
* URL 生成器基准三级决策(方案 §8.6):
* ① 站点 public_base_url(绑学校域名);
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
* theme 随 helper 走,使资产 URL 含主题段(B-3)。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
if (site.getPublicBaseUrl() != null) {
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getCode(), site.getTheme(), baseOrigin);
}
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getCode(), site.getTheme());
}
return PortalUrlHelper.fromRequest(request, site.getCode(), site.getTheme());
}
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
private PortalCategory findCategory(PortalSiteSnapshot site, Integer categoryId) {
for (PortalCategory root : site.getCategories()) {
PortalCategory hit = findCategoryDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalCategory findCategoryDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
return node;
}
for (PortalCategory child : node.getChildren()) {
PortalCategory hit = findCategoryDeep(child, categoryId);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
}
private PortalListData.Item prevArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) {
LocalDateTime pt = publishTime == null ? LocalDateTime.now() : publishTime;
List<CmsArticle> list = articleRepository.findPrevPublished(siteId,
ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1));
return list.isEmpty() ? null : toItem(list.get(0));
}
private PortalListData.Item nextArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) {
LocalDateTime pt = publishTime == null ? LocalDateTime.MIN : publishTime;
List<CmsArticle> list = articleRepository.findNextPublished(siteId,
ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1));
return list.isEmpty() ? null : toItem(list.get(0));
}
private PortalListData emptyList(Integer page) {
PortalListData data = new PortalListData();
data.setPage(page == null ? 1 : page);
return data;
}
private String seoTitle(PortalCategory category, PortalSiteSnapshot site) {
String title = category.getSeoTitle() == null || category.getSeoTitle().isEmpty()
? category.getName() : category.getSeoTitle();
return title + " - " + site.getName();
}
}
@@ -0,0 +1,37 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 门户根路径(/cms/portal/):跳转首个启用的顶层校站点(院系子站不作默认入口,《院系子站方案》)。
* 容器健康探活打此路径(docker-compose healthcheck),返回 302 即视为存活。
*/
@RestController
@RequiredArgsConstructor
public class PortalIndexController {
private final CmsSiteRepository siteRepository;
@GetMapping({"/portal", "/portal/"})
public void index(HttpServletRequest request, HttpServletResponse response) throws Exception {
// B-5:显式按 id 升序,多顶层站时 302 目标确定(healthcheck 与用户首跳不漂移)
List<CmsSite> sites = siteRepository.findByStatusOrderByIdAsc(1);
if (sites.isEmpty()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "暂无启用站点");
return;
}
CmsSite target = sites.stream()
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
.findFirst()
.orElse(sites.get(0));
response.sendRedirect(request.getContextPath() + "/portal/" + target.getCode() + "/");
}
}
@@ -0,0 +1,188 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
*/
@Component
@RequiredArgsConstructor
public class PortalPageRenderer {
/** 首页每个一级栏目取 N 条 */
private static final int HOME_BLOCK_SIZE = 8;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
private final CmsArticleRepository articleRepository;
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners);
model.addAttribute("topArticles", topArticles(site.getId()));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description"));
model.addAttribute("canonical", url.home());
// og:image(C-7):轮播首图(已补基准)优先,退站点 Logo
Object firstBanner = banners.isEmpty() ? null : banners.get(0).get("imageUrl");
Object logo = site.getConfig() == null ? null : site.getConfig().get("logo");
model.addAttribute("ogImage", firstBanner != null ? firstBanner : url.upload(
logo == null ? null : logo.toString()));
return "themes/" + site.getTheme() + "/home";
}
// ===== 内部装配(自 PortalController 原样迁移)=====
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
PortalUrlHelper url) {
List<Map<String, Object>> result = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (PortalBanner b : site.getBanners()) {
if (!position.equals(b.getPosition())) {
continue;
}
if (b.getStartTime() != null && now.isBefore(b.getStartTime())) {
continue; // 定时上线未到
}
if (b.getEndTime() != null && !now.isBefore(b.getEndTime())) {
continue; // 已过下线时间
}
Map<String, Object> item = new HashMap<>();
item.put("id", b.getId());
item.put("title", b.getTitle());
// 上传图渲染期补基准(B-1):入库相对地址 → 当前基准完整 URL
item.put("imageUrl", url.upload(b.getImageUrl()));
item.put("linkUrl", resolveBannerLink(b, url));
result.add(item);
}
return result;
}
private String resolveBannerLink(PortalBanner b, PortalUrlHelper url) {
LinkTypeEnum type = LinkTypeEnum.byCode(b.getLinkType());
if (type == LinkTypeEnum.ARTICLE) {
return url.article(b.getLinkValue());
}
if (type == LinkTypeEnum.CATEGORY) {
return url.category(b.getLinkValue());
}
return b.getLinkValue();
}
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_TOP_SIZE,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/**
* 首页"图片新闻"卡(视觉精修 D2-1):有封面的已发布文章取 3 条(发布时间倒序)。
* 无封面文章不进此卡(无封面边界由列表页文字行呈现,不造占位图)。
*/
private List<PortalListData.Item> focusArticles(Integer siteId) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, 30, Sort.by(Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
if (a.getCoverImage() == null || a.getCoverImage().trim().isEmpty()) {
continue;
}
items.add(toItem(a));
if (items.size() >= 3) {
break;
}
}
return items;
}
/**
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
*/
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
PortalUrlHelper url) {
List<Map<String, Object>> blocks = new ArrayList<>();
for (PortalCategory category : categories) {
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
continue;
}
List<Integer> categoryIds = new ArrayList<>();
collectListDescendantIds(category, categoryIds);
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_BLOCK_SIZE,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
Map<String, Object> block = new HashMap<>();
block.put("category", category);
block.put("items", items);
block.put("moreUrl", url.category(category.getId()));
blocks.add(block);
}
return blocks;
}
/** 快照栏目树深度优先收集列表型子孙 id(含自身;单页/外链子孙不入列也不下钻) */
private void collectListDescendantIds(PortalCategory category, List<Integer> ids) {
ids.add(category.getId());
for (PortalCategory child : category.getChildren()) {
if (child.getType() != null && child.getType() == CategoryTypeEnum.LIST.getCode()) {
collectListDescendantIds(child, ids);
}
}
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.cms.controller.portal;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.servlet.http.HttpServletResponse;
/**
* 门户 HTML 路由异常落地为错误页(体检 H-1/H-2 根因修复)。
*
* <p>门户控制器抛异常原本落到平台 JSON 兜底(匿名访客看到裸 {"code":6,...})。
* 本 advice 将参数类异常转 sendError(400)、其余转 sendError(500),
* 经 Boot 错误页机制渲染 templates/error/ 下的皮肤化错误页;
* 响应已提交(模板渲染中途失败)时只记日志不再改写响应。
*/
@Slf4j
@ControllerAdvice(assignableTypes = {PortalController.class, PortalIndexController.class,
PortalAssetController.class})
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PortalWebExceptionHandler {
@ExceptionHandler({MethodArgumentTypeMismatchException.class,
MissingServletRequestParameterException.class})
public void badRequest(Exception e, HttpServletResponse response) throws Exception {
if (response.isCommitted()) {
log.warn("门户参数异常但响应已提交: {}", e.getMessage());
return;
}
log.warn("门户参数异常: {}", e.getMessage());
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public void serverError(Exception e, HttpServletResponse response) throws Exception {
if (response.isCommitted()) {
log.warn("门户渲染异常且响应已提交: {}", e.getMessage());
return;
}
log.error("门户渲染异常", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}