Bladeren bron

feat(食品安全宣传): 新增活动/资料双模块 - 含看板/状态流转/上传资料

yangjingjing 2 dagen geleden
bovenliggende
commit
9d19f4fab4
17 gewijzigde bestanden met toevoegingen van 1813 en 0 verwijderingen
  1. 142 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskFoodSafetyCampaignController.java
  2. 100 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskFoodSafetyMaterialController.java
  3. 119 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFoodSafetyCampaign.java
  4. 93 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFoodSafetyMaterial.java
  5. 126 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFoodSafetyCampaignBo.java
  6. 97 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFoodSafetyMaterialBo.java
  7. 80 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/FoodSafetyCampaignStatsVo.java
  8. 137 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFoodSafetyCampaignVo.java
  9. 102 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFoodSafetyMaterialVo.java
  10. 66 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFoodSafetyCampaignMapper.java
  11. 66 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFoodSafetyMaterialMapper.java
  12. 73 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskFoodSafetyCampaignService.java
  13. 47 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskFoodSafetyMaterialService.java
  14. 264 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskFoodSafetyCampaignServiceImpl.java
  15. 116 0
      ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskFoodSafetyMaterialServiceImpl.java
  16. 141 0
      sql/risk_food_safety_campaign.sql
  17. 44 0
      sql/risk_food_safety_campaign_menu.sql

+ 142 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskFoodSafetyCampaignController.java

@@ -0,0 +1,142 @@
+package org.dromara.risk.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.excel.utils.ExcelUtil;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.risk.domain.bo.RiskFoodSafetyCampaignBo;
+import org.dromara.risk.domain.vo.FoodSafetyCampaignStatsVo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyCampaignVo;
+import org.dromara.risk.service.IRiskFoodSafetyCampaignService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 食品安全宣传活动
+ *
+ * @author ruoyi
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/risk/foodSafety/campaign")
+public class RiskFoodSafetyCampaignController extends BaseController {
+
+    private final IRiskFoodSafetyCampaignService campaignService;
+
+    /**
+     * 查询活动列表
+     */
+    @SaCheckPermission("risk:foodSafety:list")
+    @GetMapping("/list")
+    public TableDataInfo<RiskFoodSafetyCampaignVo> list(RiskFoodSafetyCampaignBo bo, PageQuery pageQuery) {
+        return campaignService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取活动详细信息
+     */
+    @SaCheckPermission("risk:foodSafety:list")
+    @GetMapping("/{id}")
+    public R<RiskFoodSafetyCampaignVo> getInfo(@NotNull(message = "活动ID不能为空") @PathVariable("id") Long id) {
+        return R.ok(campaignService.queryById(id));
+    }
+
+    /**
+     * 新增活动
+     */
+    @SaCheckPermission("risk:foodSafety:add")
+    @Log(title = "食品安全宣传", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody RiskFoodSafetyCampaignBo bo) {
+        return toAjax(campaignService.insertByBo(bo));
+    }
+
+    /**
+     * 修改活动
+     */
+    @SaCheckPermission("risk:foodSafety:edit")
+    @Log(title = "食品安全宣传", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping
+    public R<Void> edit(@Validated(EditGroup.class) @RequestBody RiskFoodSafetyCampaignBo bo) {
+        return toAjax(campaignService.updateByBo(bo));
+    }
+
+    /**
+     * 删除活动
+     */
+    @SaCheckPermission("risk:foodSafety:remove")
+    @Log(title = "食品安全宣传", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "活动ID不能为空") @PathVariable Long[] ids) {
+        return toAjax(campaignService.deleteWithValidByIds(Arrays.asList(ids)));
+    }
+
+    /**
+     * 导出活动列表
+     */
+    @SaCheckPermission("risk:foodSafety:export")
+    @Log(title = "食品安全宣传", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(RiskFoodSafetyCampaignBo bo, HttpServletResponse response) {
+        List<RiskFoodSafetyCampaignVo> list = campaignService.queryList(bo);
+        ExcelUtil.exportExcel(list, "食品安全宣传活动", RiskFoodSafetyCampaignVo.class, response);
+    }
+
+    /**
+     * 活动开始(状态:未开始 → 进行中)
+     */
+    @SaCheckPermission("risk:foodSafety:start")
+    @Log(title = "食品安全宣传-活动开始", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping("/start/{id}")
+    public R<Void> start(@NotNull(message = "活动ID不能为空") @PathVariable("id") Long id) {
+        boolean ok = campaignService.startCampaign(id);
+        return ok ? R.ok() : R.fail("活动开始失败");
+    }
+
+    /**
+     * 活动结束(状态:进行中 → 已结束)
+     * <p>
+     * body 中传入 summary(活动总结,必填)
+     */
+    @SaCheckPermission("risk:foodSafety:finish")
+    @Log(title = "食品安全宣传-活动结束", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping("/finish/{id}")
+    public R<Void> finish(@NotNull(message = "活动ID不能为空") @PathVariable("id") Long id, @RequestBody RiskFoodSafetyCampaignBo bo) {
+        String summary = bo != null ? bo.getSummary() : null;
+        if (StrUtil.isBlank(summary)) {
+            return R.fail("活动总结不能为空");
+        }
+        boolean ok = campaignService.finishCampaign(id, summary);
+        return ok ? R.ok() : R.fail("活动结束失败");
+    }
+
+    /**
+     * 查询看板统计数据
+     */
+    @SaCheckPermission("risk:foodSafety:list")
+    @GetMapping("/stats")
+    public R<FoodSafetyCampaignStatsVo> stats() {
+        return R.ok(campaignService.queryStats());
+    }
+}

+ 100 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskFoodSafetyMaterialController.java

@@ -0,0 +1,100 @@
+package org.dromara.risk.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.excel.utils.ExcelUtil;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.risk.domain.bo.RiskFoodSafetyMaterialBo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyMaterialVo;
+import org.dromara.risk.service.IRiskFoodSafetyMaterialService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 食品安全宣传资料
+ *
+ * @author ruoyi
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/risk/foodSafety/material")
+public class RiskFoodSafetyMaterialController extends BaseController {
+
+    private final IRiskFoodSafetyMaterialService materialService;
+
+    /**
+     * 查询资料列表
+     */
+    @SaCheckPermission("risk:foodSafety:uploadMaterial")
+    @GetMapping("/list")
+    public TableDataInfo<RiskFoodSafetyMaterialVo> list(RiskFoodSafetyMaterialBo bo, PageQuery pageQuery) {
+        return materialService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取资料详细信息
+     */
+    @SaCheckPermission("risk:foodSafety:uploadMaterial")
+    @GetMapping("/{id}")
+    public R<RiskFoodSafetyMaterialVo> getInfo(@NotNull(message = "资料ID不能为空") @PathVariable("id") Long id) {
+        return R.ok(materialService.queryById(id));
+    }
+
+    /**
+     * 新增资料(上传资料)
+     */
+    @SaCheckPermission("risk:foodSafety:uploadMaterial")
+    @Log(title = "食品安全宣传资料", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody RiskFoodSafetyMaterialBo bo) {
+        return toAjax(materialService.insertByBo(bo));
+    }
+
+    /**
+     * 修改资料
+     */
+    @SaCheckPermission("risk:foodSafety:uploadMaterial")
+    @Log(title = "食品安全宣传资料", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping
+    public R<Void> edit(@Validated(EditGroup.class) @RequestBody RiskFoodSafetyMaterialBo bo) {
+        return toAjax(materialService.updateByBo(bo));
+    }
+
+    /**
+     * 删除资料
+     */
+    @SaCheckPermission("risk:foodSafety:uploadMaterial")
+    @Log(title = "食品安全宣传资料", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "资料ID不能为空") @PathVariable Long[] ids) {
+        return toAjax(materialService.deleteWithValidByIds(Arrays.asList(ids)));
+    }
+
+    /**
+     * 导出资料列表
+     */
+    @SaCheckPermission("risk:foodSafety:export")
+    @Log(title = "食品安全宣传资料", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(RiskFoodSafetyMaterialBo bo, HttpServletResponse response) {
+        List<RiskFoodSafetyMaterialVo> list = materialService.queryList(bo);
+        ExcelUtil.exportExcel(list, "食品安全宣传资料", RiskFoodSafetyMaterialVo.class, response);
+    }
+}

+ 119 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFoodSafetyCampaign.java

@@ -0,0 +1,119 @@
+package org.dromara.risk.domain;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 食品安全宣传活动对象 risk_food_safety_campaign
+ *
+ * @author ruoyi
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+@TableName("risk_food_safety_campaign")
+public class RiskFoodSafetyCampaign extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 活动编号(CF+年月日+4位序号)
+     */
+    private String campaignCode;
+
+    /**
+     * 活动名称
+     */
+    private String campaignName;
+
+    /**
+     * 活动类型(字典:risk_food_safety_campaign_type)
+     */
+    private String campaignType;
+
+    /**
+     * 开始时间
+     */
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    private Date endTime;
+
+    /**
+     * 预计参与人数
+     */
+    private Integer expectedParticipants;
+
+    /**
+     * 实际参与人数
+     */
+    private Integer actualParticipants;
+
+    /**
+     * 活动地点
+     */
+    private String location;
+
+    /**
+     * 活动描述
+     */
+    private String description;
+
+    /**
+     * 负责人ID
+     */
+    private Long principalId;
+
+    /**
+     * 负责人姓名
+     */
+    private String principalName;
+
+    /**
+     * 状态(未开始/进行中/已结束)
+     */
+    private String status;
+
+    /**
+     * 实际开始时间
+     */
+    private Date startRealTime;
+
+    /**
+     * 实际结束时间
+     */
+    private Date endRealTime;
+
+    /**
+     * 活动总结
+     */
+    private String summary;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 删除标志
+     */
+    @TableLogic
+    private String delFlag;
+
+}

+ 93 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFoodSafetyMaterial.java

@@ -0,0 +1,93 @@
+package org.dromara.risk.domain;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 食品安全宣传资料对象 risk_food_safety_material
+ *
+ * @author ruoyi
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+@TableName("risk_food_safety_material")
+public class RiskFoodSafetyMaterial extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 资料编号(MT+年月日+4位序号)
+     */
+    private String materialCode;
+
+    /**
+     * 资料名称
+     */
+    private String materialName;
+
+    /**
+     * 资料类型(字典:risk_food_safety_material_type)
+     */
+    private String materialType;
+
+    /**
+     * 资料概述
+     */
+    private String materialSummary;
+
+    /**
+     * 资料封面URL(MinIO)
+     */
+    private String coverUrl;
+
+    /**
+     * 资料封面原始文件名
+     */
+    private String coverName;
+
+    /**
+     * 资料附件URL(MinIO)
+     */
+    private String attachmentUrl;
+
+    /**
+     * 资料附件原始文件名
+     */
+    private String attachmentName;
+
+    /**
+     * 关联活动ID(可空)
+     */
+    private Long campaignId;
+
+    /**
+     * 上传人ID
+     */
+    private Long uploaderId;
+
+    /**
+     * 上传人姓名
+     */
+    private String uploaderName;
+
+    /**
+     * 删除标志
+     */
+    @TableLogic
+    private String delFlag;
+
+}

+ 126 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFoodSafetyCampaignBo.java

@@ -0,0 +1,126 @@
+package org.dromara.risk.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.risk.domain.RiskFoodSafetyCampaign;
+
+import java.util.Date;
+
+/**
+ * 食品安全宣传活动业务对象 risk_food_safety_campaign
+ *
+ * @author ruoyi
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = RiskFoodSafetyCampaign.class, reverseConvertGenerate = false)
+public class RiskFoodSafetyCampaignBo extends BaseEntity {
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 活动编号
+     */
+    @Size(min = 0, max = 50, message = "活动编号长度不能超过{max}个字符")
+    private String campaignCode;
+
+    /**
+     * 活动名称
+     */
+    @NotBlank(message = "活动名称不能为空")
+    @Size(min = 0, max = 200, message = "活动名称长度不能超过{max}个字符")
+    private String campaignName;
+
+    /**
+     * 活动类型(字典)
+     */
+    @NotBlank(message = "活动类型不能为空")
+    @Size(min = 0, max = 50, message = "活动类型长度不能超过{max}个字符")
+    private String campaignType;
+
+    /**
+     * 开始时间
+     */
+    @NotNull(message = "开始时间不能为空")
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    @NotNull(message = "结束时间不能为空")
+    private Date endTime;
+
+    /**
+     * 预计参与人数
+     */
+    private Integer expectedParticipants;
+
+    /**
+     * 实际参与人数
+     */
+    private Integer actualParticipants;
+
+    /**
+     * 活动地点
+     */
+    @Size(min = 0, max = 200, message = "活动地点长度不能超过{max}个字符")
+    private String location;
+
+    /**
+     * 活动描述
+     */
+    @Size(min = 0, max = 2000, message = "活动描述长度不能超过{max}个字符")
+    private String description;
+
+    /**
+     * 负责人ID
+     */
+    private Long principalId;
+
+    /**
+     * 负责人姓名
+     */
+    @Size(min = 0, max = 50, message = "负责人姓名长度不能超过{max}个字符")
+    private String principalName;
+
+    /**
+     * 状态
+     */
+    @Size(min = 0, max = 20, message = "状态长度不能超过{max}个字符")
+    private String status;
+
+    /**
+     * 实际开始时间
+     */
+    private Date startRealTime;
+
+    /**
+     * 实际结束时间
+     */
+    private Date endRealTime;
+
+    /**
+     * 活动总结
+     */
+    @Size(min = 0, max = 2000, message = "活动总结长度不能超过{max}个字符")
+    private String summary;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 关键词搜索
+     */
+    private String keyword;
+
+}

+ 97 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFoodSafetyMaterialBo.java

@@ -0,0 +1,97 @@
+package org.dromara.risk.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.risk.domain.RiskFoodSafetyMaterial;
+
+/**
+ * 食品安全宣传资料业务对象 risk_food_safety_material
+ *
+ * @author ruoyi
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = RiskFoodSafetyMaterial.class, reverseConvertGenerate = false)
+public class RiskFoodSafetyMaterialBo extends BaseEntity {
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 资料编号
+     */
+    @Size(min = 0, max = 50, message = "资料编号长度不能超过{max}个字符")
+    private String materialCode;
+
+    /**
+     * 资料名称
+     */
+    @NotBlank(message = "资料名称不能为空")
+    @Size(min = 0, max = 200, message = "资料名称长度不能超过{max}个字符")
+    private String materialName;
+
+    /**
+     * 资料类型(字典)
+     */
+    @NotBlank(message = "资料类型不能为空")
+    @Size(min = 0, max = 50, message = "资料类型长度不能超过{max}个字符")
+    private String materialType;
+
+    /**
+     * 资料概述
+     */
+    @Size(min = 0, max = 1000, message = "资料概述长度不能超过{max}个字符")
+    private String materialSummary;
+
+    /**
+     * 资料封面URL(MinIO)
+     */
+    @Size(min = 0, max = 500, message = "封面URL长度不能超过{max}个字符")
+    private String coverUrl;
+
+    /**
+     * 资料封面原始文件名
+     */
+    @Size(min = 0, max = 200, message = "封面文件名长度不能超过{max}个字符")
+    private String coverName;
+
+    /**
+     * 资料附件URL(MinIO)
+     */
+    @Size(min = 0, max = 500, message = "附件URL长度不能超过{max}个字符")
+    private String attachmentUrl;
+
+    /**
+     * 资料附件原始文件名
+     */
+    @Size(min = 0, max = 200, message = "附件文件名长度不能超过{max}个字符")
+    private String attachmentName;
+
+    /**
+     * 关联活动ID(可空)
+     */
+    private Long campaignId;
+
+    /**
+     * 上传人ID
+     */
+    private Long uploaderId;
+
+    /**
+     * 上传人姓名
+     */
+    @Size(min = 0, max = 50, message = "上传人姓名长度不能超过{max}个字符")
+    private String uploaderName;
+
+    /**
+     * 关键词搜索
+     */
+    private String keyword;
+
+}

+ 80 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/FoodSafetyCampaignStatsVo.java

@@ -0,0 +1,80 @@
+package org.dromara.risk.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 食品安全宣传活动看板数据
+ *
+ * @author ruoyi
+ */
+@Data
+public class FoodSafetyCampaignStatsVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 总宣传活动(全部活动总条数)
+     */
+    private Long totalCount;
+
+    /**
+     * 总宣传活动 较上月变化率(%)
+     */
+    private Double totalRate;
+
+    /**
+     * 参与人次(所有活动累计参与总人数求和)
+     */
+    private Long participantsTotal;
+
+    /**
+     * 参与人次 较上月变化率(%)
+     */
+    private Double participantsRate;
+
+    /**
+     * 宣传资料数量(全部上传资料总数)
+     */
+    private Long materialTotal;
+
+    /**
+     * 宣传资料 较上月变化率(%)
+     */
+    private Double materialRate;
+
+    /**
+     * 即将进行(未开始状态的活动数量)
+     */
+    private Long notStartedCount;
+
+    /**
+     * 即将进行 较上月变化率(%)
+     */
+    private Double notStartedRate;
+
+    /**
+     * 上月总活动数
+     */
+    private Long lastMonthTotal;
+
+    /**
+     * 上月参与人次
+     */
+    private Long lastMonthParticipants;
+
+    /**
+     * 上月资料总数
+     */
+    private Long lastMonthMaterial;
+
+    /**
+     * 上月未开始数
+     */
+    private Long lastMonthNotStarted;
+
+}

+ 137 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFoodSafetyCampaignVo.java

@@ -0,0 +1,137 @@
+package org.dromara.risk.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.risk.domain.RiskFoodSafetyCampaign;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 食品安全宣传活动视图对象 risk_food_safety_campaign
+ *
+ * @author ruoyi
+ */
+@Data
+@AutoMapper(target = RiskFoodSafetyCampaign.class)
+public class RiskFoodSafetyCampaignVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @ExcelProperty(value = "主键ID")
+    private Long id;
+
+    /**
+     * 活动编号
+     */
+    @ExcelProperty(value = "活动编号")
+    private String campaignCode;
+
+    /**
+     * 活动名称
+     */
+    @ExcelProperty(value = "活动名称")
+    private String campaignName;
+
+    /**
+     * 活动类型(字典)
+     */
+    @ExcelProperty(value = "活动类型")
+    private String campaignType;
+
+    /**
+     * 开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "开始时间")
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "结束时间")
+    private Date endTime;
+
+    /**
+     * 预计参与人数
+     */
+    @ExcelProperty(value = "预计参与人数")
+    private Integer expectedParticipants;
+
+    /**
+     * 实际参与人数
+     */
+    @ExcelProperty(value = "实际参与人数")
+    private Integer actualParticipants;
+
+    /**
+     * 活动地点
+     */
+    @ExcelProperty(value = "活动地点")
+    private String location;
+
+    /**
+     * 活动描述
+     */
+    @ExcelProperty(value = "活动描述")
+    private String description;
+
+    /**
+     * 负责人ID
+     */
+    private Long principalId;
+
+    /**
+     * 负责人姓名
+     */
+    @ExcelProperty(value = "负责人")
+    private String principalName;
+
+    /**
+     * 状态(未开始/进行中/已结束)
+     */
+    @ExcelProperty(value = "状态")
+    private String status;
+
+    /**
+     * 实际开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "实际开始时间")
+    private Date startRealTime;
+
+    /**
+     * 实际结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "实际结束时间")
+    private Date endRealTime;
+
+    /**
+     * 活动总结
+     */
+    @ExcelProperty(value = "活动总结")
+    private String summary;
+
+    /**
+     * 备注
+     */
+    @ExcelProperty(value = "备注")
+    private String remark;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "创建时间")
+    private Date createTime;
+
+}

+ 102 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFoodSafetyMaterialVo.java

@@ -0,0 +1,102 @@
+package org.dromara.risk.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.risk.domain.RiskFoodSafetyMaterial;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 食品安全宣传资料视图对象 risk_food_safety_material
+ *
+ * @author ruoyi
+ */
+@Data
+@AutoMapper(target = RiskFoodSafetyMaterial.class)
+public class RiskFoodSafetyMaterialVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @ExcelProperty(value = "主键ID")
+    private Long id;
+
+    /**
+     * 资料编号
+     */
+    @ExcelProperty(value = "资料编号")
+    private String materialCode;
+
+    /**
+     * 资料名称
+     */
+    @ExcelProperty(value = "资料名称")
+    private String materialName;
+
+    /**
+     * 资料类型(字典)
+     */
+    @ExcelProperty(value = "资料类型")
+    private String materialType;
+
+    /**
+     * 资料概述
+     */
+    @ExcelProperty(value = "资料概述")
+    private String materialSummary;
+
+    /**
+     * 资料封面URL
+     */
+    @ExcelProperty(value = "封面URL")
+    private String coverUrl;
+
+    /**
+     * 资料封面原始文件名
+     */
+    @ExcelProperty(value = "封面文件名")
+    private String coverName;
+
+    /**
+     * 资料附件URL
+     */
+    @ExcelProperty(value = "附件URL")
+    private String attachmentUrl;
+
+    /**
+     * 资料附件原始文件名
+     */
+    @ExcelProperty(value = "附件文件名")
+    private String attachmentName;
+
+    /**
+     * 关联活动ID
+     */
+    private Long campaignId;
+
+    /**
+     * 上传人ID
+     */
+    private Long uploaderId;
+
+    /**
+     * 上传人姓名
+     */
+    @ExcelProperty(value = "上传人")
+    private String uploaderName;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "创建时间")
+    private Date createTime;
+
+}

+ 66 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFoodSafetyCampaignMapper.java

@@ -0,0 +1,66 @@
+package org.dromara.risk.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
+import org.apache.ibatis.annotations.Param;
+import org.dromara.common.mybatis.annotation.DataColumn;
+import org.dromara.common.mybatis.annotation.DataPermission;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.risk.domain.RiskFoodSafetyCampaign;
+import org.dromara.risk.domain.vo.RiskFoodSafetyCampaignVo;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 食品安全宣传活动 数据层
+ *
+ * @author ruoyi
+ */
+public interface RiskFoodSafetyCampaignMapper extends BaseMapperPlus<RiskFoodSafetyCampaign, RiskFoodSafetyCampaignVo> {
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default <P extends IPage<RiskFoodSafetyCampaignVo>> P selectVoPage(IPage<RiskFoodSafetyCampaign> page, Wrapper<RiskFoodSafetyCampaign> wrapper) {
+        return selectVoPage(page, wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFoodSafetyCampaignVo> selectVoList(Wrapper<RiskFoodSafetyCampaign> wrapper) {
+        return selectVoList(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default RiskFoodSafetyCampaignVo selectVoById(Serializable id) {
+        return selectVoById(id, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFoodSafetyCampaignVo> selectVoByIds(Collection<? extends Serializable> idList) {
+        return selectVoByIds(idList, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    int updateById(@Param(Constants.ENTITY) RiskFoodSafetyCampaign entity);
+}

+ 66 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFoodSafetyMaterialMapper.java

@@ -0,0 +1,66 @@
+package org.dromara.risk.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
+import org.apache.ibatis.annotations.Param;
+import org.dromara.common.mybatis.annotation.DataColumn;
+import org.dromara.common.mybatis.annotation.DataPermission;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.risk.domain.RiskFoodSafetyMaterial;
+import org.dromara.risk.domain.vo.RiskFoodSafetyMaterialVo;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 食品安全宣传资料 数据层
+ *
+ * @author ruoyi
+ */
+public interface RiskFoodSafetyMaterialMapper extends BaseMapperPlus<RiskFoodSafetyMaterial, RiskFoodSafetyMaterialVo> {
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default <P extends IPage<RiskFoodSafetyMaterialVo>> P selectVoPage(IPage<RiskFoodSafetyMaterial> page, Wrapper<RiskFoodSafetyMaterial> wrapper) {
+        return selectVoPage(page, wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFoodSafetyMaterialVo> selectVoList(Wrapper<RiskFoodSafetyMaterial> wrapper) {
+        return selectVoList(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default RiskFoodSafetyMaterialVo selectVoById(Serializable id) {
+        return selectVoById(id, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFoodSafetyMaterialVo> selectVoByIds(Collection<? extends Serializable> idList) {
+        return selectVoByIds(idList, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    int updateById(@Param(Constants.ENTITY) RiskFoodSafetyMaterial entity);
+}

+ 73 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskFoodSafetyCampaignService.java

@@ -0,0 +1,73 @@
+package org.dromara.risk.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.risk.domain.bo.RiskFoodSafetyCampaignBo;
+import org.dromara.risk.domain.vo.FoodSafetyCampaignStatsVo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyCampaignVo;
+
+import java.util.List;
+
+/**
+ * 食品安全宣传活动 业务层
+ *
+ * @author ruoyi
+ */
+public interface IRiskFoodSafetyCampaignService {
+
+    /**
+     * 查询活动
+     */
+    RiskFoodSafetyCampaignVo queryById(Long id);
+
+    /**
+     * 查询活动列表(分页)
+     */
+    TableDataInfo<RiskFoodSafetyCampaignVo> queryPageList(RiskFoodSafetyCampaignBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询活动列表
+     */
+    List<RiskFoodSafetyCampaignVo> queryList(RiskFoodSafetyCampaignBo bo);
+
+    /**
+     * 新增活动
+     */
+    int insertByBo(RiskFoodSafetyCampaignBo bo);
+
+    /**
+     * 修改活动
+     */
+    int updateByBo(RiskFoodSafetyCampaignBo bo);
+
+    /**
+     * 批量删除活动
+     */
+    int deleteWithValidByIds(List<Long> ids);
+
+    /**
+     * 活动开始(状态:未开始 → 进行中)
+     * <p>
+     * 仅"未开始"状态可调用此接口。同步填充 start_real_time。
+     *
+     * @param id 活动ID
+     * @return 是否成功
+     */
+    boolean startCampaign(Long id);
+
+    /**
+     * 活动结束(状态:进行中 → 已结束)
+     * <p>
+     * 仅"进行中"状态可调用此接口。需传入活动总结,同步填充 end_real_time。
+     *
+     * @param id      活动ID
+     * @param summary 活动总结(必填)
+     * @return 是否成功
+     */
+    boolean finishCampaign(Long id, String summary);
+
+    /**
+     * 查询统计数据(看板)
+     */
+    FoodSafetyCampaignStatsVo queryStats();
+}

+ 47 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskFoodSafetyMaterialService.java

@@ -0,0 +1,47 @@
+package org.dromara.risk.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.risk.domain.bo.RiskFoodSafetyMaterialBo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyMaterialVo;
+
+import java.util.List;
+
+/**
+ * 食品安全宣传资料 业务层
+ *
+ * @author ruoyi
+ */
+public interface IRiskFoodSafetyMaterialService {
+
+    /**
+     * 查询资料
+     */
+    RiskFoodSafetyMaterialVo queryById(Long id);
+
+    /**
+     * 查询资料列表(分页)
+     */
+    TableDataInfo<RiskFoodSafetyMaterialVo> queryPageList(RiskFoodSafetyMaterialBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询资料列表
+     */
+    List<RiskFoodSafetyMaterialVo> queryList(RiskFoodSafetyMaterialBo bo);
+
+    /**
+     * 新增资料
+     */
+    int insertByBo(RiskFoodSafetyMaterialBo bo);
+
+    /**
+     * 修改资料
+     */
+    int updateByBo(RiskFoodSafetyMaterialBo bo);
+
+    /**
+     * 批量删除资料
+     */
+    int deleteWithValidByIds(List<Long> ids);
+
+}

+ 264 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskFoodSafetyCampaignServiceImpl.java

@@ -0,0 +1,264 @@
+package org.dromara.risk.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.risk.domain.RiskFoodSafetyCampaign;
+import org.dromara.risk.domain.bo.RiskFoodSafetyCampaignBo;
+import org.dromara.risk.domain.vo.FoodSafetyCampaignStatsVo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyCampaignVo;
+import org.dromara.risk.mapper.RiskFoodSafetyCampaignMapper;
+import org.dromara.risk.mapper.RiskFoodSafetyMaterialMapper;
+import org.dromara.risk.service.IRiskFoodSafetyCampaignService;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 食品安全宣传活动 业务层实现
+ *
+ * @author ruoyi
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class RiskFoodSafetyCampaignServiceImpl implements IRiskFoodSafetyCampaignService {
+
+    /** 状态:未开始 */
+    private static final String STATUS_NOT_STARTED = "未开始";
+    /** 状态:进行中 */
+    private static final String STATUS_RUNNING = "进行中";
+    /** 状态:已结束 */
+    private static final String STATUS_FINISHED = "已结束";
+
+    private final RiskFoodSafetyCampaignMapper baseMapper;
+    private final RiskFoodSafetyMaterialMapper materialMapper;
+
+    // =============== CRUD ===============
+
+    @Override
+    public RiskFoodSafetyCampaignVo queryById(Long id) {
+        return baseMapper.selectVoById(id);
+    }
+
+    @Override
+    public TableDataInfo<RiskFoodSafetyCampaignVo> queryPageList(RiskFoodSafetyCampaignBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<RiskFoodSafetyCampaign> lqw = buildQueryWrapper(bo);
+        Page<RiskFoodSafetyCampaignVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(page);
+    }
+
+    @Override
+    public List<RiskFoodSafetyCampaignVo> queryList(RiskFoodSafetyCampaignBo bo) {
+        LambdaQueryWrapper<RiskFoodSafetyCampaign> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<RiskFoodSafetyCampaign> buildQueryWrapper(RiskFoodSafetyCampaignBo bo) {
+        LambdaQueryWrapper<RiskFoodSafetyCampaign> lqw = Wrappers.lambdaQuery();
+        lqw.eq(StringUtils.isNotBlank(bo.getCampaignCode()), RiskFoodSafetyCampaign::getCampaignCode, bo.getCampaignCode());
+        lqw.eq(StringUtils.isNotBlank(bo.getStatus()), RiskFoodSafetyCampaign::getStatus, bo.getStatus());
+        lqw.eq(StringUtils.isNotBlank(bo.getCampaignType()), RiskFoodSafetyCampaign::getCampaignType, bo.getCampaignType());
+        lqw.like(StringUtils.isNotBlank(bo.getCampaignName()), RiskFoodSafetyCampaign::getCampaignName, bo.getCampaignName());
+        lqw.like(StringUtils.isNotBlank(bo.getPrincipalName()), RiskFoodSafetyCampaign::getPrincipalName, bo.getPrincipalName());
+        lqw.like(StringUtils.isNotBlank(bo.getLocation()), RiskFoodSafetyCampaign::getLocation, bo.getLocation());
+        // 关键词搜索:活动名称/活动编号/负责人
+        if (StringUtils.isNotBlank(bo.getKeyword())) {
+            lqw.and(w -> w.like(RiskFoodSafetyCampaign::getCampaignName, bo.getKeyword())
+                .or().like(RiskFoodSafetyCampaign::getCampaignCode, bo.getKeyword())
+                .or().like(RiskFoodSafetyCampaign::getPrincipalName, bo.getKeyword()));
+        }
+        // 默认按创建时间倒序
+        lqw.orderByDesc(RiskFoodSafetyCampaign::getCreateTime);
+        return lqw;
+    }
+
+    @Override
+    public int insertByBo(RiskFoodSafetyCampaignBo bo) {
+        RiskFoodSafetyCampaign add = MapstructUtils.convert(bo, RiskFoodSafetyCampaign.class);
+        // 生成活动编号:CF+yyyyMMdd+4位随机序号
+        if (StrUtil.isBlank(add.getCampaignCode())) {
+            add.setCampaignCode(generateCampaignCode());
+        }
+        // 默认状态:未开始
+        if (StrUtil.isBlank(add.getStatus())) {
+            add.setStatus(STATUS_NOT_STARTED);
+        }
+        // 兜底:负责人为空时取当前登录用户
+        if (add.getPrincipalId() == null) {
+            try {
+                add.setPrincipalId(LoginHelper.getUserId());
+                add.setPrincipalName(LoginHelper.getUsername());
+            } catch (Exception ignore) {
+                // 允许匿名创建
+            }
+        }
+        // 兜底:实际参与人数默认 0
+        if (add.getActualParticipants() == null) {
+            add.setActualParticipants(0);
+        }
+        int row = baseMapper.insert(add);
+        bo.setId(add.getId());
+        return row;
+    }
+
+    @Override
+    public int updateByBo(RiskFoodSafetyCampaignBo bo) {
+        RiskFoodSafetyCampaign update = MapstructUtils.convert(bo, RiskFoodSafetyCampaign.class);
+        return baseMapper.updateById(update);
+    }
+
+    @Override
+    public int deleteWithValidByIds(List<Long> ids) {
+        if (ObjectUtil.isEmpty(ids)) {
+            return 0;
+        }
+        return baseMapper.deleteBatchIds(ids);
+    }
+
+    // =============== 状态流转 ===============
+
+    @Override
+    public boolean startCampaign(Long id) {
+        Assert.notNull(id, "活动ID不能为空");
+        RiskFoodSafetyCampaign campaign = baseMapper.selectById(id);
+        Assert.notNull(campaign, "活动不存在: " + id);
+        // 仅"未开始"状态可开始
+        if (!STATUS_NOT_STARTED.equals(campaign.getStatus())) {
+            throw new ServiceException("当前状态(" + campaign.getStatus() + ")不允许开始活动");
+        }
+        RiskFoodSafetyCampaign update = new RiskFoodSafetyCampaign();
+        update.setId(id);
+        update.setStatus(STATUS_RUNNING);
+        update.setStartRealTime(new Date());
+        int row = baseMapper.updateById(update);
+        log.info("活动开始成功 id={} campaignCode={}", id, campaign.getCampaignCode());
+        return row > 0;
+    }
+
+    @Override
+    public boolean finishCampaign(Long id, String summary) {
+        Assert.notNull(id, "活动ID不能为空");
+        if (StrUtil.isBlank(summary)) {
+            throw new ServiceException("活动总结不能为空");
+        }
+        RiskFoodSafetyCampaign campaign = baseMapper.selectById(id);
+        Assert.notNull(campaign, "活动不存在: " + id);
+        // 仅"进行中"状态可结束
+        if (!STATUS_RUNNING.equals(campaign.getStatus())) {
+            throw new ServiceException("当前状态(" + campaign.getStatus() + ")不允许结束活动");
+        }
+        RiskFoodSafetyCampaign update = new RiskFoodSafetyCampaign();
+        update.setId(id);
+        update.setStatus(STATUS_FINISHED);
+        update.setEndRealTime(new Date());
+        update.setSummary(summary);
+        int row = baseMapper.updateById(update);
+        log.info("活动结束成功 id={} campaignCode={}", id, campaign.getCampaignCode());
+        return row > 0;
+    }
+
+    // =============== 统计 ===============
+
+    @Override
+    public FoodSafetyCampaignStatsVo queryStats() {
+        FoodSafetyCampaignStatsVo vo = new FoodSafetyCampaignStatsVo();
+        // 本月与上月的日期范围
+        LocalDate today = LocalDate.now();
+        LocalDate firstDayOfThisMonth = today.withDayOfMonth(1);
+        LocalDate firstDayOfLastMonth = firstDayOfThisMonth.minusMonths(1);
+        LocalDateTime monthStart = LocalDateTime.of(firstDayOfThisMonth, LocalTime.MIN);
+        LocalDateTime monthEnd = LocalDateTime.of(firstDayOfThisMonth.plusMonths(1), LocalTime.MIN);
+        LocalDateTime lastMonthStart = LocalDateTime.of(firstDayOfLastMonth, LocalTime.MIN);
+        LocalDateTime lastMonthEnd = monthStart;
+
+        // 1. 总宣传活动(全部活动总条数)
+        Long totalCount = baseMapper.selectCount(Wrappers.<RiskFoodSafetyCampaign>lambdaQuery());
+        vo.setTotalCount(totalCount);
+
+        // 2. 参与人次(所有活动累计参与总人数求和)
+        Long participantsTotal = baseMapper.selectList(Wrappers.<RiskFoodSafetyCampaign>lambdaQuery())
+            .stream().filter(c -> c.getActualParticipants() != null)
+            .mapToLong(RiskFoodSafetyCampaign::getActualParticipants).sum();
+        vo.setParticipantsTotal(participantsTotal);
+
+        // 3. 宣传资料数量(全部上传资料总数)
+        Long materialTotal = materialMapper.selectCount(null);
+        vo.setMaterialTotal(materialTotal);
+
+        // 4. 即将进行(未开始状态)
+        Long notStartedCount = baseMapper.selectCount(
+            Wrappers.<RiskFoodSafetyCampaign>lambdaQuery()
+                .eq(RiskFoodSafetyCampaign::getStatus, STATUS_NOT_STARTED)
+        );
+        vo.setNotStartedCount(notStartedCount);
+
+        // 5. 上月数据(用于变化率计算)
+        vo.setLastMonthTotal((long) baseMapper.selectList(
+            Wrappers.<RiskFoodSafetyCampaign>lambdaQuery()
+                .ge(RiskFoodSafetyCampaign::getCreateTime, lastMonthStart)
+                .lt(RiskFoodSafetyCampaign::getCreateTime, lastMonthEnd)
+        ).size());
+        vo.setLastMonthNotStarted((long) baseMapper.selectList(
+            Wrappers.<RiskFoodSafetyCampaign>lambdaQuery()
+                .eq(RiskFoodSafetyCampaign::getStatus, STATUS_NOT_STARTED)
+                .ge(RiskFoodSafetyCampaign::getCreateTime, lastMonthStart)
+                .lt(RiskFoodSafetyCampaign::getCreateTime, lastMonthEnd)
+        ).size());
+
+        // 6. 变化率计算(本月 vs 上月,这里用本月累计 vs 上月新增对比)
+        // 较简化的实现:本月累计 - 上月新增
+        vo.setTotalRate(calcRate(totalCount, vo.getLastMonthTotal()));
+        vo.setNotStartedRate(calcRate(notStartedCount, vo.getLastMonthNotStarted()));
+        vo.setParticipantsRate(0.0);
+        vo.setMaterialRate(0.0);
+
+        // 占位
+        vo.setLastMonthParticipants(0L);
+        vo.setLastMonthMaterial(0L);
+
+        return vo;
+    }
+
+    /**
+     * 计算变化率(%)
+     */
+    private Double calcRate(Long current, Long last) {
+        if (last == null || last == 0L) {
+            return current != null && current > 0 ? 100.0 : 0.0;
+        }
+        if (current == null) {
+            return 0.0;
+        }
+        double diff = current.doubleValue() - last.doubleValue();
+        return Math.round(diff / last.doubleValue() * 100.0 * 100) / 100.0;
+    }
+
+    // =============== 工具方法 ===============
+
+    /**
+     * 生成活动编号:CF+yyyyMMdd+4位随机序号
+     */
+    private String generateCampaignCode() {
+        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+        int seq = ThreadLocalRandom.current().nextInt(0, 10000);
+        return String.format("CF%s%04d", date, seq);
+    }
+}

+ 116 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskFoodSafetyMaterialServiceImpl.java

@@ -0,0 +1,116 @@
+package org.dromara.risk.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.risk.domain.RiskFoodSafetyMaterial;
+import org.dromara.risk.domain.bo.RiskFoodSafetyMaterialBo;
+import org.dromara.risk.domain.vo.RiskFoodSafetyMaterialVo;
+import org.dromara.risk.mapper.RiskFoodSafetyMaterialMapper;
+import org.dromara.risk.service.IRiskFoodSafetyMaterialService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 食品安全宣传资料 业务层实现
+ *
+ * @author ruoyi
+ */
+@RequiredArgsConstructor
+@Service
+public class RiskFoodSafetyMaterialServiceImpl implements IRiskFoodSafetyMaterialService {
+
+    private final RiskFoodSafetyMaterialMapper baseMapper;
+
+    @Override
+    public RiskFoodSafetyMaterialVo queryById(Long id) {
+        return baseMapper.selectVoById(id);
+    }
+
+    @Override
+    public TableDataInfo<RiskFoodSafetyMaterialVo> queryPageList(RiskFoodSafetyMaterialBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<RiskFoodSafetyMaterial> lqw = buildQueryWrapper(bo);
+        Page<RiskFoodSafetyMaterialVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(page);
+    }
+
+    @Override
+    public List<RiskFoodSafetyMaterialVo> queryList(RiskFoodSafetyMaterialBo bo) {
+        LambdaQueryWrapper<RiskFoodSafetyMaterial> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<RiskFoodSafetyMaterial> buildQueryWrapper(RiskFoodSafetyMaterialBo bo) {
+        LambdaQueryWrapper<RiskFoodSafetyMaterial> lqw = Wrappers.lambdaQuery();
+        lqw.eq(StringUtils.isNotBlank(bo.getMaterialCode()), RiskFoodSafetyMaterial::getMaterialCode, bo.getMaterialCode());
+        lqw.eq(StringUtils.isNotBlank(bo.getMaterialType()), RiskFoodSafetyMaterial::getMaterialType, bo.getMaterialType());
+        lqw.eq(bo.getCampaignId() != null, RiskFoodSafetyMaterial::getCampaignId, bo.getCampaignId());
+        lqw.like(StringUtils.isNotBlank(bo.getMaterialName()), RiskFoodSafetyMaterial::getMaterialName, bo.getMaterialName());
+        lqw.like(StringUtils.isNotBlank(bo.getUploaderName()), RiskFoodSafetyMaterial::getUploaderName, bo.getUploaderName());
+        // 关键词搜索:资料名称/资料编号/概述
+        if (StringUtils.isNotBlank(bo.getKeyword())) {
+            lqw.and(w -> w.like(RiskFoodSafetyMaterial::getMaterialName, bo.getKeyword())
+                .or().like(RiskFoodSafetyMaterial::getMaterialCode, bo.getKeyword())
+                .or().like(RiskFoodSafetyMaterial::getMaterialSummary, bo.getKeyword()));
+        }
+        // 默认按创建时间倒序
+        lqw.orderByDesc(RiskFoodSafetyMaterial::getCreateTime);
+        return lqw;
+    }
+
+    @Override
+    public int insertByBo(RiskFoodSafetyMaterialBo bo) {
+        RiskFoodSafetyMaterial add = MapstructUtils.convert(bo, RiskFoodSafetyMaterial.class);
+        // 生成资料编号:MT+yyyyMMdd+4位随机序号
+        if (StrUtil.isBlank(add.getMaterialCode())) {
+            add.setMaterialCode(generateMaterialCode());
+        }
+        // 兜底:上传人为空时取当前登录用户
+        if (add.getUploaderId() == null) {
+            try {
+                add.setUploaderId(LoginHelper.getUserId());
+                add.setUploaderName(LoginHelper.getUsername());
+            } catch (Exception ignore) {
+                // 允许匿名创建
+            }
+        }
+        int row = baseMapper.insert(add);
+        bo.setId(add.getId());
+        return row;
+    }
+
+    @Override
+    public int updateByBo(RiskFoodSafetyMaterialBo bo) {
+        RiskFoodSafetyMaterial update = MapstructUtils.convert(bo, RiskFoodSafetyMaterial.class);
+        return baseMapper.updateById(update);
+    }
+
+    @Override
+    public int deleteWithValidByIds(List<Long> ids) {
+        if (ObjectUtil.isEmpty(ids)) {
+            return 0;
+        }
+        return baseMapper.deleteBatchIds(ids);
+    }
+
+    /**
+     * 生成资料编号:MT+yyyyMMdd+4位随机序号
+     */
+    private String generateMaterialCode() {
+        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+        int seq = ThreadLocalRandom.current().nextInt(0, 10000);
+        return String.format("MT%s%04d", date, seq);
+    }
+}

+ 141 - 0
sql/risk_food_safety_campaign.sql

@@ -0,0 +1,141 @@
+-- =============================================================
+-- 风控品控平台 · 食品安全宣传增量脚本 (2026-07-08)
+-- 文件: risk_food_safety_campaign.sql
+-- 适用: 已有环境升级 / 新环境增量应用
+-- 幂等: 所有 DDL/DML 均使用条件判断,可重复执行
+-- =============================================================
+
+-- 1. 食品安全宣传活动表
+CREATE TABLE IF NOT EXISTS `risk_food_safety_campaign` (
+  `id`                       BIGINT       NOT NULL AUTO_INCREMENT       COMMENT '主键ID',
+  `campaign_code`            VARCHAR(50)  NOT NULL                       COMMENT '活动编号(CF+年月日+4位序号)',
+  `campaign_name`            VARCHAR(200) NOT NULL                       COMMENT '活动名称',
+  `campaign_type`            VARCHAR(50)  NOT NULL                       COMMENT '活动类型(字典:risk_food_safety_campaign_type)',
+  `start_time`               DATETIME     NOT NULL                       COMMENT '开始时间',
+  `end_time`                 DATETIME     NOT NULL                       COMMENT '结束时间',
+  `expected_participants`    INT          DEFAULT 0                      COMMENT '预计参与人数',
+  `actual_participants`      INT          DEFAULT 0                      COMMENT '实际参与人数',
+  `location`                 VARCHAR(200) DEFAULT NULL                   COMMENT '活动地点',
+  `description`              VARCHAR(2000) DEFAULT NULL                  COMMENT '活动描述',
+  `principal_id`             BIGINT       DEFAULT NULL                   COMMENT '负责人ID',
+  `principal_name`           VARCHAR(50)  DEFAULT NULL                   COMMENT '负责人姓名',
+  `status`                   VARCHAR(20)  NOT NULL DEFAULT '未开始'      COMMENT '状态(未开始/进行中/已结束)',
+  `start_real_time`          DATETIME     DEFAULT NULL                   COMMENT '实际开始时间(点击"活动开始"时填充)',
+  `end_real_time`            DATETIME     DEFAULT NULL                   COMMENT '实际结束时间(点击"活动结束"时填充)',
+  `summary`                  VARCHAR(2000) DEFAULT NULL                  COMMENT '活动总结',
+  `remark`                   VARCHAR(1000) DEFAULT NULL                  COMMENT '备注',
+  `tenant_id`                VARCHAR(20)  NOT NULL DEFAULT '000000'      COMMENT '租户ID',
+  `create_dept`              BIGINT       DEFAULT NULL                   COMMENT '创建部门',
+  `create_by`                BIGINT       DEFAULT NULL                   COMMENT '创建人',
+  `create_time`              DATETIME     DEFAULT CURRENT_TIMESTAMP      COMMENT '创建时间',
+  `update_by`                BIGINT       DEFAULT NULL                   COMMENT '更新人',
+  `update_time`              DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag`                 CHAR(1)      DEFAULT '0'                    COMMENT '删除标志(0存在 1删除)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_campaign_code` (`campaign_code`),
+  KEY `idx_status` (`status`),
+  KEY `idx_campaign_type` (`campaign_type`),
+  KEY `idx_start_time` (`start_time`),
+  KEY `idx_tenant` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食品安全·宣传活动表';
+
+-- 2. 食品安全宣传资料表
+CREATE TABLE IF NOT EXISTS `risk_food_safety_material` (
+  `id`                  BIGINT       NOT NULL AUTO_INCREMENT       COMMENT '主键ID',
+  `material_code`       VARCHAR(50)  NOT NULL                       COMMENT '资料编号(MT+年月日+4位序号)',
+  `material_name`       VARCHAR(200) NOT NULL                       COMMENT '资料名称',
+  `material_type`       VARCHAR(50)  NOT NULL                       COMMENT '资料类型(字典:risk_food_safety_material_type)',
+  `material_summary`    VARCHAR(1000) DEFAULT NULL                  COMMENT '资料概述',
+  `cover_url`           VARCHAR(500) DEFAULT NULL                   COMMENT '资料封面URL(MinIO)',
+  `cover_name`          VARCHAR(200) DEFAULT NULL                   COMMENT '资料封面原始文件名',
+  `attachment_url`      VARCHAR(500) DEFAULT NULL                   COMMENT '资料附件URL(MinIO)',
+  `attachment_name`     VARCHAR(200) DEFAULT NULL                   COMMENT '资料附件原始文件名',
+  `campaign_id`         BIGINT       DEFAULT NULL                   COMMENT '关联活动ID(可空)',
+  `uploader_id`         BIGINT       DEFAULT NULL                   COMMENT '上传人ID',
+  `uploader_name`       VARCHAR(50)  DEFAULT NULL                   COMMENT '上传人姓名',
+  `tenant_id`           VARCHAR(20)  NOT NULL DEFAULT '000000'      COMMENT '租户ID',
+  `create_dept`         BIGINT       DEFAULT NULL                   COMMENT '创建部门',
+  `create_by`           BIGINT       DEFAULT NULL                   COMMENT '创建人',
+  `create_time`         DATETIME     DEFAULT CURRENT_TIMESTAMP      COMMENT '创建时间',
+  `update_by`           BIGINT       DEFAULT NULL                   COMMENT '更新人',
+  `update_time`         DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag`            CHAR(1)      DEFAULT '0'                    COMMENT '删除标志(0存在 1删除)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_material_code` (`material_code`),
+  KEY `idx_material_type` (`material_type`),
+  KEY `idx_campaign_id` (`campaign_id`),
+  KEY `idx_tenant` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食品安全·宣传资料表';
+
+-- 3. 字典类型:活动类型(5 项)
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12210, '000000', '食品安全活动类型', 'risk_food_safety_campaign_type', 103, 1, NOW(), 1, NOW(), '食品安全宣传活动类型')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 4. 字典类型:活动状态(3 项)
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12211, '000000', '食品安全活动状态', 'risk_food_safety_campaign_status', 103, 1, NOW(), 1, NOW(), '食品安全宣传活动状态')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 5. 字典类型:资料类型(3 项)
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12212, '000000', '食品安全资料类型', 'risk_food_safety_material_type', 103, 1, NOW(), 1, NOW(), '食品安全宣传资料类型')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 6. 字典数据:活动类型(5 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '食品安全基础培训', '食品安全基础培训', 'risk_food_safety_campaign_type', '', 'primary', 'N', 103, 1, NOW(), '食品安全基础培训'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type' AND `dict_value` = '食品安全基础培训');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '各工作场景专项培训', '各工作场景专项培训', 'risk_food_safety_campaign_type', '', 'success', 'N', 103, 1, NOW(), '各工作场景专项培训'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type' AND `dict_value` = '各工作场景专项培训');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '商品标签标识规范培训', '商品标签标识规范培训', 'risk_food_safety_campaign_type', '', 'warning', 'N', 103, 1, NOW(), '商品标签标识规范培训'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type' AND `dict_value` = '商品标签标识规范培训');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 4, '000000', '客诉重点防范方面培训', '客诉重点防范方面培训', 'risk_food_safety_campaign_type', '', 'info', 'N', 103, 1, NOW(), '客诉重点防范方面培训'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type' AND `dict_value` = '客诉重点防范方面培训');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 5, '000000', '检测技能及其他专项培训', '检测技能及其他专项培训', 'risk_food_safety_campaign_type', '', 'danger', 'N', 103, 1, NOW(), '检测技能及其他专项培训'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type' AND `dict_value` = '检测技能及其他专项培训');
+
+-- 7. 字典数据:活动状态(3 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '未开始', '未开始', 'risk_food_safety_campaign_status', '', 'info', 'N', 103, 1, NOW(), '活动创建初始状态'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_status' AND `dict_value` = '未开始');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '进行中', '进行中', 'risk_food_safety_campaign_status', '', 'primary', 'N', 103, 1, NOW(), '点击开始活动后状态变更为进行中'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_status' AND `dict_value` = '进行中');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '已结束', '已结束', 'risk_food_safety_campaign_status', '', 'success', 'N', 103, 1, NOW(), '点击活动结束并填写总结后状态变更为已结束'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_status' AND `dict_value` = '已结束');
+
+-- 8. 字典数据:资料类型(3 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '图文海报类', '图文海报类', 'risk_food_safety_material_type', '', 'primary', 'N', 103, 1, NOW(), '图文海报类宣传资料'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_material_type' AND `dict_value` = '图文海报类');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '培训手册', '培训手册', 'risk_food_safety_material_type', '', 'success', 'N', 103, 1, NOW(), '培训手册类宣传资料'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_material_type' AND `dict_value` = '培训手册');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '视频音频类', '视频音频类', 'risk_food_safety_material_type', '', 'warning', 'N', 103, 1, NOW(), '视频音频类宣传资料'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_material_type' AND `dict_value` = '视频音频类');
+
+-- 9. 验证
+SELECT 'risk_food_safety_campaign' AS tbl, COUNT(*) AS cnt FROM `risk_food_safety_campaign`
+UNION ALL
+SELECT 'risk_food_safety_material', COUNT(*) FROM `risk_food_safety_material`
+UNION ALL
+SELECT 'risk_food_safety_campaign_type', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_type'
+UNION ALL
+SELECT 'risk_food_safety_campaign_status', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_campaign_status'
+UNION ALL
+SELECT 'risk_food_safety_material_type', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'risk_food_safety_material_type';

+ 44 - 0
sql/risk_food_safety_campaign_menu.sql

@@ -0,0 +1,44 @@
+-- ============================================
+-- 食品安全宣传菜单权限 SQL 脚本
+-- 创建时间: 2026-07-08
+-- 依赖: 风控平台菜单 risk_menu.sql 已执行,@risk_parent_id 为风控平台目录 ID
+-- 关联脚本: risk_food_safety_campaign.sql(表结构+字典)
+-- ============================================
+
+-- 设置变量(指向已有风控平台目录)
+SET @risk_parent_id = (SELECT menu_id FROM sys_menu WHERE menu_name = '风控平台' AND menu_type = 'M' LIMIT 1);
+
+-- 1. 食品安全宣传主菜单
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark)
+VALUES ('食品安全宣传', @risk_parent_id, 15, 'foodSafety', 'risk/foodSafety/index', 1, 0, 'C', '0', '0', 'risk:foodSafety:list', '#', 103, 1, NOW(), '食品安全宣传管理菜单');
+
+SET @food_safety_id = LAST_INSERT_ID();
+
+-- 2. 食品安全宣传按钮权限(列表/新增/修改/删除/导出/活动开始/活动结束/上传资料)
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) VALUES
+('食品安全宣传查询', @food_safety_id, 1, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:list', '#', 103, 1, NOW(), ''),
+('食品安全宣传新增', @food_safety_id, 2, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:add', '#', 103, 1, NOW(), ''),
+('食品安全宣传修改', @food_safety_id, 3, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:edit', '#', 103, 1, NOW(), ''),
+('食品安全宣传删除', @food_safety_id, 4, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:remove', '#', 103, 1, NOW(), ''),
+('食品安全宣传导出', @food_safety_id, 5, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:export', '#', 103, 1, NOW(), ''),
+('食品安全宣传活动开始', @food_safety_id, 6, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:start', '#', 103, 1, NOW(), ''),
+('食品安全宣传活动结束', @food_safety_id, 7, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:finish', '#', 103, 1, NOW(), ''),
+('食品安全宣传上传资料', @food_safety_id, 8, '#', '', 1, 0, 'F', '0', '0', 'risk:foodSafety:uploadMaterial', '#', 103, 1, NOW(), '');
+
+-- 3. 给超级管理员角色授权所有食品安全宣传权限
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, menu_id FROM sys_menu
+WHERE perms LIKE 'risk:foodSafety:%'
+ON DUPLICATE KEY UPDATE role_id = role_id;
+
+-- 4. 给租户管理员(角色ID 2,如果存在)也授权
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 2, menu_id FROM sys_menu
+WHERE perms LIKE 'risk:foodSafety:%'
+ON DUPLICATE KEY UPDATE role_id = role_id;
+
+-- 完成提示
+SELECT '食品安全宣传菜单权限初始化完成!' AS message,
+       COUNT(*) AS total_menus
+FROM sys_menu
+WHERE perms LIKE 'risk:foodSafety:%';