feat: Add news information management module with API and database support

- Introduced new API endpoints for querying and managing news information in the CMS.
- Implemented data model for NewsInfo and NewsInfoQuery with validation.
- Created NewsInfoMapper for database interactions and corresponding XML mappings.
- Developed INewsInfoService interface and its implementation for business logic.
- Added RichTextSanitizer utility for cleaning HTML content to prevent XSS attacks.
- Created unit tests for RichTextSanitizer to ensure proper functionality.
- Updated database schema with news_info table and necessary fields, including status and module.
- Added migration scripts for setting up the news information module in the database.
- Integrated jsoup library for HTML sanitization.
This commit is contained in:
2026-07-20 14:54:15 +08:00
parent 3a6a4333dc
commit c1a7aa3532
14 changed files with 1024 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
package com.ruoyi.cms.controller.app;
import com.ruoyi.cms.domain.news.NewsInfoQuery;
import com.ruoyi.cms.service.news.INewsInfoService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
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;
/**
* 用户端新闻资讯公开查询接口。
*/
@Anonymous
@RestController
@RequestMapping("/app/newsInfo")
@Api(tags = "移动端:新闻资讯")
public class AppNewsInfoController extends BaseController {
@Autowired
private INewsInfoService newsInfoService;
@ApiOperation("查询已上架新闻资讯列表")
@GetMapping("/list")
public TableDataInfo list(NewsInfoQuery query) {
startPage();
return getDataTable(newsInfoService.selectPublishedList(query));
}
@ApiOperation("获取已上架新闻资讯详情")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
return success(newsInfoService.selectPublishedById(id));
}
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.cms.controller.cms;
import com.ruoyi.cms.domain.news.NewsInfo;
import com.ruoyi.cms.domain.news.NewsInfoQuery;
import com.ruoyi.cms.service.news.INewsInfoService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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;
/**
* CMS 新闻资讯管理。
*/
@Validated
@RestController
@RequestMapping("/cms/newsInfo")
@Api(tags = "CMS新闻资讯管理")
public class CmsNewsInfoController extends BaseController {
@Autowired
private INewsInfoService newsInfoService;
@PreAuthorize("@ss.hasPermi('cms:newsInfo:list')")
@ApiOperation("查询新闻资讯列表")
@GetMapping("/list")
public TableDataInfo list(NewsInfoQuery query) {
startPage();
return getDataTable(newsInfoService.selectList(query));
}
@PreAuthorize("@ss.hasPermi('cms:newsInfo:query')")
@ApiOperation("获取新闻资讯详情")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
return success(newsInfoService.selectById(id));
}
@PreAuthorize("@ss.hasPermi('cms:newsInfo:add')")
@Log(title = "新闻资讯", businessType = BusinessType.INSERT)
@ApiOperation("新增新闻资讯")
@PostMapping
public AjaxResult add(@Validated @RequestBody NewsInfo newsInfo) {
return toAjax(newsInfoService.insert(newsInfo));
}
@PreAuthorize("@ss.hasPermi('cms:newsInfo:edit')")
@Log(title = "新闻资讯", businessType = BusinessType.UPDATE)
@ApiOperation("修改新闻资讯")
@PutMapping
public AjaxResult edit(@Validated @RequestBody NewsInfo newsInfo) {
return toAjax(newsInfoService.update(newsInfo));
}
@PreAuthorize("@ss.hasPermi('cms:newsInfo:remove')")
@Log(title = "新闻资讯", businessType = BusinessType.DELETE)
@ApiOperation("删除新闻资讯")
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(newsInfoService.deleteByIds(ids));
}
@PreAuthorize("@ss.hasPermi('cms:newsInfo:status')")
@Log(title = "新闻资讯上下架", businessType = BusinessType.UPDATE)
@ApiOperation("新闻资讯上架或下架")
@PutMapping("/changeStatus")
public AjaxResult changeStatus(@RequestParam Long id, @RequestParam String status) {
return toAjax(newsInfoService.updateStatus(id, status));
}
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.cms.domain.news;
import com.ruoyi.common.core.domain.BaseEntity;
import com.ruoyi.common.xss.Xss;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/**
* 新闻资讯对象。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("新闻资讯")
public class NewsInfo extends BaseEntity {
private static final long serialVersionUID = 1L;
@ApiModelProperty("新闻资讯ID")
private Long id;
@ApiModelProperty(value = "资讯模块,对应业务字典 news_module 的 dict_value", required = true)
@NotBlank(message = "资讯模块不能为空")
@Size(max = 100, message = "资讯模块长度不能超过100个字符")
private String module;
@ApiModelProperty("资讯模块名称(业务字典标签,只读)")
private String moduleName;
@ApiModelProperty(value = "标题", required = true)
@NotBlank(message = "标题不能为空")
@Size(max = 200, message = "标题长度不能超过200个字符")
@Xss(message = "标题不能包含脚本字符")
private String title;
@ApiModelProperty(value = "富文本正文HTML", required = true)
@NotBlank(message = "正文不能为空")
private String content;
@ApiModelProperty("上下架状态0上架 1下架新增时默认下架")
@Pattern(regexp = "^[01]$", message = "状态只能是0上架或1下架")
private String status;
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.cms.domain.news;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 新闻资讯查询参数。
*/
@Data
@ApiModel("新闻资讯查询参数")
public class NewsInfoQuery {
@ApiModelProperty("页码")
private Integer pageNum = 1;
@ApiModelProperty("每页条数")
private Integer pageSize = 10;
@ApiModelProperty("标题关键词")
private String title;
@ApiModelProperty("资讯模块字典值")
private String module;
@ApiModelProperty("上下架状态0上架 1下架")
private String status;
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.cms.mapper.news;
import com.ruoyi.cms.domain.news.NewsInfo;
import com.ruoyi.cms.domain.news.NewsInfoQuery;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 新闻资讯数据访问层。
*/
public interface NewsInfoMapper {
List<NewsInfo> selectNewsInfoList(@Param("query") NewsInfoQuery query);
NewsInfo selectNewsInfoById(@Param("id") Long id);
NewsInfo selectPublishedNewsInfoById(@Param("id") Long id);
int insertNewsInfo(NewsInfo newsInfo);
int updateNewsInfo(NewsInfo newsInfo);
int updateNewsInfoStatus(@Param("id") Long id,
@Param("status") String status,
@Param("updateBy") String updateBy);
int deleteNewsInfoByIds(@Param("ids") Long[] ids, @Param("updateBy") String updateBy);
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.cms.service.news;
import com.ruoyi.cms.domain.news.NewsInfo;
import com.ruoyi.cms.domain.news.NewsInfoQuery;
import java.util.List;
/**
* 新闻资讯业务接口。
*/
public interface INewsInfoService {
List<NewsInfo> selectList(NewsInfoQuery query);
List<NewsInfo> selectPublishedList(NewsInfoQuery query);
NewsInfo selectById(Long id);
NewsInfo selectPublishedById(Long id);
int insert(NewsInfo newsInfo);
int update(NewsInfo newsInfo);
int updateStatus(Long id, String status);
int deleteByIds(Long[] ids);
}

View File

@@ -0,0 +1,127 @@
package com.ruoyi.cms.service.news.impl;
import com.ruoyi.cms.domain.BussinessDictData;
import com.ruoyi.cms.domain.news.NewsInfo;
import com.ruoyi.cms.domain.news.NewsInfoQuery;
import com.ruoyi.cms.mapper.news.NewsInfoMapper;
import com.ruoyi.cms.service.IBussinessDictTypeService;
import com.ruoyi.cms.service.news.INewsInfoService;
import com.ruoyi.cms.util.RichTextSanitizer;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 新闻资讯业务实现。
*/
@Service
public class NewsInfoServiceImpl implements INewsInfoService {
public static final String NEWS_MODULE_DICT_TYPE = "news_module";
public static final String STATUS_PUBLISHED = "0";
public static final String STATUS_OFF_SHELF = "1";
@Autowired
private NewsInfoMapper newsInfoMapper;
@Autowired
private IBussinessDictTypeService businessDictTypeService;
@Override
public List<NewsInfo> selectList(NewsInfoQuery query) {
return newsInfoMapper.selectNewsInfoList(query);
}
@Override
public List<NewsInfo> selectPublishedList(NewsInfoQuery query) {
query.setStatus(STATUS_PUBLISHED);
return newsInfoMapper.selectNewsInfoList(query);
}
@Override
public NewsInfo selectById(Long id) {
return newsInfoMapper.selectNewsInfoById(id);
}
@Override
public NewsInfo selectPublishedById(Long id) {
return newsInfoMapper.selectPublishedNewsInfoById(id);
}
@Override
@Transactional
public int insert(NewsInfo newsInfo) {
prepareAndValidate(newsInfo);
if (StringUtils.isEmpty(newsInfo.getStatus())) {
newsInfo.setStatus(STATUS_OFF_SHELF);
}
newsInfo.setCreateBy(SecurityUtils.getUsername());
return newsInfoMapper.insertNewsInfo(newsInfo);
}
@Override
@Transactional
public int update(NewsInfo newsInfo) {
if (newsInfo.getId() == null) {
throw new ServiceException("新闻资讯ID不能为空");
}
prepareAndValidate(newsInfo);
newsInfo.setUpdateBy(SecurityUtils.getUsername());
return newsInfoMapper.updateNewsInfo(newsInfo);
}
@Override
@Transactional
public int updateStatus(Long id, String status) {
if (id == null) {
throw new ServiceException("新闻资讯ID不能为空");
}
if (!STATUS_PUBLISHED.equals(status) && !STATUS_OFF_SHELF.equals(status)) {
throw new ServiceException("状态只能是0上架或1下架");
}
return newsInfoMapper.updateNewsInfoStatus(id, status, SecurityUtils.getUsername());
}
@Override
@Transactional
public int deleteByIds(Long[] ids) {
if (ids == null || ids.length == 0) {
throw new ServiceException("请选择需要删除的新闻资讯");
}
return newsInfoMapper.deleteNewsInfoByIds(ids, SecurityUtils.getUsername());
}
private void prepareAndValidate(NewsInfo newsInfo) {
if (newsInfo == null || StringUtils.isEmpty(newsInfo.getModule())
|| StringUtils.isEmpty(newsInfo.getTitle()) || StringUtils.isEmpty(newsInfo.getContent())) {
throw new ServiceException("资讯模块、标题和正文不能为空");
}
newsInfo.setModule(newsInfo.getModule().trim());
newsInfo.setTitle(newsInfo.getTitle().trim());
if (newsInfo.getModule().isEmpty() || newsInfo.getTitle().isEmpty()) {
throw new ServiceException("资讯模块和标题不能为空");
}
validateModule(newsInfo.getModule());
String cleanedContent = RichTextSanitizer.clean(newsInfo.getContent());
if (!RichTextSanitizer.hasContent(cleanedContent)) {
throw new ServiceException("正文不能为空");
}
newsInfo.setContent(cleanedContent);
}
private void validateModule(String module) {
List<BussinessDictData> modules = businessDictTypeService
.selectDictDataByType(NEWS_MODULE_DICT_TYPE);
boolean exists = modules != null && modules.stream()
.anyMatch(item -> module.equals(item.getDictValue()));
if (!exists) {
throw new ServiceException("资讯模块不存在或已停用,请先在业务字典中维护");
}
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.cms.util;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
/**
* 富文本 HTML 白名单清理。
*
* <p>保留新闻正文所需的常见排版、链接、图片和表格标签,移除脚本、事件属性、
* iframe 以及 javascript/data 等危险协议。</p>
*/
public final class RichTextSanitizer {
private static final Safelist NEWS_CONTENT_SAFELIST = Safelist.relaxed()
.addTags("figure", "figcaption", "s")
.addAttributes("a", "target", "rel")
.addAttributes("img", "loading")
.addAttributes("table", "border", "cellpadding", "cellspacing")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https");
private static final Document.OutputSettings OUTPUT_SETTINGS = new Document.OutputSettings()
.prettyPrint(false);
private RichTextSanitizer() {
}
/**
* 清理并规范化富文本 HTML。
*/
public static String clean(String html) {
if (html == null) {
return null;
}
return Jsoup.clean(html, "", NEWS_CONTENT_SAFELIST, OUTPUT_SETTINGS).trim();
}
/**
* 判断清理后的正文是否包含文字或图片内容。
*/
public static boolean hasContent(String cleanedHtml) {
if (cleanedHtml == null || cleanedHtml.trim().isEmpty()) {
return false;
}
Document document = Jsoup.parseBodyFragment(cleanedHtml);
return !document.text().trim().isEmpty() || !document.select("img").isEmpty();
}
}