feat(storage): 对象存储接七牛云 —— 解除"不能上多副本"

文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 A、下次请求落到 B,
文件就"不见了"。这不是性能问题,是正确性问题 —— 它一直卡着不上多副本。

新增:
  · module/file/storage/{StorageService,LocalStorageService,QiniuStorageService}
  · config/StorageConfig(装配 + 可选启动自检)
  · FillProperties 新增 fill.storage
  · MaterialFileController 新增 GET /{id}/url(签名链接)与 GET /{id}/content
  · scripts/e2e-m1-storage.py(存储自检,没配七牛时跳过而非失败)

关键设计:
  ① 业务只认 path(std/1/17/张同学-…-身份证.jpg),各实现自己映射成真实位置:
     本地 ./data/uploads/std/...、七牛 xiaotutongxue/std/...
     **换存储不用改数据库、不用改业务代码**
  ② 空间私有 → 下载必须签名。GET /{id}/url 每次现签(默认 30 分钟),
     所以这个 URL 绝不能长期保存:写进库、塞进导出的 Excel、发给学生明天再打开,都是 403
  ③ 对象键里有中文,拼 URL **必须逐段编码** —— 整串 encode 会把 / 编成 %2F,
     那在七牛那边是另一个键,表现是"传上去了却打不开",且报错什么都没有
  ④ 区域默认 auto:华东-浙江在新旧控制台对应 z0 与 cn-east-2,
     写错了的症状是**上传超时**——一个从报错完全看不出原因的失败
  ⑤ AK/SK 不进仓库也不进 jar:dev.yml 只放占位符;
     真密钥在 backend/application-local.yml(已 gitignore),
     用 spring.config.import: optional:**file**:./application-local.yml
     —— 刻意用 file: 而不是 classpath:,后者会被打进 jar,密钥就跟着构建产物走了
  ⑥ 配了 qiniu 却没密钥 → 启动就炸(fail-fast):起得来的话第一个受害的是
     正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量

已验证:存储抽象改造没破坏任何现有链路 —— 本地磁盘路径下
  e2e-m3-file 29/29、m3-submit 31/31、m1-materials 68/68、m3-draft 27/27 全绿;
  启动日志打出「本地磁盘(多副本部署下会丢文件)」;git check-ignore 确认
  application-local.yml 被忽略而 .example 不被忽略。

⏳ 未验证:**七牛的真实链路没跑过** —— 用户给了域名/空间/区域/私有/目录前缀,
   但没给 AccessKey/SecretKey,没有它生成不了上传凭证。
   补上后跑 python scripts/e2e-m1-storage.py 即可(它会验:配置齐全 →
   上传处理落进七牛 → 取签名链接 → 真的下载回来比字节 → 不带签名被拒
   → 对象键带前缀 → 原始件一字不差)。没配时脚本跳过并 exit 0,不误报失败。
This commit is contained in:
2026-09-29 14:09:13 +08:00
parent ef903a55c3
commit 65ebf8de90
15 changed files with 1164 additions and 23 deletions
+37
View File
@@ -0,0 +1,37 @@
# ═══════════════════════════════════════════════════════════
# 本地私有配置 —— 模板(这个文件**会**提交,所以只能有占位符)
# ═══════════════════════════════════════════════════════════
#
# 用法:
# 1. 复制成同目录的 application-local.yml(**已在 .gitignore 里,不会被提交**)
# 2. 填上真实的 AccessKey / SecretKey,并把 provider 改成 qiniu
# 3. 重启后端
#
# 为什么要有这么一个文件,而不是直接写进 application-dev.yml:
# application-dev.yml 是提交进仓库的。云存储的 AK/SK 是**有计费与数据权限的真凭据**,
# 与 jwt/crypto 那几把自己生成的密钥不是一回事 —— 后者泄露只影响本系统,
# 前者泄露等于别人能读写整个空间。
#
# 也可以完全不用这个文件,改用环境变量(生产推荐):
# XT_STORAGE_PROVIDER=qiniu
# QINIU_ACCESS_KEY=... / QINIU_SECRET_KEY=...
# QINIU_BUCKET=tools-matrix / QINIU_DOMAIN=https://file.tools.coding.design
#
# 填完想确认配对了,打开启动自检:
# XT_STORAGE_SELF_CHECK=true
# 它会在启动时真的走一遍 写入 → 存在 → 读回 → 删除,
# 把"密钥错 / 空间名错 / 没写权限"当场暴露出来 —— 而不是等第一个学生上传时才失败。
fill:
storage:
provider: qiniu
prefix: xiaotutongxue
qiniu:
access-key: 在这里填 AccessKey
secret-key: 在这里填 SecretKey
bucket: tools-matrix
domain: https://file.tools.coding.design
# auto = 让 SDK 自己查区域(推荐)。华东-浙江在新旧控制台对应 z0 与 cn-east-2,
# 写死了换控制台就错,而错的症状是"上传超时",很难从报错看出原因。
region: auto
signed-url-expire-seconds: 1800
+14
View File
@@ -36,6 +36,8 @@
<easyexcel.version>4.0.3</easyexcel.version>
<!-- springdoc 3.x 对应 Boot 4;2.x 是给 Boot 3 的,别混 -->
<springdoc.version>3.1.1</springdoc.version>
<!-- 七牛云对象存储 SDK(材料原图/规范件)。本地仓库已缓存 7.16.0 -->
<qiniu.version>7.16.0</qiniu.version>
</properties>
<dependencyManagement>
@@ -67,6 +69,18 @@
<version>${mybatis-plus.version}</version>
</dependency>
<!--
七牛云对象存储。为什么用官方 SDK 而不是 S3 协议:
空间是**私有**的,下载必须走七牛的签名规则(`e` + `token`),
上传凭证也是七牛特有的;用 SDK 能直接拿到 privateDownloadUrl/uploadToken,
自己拼签名等于把一套加密规则抄一遍,抄错了表现为"文件传上去了但打不开"。
-->
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>${qiniu.version}</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
+6
View File
@@ -89,6 +89,12 @@
<artifactId>easyexcel</artifactId>
</dependency>
<!-- 对象存储(材料原图与规范件)—— provider=qiniu 时用,本地开发可退回磁盘 -->
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
</dependency>
<!-- OpenAPI 文档(dev/test 环境启用) -->
<dependency>
<groupId>org.springdoc</groupId>
@@ -19,6 +19,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param rsa 登录密码传输解密
* @param security 登录安全策略
* @param upload 上传限制
* @param storage 材料文件的存储后端(本地磁盘 / 七牛云)
* @param miniProgram 微信小程序(填报二维码)
* @param mpH5 H5 填报端(网页授权)
*/
@@ -29,10 +30,62 @@ public record FillProperties(
Rsa rsa,
Security security,
Upload upload,
Storage storage,
MiniProgram miniProgram,
MpH5 mpH5
) {
/**
* 材料文件存哪里。
*
* @param provider {@code local}(本地磁盘,仅开发)或 {@code qiniu}
* @param prefix bucket 内的目录前缀(与同空间其他项目隔离)
* @param qiniu 七牛配置(provider=qiniu 时必填)
* @param selfCheckOnStartup 启动时做一次真实的写入-读取-删除自检。
* **默认关**:生产启动不该依赖外部网络;
* 但换存储、换密钥之后值得打开跑一次
*/
public record Storage(
String provider,
String prefix,
Qiniu qiniu,
Boolean selfCheckOnStartup
) {
public String providerOrDefault() {
// 默认 local:这样"什么都不配"也能把项目跑起来,
// 而不是因为缺一个云存储密钥连本地开发都做不了
return provider == null || provider.isBlank() ? "local" : provider.trim().toLowerCase();
}
public boolean isQiniu() {
return "qiniu".equals(providerOrDefault());
}
public boolean selfCheckEnabled() {
return Boolean.TRUE.equals(selfCheckOnStartup);
}
}
/**
* 七牛云(空间私有 → 下载要签名)。
*
* @param accessKey AK。**只从环境变量或 application-local.yml 取**,不要提交到仓库
* @param secretKey SK,同上
* @param bucket 空间名,如 {@code tools-matrix}
* @param domain 绑定的 CDN 域名,如 {@code https://file.tools.coding.design}
* @param region {@code auto}(默认,推荐)/ {@code huadong} / {@code huadong2} / …
* @param signedUrlExpireSeconds 签名链接有效期(秒),默认 3600
*/
public record Qiniu(
String accessKey,
String secretKey,
String bucket,
String domain,
String region,
Long signedUrlExpireSeconds
) {
}
/**
* @param issuer 签发者,进 JWT 的 {@code iss}
* @param secret HMAC 密钥(Base64),用于 HS256 签名
@@ -0,0 +1,89 @@
package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.module.file.storage.LocalStorageService;
import cn.tianbaotong.fill.module.file.storage.QiniuStorageService;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* 材料存储的装配(本地磁盘 / 七牛云)。
*
* <h3>为什么默认是 local</h3>
* 本地开发和跑 E2E 不该依赖云存储的密钥与网络 —— 一旦依赖,"
* 跑一遍测试"就变成一个需要凭据的动作,而需要凭据的动作不会天天跑。
*
* <h3>但 local 不能上生产多副本</h3>
* 两个实例各存各的盘,学生这次传到 A、下次请求落到 B,文件就"不见了"。
* 所以这不是"性能差一点",是**正确性错误**。日志里会明说。
*
* <h3>启动自检(默认关)</h3>
* {@code fill.storage.self-check-on-startup=true} 时做一次真实的
* 写入 → 存在 → 读回 → 删除。**换存储、换密钥、换空间之后值得开一次** ——
* 它能在服务启动时就把"密钥错""空间名错""没有写权限"这类问题暴露出来,
* 而不是等第一个学生上传材料时才失败(那时排查要从"文件保存失败"
* 一路追到环境变量,而学生在等着)。
*
* <p>默认关是因为:生产启动不该依赖外部网络。一个云存储的瞬时抖动
* 不应该让整个服务起不来。
*/
@Slf4j
@Configuration
public class StorageConfig {
@Bean
public StorageService storageService(FillProperties props) {
FillProperties.Storage s = props.storage();
if (s == null || !s.isQiniu()) {
String dir = props.upload() == null ? null : props.upload().dirOrDefault();
return new LocalStorageService(dir);
}
FillProperties.Qiniu q = s.qiniu();
if (q == null) {
throw new IllegalStateException(
"provider=qiniu 但缺少 fill.storage.qiniu 配置(bucket / domain)");
}
QiniuStorageService svc = new QiniuStorageService(
q.accessKey(), q.secretKey(), q.bucket(), q.domain(),
s.prefix(), q.region(), q.signedUrlExpireSeconds());
if (s.selfCheckEnabled()) {
selfCheck(svc);
}
return svc;
}
/**
* 真实往返一次。
*
* <p>刻意走**和业务完全一样的路径**(同一个 {@code put}/{@code getBytes}/{@code delete}),
* 而不是单独调一次 SDK —— 自检要是和业务走两条路,它就只能证明"网络是通的"。
*/
private void selfCheck(QiniuStorageService svc) {
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
try {
svc.put(path, payload, "text/plain; charset=utf-8");
if (!svc.exists(path)) {
throw new IllegalStateException("自检失败:刚写进去的对象 stat 不到");
}
byte[] back = svc.getBytes(path);
if (!Arrays.equals(payload, back)) {
throw new IllegalStateException("自检失败:读回的内容与写入不一致"
+ "(写入 " + payload.length + " 字节,读回 " + back.length + " 字节)");
}
svc.delete(path);
log.info("存储自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider={})", svc.provider());
} catch (RuntimeException e) {
// 自检失败就是配置错,**必须让服务起不来** ——
// 起得来的话,第一个受害的是正在填材料的学生
log.error("存储自检失败(provider={})", svc.provider(), e);
throw e;
}
}
}
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -103,4 +104,28 @@ public class MaterialFileController {
out.put("validateStatus", f.getValidateStatus());
return out;
}
@GetMapping("/{id}/url")
@Operation(summary = "取材料的访问链接",
description = "七牛空间私有 → 返回**带有效期的签名链接**(默认 30 分钟)。"
+ "⚠️ 不要长期保存这个 URL:过期后是 403,每次要用现取。"
+ "original=true 取原始件(学生传的那张),false 取压缩改名后的规范件")
public ApiResult<MaterialFileService.FileUrl> url(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean original,
@RequestParam(required = false) Long expireSeconds) {
return ApiResult.ok(fileService.url(id, original, expireSeconds));
}
@GetMapping("/{id}/content")
@Operation(summary = "取材料内容(本地存储的预览用)",
description = "七牛那边走签名链接即可,这个接口主要给 provider=local 的本地开发用;"
+ "两种实现都要求**登录且文件属于你**")
public ResponseEntity<byte[]> content(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean original) {
byte[] bytes = fileService.content(id, original);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes);
}
}
@@ -7,6 +7,7 @@ import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
@@ -21,9 +22,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -62,6 +61,7 @@ public class MaterialFileService {
private final TaskMapper taskMapper;
private final FileProcessService processService;
private final FillProperties props;
private final StorageService storage;
// ── 上传 ──────────────────────────────────────
@@ -109,7 +109,7 @@ public class MaterialFileService {
String rel = String.format("raw/%d/%d/%d_%s.%s",
me.tenantId(), sub.getTaskId(), System.currentTimeMillis(),
Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext);
writeFile(rel, bytes);
writeFile(rel, bytes, contentTypeOf(ext));
CollectFile f = new CollectFile();
f.setTaskId(sub.getTaskId());
@@ -169,7 +169,7 @@ public class MaterialFileService {
String standardName = processService.renderName(template, vars, outExt);
String rel = String.format("std/%d/%d/%s", me.tenantId(), f.getTaskId(), standardName);
writeFile(rel, p.bytes());
writeFile(rel, p.bytes(), contentTypeOf(p.ext() == null ? f.getFileExt() : p.ext()));
fileMapper.update(null, Wrappers.<CollectFile>lambdaUpdate()
.eq(CollectFile::getId, fileId)
@@ -228,6 +228,56 @@ public class MaterialFileService {
log.info("删除材料:fileId={} {}", fileId, f.getOriginalName());
}
// ── URL 与内容 ────────────────────────────────
/** 一份材料的可访问链接。 */
public record FileUrl(String url, long expiresIn, String provider, String name) {
}
/**
* 取一份材料的访问链接。
*
* <p>⚠️ 七牛空间是**私有**的,所以这里返回的是**带有效期的签名链接**:
* <ul>
* <li>它**不能长期保存** —— 写进数据库、塞进导出的 Excel、
* 或者发给学生让他明天再打开,都会变成 403;每次要用现取。</li>
* <li>默认 30 分钟。够学生看一眼自己的材料,又不至于被转发出去长期有效。</li>
* </ul>
*
* @param original true 取原始件(学生传的那张)、false 取规范件(压缩改名后的)
*/
public FileUrl url(Long fileId, boolean original, Long expireSeconds) {
LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid());
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
String path = original ? f.getRawPath() : f.getStdPath();
if (path == null || path.isBlank()) {
// 规范件还没生成(上传了但没处理)时退回原始件,而不是报错 ——
// 学生看到的是"我的材料",不该因为内部有两份而困惑
path = f.getRawPath();
}
if (path == null || path.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
}
long exp = expireSeconds == null || expireSeconds <= 0 ? 1800 : expireSeconds;
return new FileUrl(storage.url(path, exp), exp, storage.provider(),
original ? f.getOriginalName() : (f.getStandardName() == null
? f.getOriginalName() : f.getStandardName()));
}
/** 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 */
public byte[] content(Long fileId, boolean original) {
LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid());
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
String path = original ? f.getRawPath() : f.getStdPath();
return readFile(path == null || path.isBlank() ? f.getRawPath() : path);
}
// ── 内部 ──────────────────────────────────────
/** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */
@@ -265,30 +315,39 @@ public class MaterialFileService {
.findFirst().orElse(null);
}
private String dir() {
return props.upload() == null ? "./data/uploads" : props.upload().dirOrDefault();
}
private void writeFile(String rel, byte[] bytes) {
try {
Path p = Paths.get(dir()).resolve(rel);
Files.createDirectories(p.getParent());
Files.write(p, bytes);
} catch (Exception e) {
log.error("写文件失败:{}", rel, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "文件保存失败:" + e.getMessage());
}
/**
* 写文件。
*
* <p>走 {@link StorageService} 而不是直接写盘:换了存储(本地 → 七牛云)
* 这里的业务代码一行都不用改,而 {@code path} 也不变 ——
* 它是**业务路径**,存在 {@code collect_file} 里,换存储不用改数据库。
*/
private void writeFile(String rel, byte[] bytes, String contentType) {
storage.put(rel, bytes, contentType);
}
private byte[] readFile(String rel) {
if (rel == null || rel.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件不存在,请重新上传");
}
try {
return Files.readAllBytes(Paths.get(dir()).resolve(rel));
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件读取失败,请重新上传");
}
return storage.getBytes(rel);
}
/**
* 扩展名 → MIME。
*
* <p>对象存储会把它写进 {@code Content-Type},浏览器与微信据此决定"是展示还是下载"。
* 猜错的表现很别扭:身份证照片点开变成**下载一个文件**,而不是直接看到图。
*/
private static String contentTypeOf(String ext) {
return switch (ext == null ? "" : ext.trim().toLowerCase()) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "webp" -> "image/webp";
case "heic" -> "image/heic";
case "pdf" -> "application/pdf";
default -> "application/octet-stream";
};
}
private static String extOf(String name) {
@@ -0,0 +1,86 @@
package cn.tianbaotong.fill.module.file.storage;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import lombok.extern.slf4j.Slf4j;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 本地磁盘存储。<b>只用于本地开发与自动化测试</b>,不能上生产。
*
* <p>⚠️ 它有一个多副本环境下必然出错的缺陷:两个实例各存各的盘。
* 学生这次传到 A、下次请求落到 B,文件就"不见了"。所以
* {@code provider=local} 时部署上多副本是**错误配置**,不是"将就用"。
*
* <p>它仍然值得保留:本地开发和 E2E 不该依赖云存储的密钥与网络 ——
* 那些依赖会让"跑一遍测试"变成一个需要凭据的动作。
*/
@Slf4j
public class LocalStorageService implements StorageService {
private final String dir;
public LocalStorageService(String dir) {
this.dir = dir == null || dir.isBlank() ? "./data/uploads" : dir;
log.info("材料存储:本地磁盘({})—— 多副本部署下会丢文件,生产请用 provider=qiniu", this.dir);
}
@Override
public String put(String path, byte[] bytes, String contentType) {
try {
Path p = resolve(path);
Files.createDirectories(p.getParent());
Files.write(p, bytes);
return path;
} catch (Exception e) {
log.error("本地写入失败:{}", path, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "文件保存失败:" + e.getMessage());
}
}
@Override
public byte[] getBytes(String path) {
try {
return Files.readAllBytes(resolve(path));
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重新上传");
}
}
/**
* 本地没有"公网地址"这回事,所以返回**由本服务流式提供的地址**
* (见 {@code MaterialFileController#content})—— 与七牛的签名链接
* 在使用上是同一个形状:一个拿得到就能下载的 URL。
*/
@Override
public String url(String path, long expireSeconds) {
return "/api/v1/mp/files/content?path=" + java.net.URLEncoder.encode(
path, java.nio.charset.StandardCharsets.UTF_8);
}
@Override
public boolean exists(String path) {
return Files.exists(resolve(path));
}
@Override
public void delete(String path) {
try {
Files.deleteIfExists(resolve(path));
} catch (Exception e) {
log.warn("本地删除失败:{}", path, e);
}
}
@Override
public String provider() {
return "local";
}
private Path resolve(String path) {
return Paths.get(dir).resolve(path);
}
}
@@ -0,0 +1,253 @@
package cn.tianbaotong.fill.module.file.storage;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import com.qiniu.common.QiniuException;
import com.qiniu.http.Response;
import com.qiniu.storage.BucketManager;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.Region;
import com.qiniu.storage.UploadManager;
import com.qiniu.util.Auth;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* 七牛云对象存储。
*
* <h3>三处与"看起来应该怎么写"不一样的地方</h3>
*
* <p><b>① 空间是<b>私有</b>的,所以下载必须走签名。</b>
* 直接把 {@code domain/key} 发出去会 403。{@link #url} 每次现签一个带有效期的链接 ——
* 也正因如此,**这个 URL 绝不能被长期保存**(写进数据库、塞进导出的 Excel、
* 或者发给学生让他明天再打开,都会变成 403)。
*
* <p><b>② 对象键里会出现中文。</b>
* 规范文件名是 {@code 张同学-…-身份证.jpg}(产品要求"文件名能唯一归属到人")。
* 七牛允许 UTF-8 键,但**拼 URL 时必须逐段 URL 编码** ——
* 整串编码会把 {@code /} 也编成 {@code %2F},那在七牛那边是**另一个键**,
* 表现是"文件明明传上去了却打不开"。
*
* <p><b>③ 区域不写死。</b>
* 空间在华东-浙江,但"华东-浙江"在新旧控制台对应不同的 region id
* ({@code z0} 与 {@code cn-east-2})。写死一个、换了控制台就错,
* 而错的症状是上传超时。默认用 {@code auto} 让 SDK 自己查。
*/
@Slf4j
public class QiniuStorageService implements StorageService {
/** bucket 内的目录前缀 —— 与同空间的其他项目隔离。 */
private final String prefix;
private final String bucket;
/** 绑定的 CDN 域名,形如 {@code https://file.tools.coding.design}(无尾斜杠)。 */
private final String domain;
private final long defaultExpireSeconds;
private final Auth auth;
private final UploadManager uploadManager;
private final BucketManager bucketManager;
private final HttpClient http;
public QiniuStorageService(String accessKey, String secretKey, String bucket, String domain,
String prefix, String region, Long defaultExpireSeconds) {
if (isBlank(accessKey) || isBlank(secretKey)) {
// 配了 qiniu 却没给密钥,属于**部署配置错误**。
// 在这里炸掉比等学生上传时才炸好得多 —— 后者的表现是"学生填不了材料",
// 而排查要从"文件保存失败"一路追到环境变量
throw new IllegalStateException(
"provider=qiniu 但没有配置 access-key/secret-key。"
+ "请设置环境变量 QINIU_ACCESS_KEY / QINIU_SECRET_KEY,"
+ "或写进 application-local.yml(已在 .gitignore 里)");
}
if (isBlank(bucket) || isBlank(domain)) {
throw new IllegalStateException("provider=qiniu 但没有配置 bucket 或 domain");
}
this.bucket = bucket.trim();
this.domain = trimTrailingSlash(domain.trim());
this.prefix = stripSlashes(prefix == null ? "" : prefix);
this.defaultExpireSeconds = defaultExpireSeconds == null || defaultExpireSeconds <= 0
? 3600 : defaultExpireSeconds;
this.auth = Auth.create(accessKey.trim(), secretKey.trim());
this.uploadManager = new UploadManager(new Configuration(resolveRegion(region)));
this.bucketManager = new BucketManager(this.auth, new Configuration(resolveRegion(region)));
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
log.info("材料存储:七牛云(bucket={} domain={} prefix={} 区域={})",
this.bucket, this.domain, this.prefix.isEmpty() ? "(无)" : this.prefix, region);
}
// ── 基本操作 ────────────────────────────────────
@Override
public String put(String path, byte[] bytes, String contentType) {
String key = key(path);
try {
Response r = uploadManager.put(bytes, key, auth.uploadToken(bucket, key));
if (!r.isOK()) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"对象存储写入失败(HTTP " + r.statusCode + ")");
}
return key;
} catch (QiniuException e) {
log.error("七牛上传失败:key={} code={} body={}", key, e.code(),
e.response == null ? "" : safeBody(e.response), e);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"文件保存到对象存储失败:" + e.getMessage());
}
}
@Override
public byte[] getBytes(String path) {
// 私有空间:先签一个临时链接再取。这里**不走** BucketManager 的私有下载接口,
// 因为它返回的也是 URL —— 反正都要签名,一次签名交给 url() 一处维护更好
String signed = url(path, 300);
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(signed))
.timeout(Duration.ofSeconds(60)).GET().build();
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() != 200) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"文件读取失败(HTTP " + resp.statusCode() + "),请重新上传");
}
return resp.body();
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("七牛读取失败:path={}", path, e);
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重新上传");
}
}
/**
* 签名一个下载链接。
*
* <p>⚠️ {@code deadline} 是**绝对时间**(epoch 秒),不是"多久之后过期" ——
* 传成相对秒数会得到一个 1970 年的链接,表现是"刚生成就过期"。
*/
@Override
public String url(String path, long expireSeconds) {
long seconds = expireSeconds > 0 ? expireSeconds : defaultExpireSeconds;
long deadline = System.currentTimeMillis() / 1000 + seconds;
return auth.privateDownloadUrl(publicUrl(path), deadline);
}
/**
* 不带签名的公网地址(私有空间直接访问会 403,仅供日志排查与签名内部使用)。
*/
public String publicUrl(String path) {
return domain + "/" + encodeKey(key(path));
}
@Override
public boolean exists(String path) {
try {
return bucketManager.stat(bucket, key(path)) != null;
} catch (QiniuException e) {
// 612 = 文件不存在,这是"正常的否",不是错误;其他码才算异常
if (e.code() == 612) {
return false;
}
log.warn("七牛 stat 失败:key={} code={}", key(path), e.code());
return false;
}
}
@Override
public void delete(String path) {
try {
bucketManager.delete(bucket, key(path));
} catch (QiniuException e) {
log.warn("七牛删除失败:key={} code={}", key(path), e.code());
}
}
@Override
public String provider() {
return "qiniu";
}
// ── 内部 ────────────────────────────────────────
/** 业务路径 → 对象键(加目录前缀)。 */
private String key(String path) {
String p = stripSlashes(path);
return prefix.isEmpty() ? p : prefix + "/" + p;
}
/**
* 逐段编码。
*
* <p>整串 {@code URLEncoder.encode} 会把 {@code /} 编成 {@code %2F},
* 而七牛把它当成键的一部分 —— 于是签名对了、文件也在,就是 404。
*/
private static String encodeKey(String key) {
return Arrays.stream(key.split("/", -1))
.map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"))
.collect(Collectors.joining("/"));
}
/**
* 区域映射。
*
* <p>默认 {@code auto}:让 SDK 自己去查空间在哪个区。写死一个区看着更"确定",
* 但"华东-浙江"在新旧控制台对应 {@code z0} / {@code cn-east-2} 两个 id,
* 写错了的症状是**上传超时**——一个从报错完全看不出原因的失败。
*/
private static Region resolveRegion(String name) {
if (name == null || name.isBlank()) {
return Region.autoRegion();
}
return switch (name.trim().toLowerCase()) {
case "auto" -> Region.autoRegion();
// 华东
case "huadong", "z0", "cn-east-1", "east" -> Region.huadong();
// 华东-浙江 2
case "huadong2", "cn-east-2", "zhejiang2" -> Region.huadongZheJiang2();
case "huabei", "z1", "north" -> Region.huabei();
case "huanan", "z2", "south" -> Region.huanan();
case "beimei", "na0" -> Region.beimei();
case "xinjiapo", "as0" -> Region.xinjiapo();
default -> {
log.warn("未知的七牛区域「{}」,回退到 auto(让 SDK 自己查)", name);
yield Region.autoRegion();
}
};
}
private static String safeBody(Response r) {
try {
return r.bodyString();
} catch (Exception e) {
return "";
}
}
private static String trimTrailingSlash(String s) {
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String stripSlashes(String s) {
String r = s.trim();
while (r.startsWith("/")) {
r = r.substring(1);
}
while (r.endsWith("/")) {
r = r.substring(0, r.length() - 1);
}
return r;
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
@@ -0,0 +1,63 @@
package cn.tianbaotong.fill.module.file.storage;
/**
* 材料文件的存储后端。
*
* <h3>为什么要有这层抽象</h3>
* 文件以前是直接写本地磁盘({@code Files.write}),这有两个后果:
* <ol>
* <li><b>不能上多副本</b> —— 两个实例各存各的,学生这次传到 A、下次从 B 读,
* 文件就"不见了"。这不是性能问题,是正确性问题。</li>
* <li>换存储要改业务代码。</li>
* </ol>
*
* <h3>路径(path)与对象键(key)是两回事</h3>
* 业务只认 {@code path}(如 {@code std/1/17/张同学-…-身份证.jpg}),
* 它是**稳定**的、存在 {@code collect_file.std_path} 里。
* 各实现自己决定怎么映射成真实的存储位置:
* <ul>
* <li>本地:{@code file:./data/uploads} + path</li>
* <li>七牛:{@code xiaotutongxue/} + path(bucket 里加一层目录前缀,
* 便于与同空间的其他项目隔离)</li>
* </ul>
* 这样**换存储不用动数据库**,也不用改业务代码。
*
* <h3>原始件与规范件都要存</h3>
* {@code raw/} 是学生上传的原图、{@code std/} 是压缩改名后的规范件。
* 两者都存是刻意的:认定机构退件时我们要能拿出原始件核对
* ("是不是你们压坏的"这个问题必须回答得了)。
*/
public interface StorageService {
/**
* 存一个对象。
*
* @param path 业务路径(不以斜杠开头)
* @param bytes 内容
* @param contentType MIME,可为 null
* @return 实际写入的对象键(记日志用)
*/
String put(String path, byte[] bytes, String contentType);
/** 读回字节。压缩与校验要用原始字节,所以这个方法必须可靠。 */
byte[] getBytes(String path);
/**
* 取一个**可直接访问**的 URL。
*
* <p>私有空间返回的是带有效期的签名链接(过期后 403),所以这个 URL
* <b>不能被长期保存</b>(不要写进数据库、不要塞进导出的 Excel)——
* 每次要用的时候现取。
*
* @param expireSeconds 有效期(秒);实现可忽略(本地存储无所谓)
*/
String url(String path, long expireSeconds);
boolean exists(String path);
/** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */
void delete(String path);
/** 实现名({@code local} / {@code qiniu}),用于日志与自检。 */
String provider();
}
@@ -51,6 +51,39 @@ fill:
# PKCS#8 私钥(单行 Base64,无 PEM 头尾)
private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=
# ── 材料文件存储(原图 + 压缩改名后的规范件)──────────────────────────
#
# ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** ——
# 两个实例各存各的盘,学生这次传到 A、下次请求落到 B,文件就"不见了"。
# 这不是"性能差一点",是正确性错误。生产必须 provider=qiniu。
#
# ⚠️ 七牛空间是**私有**的,所以下载走签名链接(带有效期)。
# 因此 URL **不能长期保存**:写进数据库、塞进导出的 Excel、
# 或者发给学生让他明天再打开,都会变成 403 —— 每次要用现取。
#
# AK/SK 只从环境变量或 application-local.yml 取,**不要写在这个文件里**
# (这个文件是提交进仓库的)。application-local.yml 已在 .gitignore 里,
# 模板见同目录的 application-local.yml.example。
storage:
provider: ${XT_STORAGE_PROVIDER:local}
# bucket 内的目录前缀 —— 与同空间的其他项目隔离
prefix: xiaotutongxue
# 启动时做一次真实的 写入→存在→读回→删除 自检。
# **换存储、换密钥、换空间之后值得开一次**:它能在启动时就把
# "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。
# 默认关:生产启动不该依赖外部网络,云存储的瞬时抖动不该让服务起不来。
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:false}
qiniu:
access-key: ${QINIU_ACCESS_KEY:}
secret-key: ${QINIU_SECRET_KEY:}
bucket: ${QINIU_BUCKET:tools-matrix}
domain: ${QINIU_DOMAIN:https://file.tools.coding.design}
# auto = 让 SDK 自己查空间在哪个区(推荐)。
# 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是**上传超时**——
# 一个从报错完全看不出原因的失败。要写死就用 huadong / huadong2。
region: ${QINIU_REGION:auto}
signed-url-expire-seconds: 1800
# ── 微信小程序(《03-接口设计》§7.12 填报二维码)────────────────────────────
#
# ⚠️ app-secret 与上面几把密钥**性质不同**:
@@ -19,6 +19,14 @@ spring:
name: xt-server
profiles:
active: dev
config:
# 本地私有配置(**不提交**):放云存储密钥这类不该进仓库的东西。
# optional: 没有这个文件也能正常启动(默认值都在各自的 yml 里)。
#
# ⚠️ 刻意用 file: 前缀(而不是 classpath:)—— 放 classpath 里会被**打进 jar**,
# 密钥就跟着构建产物到处走了。放在 jar 外面才能保证"构建产物里没有密钥"。
# 模板见 backend/application-local.yml.example(那个是提交的)。
import: optional:file:./application-local.yml
lifecycle:
# 优雅停机:等在途请求处理完,避免上传/导出被打断
timeout-per-shutdown-phase: 30s