From b8019ac4eee4c45c03a37c78040f6055a2a3d42d Mon Sep 17 00:00:00 2001 From: zhonghua Date: Wed, 15 Apr 2026 11:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=91=8A=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=20OSS=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20svg=E3=80=81=E7=BC=96=E8=BE=91=E6=97=B6=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../controller/ContestNoticeController.java | 42 ++- .../biz/contest/dto/CreateNoticeDto.java | 28 ++ .../biz/contest/entity/BizContestNotice.java | 6 + .../entity/BizContestNoticeAttachment.java | 44 ++++ .../mapper/ContestNoticeAttachmentMapper.java | 9 + .../service/IContestNoticeService.java | 11 + .../impl/ContestNoticeServiceImpl.java | 125 +++++++++ .../modules/oss/config/OssConfig.java | 2 +- .../V22__contest_notice_attachment.sql | 22 ++ .../src/api/contests.ts | 16 ++ .../src/views/contests/notices/Index.vue | 247 +++++++++++------- oss-direct-upload-demo/backend/OssConfig.java | 2 +- 12 files changed, 449 insertions(+), 105 deletions(-) create mode 100644 lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNoticeAttachment.java create mode 100644 lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/mapper/ContestNoticeAttachmentMapper.java create mode 100644 lesingle-creation-backend/src/main/resources/db/migration/V22__contest_notice_attachment.sql diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/controller/ContestNoticeController.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/controller/ContestNoticeController.java index 4e86a28..dcd61ab 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/controller/ContestNoticeController.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/controller/ContestNoticeController.java @@ -13,6 +13,8 @@ import com.lesingle.modules.biz.contest.dto.CreateNoticeDto; import com.lesingle.modules.biz.contest.entity.BizContestNotice; import com.lesingle.modules.biz.contest.service.IContestNoticeService; import com.lesingle.security.annotation.RequirePermission; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -33,6 +35,7 @@ import java.util.Map; public class ContestNoticeController { private final IContestNoticeService noticeService; + private final ObjectMapper objectMapper; /** * 解析日期时间字符串,兼容 ISO 格式(带毫秒和 Z 时区标记) @@ -92,6 +95,7 @@ public class ContestNoticeController { .eq(BizContestNotice::getTenantId, tenantId) .orderByDesc(BizContestNotice::getCreateTime)); noticeService.fillContestInfo(list); + noticeService.fillNoticeAttachments(list); return Result.success(list); } @@ -118,6 +122,7 @@ public class ContestNoticeController { wrapper.orderByDesc(BizContestNotice::getCreateTime); Page result = noticeService.page(new Page<>(page, pageSize), wrapper); noticeService.fillContestInfo(result.getRecords()); + noticeService.fillNoticeAttachments(result.getRecords()); return Result.success(PageResult.from(result)); } @@ -128,6 +133,7 @@ public class ContestNoticeController { BizContestNotice notice = noticeService.getById(id); if (notice != null) { noticeService.fillContestInfo(Collections.singletonList(notice)); + noticeService.fillNoticeAttachments(Collections.singletonList(notice)); } return Result.success(notice); } @@ -149,40 +155,40 @@ public class ContestNoticeController { uw.eq(BizContestNotice::getId, id); uw.eq(BizContestNotice::getTenantId, tenantId); - boolean hasUpdate = false; + boolean hasFieldUpdate = false; if (body.containsKey("title")) { Object v = body.get("title"); if (v != null) { uw.set(BizContestNotice::getTitle, String.valueOf(v)); - hasUpdate = true; + hasFieldUpdate = true; } } if (body.containsKey("content")) { Object v = body.get("content"); if (v != null) { uw.set(BizContestNotice::getContent, String.valueOf(v)); - hasUpdate = true; + hasFieldUpdate = true; } } if (body.containsKey("noticeType")) { Object v = body.get("noticeType"); if (v != null) { uw.set(BizContestNotice::getNoticeType, String.valueOf(v)); - hasUpdate = true; + hasFieldUpdate = true; } } if (body.containsKey("priority")) { Object v = body.get("priority"); if (v instanceof Number) { uw.set(BizContestNotice::getPriority, ((Number) v).intValue()); - hasUpdate = true; + hasFieldUpdate = true; } } if (body.containsKey("contestId")) { Object v = body.get("contestId"); if (v instanceof Number) { uw.set(BizContestNotice::getContestId, ((Number) v).longValue()); - hasUpdate = true; + hasFieldUpdate = true; } } // 仅当请求体包含 publishTime 键时才改发布时间:null / 空串 = 取消发布(必须写入 SQL NULL) @@ -190,20 +196,36 @@ public class ContestNoticeController { Object v = body.get("publishTime"); if (v == null || (v instanceof String && !StringUtils.hasText((String) v))) { uw.set(BizContestNotice::getPublishTime, null); - hasUpdate = true; + hasFieldUpdate = true; } else if (v instanceof String && StringUtils.hasText((String) v)) { LocalDateTime pt = parseDateTime((String) v); if (pt != null) { uw.set(BizContestNotice::getPublishTime, pt); - hasUpdate = true; + hasFieldUpdate = true; } } } - if (!hasUpdate) { + boolean attachmentSynced = false; + if (body.containsKey("attachments")) { + Object raw = body.get("attachments"); + List items; + if (raw == null) { + items = Collections.emptyList(); + } else { + items = objectMapper.convertValue(raw, new TypeReference>() { + }); + } + noticeService.syncNoticeAttachments(id, tenantId, items); + attachmentSynced = true; + } + + if (!hasFieldUpdate && !attachmentSynced) { return Result.success(); } - noticeService.getBaseMapper().update(null, uw); + if (hasFieldUpdate) { + noticeService.getBaseMapper().update(null, uw); + } return Result.success(); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateNoticeDto.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateNoticeDto.java index 4533ef4..881a4ea 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateNoticeDto.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateNoticeDto.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; +import java.util.List; + @Data @Schema(description = "创建公告DTO") public class CreateNoticeDto { @@ -29,4 +31,30 @@ public class CreateNoticeDto { @Schema(description = "发布时间") private String publishTime; + + /** + * 公告附件全量列表;与 PATCH 对齐。为 null 或省略表示创建时不写附件;传空数组表示无附件。 + */ + @Schema(description = "公告附件列表:id 为空表示新增,已有 id 表示保留/更新;全量覆盖") + private List attachments; + + @Data + @Schema(description = "公告附件项") + public static class NoticeAttachmentItem { + + @Schema(description = "附件主键,新建不传") + private Long id; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "文件访问 URL") + private String fileUrl; + + private String format; + + private String fileType; + + private String size; + } } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNotice.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNotice.java index 6c2216e..09accd5 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNotice.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNotice.java @@ -8,6 +8,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import java.time.LocalDateTime; +import java.util.List; /** * 活动公告实体 @@ -47,4 +48,9 @@ public class BizContestNotice extends BaseEntity { @Schema(description = "关联活动") @TableField(exist = false) private BizContest contest; + + /** 公告附件(仅查询接口填充,不落库) */ + @Schema(description = "公告附件列表") + @TableField(exist = false) + private List attachments; } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNoticeAttachment.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNoticeAttachment.java new file mode 100644 index 0000000..cb5df3a --- /dev/null +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/entity/BizContestNoticeAttachment.java @@ -0,0 +1,44 @@ +package com.lesingle.modules.biz.contest.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.lesingle.common.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 活动公告附件 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_biz_contest_notice_attachment") +@Schema(description = "活动公告附件实体") +public class BizContestNoticeAttachment extends BaseEntity { + + @Schema(description = "公告ID") + @TableField("notice_id") + private Long noticeId; + + @Schema(description = "租户ID") + @TableField("tenant_id") + private Long tenantId; + + @Schema(description = "文件名称") + @TableField("file_name") + private String fileName; + + @Schema(description = "文件URL") + @TableField("file_url") + private String fileUrl; + + @Schema(description = "文件格式") + private String format; + + @Schema(description = "文件类型") + @TableField("file_type") + private String fileType; + + @Schema(description = "文件大小") + private String size; +} diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/mapper/ContestNoticeAttachmentMapper.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/mapper/ContestNoticeAttachmentMapper.java new file mode 100644 index 0000000..aee45c5 --- /dev/null +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/mapper/ContestNoticeAttachmentMapper.java @@ -0,0 +1,9 @@ +package com.lesingle.modules.biz.contest.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.lesingle.modules.biz.contest.entity.BizContestNoticeAttachment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContestNoticeAttachmentMapper extends BaseMapper { +} diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/IContestNoticeService.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/IContestNoticeService.java index b4602a4..e6d5274 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/IContestNoticeService.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/IContestNoticeService.java @@ -1,6 +1,7 @@ package com.lesingle.modules.biz.contest.service; import com.baomidou.mybatisplus.extension.service.IService; +import com.lesingle.modules.biz.contest.dto.CreateNoticeDto; import com.lesingle.modules.biz.contest.entity.BizContestNotice; import java.util.List; @@ -11,4 +12,14 @@ public interface IContestNoticeService extends IService { * 批量填充关联活动名称(仅设置 id、contestName,供前端展示) */ void fillContestInfo(List notices); + + /** + * 批量填充公告附件(当前租户) + */ + void fillNoticeAttachments(List notices); + + /** + * 全量同步公告附件:列表中有 id 的保留并更新;无 id 的新增;库中已有但未出现在列表中的删除。 + */ + void syncNoticeAttachments(Long noticeId, Long tenantId, List items); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java index b980637..2352832 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestNoticeServiceImpl.java @@ -1,14 +1,25 @@ package com.lesingle.modules.biz.contest.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.lesingle.common.enums.ErrorCode; +import com.lesingle.common.exception.BusinessException; +import com.lesingle.common.util.SecurityUtil; +import com.lesingle.modules.biz.contest.dto.CreateNoticeDto; import com.lesingle.modules.biz.contest.entity.BizContest; import com.lesingle.modules.biz.contest.entity.BizContestNotice; +import com.lesingle.modules.biz.contest.entity.BizContestNoticeAttachment; +import com.lesingle.modules.biz.contest.mapper.ContestNoticeAttachmentMapper; import com.lesingle.modules.biz.contest.mapper.ContestNoticeMapper; import com.lesingle.modules.biz.contest.service.IContestNoticeService; import com.lesingle.modules.biz.contest.service.IContestService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.io.Serializable; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -21,6 +32,7 @@ import java.util.stream.Collectors; public class ContestNoticeServiceImpl extends ServiceImpl implements IContestNoticeService { private final IContestService contestService; + private final ContestNoticeAttachmentMapper noticeAttachmentMapper; @Override public void fillContestInfo(List notices) { @@ -51,4 +63,117 @@ public class ContestNoticeServiceImpl extends ServiceImpl notices) { + if (notices == null || notices.isEmpty()) { + return; + } + Set ids = notices.stream() + .map(BizContestNotice::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (ids.isEmpty()) { + return; + } + Long tenantId = SecurityUtil.getCurrentTenantId(); + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.in(BizContestNoticeAttachment::getNoticeId, ids); + qw.eq(BizContestNoticeAttachment::getTenantId, tenantId); + qw.orderByAsc(BizContestNoticeAttachment::getCreateTime); + List all = noticeAttachmentMapper.selectList(qw); + Map> byNotice = all.stream() + .collect(Collectors.groupingBy(BizContestNoticeAttachment::getNoticeId)); + for (BizContestNotice notice : notices) { + notice.setAttachments(byNotice.getOrDefault(notice.getId(), Collections.emptyList())); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void syncNoticeAttachments(Long noticeId, Long tenantId, List items) { + if (items == null) { + return; + } + BizContestNotice n = getOne( + new LambdaQueryWrapper() + .eq(BizContestNotice::getId, noticeId) + .eq(BizContestNotice::getTenantId, tenantId)); + if (n == null) { + throw BusinessException.of(ErrorCode.NOT_FOUND, "公告不存在"); + } + + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.eq(BizContestNoticeAttachment::getNoticeId, noticeId); + qw.eq(BizContestNoticeAttachment::getTenantId, tenantId); + List existing = noticeAttachmentMapper.selectList(qw); + Set keepIds = items.stream() + .map(CreateNoticeDto.NoticeAttachmentItem::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + for (BizContestNoticeAttachment row : existing) { + if (!keepIds.contains(row.getId())) { + noticeAttachmentMapper.deleteById(row.getId()); + } + } + + for (CreateNoticeDto.NoticeAttachmentItem item : items) { + if (!StringUtils.hasText(item.getFileUrl())) { + continue; + } + String fileName = StringUtils.hasText(item.getFileName()) ? item.getFileName().trim() : "附件"; + + if (item.getId() == null) { + BizContestNoticeAttachment na = new BizContestNoticeAttachment(); + na.setNoticeId(noticeId); + na.setTenantId(tenantId); + na.setFileName(fileName); + na.setFileUrl(item.getFileUrl().trim()); + na.setFormat(item.getFormat()); + na.setFileType(item.getFileType()); + na.setSize(item.getSize()); + na.setValidState(1); + noticeAttachmentMapper.insert(na); + } else { + BizContestNoticeAttachment a = noticeAttachmentMapper.selectById(item.getId()); + if (a == null || !noticeId.equals(a.getNoticeId()) || !tenantId.equals(a.getTenantId())) { + throw BusinessException.of(ErrorCode.BAD_REQUEST, "附件不存在或不属于该公告:" + item.getId()); + } + a.setFileName(fileName); + a.setFileUrl(item.getFileUrl().trim()); + if (item.getFormat() != null) { + a.setFormat(item.getFormat()); + } + if (item.getFileType() != null) { + a.setFileType(item.getFileType()); + } + if (item.getSize() != null) { + a.setSize(item.getSize()); + } + noticeAttachmentMapper.updateById(a); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeById(Serializable id) { + Long tenantId = SecurityUtil.getCurrentTenantId(); + BizContestNotice n = getOne( + new LambdaQueryWrapper() + .eq(BizContestNotice::getId, id) + .eq(BizContestNotice::getTenantId, tenantId)); + if (n == null) { + return false; + } + LambdaQueryWrapper aw = new LambdaQueryWrapper<>(); + aw.eq(BizContestNoticeAttachment::getNoticeId, id); + aw.eq(BizContestNoticeAttachment::getTenantId, tenantId); + List rows = noticeAttachmentMapper.selectList(aw); + for (BizContestNoticeAttachment row : rows) { + noticeAttachmentMapper.deleteById(row.getId()); + } + return super.removeById(id); + } } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/oss/config/OssConfig.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/oss/config/OssConfig.java index 8fa0278..8ead46c 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/oss/config/OssConfig.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/oss/config/OssConfig.java @@ -35,7 +35,7 @@ public class OssConfig { /** 允许的文件扩展名 */ private String[] allowedExtensions = { - ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".mp4", ".mp3", ".wav", ".avi", ".zip", ".rar", diff --git a/lesingle-creation-backend/src/main/resources/db/migration/V22__contest_notice_attachment.sql b/lesingle-creation-backend/src/main/resources/db/migration/V22__contest_notice_attachment.sql new file mode 100644 index 0000000..0401f9c --- /dev/null +++ b/lesingle-creation-backend/src/main/resources/db/migration/V22__contest_notice_attachment.sql @@ -0,0 +1,22 @@ +-- 公告附件表(与活动附件独立,按 notice_id 归属) +CREATE TABLE t_biz_contest_notice_attachment ( + id BIGINT NOT NULL AUTO_INCREMENT, + notice_id BIGINT NOT NULL COMMENT '公告ID', + tenant_id BIGINT NOT NULL COMMENT '租户ID', + file_name VARCHAR(512) NOT NULL COMMENT '文件名称', + file_url VARCHAR(2048) NOT NULL COMMENT '文件URL', + format VARCHAR(32) DEFAULT NULL COMMENT '扩展名', + file_type VARCHAR(128) DEFAULT NULL COMMENT 'MIME 类型', + size VARCHAR(64) DEFAULT NULL COMMENT '文件大小', + create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号', + update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除', + creator INT DEFAULT NULL COMMENT '创建人ID', + modifier INT DEFAULT NULL COMMENT '修改人ID', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态:1-有效,2-失效', + PRIMARY KEY (id), + INDEX idx_notice_id (notice_id), + INDEX idx_tenant_notice (tenant_id, notice_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动公告附件表'; diff --git a/lesingle-creation-frontend/src/api/contests.ts b/lesingle-creation-frontend/src/api/contests.ts index 41a6046..5e9be36 100644 --- a/lesingle-creation-frontend/src/api/contests.ts +++ b/lesingle-creation-frontend/src/api/contests.ts @@ -542,6 +542,18 @@ export interface CreateScoreForm { } // ==================== 公告相关类型 ==================== +/** 公告附件(与后端 BizContestNoticeAttachment 对齐) */ +export interface ContestNoticeAttachment { + id: number; + noticeId?: number; + fileName: string; + fileUrl: string; + format?: string; + fileType?: string; + size?: string; + createTime?: string; +} + export interface ContestNotice { id: number; contestId: number; @@ -556,6 +568,8 @@ export interface ContestNotice { modifyTime?: string; validState?: number; contest?: Contest; + /** 公告附件(详情/列表接口填充) */ + attachments?: ContestNoticeAttachment[]; } export interface CreateNoticeForm { @@ -564,6 +578,8 @@ export interface CreateNoticeForm { content: string; noticeType?: "system" | "manual" | "urgent"; priority?: number; + /** 与后端 CreateNoticeDto.attachments 一致;创建时随 POST 全量提交 */ + attachments?: ContestAttachmentInput[]; } // ==================== 评委相关类型 ==================== diff --git a/lesingle-creation-frontend/src/views/contests/notices/Index.vue b/lesingle-creation-frontend/src/views/contests/notices/Index.vue index e60ce3e..d3bbbb8 100644 --- a/lesingle-creation-frontend/src/views/contests/notices/Index.vue +++ b/lesingle-creation-frontend/src/views/contests/notices/Index.vue @@ -125,8 +125,8 @@ - +