diff --git a/docs/cms-news-info-api.md b/docs/cms-news-info-api.md new file mode 100644 index 0000000..48da91c --- /dev/null +++ b/docs/cms-news-info-api.md @@ -0,0 +1,207 @@ +# 新闻资讯 API + +## 约定 + +- 基础地址:`/api` +- CMS 接口需要登录,并按菜单权限校验。 +- 上下架状态:`0` 上架,`1` 下架。新增新闻默认下架。 +- `module` 保存业务字典 `news_module` 的 `dict_value`,展示名称来自 `dict_label`。管理员可以在 CMS 的“业务字典”中继续维护模块项。 +- `content` 为富文本 HTML。服务端保存前会按白名单清理脚本、事件属性、危险协议等内容。 +- 列表接口返回分页结构:`{ code, msg, total, rows }`;详情和写操作返回标准 RuoYi 结构。 + +## CMS 管理接口 + +### 1. 查询新闻资讯列表 + +```http +GET /api/cms/newsInfo/list +``` + +权限:`cms:newsInfo:list` + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `pageNum` | integer | 否 | 页码,默认 `1` | +| `pageSize` | integer | 否 | 每页条数,默认 `10` | +| `title` | string | 否 | 标题关键词 | +| `module` | string | 否 | `news_module` 字典值 | +| `status` | string | 否 | `0` 上架,`1` 下架 | + +示例: + +```http +GET /api/cms/newsInfo/list?pageNum=1&pageSize=10&module=interview_tips&status=1 +``` + +响应示例: + +```json +{ + "code": 200, + "msg": "查询成功", + "total": 1, + "rows": [ + { + "id": 1, + "module": "interview_tips", + "moduleName": "面试技巧", + "title": "结构化面试准备指南", + "status": "1", + "createTime": "2026-07-20 15:00:00", + "updateTime": null + } + ] +} +``` + +列表不返回正文,查看正文使用详情接口。 + +### 2. 查询新闻资讯详情 + +```http +GET /api/cms/newsInfo/{id} +``` + +权限:`cms:newsInfo:query` + +响应示例: + +```json +{ + "code": 200, + "msg": "查询成功", + "data": { + "id": 1, + "module": "interview_tips", + "moduleName": "面试技巧", + "title": "结构化面试准备指南", + "content": "

准备重点

先了解岗位要求。

", + "status": "1", + "createBy": "admin", + "createTime": "2026-07-20 15:00:00", + "updateBy": "admin", + "updateTime": null, + "remark": "" + } +} +``` + +### 3. 新增新闻资讯 + +```http +POST /api/cms/newsInfo +Content-Type: application/json +``` + +权限:`cms:newsInfo:add` + +请求体: + +```json +{ + "module": "job_information", + "title": "求职资讯标题", + "content": "

经过白名单清理的富文本正文。

", + "status": "1", + "remark": "" +} +``` + +`module` 必须是启用状态的 `news_module` 字典值;`status` 省略时按下架处理。标题不能为空且最多 200 个字符,正文必须包含文字或图片内容。 + +### 4. 修改新闻资讯 + +```http +PUT /api/cms/newsInfo +Content-Type: application/json +``` + +权限:`cms:newsInfo:edit` + +请求体与新增相同,必须带 `id`: + +```json +{ + "id": 1, + "module": "interview_tips", + "title": "更新后的标题", + "content": "

更新后的正文。

", + "status": "1", + "remark": "" +} +``` + +### 5. 上架或下架 + +```http +PUT /api/cms/newsInfo/changeStatus?id=1&status=0 +``` + +权限:`cms:newsInfo:status` + +参数 `status` 只能是 `0`(上架)或 `1`(下架)。接口会同时记录更新人和更新时间。 + +### 6. 删除新闻资讯 + +```http +DELETE /api/cms/newsInfo/{ids} +``` + +权限:`cms:newsInfo:remove` + +`ids` 支持逗号分隔的多个 ID,例如: + +```http +DELETE /api/cms/newsInfo/1,2,3 +``` + +删除使用软删除,数据不会从数据库物理移除。 + +## 公开查询接口 + +### 7. 查询已上架新闻资讯 + +```http +GET /api/app/newsInfo/list +``` + +无需登录。参数与 CMS 列表相同,但服务端始终强制 `status=0`;调用方可使用 `title` 和 `module` 过滤。列表同样不返回正文。 + +### 8. 查询已上架新闻资讯详情 + +```http +GET /api/app/newsInfo/{id} +``` + +无需登录。只有同时满足“未删除、已上架”的记录才会返回;下架或不存在时 `data` 为 `null`。 + +## 业务字典 + +新闻模块选项通过现有接口查询: + +```http +GET /api/cms/dict/data/type/news_module +``` + +典型初始化数据: + +| `dict_value` | `dict_label` | +| --- | --- | +| `job_information` | 求职资讯 | +| `interview_tips` | 面试技巧 | +| `resume_guide` | 简历指南 | +| `industry_guide` | 行业指南 | +| `policy_regulations` | 政策法规 | + +## 图片上传 + +富文本编辑器图片使用现有通用上传接口: + +```http +POST /api/common/upload +Content-Type: multipart/form-data +``` + +表单字段:`file`。响应中的 `url` 用于插入富文本图片。 diff --git a/ruoyi-bussiness/pom.xml b/ruoyi-bussiness/pom.xml index 2e66ffb..e6a9ffb 100644 --- a/ruoyi-bussiness/pom.xml +++ b/ruoyi-bussiness/pom.xml @@ -127,6 +127,12 @@ hutool-all 5.7.22 + + + org.jsoup + jsoup + 1.15.4 + org.springframework.boot spring-boot-starter-websocket diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppNewsInfoController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppNewsInfoController.java new file mode 100644 index 0000000..d2117ec --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppNewsInfoController.java @@ -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)); + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsNewsInfoController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsNewsInfoController.java new file mode 100644 index 0000000..eaadd99 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsNewsInfoController.java @@ -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)); + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfo.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfo.java new file mode 100644 index 0000000..1727dab --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfo.java @@ -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; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfoQuery.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfoQuery.java new file mode 100644 index 0000000..90e3fdb --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/news/NewsInfoQuery.java @@ -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; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/news/NewsInfoMapper.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/news/NewsInfoMapper.java new file mode 100644 index 0000000..35c884b --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/news/NewsInfoMapper.java @@ -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 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); +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/INewsInfoService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/INewsInfoService.java new file mode 100644 index 0000000..da6e0fc --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/INewsInfoService.java @@ -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 selectList(NewsInfoQuery query); + + List 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); +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/impl/NewsInfoServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/impl/NewsInfoServiceImpl.java new file mode 100644 index 0000000..9917fcc --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/news/impl/NewsInfoServiceImpl.java @@ -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 selectList(NewsInfoQuery query) { + return newsInfoMapper.selectNewsInfoList(query); + } + + @Override + public List 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 modules = businessDictTypeService + .selectDictDataByType(NEWS_MODULE_DICT_TYPE); + boolean exists = modules != null && modules.stream() + .anyMatch(item -> module.equals(item.getDictValue())); + if (!exists) { + throw new ServiceException("资讯模块不存在或已停用,请先在业务字典中维护"); + } + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/RichTextSanitizer.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/RichTextSanitizer.java new file mode 100644 index 0000000..403cbc8 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/RichTextSanitizer.java @@ -0,0 +1,49 @@ +package com.ruoyi.cms.util; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; + +/** + * 富文本 HTML 白名单清理。 + * + *

保留新闻正文所需的常见排版、链接、图片和表格标签,移除脚本、事件属性、 + * iframe 以及 javascript/data 等危险协议。

+ */ +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(); + } +} diff --git a/ruoyi-bussiness/src/main/resources/mapper/news/NewsInfoMapper.xml b/ruoyi-bussiness/src/main/resources/mapper/news/NewsInfoMapper.xml new file mode 100644 index 0000000..46f9146 --- /dev/null +++ b/ruoyi-bussiness/src/main/resources/mapper/news/NewsInfoMapper.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + (select data.dict_label + from bussiness_dict_data data + where data.dict_type = 'news_module' + and data.dict_value = news.module + order by data.dict_sort, data.dict_code + limit 1) as module_name + + + + select news.id, news.module, , news.title, + news.status, news.create_time, news.update_time + from news_info news + + + + select news.id, news.module, , news.title, + news.content, news.status, news.create_by, news.create_time, + news.update_by, news.update_time, news.remark + from news_info news + + + + + + + + + + insert into news_info ( + module, title, content, status, create_by, create_time, del_flag, remark + ) values ( + #{module}, #{title}, #{content}, #{status}, #{createBy}, CURRENT_TIMESTAMP, '0', + COALESCE(#{remark}, '') + ) + + + + update news_info + set module = #{module}, + title = #{title}, + content = #{content}, + + status = #{status}, + + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP, + remark = COALESCE(#{remark}, '') + where id = #{id} and del_flag = '0' + + + + update news_info + set status = #{status}, update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + where id = #{id} and del_flag = '0' + + + + update news_info + set del_flag = '2', update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + where del_flag = '0' + and id in + + #{id} + + + + diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/util/RichTextSanitizerTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/util/RichTextSanitizerTest.java new file mode 100644 index 0000000..c1a9c58 --- /dev/null +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/util/RichTextSanitizerTest.java @@ -0,0 +1,37 @@ +package com.ruoyi.cms.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RichTextSanitizerTest { + + @Test + void shouldKeepCommonRichTextAndRemoveExecutableContent() { + String source = "

标题

正文

" + + "" + + "危险链接" + + ""; + + String cleaned = RichTextSanitizer.clean(source); + + assertTrue(cleaned.contains("

标题

")); + assertTrue(cleaned.contains("正文")); + assertTrue(cleaned.contains("https://example.com/news.png")); + assertFalse(cleaned.contains("script")); + assertFalse(cleaned.contains("alert")); + assertFalse(cleaned.contains("onclick")); + assertFalse(cleaned.contains("onerror")); + assertFalse(cleaned.contains("javascript:")); + } + + @Test + void shouldTreatEditorPlaceholderAsEmpty() { + String cleaned = RichTextSanitizer.clean("


"); + + assertFalse(RichTextSanitizer.hasContent(cleaned)); + assertTrue(RichTextSanitizer.hasContent("

有效正文

")); + assertTrue(RichTextSanitizer.hasContent("")); + } +} diff --git a/sql/migration_add_job_status_and_key_populations.sql b/sql/migration_add_job_status_and_key_populations.sql new file mode 100644 index 0000000..241157e --- /dev/null +++ b/sql/migration_add_job_status_and_key_populations.sql @@ -0,0 +1,20 @@ +-- 岗位上下架状态、是否重点人群及重点人群类型字段。 +-- 执行目标:对应环境 HighGo 数据库的 shz schema。 +-- 可重复执行:字段使用 IF NOT EXISTS,列注释可重复设置。 +-- 字段定义与测试库 shz.job 保持一致:允许为空且不设置默认值。 + +BEGIN; + +ALTER TABLE "shz"."job" + ADD COLUMN IF NOT EXISTS "job_status" VARCHAR(2), + ADD COLUMN IF NOT EXISTS "is_key_populations" VARCHAR(2), + ADD COLUMN IF NOT EXISTS "key_populations" VARCHAR(20); + +COMMENT ON COLUMN "shz"."job"."job_status" + IS '是否下架(0上架,1下架)'; +COMMENT ON COLUMN "shz"."job"."is_key_populations" + IS '是否重点人群'; +COMMENT ON COLUMN "shz"."job"."key_populations" + IS '重点人群(1农民工、2团场职工、3失业人员、4零就业家庭、5零工人员、6退役军人)'; + +COMMIT; diff --git a/sql/news_info.sql b/sql/news_info.sql new file mode 100644 index 0000000..7664505 --- /dev/null +++ b/sql/news_info.sql @@ -0,0 +1,212 @@ +-- 新闻资讯:数据表、业务字典与 CMS 菜单权限。 +-- 执行目标:测试/生产 HighGo 数据库的 shz schema。 +-- 可重复执行:表/索引、字典类型/字典项、菜单/按钮均做幂等处理。 + +BEGIN; + +-- 显式创建序列以兼容生产 HighGo(生产环境不识别 BIGSERIAL 类型别名)。 +CREATE SEQUENCE IF NOT EXISTS "shz"."news_info_id_seq" + START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; + +CREATE TABLE IF NOT EXISTS "shz"."news_info" ( + "id" BIGINT NOT NULL DEFAULT nextval('shz.news_info_id_seq'::regclass) PRIMARY KEY, + "module" VARCHAR(100) NOT NULL, + "title" VARCHAR(200) NOT NULL, + "content" TEXT NOT NULL, + "status" CHAR(1) NOT NULL DEFAULT '1', + "create_by" VARCHAR(64) NOT NULL DEFAULT '', + "create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_by" VARCHAR(64) NOT NULL DEFAULT '', + "update_time" TIMESTAMP, + "del_flag" CHAR(1) NOT NULL DEFAULT '0', + "remark" VARCHAR(500) NOT NULL DEFAULT '', + CONSTRAINT "chk_news_info_status" CHECK ("status" IN ('0', '1')), + CONSTRAINT "chk_news_info_del_flag" CHECK ("del_flag" IN ('0', '2')) +); + +COMMENT ON TABLE "shz"."news_info" IS '新闻资讯表'; +COMMENT ON COLUMN "shz"."news_info"."id" IS '新闻资讯ID'; +COMMENT ON COLUMN "shz"."news_info"."module" IS '资讯模块,对应业务字典 news_module 的 dict_value'; +COMMENT ON COLUMN "shz"."news_info"."title" IS '标题'; +COMMENT ON COLUMN "shz"."news_info"."content" IS '富文本正文(经过服务端白名单清理的HTML)'; +COMMENT ON COLUMN "shz"."news_info"."status" IS '上下架状态(0上架 1下架)'; +COMMENT ON COLUMN "shz"."news_info"."create_by" IS '创建者'; +COMMENT ON COLUMN "shz"."news_info"."create_time" IS '创建时间'; +COMMENT ON COLUMN "shz"."news_info"."update_by" IS '更新者'; +COMMENT ON COLUMN "shz"."news_info"."update_time" IS '更新时间'; +COMMENT ON COLUMN "shz"."news_info"."del_flag" IS '删除标志(0存在 2删除)'; +COMMENT ON COLUMN "shz"."news_info"."remark" IS '备注'; + +CREATE INDEX IF NOT EXISTS "idx_news_info_module" + ON "shz"."news_info" ("module"); +CREATE INDEX IF NOT EXISTS "idx_news_info_status_created" + ON "shz"."news_info" ("status", "create_time" DESC) + WHERE "del_flag" = '0'; + +-- 资讯模块使用“业务字典”,资讯表只保存 dict_value;管理员可在现有业务字典页面继续添加模块项。 +INSERT INTO "shz"."bussiness_dict_type" ( + "dict_id", "dict_name", "dict_type", "status", "create_by", "create_time", "remark" +) +SELECT + (SELECT COALESCE(MAX("dict_id"), 0) + 1 FROM "shz"."bussiness_dict_type"), + '新闻资讯模块', + 'news_module', + '0', + 'system', + CURRENT_TIMESTAMP, + '新闻资讯模块,资讯表保存 dict_value,可在业务字典中扩展' +WHERE NOT EXISTS ( + SELECT 1 + FROM "shz"."bussiness_dict_type" + WHERE "dict_type" = 'news_module' +); + +WITH seed("dict_sort", "dict_label", "dict_value", "list_class", "is_default") AS ( + VALUES + (1, '求职资讯', 'job_information', 'primary', 'Y'), + (2, '面试技巧', 'interview_tips', 'success', 'N'), + (3, '简历指南', 'resume_guide', 'warning', 'N'), + (4, '行业指南', 'industry_guide', 'info', 'N'), + (5, '政策法规', 'policy_regulations', 'danger', 'N') +), +new_rows AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY seed."dict_sort") AS rn, + seed.* + FROM seed + WHERE NOT EXISTS ( + SELECT 1 + FROM "shz"."bussiness_dict_data" data + WHERE data."dict_type" = 'news_module' + AND data."dict_value" = seed."dict_value" + ) +), +base AS ( + SELECT COALESCE(MAX("dict_code"), 0) AS max_code + FROM "shz"."bussiness_dict_data" +) +INSERT INTO "shz"."bussiness_dict_data" ( + "dict_code", "dict_sort", "dict_label", "dict_value", "dict_type", + "css_class", "list_class", "is_default", "status", "create_by", "create_time", "remark" +) +SELECT + base.max_code + new_rows.rn, + new_rows."dict_sort", + new_rows."dict_label", + new_rows."dict_value", + 'news_module', + NULL, + new_rows."list_class", + new_rows."is_default", + '0', + 'system', + CURRENT_TIMESTAMP, + '新闻资讯模块' +FROM new_rows +CROSS JOIN base; + +-- CMS 一级目录。 +INSERT INTO "shz"."sys_menu" ( + "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", + "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", + "create_by", "create_time", "update_by", "update_time", "remark", "menu_id" +) +SELECT + '新闻资讯', 0, 8, 'news-information', '', '', 'NewsInformation', + 1, 0, 'M', '0', '0', '', 'ReadOutlined', + 'system', CURRENT_TIMESTAMP, '', NULL, '新闻资讯管理目录', ids."menu_id" +FROM ( + SELECT COALESCE(MAX("menu_id"), 0) + 1 AS "menu_id" + FROM "shz"."sys_menu" +) ids +WHERE NOT EXISTS ( + SELECT 1 + FROM "shz"."sys_menu" + WHERE "parent_id" = 0 AND "path" = 'news-information' +); + +-- CMS 资讯管理页面;页面权限同时作为列表权限。 +INSERT INTO "shz"."sys_menu" ( + "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", + "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", + "create_by", "create_time", "update_by", "update_time", "remark", "menu_id" +) +SELECT + '资讯管理', parent."menu_id", 1, 'news-info', 'NewsInfo/index', '', 'NewsInfo', + 1, 0, 'C', '0', '0', 'cms:newsInfo:list', 'ProfileOutlined', + 'system', CURRENT_TIMESTAMP, '', NULL, '新闻资讯管理页面', ids."menu_id" +FROM ( + SELECT "menu_id" + FROM "shz"."sys_menu" + WHERE "parent_id" = 0 AND "path" = 'news-information' + ORDER BY "menu_id" + LIMIT 1 +) parent +CROSS JOIN ( + SELECT COALESCE(MAX("menu_id"), 0) + 1 AS "menu_id" + FROM "shz"."sys_menu" +) ids +WHERE NOT EXISTS ( + SELECT 1 + FROM "shz"."sys_menu" + WHERE "perms" = 'cms:newsInfo:list' +); + +-- 页面按钮权限。 +WITH parent AS ( + SELECT "menu_id" + FROM "shz"."sys_menu" + WHERE "perms" = 'cms:newsInfo:list' + ORDER BY "menu_id" + LIMIT 1 +), +seed("order_num", "menu_name", "perms", "remark") AS ( + VALUES + (1, '新闻资讯查询', 'cms:newsInfo:query', '查询新闻资讯详情'), + (2, '新闻资讯新增', 'cms:newsInfo:add', '新增新闻资讯'), + (3, '新闻资讯修改', 'cms:newsInfo:edit', '修改新闻资讯'), + (4, '新闻资讯删除', 'cms:newsInfo:remove', '删除新闻资讯'), + (5, '新闻资讯上下架', 'cms:newsInfo:status', '新闻资讯上架或下架') +), +new_rows AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY seed."order_num") AS rn, + seed.* + FROM seed + WHERE NOT EXISTS ( + SELECT 1 + FROM "shz"."sys_menu" existing + WHERE existing."perms" = seed."perms" + ) +), +base AS ( + SELECT COALESCE(MAX("menu_id"), 0) AS max_id + FROM "shz"."sys_menu" +) +INSERT INTO "shz"."sys_menu" ( + "menu_id", "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", + "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", + "create_by", "create_time", "update_by", "update_time", "remark" +) +SELECT + base.max_id + new_rows.rn, + new_rows."menu_name", + parent."menu_id", + new_rows."order_num", + '', '', '', '', + 1, 0, 'F', '0', '0', new_rows."perms", '#', + 'system', CURRENT_TIMESTAMP, '', NULL, new_rows."remark" +FROM new_rows +CROSS JOIN parent +CROSS JOIN base; + +COMMIT; + +-- 说明:管理员角色通过 *:*:* 自动拥有全部权限;其他角色请在 CMS“角色管理”中按需分配“新闻资讯”。 +-- 验证: +-- SELECT column_name, data_type FROM information_schema.columns +-- WHERE table_schema = 'shz' AND table_name = 'news_info' ORDER BY ordinal_position; +-- SELECT dict_label, dict_value FROM shz.bussiness_dict_data +-- WHERE dict_type = 'news_module' ORDER BY dict_sort; +-- SELECT menu_id, menu_name, parent_id, menu_type, perms FROM shz.sys_menu +-- WHERE path = 'news-information' OR perms LIKE 'cms:newsInfo:%' ORDER BY menu_id;