基于Spring Boot的素材来源登记与合规下架系统设计
2026/9/14 21:11:20 网站建设 项目流程

一场演唱会结束后的第二天,往往是素材传播最密集的时间段。运营团队如果想做一个粉丝视角的回顾合集,把网上出现的现场图、观众视角视频、官方预告片混在一起展示,最先遇到的麻烦通常不是剪辑效果,而是这些素材从哪里来、是否经过授权、如果有人通过私信要求删除时,后台该按什么流程执行。很多内容管理后台只解决“文件上传、列表展示、链接删除”,却没有把“来源信息”和“删除请求”当成一等数据来建模,结果真正要处理投诉时只能人肉查聊天记录,删完文件后无法形成可追溯的下架记录。这篇文章就以视频素材合集管理项目为背景,从表结构、接口、审核流程、对象存储操作几个层面,实现一套基于 Spring Boot 的内容素材登记与合规下架系统。整套方案跑通后,一个文件能够回答四类问题:它从哪里采集、授权状态是什么、是否被投诉过、删除流程执行到了哪一步。

1. 先想清楚:素材合集管理的难点不是文件存储,而是来源和状态

1.1 为什么“来源”必须和文件一起存储

在普通文件上传模块里,业务方只需要关心文件名、文件大小、上传者、上传时间。但演唱会回顾类页面不一样。页面里的视频和图片很可能来自不同作者的同一个线上发布渠道,有些是作者主动投稿,有些是运营团队从平台公开内容里收集,有些则来自其他粉丝整理的合集。这些素材的授权边界完全不同。

如果不记录来源,删除流程就无法判断“该联系谁去确认授权”,也无法判断“删除请求是否真的是由原作者或版权权利人发出”。因此,材料主表里不能只有存储路径,还必须包含原始作者、来源地址、来源平台、授权类型这四类字段。这里的授权类型至少应该区分四档:

授权类型含义使用建议
AUTHORIZED已获得作者或权利人明确授权可以进入合集页面正式展示
PUBLIC_SHARE仅在公开平台看到作者主动分享建议用于展示前人工复核
UNKNOWN无法确认原始来源不推荐直接进入合集页面
NEED_DELETE已收到删除请求或作者表示反对禁止继续展示,进入下架流程

“来源”之所以要成为核心字段,还有一个实际原因:当同一个视频被多次转载时,删除请求可能针对的是原始作者的视频,也可能是管理员收到私信后针对页面上的某一条链接。如果系统只有一条素材记录而没有来源链,运营人员很可能删错对象。正确做法是每个文件上传成功后立即登记来源 URL、作者昵称和来源平台,后续收到任何投诉,都能先通过素材编号定位到这条来源链。

1.2 删除不是 delete 一条记录那么简单

数据库只要执行一条 UPDATE 或 DELETE 语句,记录状态就会变化,但这种“删除”离真正下架还很远。一个视频或图片在页面里被引用,通常经过三层:数据库记录、对象存储文件、CDN 或代理缓存。只有这三层都处理完,用户访问时才会完全看不到。

在工程实现里,处理删除请求至少要经过四个阶段:请求登记、运营审核、文件删除、结果通知。其中文件删除又分为先标记再删除,再从对象存储移除,最后做缓存失效。直接删文件看起来很彻底,但如果页面还在通过旧链接引用,就会出现图裂或播放失败。更稳妥的是先把素材状态改为 DELETING,让前端在展示时先不渲染这条素材,再执行存储删除,最后把状态修改为 DELETED。

这里还有一个容易忽略的问题:删除操作不能和数据库事务放在同一个方法里。数据库事务适合保护“订单状态变化”“账户余额扣减”这类操作,但对 MinIO 这类外部存储,删除文件是一个不可回滚的外部动作。如果先删了文件,再更新数据库时抛异常,事务回滚会让数据库里仍然显示文件存在,而实际文件已经不存在。所以,正确流程是把素材状态先改为 DELETING 并提交,然后调用对象存储删除接口,确认删除成功后,再把状态更新为 DELETED。

注意:收到私聊里的删除请求,不等于系统必须立即删文件。删除请求只是触发审核的任务,审核通过后执行下架,审核不通过时应该记录理由并通知请求方。

1.3 来自私聊渠道的删除请求需要被建模

用户材料里提到的“建议者私聊删除”,在业务上是一个很典型的需求,但技术实现不能停留在“运营人员收到一条私信然后手动处理”。私聊只是一个触点,真正进入系统后,它应该被转换为一条具备唯一编号、请求来源、请求人、联系渠道、请求原因、证明材料、处理状态和审核人的删除工单。

这样的建模有两个好处。第一,同一个素材被多次投诉时,后台可以合并处理,避免重复删除一个文件。第二,请求状态可查询,运营人员可以追踪到每一条私聊对应的工单处理到哪一步。如果只是把“私聊”记录下来,没有形成结构化请求,后续审计时很难解释为什么某个视频被下架。

从系统设计角度看,删除请求应有独立的状态机:PENDING、PASSED、REJECTED、CANCELLED。Pending 表示待审核,Passed 表示审核通过并已进入删除流程,Rejected 表示审核不通过,Cancelled 表示请求方主动撤销。素材本身则使用 ACTIVE、DELETING、DELETED、DELETE_FAILED 四类状态,避免把“请求状态”和“文件状态”混在一张表里。

2. 环境准备:用 Spring Boot 3 搭建素材下架管理服务

2.1 技术选型与依赖

这套示例不需要复杂的中间件,核心依赖是 Spring Boot、Spring Data JPA、MySQL 和 MinIO。Spring Data JPA 可以快速完成实体与表的映射,MinIO 负责存放图片和视频文件,MySQL 保存素材元数据和删除工单。

如果只是想验证流程,数据库可以使用本地 MySQL 8.0,对象存储可以使用 Docker 启动一个 MinIO 服务。下面的版本只是示例,实际项目落地前需要确认与当前团队的 Spring Boot 版本一致。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.12</version> </dependency> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

说明:commons-codec 主要用于计算文件 SHA-256,用于指纹去重和删除后防重复上传。MinIO 的版本需要在正式项目里核对,不同 8.x 小版本的 API 有细微差别。

2.2 配置 MySQL 和 MinIO

application.yml中配置数据源、JPA 和 MinIO。MinIO 的 endpoint 在测试环境通常是http://127.0.0.1:9000,accessKey 和 secretKey 对应容器启动时设置的值。

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/material_console?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: true open-in-view: false minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: material-bucket

JPA 的ddl-auto设置为none,表结构由数据库迁移脚本或手工 SQL 创建。这样做的原因是生产环境很少允许 Hibernate 自动改表结构,显式管理 SQL 可以避免误删字段。

如果需要启动本地 MinIO 做测试,可以执行:

docker run -d \ --name minio-material \ -p 9000:9000 \ -p 9001:9001 \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=minioadmin \ minio/minio server /data --console-address ":9001"

启动后用浏览器打开http://127.0.0.1:9001,默认用户名和密码都是minioadmin。先创建material-bucket,或者由代码在启动时自动创建。

@Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }

2.3 项目目录结构与模块划分

示例项目按素材、下架请求、存储、通知四个模块拆分。不要把所有 Controller、Service、Entity 都堆在同一个包下,否则随着素材类型增加,代码会迅速变得难维护。

src/main/java/com/example/materialconsole ├── MaterialConsoleApplication.java ├── common │ └── BizException.java ├── config │ └── MinioConfig.java ├── material │ ├── controller │ │ ├── MaterialController.java │ │ └── TakeDownController.java │ ├── dto │ │ ├── CreateTakeDownRequest.java │ │ └── MaterialUploadResponse.java │ ├── entity │ │ ├── ContentMaterial.java │ │ └── TakeDownRequest.java │ ├── repository │ │ ├── ContentMaterialRepository.java │ │ └── TakeDownRequestRepository.java │ └── service │ ├── MaterialService.java │ └── TakeDownService.java ├── storage │ └── MinioStorageService.java └── notify ├── MaterialNotifier.java └── NotifyMessage.java

这个结构的关键点在于:MaterialService 负责素材上传和状态流转,TakeDownService 负责删除工单的创建和审批,存储逻辑单独隔离在 MinioStorageService 中。这样做之后,如果将来对象存储换成阿里云 OSS 或腾讯云 COS,只需要替换 storage 包中的实现。

3. 表结构设计:让每个文件都能回答“从哪里来、现在是什么状态”

3.1 素材主表:记录文件本身和来源信息

素材主表中的字段可以分成三组:文件标识、内容业务信息、来源与授权信息。文件标识包括素材编号、文件类型、存储桶、对象键、SHA-256 值。业务信息包括标题、状态。来源与授权信息包括原创作者、来源地址、来源平台和授权类型。

CREATE TABLE content_material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_no VARCHAR(64) NOT NULL, title VARCHAR(255) NOT NULL, file_type VARCHAR(20) NOT NULL, bucket_name VARCHAR(100) NOT NULL, object_key VARCHAR(500) NOT NULL, content_sha256 CHAR(64) NOT NULL, original_author VARCHAR(255), source_url VARCHAR(1000), source_platform VARCHAR(100), license_type VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', delete_reason VARCHAR(1000), version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_material_no (material_no), KEY idx_sha256_status (content_sha256, status), KEY idx_status (status) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

material_no 是业务编号,在接口层暴露,避免把数据库自增 ID 直接对外返回。object_key 是对象存储在 MinIO 中的完整路径,比如material/2025-06-01/uuid-001.mp4。source_url 保存素材公开平台地址,original_author 保存作者昵称。

SHA-256 的作用不只是文件去重,还是防止已删除文件再次被上传的重要依据。文件删除后,如果重新上传时系统通过 SHA-256 发现存在相同的历史 DELETED 记录,就需要触发人工审核,而不是让文件直接回到合集页面。

3.2 删除请求表:把私聊反馈变成可流转的工单

删除请求表对应收到站内私信、评论区留言或邮箱反馈后的结构化登记。由于请求来源不同,不能把所有信息都塞在“反馈内容”一个字段里。

CREATE TABLE take_down_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_no VARCHAR(64) NOT NULL, material_no VARCHAR(64) NOT NULL, request_biz_key CHAR(64) NOT NULL, request_source VARCHAR(30) NOT NULL, requester_name VARCHAR(255), contact_channel VARCHAR(30), contact_value VARCHAR(255), reason VARCHAR(1000) NOT NULL, proof_url VARCHAR(1000), status VARCHAR(20) NOT NULL DEFAULT 'PENDING', reject_reason VARCHAR(1000), handled_by VARCHAR(64), handled_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_request_no (request_no), UNIQUE KEY uk_request_biz_key (request_biz_key), KEY idx_material_no (material_no) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

request_source 可以填写 PRIVATE_MESSAGE、EMAIL、PLATFORM_REPORT 等,用来表示这条请求来自哪个渠道。request_biz_key 是一个 SHA-256 值,由 material_no、connectValue 和 reason 一起计算,作用是幂等:同一投诉人针对同一个素材提交相同原因的请求时,系统不应该创建第二条工单。

status 的流转方向必须明确,不能允许从 PASSED 回退到 PENDING,否则审核后的记录会被反复修改。

3.3 状态机设计和更新时间说明

素材状态和删除请求状态需要分开。素材状态针对“文件还能不能展示”,删除请求状态针对“请求走到了哪一步”。

实体状态含义
ContentMaterialACTIVE正常展示
ContentMaterialDELETING已进入删除流程,等待外部存储删除
ContentMaterialDELETED文件已删除或已停止展示
ContentMaterialDELETE_FAILED删除失败,需要重试或人工介入
TakeDownRequestPENDING待运营审核
TakeDownRequestPASSED审核通过,已经触发删除流程
TakeDownRequestREJECTED审核不通过
TakeDownRequestCANCELLED请求方撤销

created_at 和 updated_at 字段建议在数据库层写入默认值,避免每个 Service 方法都重复赋值。

ALTER TABLE content_material MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;

4. 实现上传和删除请求:给素材打上可追溯标记

4.1 上传素材时校验指纹和是否处于黑名单

上传素材时,不能“拿到文件直接存到 MinIO 再插一条记录”。先计算 SHA-256,然后检查是否存在相同哈希且状态为 DELETED 的历史素材。如果存在,说明这个文件之前被下架过,需要人工确认原因。

@Service public class MaterialService { private final ContentMaterialRepository materialRepository; private final MinioStorageService storageService; public MaterialService(ContentMaterialRepository materialRepository, MinioStorageService storageService) { this.materialRepository = materialRepository; this.storageService = storageService; } public ContentMaterial upload(MultipartFile file, String title, String originalAuthor, String sourceUrl, String sourcePlatform, String licenseType) { String sha256; try { sha256 = org.apache.commons.codec.digest.DigestUtils.sha256Hex(file.getInputStream()); } catch (IOException e) { throw new BizException("读取文件失败"); } boolean deletedBefore = materialRepository.existsByContentSha256AndStatus(sha256, "DELETED"); if (deletedBefore) { throw new BizException("该文件存在历史删除记录,请先确认授权或执行人工审核"); } String objectKey = "material/" + LocalDate.now() + "/" + UUID.randomUUID() + "-" + file.getOriginalFilename(); storageService.put(file, objectKey); ContentMaterial material = new ContentMaterial(); material.setMaterialNo("CM" + System.currentTimeMillis()); material.setTitle(title); material.setFileType(file.getContentType()); material.setBucketName(storageService.getDefaultBucket()); material.setObjectKey(objectKey); material.setContentSha256(sha256); material.setOriginalAuthor(originalAuthor); material.setSourceUrl(sourceUrl); material.setSourcePlatform(sourcePlatform); material.setLicenseType(licenseType); material.setStatus("ACTIVE"); return materialRepository.save(material); } }

这里需要特别解释一个顺序问题:先计算 SHA-256,再上传到 MinIO。如果先上传再计算哈希,遇到文件被删除过或校验失败的情况,对象存储里已经多了一个无用文件,还要补做一次清理。计算哈希会消耗一定 CPU,但对图片和短视频来说可以接受。

MinioStorageService.put方法示例:

@Service public class MinioStorageService { private final MinioClient minioClient; @Value("${minio.bucket}") private String defaultBucket; public MinioStorageService(MinioClient minioClient) { this.minioClient = minioClient; } public void put(MultipartFile file, String objectKey) { try { boolean bucketExists = minioClient.bucketExists( BucketExistsArgs.builder().bucket(defaultBucket).build()); if (!bucketExists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(defaultBucket).build()); } minioClient.putObject(PutObjectArgs.builder() .bucket(defaultBucket) .object(objectKey) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); } catch (Exception e) { throw new BizException("对象存储上传失败"); } } public void delete(Material material) { try { minioClient.removeObject(RemoveObjectArgs.builder() .bucket(material.getBucketName()) .object(material.getObjectKey()) .build()); } catch (Exception e) { throw new BizException("对象存储删除失败"); } } }

注意:MinIO 的客户端 API 版本不同时,removeObject的包名和参数类可能不同,实际项目中需要先查询当前依赖版本的接口签名。

4.2 创建删除请求,先审核再执行删除

删除请求创建接口接收的并不只是“投诉文本”。ContactChannel 和 contactValue 必须结构化,否则后续要通过站内私信回复请求者时,系统不知道应该往哪里发消息。

public class CreateTakeDownRequest { private String requesterName; private String contactChannel; private String contactValue; private String reason; private String proofUrl; }

创建删除工单的逻辑在 TakeDownService 中:

@Service public class TakeDownService { private final ContentMaterialRepository materialRepository; private final TakeDownRequestRepository requestRepository; private final MaterialNotifier notifier; public TakeDownRequest create(String materialNo, CreateTakeDownRequest request) { ContentMaterial material = materialRepository.findByMaterialNo(materialNo) .orElseThrow(() -> new BizException("素材不存在")); if ("DELETED".equals(material.getStatus())) { throw new BizException("素材已经处于删除状态"); } String bizKey = DigestUtils.sha256Hex( materialNo + ":" + request.getContactValue() + ":" + request.getReason()); if (requestRepository.existsByRequestBizKey(bizKey)) { throw new BizException("相同删除请求已存在,请勿重复提交"); } TakeDownRequest entity = new TakeDownRequest(); entity.setRequestNo("TD" + System.currentTimeMillis()); entity.setMaterialNo(materialNo); entity.setRequestBizKey(bizKey); entity.setRequestSource("PRIVATE_MESSAGE"); entity.setRequesterName(request.getRequesterName()); entity.setContactChannel(request.getContactChannel()); entity.setContactValue(request.getContactValue()); entity.setReason(request.getReason()); entity.setProofUrl(request.getProofUrl()); entity.setStatus("PENDING"); return requestRepository.save(entity); } }

创建工单后,不应该立即调用删除文件操作。原因在于“私聊删除”场景里的请求者不一定就是素材原作者。如果某个用户看到不喜欢的内容就提交删除请求,系统直接删除会带来风险。正确做法是创建 PENDING 工单,再调用pass方法由运营人员审核。

4.3 删除的幂等处理和通知落库

审核通过后的pass方法,参考下面的代码:

@Transactional public void pass(String requestNo, String operator) { TakeDownRequest request = requestRepository.findByRequestNo(requestNo) .orElseThrow(() -> new BizException("删除请求不存在")); if (!"PENDING".equals(request.getStatus())) { throw new BizException("当前请求状态不允许审核通过"); } ContentMaterial material = materialRepository.findByMaterialNo(request.getMaterialNo()) .orElseThrow(() -> new BizException("素材不存在")); request.setStatus("PASSED"); request.setHandledBy(operator); request.setHandledAt(LocalDateTime.now()); requestRepository.save(request); material.setStatus("DELETING"); material.setDeleteReason(request.getReason()); materialRepository.save(material); }

这个阶段不要直接删除对象存储文件。你可以再增加一个DoDeleteService,扫描所有 DELETING 超过 1 分钟的素材,先确认请求审核已经通过,再执行 MinIO 删除操作,这样删除流程被拆成多个独立步骤,数据库事务不会因为外部存储调用而长时间打开。

如果希望在一个请求里完成全部流程,测试环境可以使用下面的简版方法,但生产环境建议按任务表方式异步执行:

public void executeDelete(ContentMaterial material) { material.setStatus("DELETING"); materialRepository.save(material); try { storageService.delete(material); material.setStatus("DELETED"); materialRepository.save(material); } catch (Exception e) { material.setStatus("DELETE_FAILED"); material.setDeleteReason(e.getMessage()); materialRepository.save(material); throw e; } }

执行删除后,需要通知请求方并落一条通知日志。通知日志不是可有可无的辅助功能,它是“私聊删除”闭环里最重要的审计证据。建议新建notification_log表:request_no、channel、target、content_template、status、error_message、created_at。每次发送前,先按 request_no 和 channel 查询是否已经存在成功记录,如果已经发送过,直接跳过。

5. 用接口把流程串起来:上传、投诉、审核、删除

5.1 需要暴露的 API 与请求参数

一个最小闭环需要四类接口:素材上传、创建删除请求、审核通过、查询素材状态。

方法路径作用
POST/api/material/upload上传素材
POST/api/material/{materialNo}/take-down对素材发起删除请求
POST/api/take-down/{requestNo}/reject驳回删除请求
POST/api/take-down/{requestNo}/pass审核通过并执行删除
GET/api/material/{materialNo}查询素材状态

Controller 层尽量保持薄,只负责参数转换和异常包装。

5.2 Controller 层代码示例

MaterialController 示例:

@RestController @RequestMapping("/api/material") public class MaterialController { private final MaterialService materialService; public MaterialController(MaterialService materialService) { this.materialService = materialService; } @PostMapping("/upload") public ContentMaterial upload(@RequestParam("file") MultipartFile file, @RequestParam String title, @RequestParam(required = false) String originalAuthor, @RequestParam(required = false) String sourceUrl, @RequestParam(required = false) String sourcePlatform, @RequestParam(defaultValue = "UNKNOWN") String licenseType) { return materialService.upload(file, title, originalAuthor, sourceUrl, sourcePlatform, licenseType); } @GetMapping("/{materialNo}") public ContentMaterial detail(@PathVariable String materialNo) { return materialService.detail(materialNo); } }

TakeDownController 示例:

@RestController @RequestMapping("/api") public class TakeDownController { private final TakeDownService takeDownService; public TakeDownController(TakeDownService takeDownService) { this.takeDownService = takeDownService; } @PostMapping("/material/{materialNo}/take-down") public TakeDownRequest create(@PathVariable String materialNo, @RequestBody CreateTakeDownRequest request) { return takeDownService.create(materialNo, request); } @PostMapping("/take-down/{requestNo}/pass") public String pass(@PathVariable String requestNo, @RequestParam String operator) { takeDownService.pass(requestNo, operator); return "ok"; } }

这里的 pass 方法可以包含一个参数needDeleteNow,测试环境直接同步删除,生产环境则异步执行。把 API 设计成同步还是异步,取决于页面对下架时效的要求。如果是运营后台内部的审核操作,通常不需要用户等待文件删除完成,可以先返回“已通过”,后台任务继续删除。

5.3 消息通知与“私聊”场景对接

在真实项目里,“私聊删除”中的私聊可能发生在站内私信、微信公众号后台、邮件或客服系统。系统需要抽象一个 Notifier 接口,而不是每个 Service 都直接调用某一个 IM 客户端。

public interface MaterialNotifier { boolean support(String channel); void send(NotifyMessage message); }

NotifyMessage 中至少包含 requestNo、contactChannel、contactValue、title、content 五个字段。示例:

NotifyMessage message = new NotifyMessage(); message.setRequestNo(request.getRequestNo()); message.setContactChannel(request.getContactChannel()); message.setContactValue(request.getContactValue()); message.setTitle("素材删除结果通知"); message.setContent("您反馈的素材已处理:" + detail);

如果 contactChannel 是站内私信,contactValue 就是用户 ID;如果是邮件,contactValue 就是邮箱。将来接入新的渠道时,只需要增加一个实现 MaterialNotifier 的类,并通过 support 方法判断。

6. 本地验证:从上传到删除的全链路测试

6.1 准备测试数据和启动服务

按前面的 SQL 在 MySQL 中创建content_material表和take_down_request表,然后启动 Spring Boot 应用和 MinIO。第一次启动验证时,重点检查三个点:应用能否连上 MySQL、MinIO bucket 是否创建成功、上传接口能否正确写入素材记录。

准备一个测试图片test-photo.jpg,执行上传请求:

curl -X POST http://127.0.0.1:8080/api/material/upload \ -F "file=@test-photo.jpg" \ -F "title=现场舞台图" \ -F "originalAuthor=网友小A" \ -F "sourceUrl=https://example.com/post/1001" \ -F "sourcePlatform=example" \ -F "licenseType=PUBLIC_SHARE"

正常情况下返回内容包含 materialNo 和 status=ACTIVE。

6.2 验证删除请求与重复提交场景

先发起一条删除请求:

curl -X POST http://127.0.0.1:8080/api/material/CM1717000000000/take-down \ -H "Content-Type: application/json" \ -d '{ "requesterName": "原作者小A", "contactChannel": "PRIVATE_MESSAGE", "contactValue": "user_1001", "reason": "未授权使用我的照片", "proofUrl": "https://example.com/author-info" }'

返回结果中应有 requestNo,并且 status=PENDING。再次提交完全相同的 JSON,系统应该抛异常或返回“相同删除请求已存在”,这是幂等校验的预期结果。

执行审核通过:

curl -X POST "http://127.0.0.1:8080/api/take-down/TD1717000000000/pass?operator=admin"

如果实现中直接调用 executeDelete,那么随后查询素材状态应该变成 DELETED。

curl http://127.0.0.1:8080/api/material/CM1717000000000

返回的 status 字段为 DELETED。

6.3 验证 MinIO 文件确实已删除

素材状态变成 DELETED 只说明数据库层面不再

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询