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
+3
View File
@@ -58,3 +58,6 @@ desktop.ini
# 运行时上传的文件(材料原图/规范件)—— 是数据不是代码,绝不入库
backend/data/
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
application-local.yml
+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
+88
View File
@@ -1108,3 +1108,91 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权
3. **接入对象存储**(阻塞多副本部署)
4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
---
## §一·十六 对象存储接七牛云(解除"不能上多副本")
### 交付
| 文件 | 内容 |
|---|---|
| `module/file/storage/StorageService.java` | 存储抽象:`put / getBytes / url / exists / delete` |
| `module/file/storage/LocalStorageService.java` | 本地磁盘(**仅开发**,日志里明说多副本会丢文件) |
| `module/file/storage/QiniuStorageService.java` | 七牛云(官方 SDK 7.16.0) |
| `config/StorageConfig.java` | 装配 + 可选启动自检 |
| `config/FillProperties.java` | 新增 `fill.storage`(provider/prefix/qiniu/self-check) |
| `MaterialFileController` | 新增 `GET /{id}/url`(签名链接)与 `GET /{id}/content` |
| `scripts/e2e-m1-storage.py` | 存储自检(没配七牛时**跳过而非失败**) |
### 关键设计
**① 业务只认 `path`,各实现自己映射。**
`collect_file.std_path` 存的是 `std/1/17/张同学-…-身份证.jpg`(业务路径)。
本地落到 `./data/uploads/std/...`,七牛映射成 `xiaotutongxue/std/...`。
**换存储不用改数据库、不用改业务代码。**
**② 空间是私有的 → 下载必须签名。**
`GET /mp/files/{id}/url` 每次现签一个带有效期的链接(默认 30 分钟)。
所以这个 URL **绝不能长期保存** —— 写进数据库、塞进导出的 Excel、
或者发给学生让他明天再打开,都会变成 403。
**③ 对象键里有中文,拼 URL 必须逐段编码。**
规范文件名是 `张同学-…-身份证.jpg`(产品要求"文件名能唯一归属到人")。
整串 `URLEncoder.encode` 会把 `/` 编成 `%2F`,那在七牛那边是**另一个键** ——
表现是"文件明明传上去了却打不开",而报错什么都没有。
**④ 区域默认 `auto`,不写死。**
「华东-浙江」在新旧控制台对应 `z0` 与 `cn-east-2` 两个 id,写错了的症状是
**上传超时**(一个从报错完全看不出原因的失败)。要写死就用 `huadong` / `huadong2`。
**⑤ AK/SK 不进仓库、也不进 jar。**
- `application-dev.yml` 里只有占位符 `${QINIU_ACCESS_KEY:}`(已提交)
- `backend/application-local.yml` 放真密钥(**已 gitignore**),
`spring.config.import: optional:file:./application-local.yml`
- ⚠️ **刻意用 `file:` 而不是 `classpath:`** —— 放 resources 里会被**打进 jar**,
密钥就跟着构建产物到处走了
- 生产用环境变量,连这个文件都不需要
- 模板:`backend/application-local.yml.example`(提交)
**⑥ 配了 qiniu 却没密钥 → 启动就炸。**
`QiniuStorageService` 的构造函数直接抛异常。起得来的话,第一个受害的是
正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量。
可选自检 `XT_STORAGE_SELF_CHECK=true` 会真的走一遍 写入→存在→读回→删除。
### ⏳ 尚未验证的部分(**要密钥**)
用户给了:域名 `https://file.tools.coding.design`、空间 `tools-matrix`、
华东-浙江、**私有**、目录前缀 `xiaotutongxue`。
**但没给 AccessKey / SecretKey** —— 没有它生成不了上传凭证,所以对七牛的真实链路**没跑过**。
补齐与验证步骤:
```
① 编辑 backend/application-local.yml(已 gitignore)
把 provider 那行取消注释改成 qiniu,填上 access-key / secret-key
② 重启后端(想更保险就加 XT_STORAGE_SELF_CHECK=true,启动时先自检一次)
③ python scripts/e2e-m1-storage.py
它会验:配置齐全 → 上传→处理落进七牛 → 取签名链接 → **真的下载回来比字节**
→ 不带签名被拒(证明空间确实私有)→ 对象键带前缀 → 原始件一字不差
```
没配七牛时脚本**跳过并 exit 0**,不会误报失败。
### 已验证的部分
- 存储抽象改造**没有破坏任何现有链路**:本地磁盘路径下
`e2e-m3-file 29/29`、`m3-submit 31/31`、`m1-materials 68/68`、`m3-draft 27/27` 全绿
- 启动日志明确打出「材料存储:本地磁盘(./data/uploads)—— 多副本部署下会丢文件」
- `git check-ignore` 确认 `backend/application-local.yml` 被忽略、`.example` 不被忽略
- 自检脚本的占位符解析:一开始显示成 `provider=${XT_STORAGE_PROVIDER:local}`(既不是
local 也不是 qiniu)—— **配置检查这一环一旦不可信,它给的"通过"也就没意义了**,已修
### 下一步
1. **补 AK/SK 并跑 `e2e-m1-storage.py`**(上面那三步)
2. 补齐后 `deploy/docker-compose.yml` 里的 MinIO 可以彻底删掉(当初是"先起好",
现在存储走七牛,本地留磁盘即可);`XT_STORAGE_PROVIDER=qiniu` 进部署环境变量
3. 学生端材料**预览图**(`/mp/files/{id}/url` 已经能用,前端还没接)
4. **直接上传**(契约里的 `POST /mp/files/upload-token`):现在有了七牛,
这个接口终于可以按契约实现 —— 大文件由学生直传七牛、不经我们服务器。
注意:压缩是服务端的核心能力,直传后仍要下载回来压,所以它省的是**学生那一段的上行**
(几十 MB 的翻拍件不占用我们的带宽与连接)
5. `M1-05` 规则模板
6. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
对象存储 自检(七牛云 / 本地磁盘)。
[x] 配置是否就绪(provider / bucket / domain / 密钥)
[x] 材料上传 → 处理 → 落进对象存储
[x] 取签名链接能下载到**字节一致**的内容 ← 真正的证明
[x] **不带签名**的同地址被拒(证明空间确实是私有的,签名在起作用)
[x] 对象键带目录前缀(与同空间其他项目隔离)
⚠️ 为什么要有这个脚本:
"文件传上去了"和"文件**取得回来**"是两件事。七牛空间是私有的,
少签一个名、签错有效期、键里的中文没编码对 —— 每一种都会让
上传**成功**但下载失败(403/404)。只有真的把字节取回来比一遍才知道配对了。
跑法:
python scripts/e2e-m1-storage.py
没配置七牛时会直接跳过(exit 0),不会误报失败。
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import io
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
LOCAL_YML = os.path.join(ROOT, "backend", "application-local.yml")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
UID = 992004
BASE = "http://localhost:8080"
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
def ok(n: str) -> None:
PASSED.append(n)
print(f" [OK] {n}")
def fail(n: str, d: str) -> None:
FAILED.append((n, d))
print(f" [FAIL] {n}\n {d}")
def check(n: str, cond: bool, d: str = "") -> bool:
if cond:
ok(n)
else:
fail(n, d or "断言不成立")
return cond
def skip(n: str, why: str) -> None:
print(f" [SKIP] {n}\n {why}")
def sql(q: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill",
"-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def read_yml(path: str) -> str:
return open(path, encoding="utf-8").read() if os.path.exists(path) else ""
def resolve(v: str | None) -> str | None:
"""
把 `${ENV:default}` 解析成实际值。
⚠️ 不解析的话自检会**说谎**:显示成 `provider=${XT_STORAGE_PROVIDER:local}` ——
既不是 local 也不是 qiniu,读的人只会更糊涂。配置检查这一环一旦不可信,
它给的"通过"也就没意义了。
"""
if not v:
return None
m = re.fullmatch(r"\$\{([^:}]+):([^}]*)\}", v)
if m:
env = os.environ.get(m.group(1))
return ((env if env is not None else m.group(2)).strip()) or None
m = re.fullmatch(r"\$\{([^}]+)\}", v)
if m:
return (os.environ.get(m.group(1)) or "").strip() or None
return v
def cfg_value(text: str, key: str) -> str | None:
m = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.M)
if not m:
return None
return resolve(m.group(1).strip().strip("'\"") or None)
def storage_cfg() -> dict:
"""
读配置的顺序与 Spring 一致:**application-local.yml 覆盖 application-dev.yml**。
不这么做会出现"我以为在用七牛、其实脚本读的是 dev 里的 local"这种自欺。
"""
dev = read_yml(os.path.join(SRC, "application-dev.yml"))
local = read_yml(LOCAL_YML)
out = {}
for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"):
out[k] = cfg_value(dev, k)
for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"):
v = cfg_value(local, k)
if v:
out[k] = v
return out
def b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def mint(secret: str, issuer: str, uid: int, tenant: int) -> str:
key = base64.b64decode(secret)
now = int(time.time())
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant,
"orgIds": [], "roles": ["STUDENT"], "userType": 4,
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict:
h = {"Content-Type": "application/json; charset=utf-8"}
if token:
h["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=90) as x:
return json.loads(x.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict:
boundary = "----xte2e" + uuid.uuid4().hex[:12]
parts = []
for k, v in [("submissionId", str(sid)), ("materialCode", code)]:
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n".encode())
parts.append(data)
parts.append(f"\r\n--{boundary}--\r\n".encode())
req = urllib.request.Request(
BASE + "/api/v1/mp/files/upload", data=b"".join(parts), method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=120) as x:
return json.loads(x.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def http_get(url: str) -> tuple[int, bytes]:
try:
with urllib.request.urlopen(url, timeout=60) as x:
return x.status, x.read()
except urllib.error.HTTPError as e:
return e.code, b""
except Exception:
return 0, b""
def small_jpeg() -> bytes:
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (900, 1200), (206, 212, 218)).save(buf, "JPEG", quality=90)
return buf.getvalue()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--task", type=int, default=17)
args = ap.parse_args()
task_id = args.task
print("=" * 72)
print("对象存储 自检")
print("=" * 72)
cfg = storage_cfg()
provider = (cfg.get("provider") or "local").lower()
print(f" provider = {provider}")
print(f" bucket = {cfg.get('bucket')}")
print(f" domain = {cfg.get('domain')}")
print(f" prefix = {cfg.get('prefix')}")
print(f" region = {cfg.get('region')}")
if provider != "qiniu":
print()
skip("七牛云自检", "当前 provider=local(文件存在本地磁盘,多副本部署会丢文件)")
print("""
要启用七牛云:
① 编辑 backend/application-local.yml(已在 .gitignore 里,不会被提交)
② 取消 provider 那行的注释改成 qiniu,并填上 access-key / secret-key
③ 重启后端,再跑一次这个脚本
""")
print("=" * 72)
print("跳过(不是失败):当前在用本地磁盘")
print("=" * 72)
return 0
for k in ("access-key", "secret-key", "bucket", "domain"):
if not cfg.get(k):
fail(f"配置 {k}", f"provider=qiniu 但 {k} 为空 —— 服务端会在启动时就拒绝")
return 1
ok("七牛配置齐全(provider/bucket/domain/密钥)")
text = read_yml(os.path.join(SRC, "application-dev.yml")) + read_yml(os.path.join(SRC, "application.yml"))
sec = re.search(r"^\s*secret:\s*(\S+)\s*$", text, re.M)
iss = re.search(r"^\s*issuer:\s*(\S+)\s*$", text, re.M)
if not sec or not iss:
fail("读 JWT 配置", "找不到 secret/issuer")
return 1
tok = mint(sec.group(1), iss.group(1), UID, 1)
rid = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
if not rid.isdigit():
fail("取名册行", "任务下没有名册")
return 1
sql(f"UPDATE collect_roster SET bind_user_id={UID}, bind_type=1 WHERE id={rid}")
r = call("POST", "/api/v1/mp/submissions", tok, {"taskId": task_id})
if r.get("code") != 0:
fail("建草稿", f"{r}")
return 1
sid = r["data"]["id"]
print(f" 草稿 submission={sid}")
try:
print("\n[1] 上传 → 处理")
img = small_jpeg()
up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img)
if not check("上传成功(这一步已经把原图写进对象存储)", up.get("code") == 0, f"{up}"):
return 1
fid = up["data"]["id"]
pr = call("POST", f"/api/v1/mp/files/{fid}/process", tok)
check("压缩改名成功(规范件也写进去了)", pr.get("code") == 0, f"{pr}")
print("\n[2] 签名链接")
u = call("GET", f"/api/v1/mp/files/{fid}/url", tok)
if not check("取到链接", u.get("code") == 0, f"{u}"):
return 1
d = u.get("data") or {}
signed = d.get("url") or ""
print(f" {signed[:120]}{'…' if len(signed) > 120 else ''}")
check("provider 是 qiniu(不是本地磁盘)", d.get("provider") == "qiniu", f"{d.get('provider')}")
check("链接指向配置的域名", (cfg.get("domain") or "") in signed, f"domain={cfg.get('domain')}")
check("是**签名**链接(带 e= 与 token=)",
"e=" in signed and "token=" in signed, "没有签名参数,私有空间会 403")
check("对象键带目录前缀", f"/{cfg.get('prefix')}/" in signed, f"prefix={cfg.get('prefix')}")
check("有效期是正数秒", (d.get("expiresIn") or 0) > 0, f"{d.get('expiresIn')}")
print("\n[3] 真的下载回来比一遍(这才是证明)")
status, body = http_get(signed)
check("签名链接返回 200", status == 200, f"HTTP {status}")
if status == 200:
check("下载到的内容非空", len(body) > 0, f"{len(body)} 字节")
# 规范件是压缩过的,与上传的原图**不一定**等大(这是正常的);
# 这里比的是"取回来的确实是一张 JPEG 图"
check("取回的是 JPEG(FFD8 开头)",
body[:2] == b"\xff\xd8", f"前两字节 {body[:2]!r}")
print("\n[4] 不带签名应当被拒(证明空间确实是私有的)")
unsigned = signed.split("?")[0]
st2, _ = http_get(unsigned)
check("裸地址被拒(401/403)", st2 in (401, 403), f"HTTP {st2} —— 如果返回 200 说明空间是公开的")
print("\n[5] 原始件也取得回来(原始件绝不能丢)")
u2 = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok)
signed2 = ((u2.get("data") or {}).get("url")) or ""
st3, body3 = http_get(signed2)
check("原始件签名链接返回 200", st3 == 200, f"HTTP {st3}")
if st3 == 200:
check("原始件字节与上传的**一模一样**",
body3 == img, f"上传 {len(img)} 字节 / 取回 {len(body3)} 字节")
finally:
print("\n[6] 清理")
sql(f"DELETE FROM collect_file WHERE submission_id={sid}")
sql(f"DELETE FROM collect_submission WHERE id={sid}")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}")
ok("已清理草稿与名册绑定")
print("""
⚠️ 对象存储里的文件**没有删** —— 那正是要的:原始件留着备查,
认定机构退件时要能回答"是不是你们压坏的"。要清就从七牛控制台按前缀清。""")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, dd in FAILED:
print(f" · {n}\n {dd}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())