feat(h5): 学生端材料预览 + 删掉部署里的 MinIO
一、部署配置清理
docker-compose.yml 里的 MinIO 删了(连 minio-data 卷)。它当初是
"M0 阶段不写入但先起好 —— 本地/生产走同一套 S3 接口",但代码最终走
七牛官方 SDK,那个容器**从来没被连过** —— 留着只会让人以为"本地还有个
对象存储要用"。.env.example / .env 的 MINIO_* 换成 QINIU_* + XT_STORAGE_*。
⚠️ 删的时候我自己犯了个错:用 volumes:\n 当插入锚点,结果匹配到了
**pg 服务内部的 ` volumes:`**,把注释插进 pg 定义里还把它顶到第 0 列,
**YAML 结构就这么坏了**。好在写完立刻用 yaml.safe_load 复查(services: [pg, redis])
才发现。教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。
二、学生端材料预览(新功能)
已传材料每行加「预览」,点开全屏浮层(深底 —— 材料照片多是白底证件件,
浅底会看不清边界),点图片以外关闭。
**两种存储两种取法,不能只写一种**:
· 七牛:接口返回带签名的绝对 URL,直接给 <image src> 用
· 本地磁盘:那个接口要带 Authorization,而 <image src> 发不了请求头 ——
先用 uni.request 取字节,再转 blob URL
只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
签名链接有有效期(30 分钟),所以每次预览都现取,不缓存 URL。
顺手修两个后端问题:
· LocalStorageService.url() 返回的路径指向一个**不存在的接口**
(我建的是 /{id}/content,不是 ?path=)→ 改由 MaterialFileService 按
provider 决定:七牛给签名链接、本地给 /{id}/content(按 id 取能复用归属校验)
· /content 原来固定 application/octet-stream —— 表现是**点开身份证照片
变成下载一个文件**。现在按文件名推 Content-Type + inline
三、浏览器实测
点预览 → 浮层打开、图片真的解码(naturalWidth=900、complete=true);
直接 fetch 那个签名 URL → 200 / 17728 字节。
中间发现一个视觉问题:**浮层标题与「关闭」被 uni-app 导航栏盖住了**
(page-head 的 z-index 是 998,我给的 95)—— 看起来像"标题没渲染出来"。
改成 9999 + padding-top 让出导航高度;问题清单浮层一并提到 9998。复验通过。
四、顺带
dev-seed-warn-state.py 改为没有可用草稿时自己建一份 —— 原来写死 submission id,
被 E2E 清理后就报"填报单不存在或不属于你",而那看起来像权限问题。
验收:e2e-m3-file 32/32、m3-submit 31/31、m1-storage 16/16
This commit is contained in:
+9
-3
@@ -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.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -123,9 +124,14 @@ public class MaterialFileController {
|
||||
+ "两种实现都要求**登录且文件属于你**")
|
||||
public ResponseEntity<byte[]> content(@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean original) {
|
||||
byte[] bytes = fileService.content(id, original);
|
||||
MaterialFileService.Content c = fileService.content(id, original);
|
||||
// 按文件名推 Content-Type:猜错的表现很别扭(身份证照片点开变成下载一个文件)
|
||||
MediaType type = MediaTypeFactory.getMediaType(c.name())
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(bytes);
|
||||
.contentType(type)
|
||||
// inline:让浏览器直接展示(图片/PDF),而不是弹出下载
|
||||
.header("Content-Disposition", "inline")
|
||||
.body(c.bytes());
|
||||
}
|
||||
}
|
||||
|
||||
+27
-4
@@ -262,20 +262,43 @@ public class MaterialFileService {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
|
||||
}
|
||||
long exp = expireSeconds == null || expireSeconds <= 0 ? 1800 : expireSeconds;
|
||||
return new FileUrl(storage.url(path, exp), exp, storage.provider(),
|
||||
String url = storage.url(path, exp);
|
||||
if (!"qiniu".equals(storage.provider())) {
|
||||
// 本地存储没有"公网地址"这回事:七牛那边给的是带签名的绝对 URL,
|
||||
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
|
||||
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
|
||||
// 而按 id 取能复用"这份文件属于你"的归属校验)
|
||||
url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + original;
|
||||
}
|
||||
return new FileUrl(url, exp, storage.provider(),
|
||||
original ? f.getOriginalName() : (f.getStandardName() == null
|
||||
? f.getOriginalName() : f.getStandardName()));
|
||||
}
|
||||
|
||||
/** 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 */
|
||||
public byte[] content(Long fileId, boolean original) {
|
||||
/** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */
|
||||
public record Content(byte[] bytes, String name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。
|
||||
*
|
||||
* <p>连文件名一起返回,是因为要由它推 {@code Content-Type} ——
|
||||
* 否则浏览器拿到 {@code application/octet-stream},表现是
|
||||
* <b>点开身份证照片变成了下载一个文件</b>,而不是直接看到图。
|
||||
*/
|
||||
public Content 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);
|
||||
if (path == null || path.isBlank()) {
|
||||
path = f.getRawPath();
|
||||
}
|
||||
String name = original ? f.getOriginalName()
|
||||
: (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName());
|
||||
return new Content(readFile(path), name);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
+10
-5
@@ -12,8 +12,13 @@ PG_PORT=5432
|
||||
REDIS_PORT=16379
|
||||
REDIS_PASSWORD=redis_dev_pwd
|
||||
|
||||
# MinIO
|
||||
MINIO_USER=minioadmin
|
||||
MINIO_PASSWORD=minioadmin
|
||||
MINIO_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
# ── 对象存储(七牛云)───────────────────────────────
|
||||
# ⚠️ AK/SK 是**有计费与数据权限的真凭据**,不要写进 .env.example(那个是提交的)
|
||||
XT_STORAGE_PROVIDER=qiniu
|
||||
QINIU_ACCESS_KEY=
|
||||
QINIU_SECRET_KEY=
|
||||
QINIU_BUCKET=tools-matrix
|
||||
QINIU_DOMAIN=https://file.tools.coding.design
|
||||
QINIU_REGION=auto
|
||||
# 换存储/换密钥之后值得开一次:启动时真的走一遍 写入→存在→读回→删除
|
||||
XT_STORAGE_SELF_CHECK=false
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
#
|
||||
# ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379
|
||||
# (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们)
|
||||
#
|
||||
# 对象存储:用**七牛云**,这里不起容器。
|
||||
# 材料原图与规范件存七牛(空间 tools-matrix,私有 → 下载走签名链接),
|
||||
# 由后端用官方 SDK 直连,不走 S3 协议。
|
||||
# 曾经起过一个 MinIO 备用("本地/生产走同一套 S3 接口"),但代码最终走的是七牛 SDK,
|
||||
# 那个容器从来没被连过 —— 留着只会让人以为"本地还有个对象存储要用",所以删掉了。
|
||||
# 本地开发也不需要它:provider=local 时文件落在 backend/data/uploads。
|
||||
|
||||
name: xiaotutongxue
|
||||
|
||||
@@ -63,31 +70,6 @@ services:
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
|
||||
# ── MinIO(对象存储,S3 兼容)─────────────────────
|
||||
# M0 阶段不写入,但先起好——本地/生产的存储层走同一套 S3 接口,
|
||||
# 换阿里云 OSS 只改配置,不改代码。
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: xt-minio
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${MINIO_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
|
||||
@@ -1334,3 +1334,68 @@ m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48
|
||||
`_selfcheck/`、`_probe_/`)。**这是刻意的**:原始件要留着备查。
|
||||
要清就在七牛控制台按前缀清,或跑一次带清理的脚本。
|
||||
- `backend/data/uploads/` 里有本地磁盘时代的旧测试文件(已 gitignore),可随时删。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## §一·十九 部署配置清理 + 学生端材料预览
|
||||
|
||||
### ① 部署配置:删掉 MinIO
|
||||
`deploy/docker-compose.yml` 里的 MinIO 删了(连带 `minio-data` 卷)。
|
||||
它当初是"M0 阶段不写入,但先起好 —— 本地/生产走同一套 S3 接口",
|
||||
但代码最终走的是**七牛官方 SDK**,那个容器**从来没被连过** ——
|
||||
留着只会让人以为"本地还有个对象存储要用"。已在文件头写明原因。
|
||||
`.env.example` / `.env` 里的 `MINIO_*` 换成 `QINIU_*` + `XT_STORAGE_*`。
|
||||
|
||||
> ⚠️ 我自己在删的时候犯了个错,值得记:用 `volumes:
|
||||
` 当锚点插入说明,
|
||||
> 结果匹配到了 **pg 服务内部的 ` volumes:`**,把注释插进了 pg 的定义里,
|
||||
> 还把它顶到了第 0 列 —— **YAML 结构就这么坏了**(`pg.volumes` 变成顶层键)。
|
||||
> 好在写完立刻用 `yaml.safe_load` 复查了一遍(`services: [pg, redis]`)才发现。
|
||||
> **教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。**
|
||||
|
||||
### ② 学生端材料预览(新功能)
|
||||
已传材料每一行现在有「预览」,点开是全屏浮层(深底 —— 材料照片多是白底证件件,
|
||||
浅底会看不清边界),点图片以外的地方关闭。
|
||||
|
||||
**两种存储两种取法,不能只写一种**:
|
||||
- **七牛**:接口返回**带签名的绝对 URL**,直接给 `<image src>` 用;
|
||||
- **本地磁盘**:那个接口要带 `Authorization`,而 `<image src>` **发不了请求头** ——
|
||||
所以先用 `uni.request` 把字节取回来,再转成 blob URL。
|
||||
|
||||
> 只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
|
||||
> 另外:签名链接有有效期(30 分钟),所以**每次预览都现取**,不要缓存 URL ——
|
||||
> 缓存的那份过期后是 403,而学生只会觉得"打不开"。
|
||||
|
||||
**顺手修的两个后端问题**:
|
||||
1. `LocalStorageService.url()` 返回的路径指向一个**不存在的接口**
|
||||
(我当时建的是 `/{id}/content`,不是 `?path=`)——
|
||||
现在由 `MaterialFileService` 按 provider 决定:七牛给签名链接、本地给 `/{id}/content`
|
||||
(按 id 取能复用"这份文件属于你"的归属校验)
|
||||
2. `/content` 原来固定返回 `application/octet-stream` ——
|
||||
表现是**点开身份证照片变成下载一个文件**。现在按文件名推 Content-Type + `inline`
|
||||
|
||||
### ③ 浏览器实测
|
||||
- 点「预览」→ 全屏浮层打开、图片**真的解码出来**(`naturalWidth=900`、`complete=true`)
|
||||
- 直接 fetch 那个签名 URL:**200 / 17728 字节**
|
||||
- 中间发现一个视觉问题:**浮层的标题与「关闭」被 uni-app 的导航栏盖住了**
|
||||
(uni-app 的 page-head `z-index` 是 **998**,我给的 95)——
|
||||
看起来像"标题没渲染出来"。改成 9999 + `padding-top` 让出导航栏高度;
|
||||
问题清单浮层的 z-index 一并提到 9998
|
||||
- 修完复验:`headVisible: true`、标题与「关闭」都在顶层可见、图片铺满宽度
|
||||
|
||||
### ④ 顺带
|
||||
`dev-seed-warn-state.py` 改为**没有可用草稿时自己建一份** ——
|
||||
原来写死 submission id,被 E2E 清理后就报"填报单不存在或不属于你",
|
||||
**而那看起来像权限问题,不像"数据被清了"**。
|
||||
|
||||
### 验收
|
||||
`e2e-m3-file 32/32`、`m3-submit 31/31`、`m1-storage 16/16`
|
||||
|
||||
### 下一步
|
||||
1. **直传** `POST /mp/files/upload-token`(有了七牛就能按契约实现;
|
||||
省的是学生那一段上行 —— 几十 MB 的翻拍件不占用我们的带宽与连接)
|
||||
2. `M1-05` 规则模板
|
||||
3. 学生端提交前的**图片自检提示**(现在只有校验清单,预览已经能用,可以串起来)
|
||||
4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、
|
||||
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
|
||||
|
||||
@@ -88,7 +88,12 @@
|
||||
<view v-for="f in (filesByMaterial[m.materialCode] || [])" :key="f.id" class="fill__file">
|
||||
<view class="fill__file-line">
|
||||
<text class="fill__file-name">{{ f.standardName || f.originalName }}</text>
|
||||
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
|
||||
<view class="fill__file-ops">
|
||||
<!-- 预览:学生最在意的是"我传对了吗"。没有预览他只能靠文件名猜,
|
||||
而"看着差不多"的照片(比如背面传成了正面)只有看才认得出 -->
|
||||
<text class="fill__file-view" @click="preview(f)">预览</text>
|
||||
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="fill__file-meta">
|
||||
<text v-if="f.standardName && f.rawSize > f.finalSize">
|
||||
@@ -121,6 +126,16 @@
|
||||
用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本,
|
||||
而问题清单要分级上色、每条要带规则编号 —— 这些正是学生能看懂"哪里不对"的关键。
|
||||
全屏纯文本会让人只看到"提交失败",然后放弃 -->
|
||||
<!-- 材料预览。全屏浮层,点图片以外的地方关闭 —— 学生看完就退回表单,不要多一步"关闭" -->
|
||||
<view v-if="previewSrc" class="fill__viewer" @click="closePreview">
|
||||
<view class="fill__viewer-head">
|
||||
<text class="fill__viewer-name">{{ previewName }}</text>
|
||||
<text class="fill__viewer-close">关闭</text>
|
||||
</view>
|
||||
<image class="fill__viewer-img" :src="previewSrc" mode="widthFix" @click.stop />
|
||||
<view class="fill__viewer-hint">点图片以外的地方关闭</view>
|
||||
</view>
|
||||
|
||||
<view v-if="report" class="fill__mask" @click="report = null">
|
||||
<view class="fill__sheet" @click.stop>
|
||||
<view class="fill__sheet-head">
|
||||
@@ -551,6 +566,87 @@ function removeFile(f: UploadedFile) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── 材料预览 ─────────────────────────────────
|
||||
|
||||
const previewSrc = ref<string | null>(null)
|
||||
const previewName = ref('')
|
||||
const previewing = ref(false)
|
||||
|
||||
/** 扩展名 → MIME。做 blob 时要用,猜错的表现是"图裂了"。 */
|
||||
function mimeOf(name: string): string {
|
||||
const ext = (name.split('.').pop() || '').toLowerCase()
|
||||
if (ext === 'png') return 'image/png'
|
||||
if (ext === 'pdf') return 'application/pdf'
|
||||
if (ext === 'webp') return 'image/webp'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览一份材料。
|
||||
*
|
||||
* <p>两种存储两种取法,**不能只写一种**:
|
||||
* <ul>
|
||||
* <li>七牛:接口返回的是**带签名的绝对 URL**,可以直接交给 {@code <image src>};</li>
|
||||
* <li>本地磁盘:那个接口要带 Authorization 头,而 {@code <image src>} **发不了请求头** ——
|
||||
* 所以先用 uni.request 把字节取回来,再转成 blob URL。
|
||||
* 只写七牛那一种,本地开发时预览会一片空白,而原因完全看不出来。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>⚠️ 签名链接有有效期(默认 30 分钟),所以**每次预览都现取**,
|
||||
* 不要把 URL 缓存起来 —— 缓存那份过期后是 403,而学生只会觉得"打不开"。
|
||||
*/
|
||||
async function preview(f: UploadedFile) {
|
||||
if (previewing.value) return
|
||||
previewing.value = true
|
||||
uni.showLoading({ title: '加载中…' })
|
||||
try {
|
||||
const r = await request<{ url: string, name: string }>({
|
||||
url: `/api/v1/mp/files/${f.id}/url`,
|
||||
token: token()
|
||||
})
|
||||
previewName.value = r.name || f.standardName || f.originalName
|
||||
if (/^https?:\/\//i.test(r.url)) {
|
||||
previewSrc.value = r.url
|
||||
} else {
|
||||
const buf = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: API_BASE + r.url,
|
||||
header: token() ? { Authorization: `Bearer ${token()}` } : {},
|
||||
responseType: 'arraybuffer',
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) resolve(res.data as ArrayBuffer)
|
||||
else reject(new Error(`打不开(HTTP ${res.statusCode})`))
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || '打不开'))
|
||||
})
|
||||
})
|
||||
revokePreview()
|
||||
previewSrc.value = URL.createObjectURL(new Blob([buf], { type: mimeOf(previewName.value) }))
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showModal({
|
||||
title: '打不开这份材料',
|
||||
content: e instanceof Error ? e.message : '请稍后重试',
|
||||
showCancel: false
|
||||
})
|
||||
} finally {
|
||||
previewing.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
/** blob URL 是浏览器持有的内存,不释放就会一直占着(学生可能连看十几张)。 */
|
||||
function revokePreview() {
|
||||
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewSrc.value)
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
revokePreview()
|
||||
previewSrc.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交按钮的入口。
|
||||
*
|
||||
@@ -770,9 +866,33 @@ page { background-color: $page-bg; }
|
||||
background: #f0f0f0; border-radius: 12rpx;
|
||||
}
|
||||
|
||||
&__file-ops { display: flex; flex-shrink: 0; align-items: baseline; gap: 20rpx; }
|
||||
&__file-view { font-size: 24rpx; color: $primary; }
|
||||
|
||||
// ── 材料预览(全屏,深底 —— 材料照片常是白底的证件件,浅底会看不清边界)──
|
||||
//
|
||||
// ⚠️ z-index 必须**高过 uni-app 的页面导航栏**(它是 998),否则浮层的标题与
|
||||
// 「关闭」会被导航栏盖住 —— 而那看起来像"标题没渲染出来"。
|
||||
// 同时用 padding-top 给导航栏让出位置(44px ≈ 88rpx),再叠加刘海屏安全区。
|
||||
&__viewer {
|
||||
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9999;
|
||||
display: flex; flex-direction: column; overflow-y: auto; background: rgba(0, 0, 0, .92);
|
||||
padding-top: calc(88rpx + constant(safe-area-inset-top));
|
||||
padding-top: calc(88rpx + env(safe-area-inset-top));
|
||||
}
|
||||
&__viewer-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 32rpx 28rpx 16rpx;
|
||||
}
|
||||
&__viewer-name { flex: 1; font-size: 28rpx; color: #fff; word-break: break-all; }
|
||||
&__viewer-close { flex-shrink: 0; margin-left: 24rpx; font-size: 28rpx; color: #fff; }
|
||||
&__viewer-img { width: 100%; }
|
||||
&__viewer-hint { padding: 24rpx; font-size: 24rpx; text-align: center; color: rgba(255, 255, 255, .6); }
|
||||
|
||||
// ── 校验问题清单(红黄绿)──
|
||||
&__mask {
|
||||
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 90;
|
||||
/* 同样要盖过 uni-app 的导航栏(998),否则清单高一点时顶部会被压住 */
|
||||
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9998;
|
||||
display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45);
|
||||
}
|
||||
&__sheet {
|
||||
|
||||
@@ -28,7 +28,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
|
||||
UID = 151
|
||||
TASK = 17
|
||||
SID = int(sys.argv[1]) if len(sys.argv) > 1 else 21
|
||||
WANT_SID = int(sys.argv[1]) if len(sys.argv) > 1 else None
|
||||
|
||||
|
||||
def read_cfg() -> dict:
|
||||
@@ -97,9 +97,31 @@ def upload(token, sid, code, filename, data):
|
||||
return {"_http": e.code}
|
||||
|
||||
|
||||
def ensure_draft(token: str, task_id: int, want: int | None) -> int:
|
||||
"""
|
||||
拿一份可用的草稿 id。
|
||||
|
||||
<p>不写死某个 id:草稿会被 E2E 清理掉,写死的脚本下次跑就报
|
||||
"填报单不存在或不属于你" —— 而那看起来像权限问题,不像"数据被清了"。
|
||||
没给 id 或给的那个不可用,就现建一份(服务端是幂等的)。
|
||||
"""
|
||||
if want:
|
||||
r = call("GET", f"/api/v1/mp/files/submissions/{want}", token)
|
||||
if r.get("code") == 0:
|
||||
return want
|
||||
print(f" 给定的草稿 {want} 不可用({r.get('msg')}),改为现建一份")
|
||||
r = call("POST", "/api/v1/mp/submissions", token, {"taskId": task_id})
|
||||
if r.get("code") != 0:
|
||||
raise SystemExit(f" 建草稿失败:{r}")
|
||||
sid = r["data"]["id"]
|
||||
print(f" 已建草稿 submission={sid}")
|
||||
return sid
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = read_cfg()
|
||||
tok = mint(cfg["jwt"], cfg["issuer"], UID, 1)
|
||||
SID = ensure_draft(tok, TASK, WANT_SID)
|
||||
|
||||
# ① 填掉全部必填(key 是 snake_case;is_retake=false 让补考三科不可见 → 不必填)
|
||||
form = {"work_unit": "无", "is_retake": False, "is_student": True,
|
||||
|
||||
Reference in New Issue
Block a user