init local code
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user