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:
2026-09-29 15:16:36 +08:00
parent d8463a051f
commit fe7615d12b
7 changed files with 263 additions and 40 deletions
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -123,9 +124,14 @@ public class MaterialFileController {
+ "两种实现都要求**登录且文件属于你**") + "两种实现都要求**登录且文件属于你**")
public ResponseEntity<byte[]> content(@PathVariable Long id, public ResponseEntity<byte[]> content(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean original) { @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() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(type)
.body(bytes); // inline:让浏览器直接展示(图片/PDF),而不是弹出下载
.header("Content-Disposition", "inline")
.body(c.bytes());
} }
} }
@@ -262,20 +262,43 @@ public class MaterialFileService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
} }
long exp = expireSeconds == null || expireSeconds <= 0 ? 1800 : expireSeconds; 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 original ? f.getOriginalName() : (f.getStandardName() == null
? f.getOriginalName() : f.getStandardName())); ? f.getOriginalName() : f.getStandardName()));
} }
/** 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 */ /** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */
public byte[] content(Long fileId, boolean original) { 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(); LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid()); CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid());
if (f == null) { if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
} }
String path = original ? f.getRawPath() : f.getStdPath(); 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
View File
@@ -12,8 +12,13 @@ PG_PORT=5432
REDIS_PORT=16379 REDIS_PORT=16379
REDIS_PASSWORD=redis_dev_pwd REDIS_PASSWORD=redis_dev_pwd
# MinIO # ── 对象存储(七牛云)───────────────────────────────
MINIO_USER=minioadmin # ⚠️ AK/SK 是**有计费与数据权限的真凭据**,不要写进 .env.example(那个是提交的)
MINIO_PASSWORD=minioadmin XT_STORAGE_PROVIDER=qiniu
MINIO_PORT=9000 QINIU_ACCESS_KEY=
MINIO_CONSOLE_PORT=9001 QINIU_SECRET_KEY=
QINIU_BUCKET=tools-matrix
QINIU_DOMAIN=https://file.tools.coding.design
QINIU_REGION=auto
# 换存储/换密钥之后值得开一次:启动时真的走一遍 写入→存在→读回→删除
XT_STORAGE_SELF_CHECK=false
+7 -25
View File
@@ -7,6 +7,13 @@
# #
# ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379 # ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379
# (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们) # (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们)
#
# 对象存储:用**七牛云**,这里不起容器。
# 材料原图与规范件存七牛(空间 tools-matrix,私有 → 下载走签名链接),
# 由后端用官方 SDK 直连,不走 S3 协议。
# 曾经起过一个 MinIO 备用("本地/生产走同一套 S3 接口"),但代码最终走的是七牛 SDK,
# 那个容器从来没被连过 —— 留着只会让人以为"本地还有个对象存储要用",所以删掉了。
# 本地开发也不需要它:provider=local 时文件落在 backend/data/uploads。
name: xiaotutongxue name: xiaotutongxue
@@ -63,31 +70,6 @@ services:
retries: 12 retries: 12
start_period: 5s 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: volumes:
pg-data: pg-data:
redis-data: redis-data:
minio-data:
+65
View File
@@ -1334,3 +1334,68 @@ m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48
`_selfcheck/`、`_probe_/`)。**这是刻意的**:原始件要留着备查。 `_selfcheck/`、`_probe_/`)。**这是刻意的**:原始件要留着备查。
要清就在七牛控制台按前缀清,或跑一次带清理的脚本。 要清就在七牛控制台按前缀清,或跑一次带清理的脚本。
- `backend/data/uploads/` 里有本地磁盘时代的旧测试文件(已 gitignore),可随时删。 - `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 微信三通道体积上限真机实测
+121 -1
View File
@@ -88,8 +88,13 @@
<view v-for="f in (filesByMaterial[m.materialCode] || [])" :key="f.id" class="fill__file"> <view v-for="f in (filesByMaterial[m.materialCode] || [])" :key="f.id" class="fill__file">
<view class="fill__file-line"> <view class="fill__file-line">
<text class="fill__file-name">{{ f.standardName || f.originalName }}</text> <text class="fill__file-name">{{ f.standardName || f.originalName }}</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> <text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
</view> </view>
</view>
<view class="fill__file-meta"> <view class="fill__file-meta">
<text v-if="f.standardName && f.rawSize > f.finalSize"> <text v-if="f.standardName && f.rawSize > f.finalSize">
{{ kb(f.rawSize) }} → <text class="fill__file-ok">{{ kb(f.finalSize) }}</text> {{ kb(f.rawSize) }} → <text class="fill__file-ok">{{ kb(f.finalSize) }}</text>
@@ -121,6 +126,16 @@
用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本, 用页面内浮层而不是 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 v-if="report" class="fill__mask" @click="report = null">
<view class="fill__sheet" @click.stop> <view class="fill__sheet" @click.stop>
<view class="fill__sheet-head"> <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; 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 { &__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); display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45);
} }
&__sheet { &__sheet {
+23 -1
View File
@@ -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") SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
UID = 151 UID = 151
TASK = 17 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: def read_cfg() -> dict:
@@ -97,9 +97,31 @@ def upload(token, sid, code, filename, data):
return {"_http": e.code} 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: def main() -> int:
cfg = read_cfg() cfg = read_cfg()
tok = mint(cfg["jwt"], cfg["issuer"], UID, 1) tok = mint(cfg["jwt"], cfg["issuer"], UID, 1)
SID = ensure_draft(tok, TASK, WANT_SID)
# ① 填掉全部必填(key 是 snake_case;is_retake=false 让补考三科不可见 → 不必填) # ① 填掉全部必填(key 是 snake_case;is_retake=false 让补考三科不可见 → 不必填)
form = {"work_unit": "无", "is_retake": False, "is_student": True, form = {"work_unit": "无", "is_retake": False, "is_student": True,