默认关是因为:生产启动不该依赖外部网络。一个云存储的瞬时抖动 + * 不应该让整个服务起不来。 + */ +@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; + } + + /** + * 真实往返一次。 + * + *
刻意走**和业务完全一样的路径**(同一个 {@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;
+ }
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
index 6b1c946..88a7b4e 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
@@ -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 ⚠️ 七牛空间是**私有**的,所以这里返回的是**带有效期的签名链接**:
+ * 走 {@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。
+ *
+ * 对象存储会把它写进 {@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) {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java
new file mode 100644
index 0000000..9867dc7
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java
@@ -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;
+
+/**
+ * 本地磁盘存储。只用于本地开发与自动化测试,不能上生产。
+ *
+ * ⚠️ 它有一个多副本环境下必然出错的缺陷:两个实例各存各的盘。
+ * 学生这次传到 A、下次请求落到 B,文件就"不见了"。所以
+ * {@code provider=local} 时部署上多副本是**错误配置**,不是"将就用"。
+ *
+ * 它仍然值得保留:本地开发和 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);
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java
new file mode 100644
index 0000000..573d41e
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java
@@ -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;
+
+/**
+ * 七牛云对象存储。
+ *
+ * ① 空间是私有的,所以下载必须走签名。
+ * 直接把 {@code domain/key} 发出去会 403。{@link #url} 每次现签一个带有效期的链接 ——
+ * 也正因如此,**这个 URL 绝不能被长期保存**(写进数据库、塞进导出的 Excel、
+ * 或者发给学生让他明天再打开,都会变成 403)。
+ *
+ * ② 对象键里会出现中文。
+ * 规范文件名是 {@code 张同学-…-身份证.jpg}(产品要求"文件名能唯一归属到人")。
+ * 七牛允许 UTF-8 键,但**拼 URL 时必须逐段 URL 编码** ——
+ * 整串编码会把 {@code /} 也编成 {@code %2F},那在七牛那边是**另一个键**,
+ * 表现是"文件明明传上去了却打不开"。
+ *
+ * ③ 区域不写死。
+ * 空间在华东-浙江,但"华东-浙江"在新旧控制台对应不同的 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 ⚠️ {@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;
+ }
+
+ /**
+ * 逐段编码。
+ *
+ * 整串 {@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("/"));
+ }
+
+ /**
+ * 区域映射。
+ *
+ * 默认 {@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();
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java
new file mode 100644
index 0000000..53a485e
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java
@@ -0,0 +1,63 @@
+package cn.tianbaotong.fill.module.file.storage;
+
+/**
+ * 材料文件的存储后端。
+ *
+ * 私有空间返回的是带有效期的签名链接(过期后 403),所以这个 URL
+ * 不能被长期保存(不要写进数据库、不要塞进导出的 Excel)——
+ * 每次要用的时候现取。
+ *
+ * @param expireSeconds 有效期(秒);实现可忽略(本地存储无所谓)
+ */
+ String url(String path, long expireSeconds);
+
+ boolean exists(String path);
+
+ /** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */
+ void delete(String path);
+
+ /** 实现名({@code local} / {@code qiniu}),用于日志与自检。 */
+ String provider();
+}
diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml
index 26d61e2..6a516f4 100644
--- a/backend/xt-server/src/main/resources/application-dev.yml
+++ b/backend/xt-server/src/main/resources/application-dev.yml
@@ -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 与上面几把密钥**性质不同**:
diff --git a/backend/xt-server/src/main/resources/application.yml b/backend/xt-server/src/main/resources/application.yml
index 17e93b7..9a53878 100644
--- a/backend/xt-server/src/main/resources/application.yml
+++ b/backend/xt-server/src/main/resources/application.yml
@@ -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
diff --git a/docs/交接摘要.md b/docs/交接摘要.md
index 7f3387f..afabb5a 100644
--- a/docs/交接摘要.md
+++ b/docs/交接摘要.md
@@ -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 微信三通道体积上限真机实测
diff --git a/scripts/e2e-m1-storage.py b/scripts/e2e-m1-storage.py
new file mode 100644
index 0000000..d883a26
--- /dev/null
+++ b/scripts/e2e-m1-storage.py
@@ -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())
+ *
+ *
+ * @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());
- }
+ /**
+ * 写文件。
+ *
+ * 三处与"看起来应该怎么写"不一样的地方
+ *
+ * 为什么要有这层抽象
+ * 文件以前是直接写本地磁盘({@code Files.write}),这有两个后果:
+ *
+ *
+ *
+ * 路径(path)与对象键(key)是两回事
+ * 业务只认 {@code path}(如 {@code std/1/17/张同学-…-身份证.jpg}),
+ * 它是**稳定**的、存在 {@code collect_file.std_path} 里。
+ * 各实现自己决定怎么映射成真实的存储位置:
+ *
+ *
+ * 这样**换存储不用动数据库**,也不用改业务代码。
+ *
+ * 原始件与规范件都要存
+ * {@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。
+ *
+ *