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:
@@ -58,3 +58,6 @@ desktop.ini
|
||||
|
||||
# 运行时上传的文件(材料原图/规范件)—— 是数据不是代码,绝不入库
|
||||
backend/data/
|
||||
|
||||
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
|
||||
application-local.yml
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+81
-22
@@ -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) {
|
||||
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+253
@@ -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();
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
|
||||
@@ -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 微信三通道体积上限真机实测
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user