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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user