studycn 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT.md ADDED
@@ -0,0 +1,13 @@
1
+ # 考试中心 Agent 操作约定
2
+
3
+ 使用同目录 `study.mjs`,Node.js 22+。读取 `README.md` 后操作;服务与凭证从 `STUDY_URL` / `STUDY_TOKEN` 或 `--token-file` 提供,不把凭证写入题目、日志或对话。
4
+
5
+ 先whoami、banks、capabilities,只操作授权范围。六题型支持文字、图片、视频。题干content/mediaIds、标准答案referenceAnswer/answerMediaIds、解析explanation/explanationMediaIds、选项options[].mediaIds组合使用,素材先upload,用真实返回id引用,不保存临时URL。填空与选择题仍需可机读答案;主观题rubric必填并人工评阅,编程不执行代码。
6
+
7
+ 先本地 validate,再服务端 validate 或 import --dry-run;新建最多100道一批。写入采用固定 --request-id,每个独立操作新标识,同一操作重试复用。
8
+
9
+ 更新先 get,编辑返回的完整 JSON,保留 id 与原 version。409重新读取并比较,不能自动覆盖别人的修改;401/403报告授权问题,不使用网页账号密码或其他凭证绕过。
10
+
11
+ 检查退出码与 JSON;0成功,1校验/参数错误,2服务/网络错误。业务命令不依赖自然语言解析。遇到网络错误按输出的 idempotencyKey 重试。写入后 get 核验。
12
+
13
+ CLI 1.2的call覆盖各业务模块,参见OPERATIONS.md。账号角色、部门范围、对象所有权与凭证模块权限均需满足。只执行用户明确要求的业务动作;自动出题不包含发布考试、发布成绩、权限变更、发送催办。不要把题库写入成功表述为考试或成绩已发布。通用幂等重试仅返回completed/replayed,随后查询核对当前对象。
package/OPERATIONS.md ADDED
@@ -0,0 +1,36 @@
1
+ # CLI 1.2 业务操作
2
+
3
+ `study capabilities`返回模块权限表,`study whoami`返回当前有效授权。
4
+
5
+ `study call --path PATH --method GET|POST|DELETE --file 参数.json --request-id 固定标识`调用与网页相同的业务操作。GET默认,可省略file和请求标识。路径不带`/api/`或前导斜线。
6
+
7
+ | 模块 | 路径 | 操作权限 |
8
+ |---|---|---|
9
+ | 项目/部门 | projects / departments;删除 /ID | organization:read/write |
10
+ | 人员 | people;people/move;people/delete | people:read/write |
11
+ | 题库 | banks;删除 banks/ID | questions:read / banks:write |
12
+ | 试卷 | papers;删除 /ID | papers:read/write |
13
+ | 考试 | exams;exams/ID/publish、copy、void、finish、remind | exams:read/write |
14
+ | 名单 | enrollments/考试ID | exams:read / enrollments:write |
15
+ | 答卷 | attempts/答卷ID;/save、submit、terminate、attachments | 本人 self:read/write,管理 grades:read / exams:write |
16
+ | 成绩 | grades;grades/ID/adjust、confirm、publish | grades:read/write |
17
+ | 复核 | reviews | reviews:read/write;本人申请 self:write |
18
+ | 导出 | export/results、analysis、grades、attempts、exams、enrollments | exports:read + grades:read |
19
+ | 题库导出 | export/questions?bankId=ID | exports:read + questions:read |
20
+ | 人员导出 | export/people | exports:read + people:read |
21
+ | 模板 | templates/questions、people、enrollments | exports:read |
22
+ | Excel | imports/preview;imports/commit | questions:write / people:write / enrollments:write,名单导入同时需people:write |
23
+ | 本人 | personal;account;notices/ID | self:read/write |
24
+ | 配置 | settings | settings:read/write |
25
+ | 任务 | jobs;jobs/ai;jobs/ID | jobs:read/write |
26
+ | 凭证 | agent-keys;删除 /ID | credentials:write,不得扩大当前凭证范围 |
27
+
28
+ 示例:`study call --path projects`;`study call --path departments --method POST --file department.json --request-id department-001`;`study call --path "export/results?examId=实际ID" --out results.xlsx`。
29
+
30
+ 固定试卷JSON:`{"name":"试卷","description":"","mode":"fixed","bankIds":["实际题库ID"],"questionIds":["实际题目ID"],"mandatoryIds":[],"quotas":[],"shuffleQuestions":false,"shuffleOptions":false,"allowCorrectSupplement":false,"wrongRatio":0}`。保存已有对象保留id和version;逐题附件通过questionSettings设置。
31
+
32
+ Excel预览参数 `{"type":"questions","targetId":"实际题库ID"}`,`study call --path imports/preview --method POST --file import.json --upload questions.xlsx --request-id preview-001`。提交参数保留相同type、targetId和预览返回token,再调用imports/commit。
33
+
34
+ 通用请求返回`result`;文件下载用`--out`;媒体附件GET可返回签名URL。写入在请求级事务内执行,与幂等标识一起提交。重试返回completed与replayed,随后查询当前对象,不返还旧数据。逻辑删除保留历史答卷、成绩及日志。
35
+
36
+ 技能ZIP内附首次授权与持久化脚本。首次连接验证后在本人私密目录保存凭证,不调用钥匙串,不保存网页登录密码。豆包宿主需提供Node22+、终端工具、联网与持久化私密目录。
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # 考试中心:网页编辑器、Agent API 与 CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 服务:https://study.aimianshi.online/
4
+ API:`https://study.aimianshi.online/api/agent/v1`
5
+ 格式版本:1。支持单选、多选、判断、填空、简答和编程题;编程题以代码文本或附件作答,人工评分,不执行代码。
6
+
7
+ ## 网页使用
8
+
9
+ 题库管理 → 管理题目 → 新建/编辑。题干与解析使用可视化编辑器,支持标题、加粗、斜体、列表、引用、表格;可切换 Markdown。右侧实时预览正确答案、选项图片与解析。知识点和标签可手动填,也可由 Agent 在 JSON 中提供,系统不会自动猜测。
10
+
11
+ 题干、标准答案、解析和每个选项均可组合文字、图片、视频及附件,单文件最大100MB。图片支持PNG/JPG/WebP/GIF,视频支持MP4/WebM/MOV,推荐浏览器通用的H.264/AAC MP4。JSON使用上传返回的编号:题干`mediaIds`、答案`answerMediaIds`、解析`explanationMediaIds`、选项`options[].mediaIds`,兼容图片`imageId`。Excel填写已上传完整文件名,对应“题目素材”“答案素材”“解析素材”“选项素材A–L”,多个用分号分隔。
12
+
13
+ “Agent JSON”可粘贴结构化题目、校验并应用到编辑器,也可导出 JSON。网页、CLI、Agent API 使用同一 JSON 校验器。草稿、Markdown 模式与页面位置在当前浏览器刷新后保留;关闭弹窗也保留草稿,保存成功清除。退出账号时清除该账号浏览器草稿。
14
+
15
+ ## 凭证与权限
16
+
17
+ 题库管理、系统管理或账号设置 → Agent 接入。有效期30/90/365天,按业务模块选择权限;题库操作须指定题库。题库查询包含标准答案与解析,只应授权维护人员。每人可授权本人操作;当前角色允许的操作才可勾选。
18
+
19
+ 完整凭证仅展示一次,服务器仅存SHA-256摘要。可撤销,过期、停用或当前权限变化即时生效。CLI 1.2支持人员、项目部门、试卷考试、名单、答卷、评分复核、配置、后台任务和导出,各模块分别授权。账号密码与邮箱验证码在网页处理。操作仍受账号角色、部门范围、题库范围、对象所有权限制。所有Agent写入记录操作者、凭证名称与请求编号。
20
+
21
+ HTTP 身份:`Authorization: Bearer <访问凭证>`。网页 Session 不适用于 Agent API。没有自动把 AI 服务配置或其密钥交给 Agent。
22
+
23
+ ## CLI 快速开始
24
+
25
+ 有 Node.js 22+ 的电脑可从官方 npm 安装:
26
+
27
+ ```sh
28
+ npm install -g studycn
29
+ study version
30
+ study help
31
+ ```
32
+
33
+ npm 包名为 `studycn`,安装后的命令为 `study`,无需另下 ZIP。下面的官网安装脚本会自动准备 Node.js 运行环境。
34
+
35
+ 直接下载安装到自己的电脑,无需管理员权限。Mac / Linux:
36
+
37
+ ```sh
38
+ curl -fsSL https://study.aimianshi.online/agent/install.sh | bash && . "$HOME/.local/share/study/env"
39
+ study version
40
+ ```
41
+
42
+ Windows PowerShell:
43
+
44
+ ```powershell
45
+ irm https://study.aimianshi.online/agent/install.ps1 | iex
46
+ study version
47
+ $env:STUDY_TOKEN = '在网页创建的访问凭证'
48
+ study whoami
49
+ ```
50
+
51
+ 安装脚本将 CLI 放入用户目录,配置用户 PATH;已有 Node.js 22+ 则复用,否则从 Node.js 官方下载 22 LTS 并校验 SHA256。CLI 安装包同样校验 SHA256。重复安装会更新 CLI。Mac/Linux 的环境加载命令使当前终端立即可用,新终端无需再次加载。脚本不读写浏览器账号或访问凭证。
52
+
53
+ Mac/Linux 默认安装目录为 `~/.local/share/study`,命令入口为 `~/.local/bin/study`,环境文件为 `~/.local/share/study/env`。安装脚本向当前用户的 `.zshrc` 或 `.bashrc` 追加环境加载行;Bash 同时维护 `.bash_profile`。Windows 默认安装目录为 `%LOCALAPPDATA%\StudyCLI`,命令入口为该目录下的 `bin\study.cmd`,同时更新当前终端和用户 PATH。Windows ARM64/x64、Mac ARM64/x64、Linux ARM64/x64 由安装脚本识别。
54
+
55
+ Mac/Linux可用`STUDY_INSTALL_DIR`、`STUDY_BIN_DIR`自定义目录;`STUDY_NO_PROFILE=1`禁止修改终端配置,此时自行加载env。当前官网与Skill内置CLI版本1.2.0,不调用系统钥匙串。Windows脚本尚未实机验证。npm版本以实际发布记录为准;官网安装脚本始终下载本站完整包。
56
+
57
+ 已有 Node.js 22+ 也可使用 npm:
58
+
59
+ ```sh
60
+ npm install -g https://study.aimianshi.online/agent/study-cli.tgz
61
+ study help
62
+ ```
63
+
64
+ 全局 npm 默认目录无写权限时使用上方用户目录安装脚本。下载 ZIP 后仍可运行 `node study.mjs`,需保留 `study.mjs`、`question-format.mjs`、`question.schema.json` 在同一目录。以下命令使用安装后的 `study`:
65
+
66
+ ```sh
67
+ export STUDY_URL=https://study.aimianshi.online
68
+ export STUDY_TOKEN='在网页创建的访问凭证'
69
+ study help
70
+ study whoami
71
+ study banks
72
+ study validate --file questions.example.json --bank '题库编号'
73
+ study import --file questions.example.json --bank '题库编号' --dry-run
74
+ study import --file questions.example.json --bank '题库编号' --request-id import-20261111-001
75
+ study questions --bank '题库编号' --kind multiple --limit 50
76
+ study get --id '题目编号' --out question.json
77
+ # 编辑 question.json,保留 id 和原 version
78
+ study update --file question.json --request-id update-20261111-001
79
+ study upload --file option.png --request-id upload-20261111-001
80
+ study resources
81
+ study resource --id '素材编号' --out option.png
82
+ ```
83
+
84
+ 也可用 `--token-file /安全目录/凭证文件`,文件仅保存凭证。CLI 不写本地账号配置、不调用系统钥匙串、不输出凭证。`--file -` 从标准输入读 JSON。`--url` 指定另一个考试中心服务,须 HTTPS;本机 HTTP 仅允许回环地址。
85
+
86
+ 所有业务命令 stdout 输出 JSON;`help` 输出中文帮助,`help --json` 输出 JSON。create/import/update 均支持 `--dry-run`,只校验、不写入。退出码:0 成功,1 参数/格式/校验失败,2 HTTP/网络失败。HTTP 错误含 `status`、错误 `code`、诊断 `requestId` 及写入 `idempotencyKey`,便于 Agent 判断下一步。
87
+
88
+ ## 题目格式
89
+
90
+ 完整机器约束见 `question.schema.json`;示例见 `questions.example.json`。
91
+
92
+ | 字段 | 规则 |
93
+ |---|---|
94
+ | bankId | 必填;来自 `banks`;须在凭证授权题库内 |
95
+ | kind | `single` 单选、`multiple` 多选、`boolean` 判断、`fill` 填空、`short` 简答、`coding` 编程 |
96
+ | content / explanation | 必填 Markdown,最大各 20000 字符 |
97
+ | options | 单选/多选 2–12 项,每项至少有 text 或 imageId |
98
+ | options.id | 唯一编号;省略按 A–L 自动生成,correct 引用此值 |
99
+ | options.imageId | PNG/JPG/WebP 的已上传素材 id,不使用文件名或公网图片 URL |
100
+ | correct | 数组;单选/判断一项,多选至少两项,例如 `["A","C"]` |
101
+ | blankAnswers | 填空必填二维数组,如 `[["北京","北京市"],["1949"]]`;题干依次用 `{{1}}`、`{{2}}`,最多20空。按空均分,匹配忽略大小写、全半角、首尾空白 |
102
+ | referenceAnswer / rubric | 简答和编程必填:参考答案及逐项评分标准,支持 Markdown / 代码块 |
103
+ | points | 默认 10;大于 0、不超过 10000,最多两位小数 |
104
+ | options.score | 正确项全部不填时按分均分(10÷3 → 3.34/3.33/3.33);手动填时每个正确项都须填,合计等于 points,错误项须为 0 |
105
+ | difficulty | `easy` / `medium` / `hard`,默认 medium |
106
+ | knowledge / tags | 可选数组,由人或 Agent 提供,不自动生成 |
107
+ | mediaIds | 可选题目附件 id 数组,上传返回编号 |
108
+ | status | `active` / `disabled`,默认 active |
109
+ | id / version | 更新时必须保留读取到的编号与原版本;新建不传 |
110
+
111
+ 填空、简答、编程题的options和correct为空。选择题正确选项与填空题可接受答案继续采用可机读格式;可追加答案媒体。主观参考答案可用文字或媒体,评分标准仍须文字。答题者附件由试卷questionSettings逐题开启,默认关闭;最多5个,图片视频各100MB,其他附件20MB。交卷后锁定;操作答卷及成绩须另行授权,评分遵循“改分→确认→再次发布”。
112
+
113
+ 答卷采用 `{selected:[], text:"", blanks:[], attachmentIds:[]}`,仍兼容旧选择题数组。简答、编程成绩带逐题待评分状态,全部人工评分后才可确认,再发布。AI 仅提供复核意见。Excel 模板最后三列为“填空答案”“参考答案”“评分标准”,示例覆盖六种题型。
114
+
115
+ 判断题可省略 options,系统补 A=正确、B=错误。正确项填 `["A"]` 或 `["B"]`。显式 options 时固定两个选项“正确”“错误”,不使用图片。多选漏选按所选正确项分值累计,选错任意一项整题 0 分。
116
+
117
+ JSON Schema用于形状、范围和必填检查;引用存在、两位小数、分值合计、判断选项顺序及业务权限,以共用校验器和 API 为准。API 拒绝未知写入字段;get 返回的 ownerId/createdAt/updatedAt 为只读字段,原样回传时忽略。
118
+
119
+ ## HTTP 接口
120
+
121
+ 除素材下载外,返回 JSON,均不缓存;无跨站 CORS。错误:
122
+
123
+ ```json
124
+ {"error":{"code":"VERSION_CONFLICT","message":"内容已被其他操作更新,请刷新后重试","requestId":"诊断编号"}}
125
+ ```
126
+
127
+ | 方法 / 路径 | 权限 | 请求与返回 |
128
+ |---|---|---|
129
+ | GET /me | questions:read | 返回账号名称、凭证名称、scopes、bankIds、版本 |
130
+ | GET /banks | questions:read | `{items:[题库]}` |
131
+ | GET /questions | questions:read | `bankId,kind,q,knowledge,status,offset,limit`;返回 `{items,total,offset,limit}`,默认50,最多100 |
132
+ | GET /questions/:id | questions:read | 返回完整题目,包含 id/version/答案/解析 |
133
+ | POST /validate | questions:read | 单题对象或 `{items:[题目]}`;返回 `{valid,items,errors:[{index,path,message}]}`,不写题目,校验权限、素材、版本 |
134
+ | POST /questions | questions:write | 单题对象,新建;不带 id/version |
135
+ | PUT /questions/:id | questions:write | 完整题目对象;id匹配路径、version匹配旧版本,更新成功 version+1 |
136
+ | POST /imports | questions:write | `{items:[新题]}`,1–100道,只新建;任一道失败整个事务回滚 |
137
+ | GET /resources | questions:read | `{items:[素材]}`,所属账号素材与授权题库引用素材 |
138
+ | POST /resources | resources:write | multipart/form-data 的 file 字段;返回 id/name/mime/size 等 |
139
+ | GET /resources/:id | questions:read | 返回文件二进制 |
140
+
141
+ 写入题目返回 `{items,count,requestId,replayed}`。POST/PUT 写入(含上传)必须有 `Idempotency-Key`,8–100 位字母、数字、下划线、点、冒号或短横线。相同凭证+标识+内容重试返回原结果、不重复入库,`replayed=true`;相同标识用于不同内容返回409。CLI自动生成标识,建议批次明确传 `--request-id` 以便跨进程重试。每个新操作使用新的标识。
142
+
143
+ JSON请求2MB,素材文件100MB、请求102MB。校验不等于写入成功,并发版本、重复题及授权变化在最终事务拦截。图片与视频放在私有TOS的Study专用目录,文字在数据库。浏览器调用`/api/resources/ID/access?attemptId=答卷ID`取得单文件5分钟签名URL,随后直接访问TOS,过期重新授权。TOS密钥不发给浏览器。答案和解析媒体在成绩正式发布且到达个人解析开放时间后开放,猜测编号不能提前访问。旧素材兼容本地存储。
144
+
145
+ 401:缺少/失效凭证;403:权限不足;404:题目/素材不存在或不可访问;409:版本冲突、幂等标识冲突;400:格式或重复题;413:超大小;500:服务错误。401/403不能自动扩大权限,409须重读核对,禁止用新版本直接覆盖旧草稿。
146
+
147
+ ## Agent 推荐步骤
148
+
149
+ 1. `whoami` 与 `banks` 确认范围。
150
+ 2. 根据需求生成六种题型、答案、解析与知识点;图片视频先upload,用返回id填写对应媒体字段。
151
+ 3. `validate` 本地校验,`validate --remote` 或 `import --dry-run` 校验服务端权限与引用。
152
+ 4. 新建使用 create/import,固定 request-id;更新先 get,核对内容后保留 version 执行 update。
153
+ 5. 再次 get/questions 核验。考试或成绩发布仍由网页正常人工流程完成。
154
+
155
+ 仓库内维护时可从 `public/agent` 获取全部源码。下载的工具包不包含任何正式凭证或业务数据。
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "studycn",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "考试中心 Agent CLI",
5
+ "homepage": "https://study.aimianshi.online/",
6
+ "keywords": ["study", "exam", "cli", "agent", "question-bank"],
7
+ "type": "module",
8
+ "bin": {"study": "./study.mjs"},
9
+ "engines": {"node": ">=22"},
10
+ "publishConfig": {"registry": "https://registry.npmjs.org/", "access": "public"},
11
+ "files": ["study.mjs", "question-format.mjs", "question.schema.json", "questions.example.json", "README.md", "AGENT.md", "OPERATIONS.md"],
12
+ "license": "UNLICENSED"
13
+ }
@@ -0,0 +1,103 @@
1
+ export const formatVersion = "1";
2
+ export const letters = "ABCDEFGHIJKL";
3
+ export function equalScores(points, count) {
4
+ if (!count) return [];
5
+ const cents = Math.round(points * 100), base = Math.floor(cents / count);
6
+ return Array.from({length: count}, (_, i) => (base + (i < cents % count ? 1 : 0)) / 100);
7
+ }
8
+ const fail = (path, message) => {const error = new Error(message); error.path = path; throw error;};
9
+ const string = (value, path, max = 20000) => {
10
+ if (typeof value !== "string" || value.length > max) fail(path, `${path} 必须是长度不超过 ${max} 的文字`);
11
+ return value.trim();
12
+ };
13
+ const list = (value, path) => {
14
+ if (value === undefined) return [];
15
+ if (!Array.isArray(value) || value.length > 100) fail(path, `${path} 必须是最多 100 项的数组`);
16
+ const values = value.map((v, i) => string(v, `${path}[${i}]`, 200));
17
+ if (values.some(v => !v) || new Set(values).size !== values.length) fail(path, `${path} 不能包含空值或重复项`);
18
+ return values;
19
+ };
20
+ const numeric = (value, path) => {
21
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value * 100 - Math.round(value * 100)) > 1e-6) fail(path, `${path} 必须是最多两位小数的数字`);
22
+ return value;
23
+ };
24
+ export function normalizeQuestion(input) {
25
+ if (!input || typeof input !== "object" || Array.isArray(input)) fail("$", "题目必须是 JSON 对象");
26
+ const allowed = ["id", "version", "bankId", "kind", "content", "options", "correct", "points", "explanation", "difficulty", "knowledge", "tags", "mediaIds", "answerMediaIds", "explanationMediaIds", "status", "ownerId", "createdAt", "updatedAt", "blankAnswers", "referenceAnswer", "rubric"];
27
+ for (const key of Object.keys(input)) if (!allowed.includes(key)) fail(key, `不支持字段 ${key}`);
28
+ const kind = input.kind;
29
+ if (!["single", "multiple", "boolean", "fill", "short", "coding"].includes(kind)) fail("kind", "支持 single 单选、multiple 多选、boolean 判断、fill 填空、short 简答、coding 编程");
30
+ const points = numeric(input.points ?? 10, "points");
31
+ if (points <= 0 || points > 10000) fail("points", "题目分值须大于 0 且不超过 10000");
32
+ if (["fill", "short", "coding"].includes(kind)) {
33
+ if ((input.options !== undefined && (!Array.isArray(input.options) || input.options.length)) || (input.correct !== undefined && (!Array.isArray(input.correct) || input.correct.length))) fail("options", "填空和主观题不填写选项或正确选项");
34
+ const result = commonQuestion(input, {kind, points, options: [], correct: []});
35
+ if (kind === "fill") {
36
+ if (!Array.isArray(input.blankAnswers) || input.blankAnswers.length < 1 || input.blankAnswers.length > 20) fail("blankAnswers", "填空须为 1–20 空,每空答案为数组");
37
+ result.blankAnswers = input.blankAnswers.map((values, i) => {
38
+ if (!Array.isArray(values) || values.length < 1 || values.length > 20) fail(`blankAnswers[${i}]`, "每空须有 1–20 个可接受答案");
39
+ const items = values.map(v => string(v, `blankAnswers[${i}]`, 2000));
40
+ if (items.some(v => !v) || new Set(items).size !== items.length) fail(`blankAnswers[${i}]`, "每空答案不能为空或重复");
41
+ return items;
42
+ });
43
+ const markers = [...result.content.matchAll(/\{\{(\d+)\}\}/g)].map(m => Number(m[1]));
44
+ if (markers.length !== result.blankAnswers.length || !markers.every((v,i) => v === i + 1)) fail("content", "题干按顺序使用 {{1}}、{{2}},数量须与答案一致");
45
+ } else {
46
+ result.referenceAnswer = string(input.referenceAnswer ?? "", "referenceAnswer");
47
+ result.rubric = string(input.rubric ?? "", "rubric");
48
+ if ((!result.referenceAnswer && !result.answerMediaIds.length) || !result.rubric) fail("referenceAnswer", "主观题须提供文字或媒体参考答案,并填写评分标准,逐题人工评阅");
49
+ }
50
+ return result;
51
+ }
52
+ const rawOptions = input.options ?? (kind === "boolean" ? [{id: "A", text: "正确"}, {id: "B", text: "错误"}] : []);
53
+ if (!Array.isArray(rawOptions) || rawOptions.length < 2 || rawOptions.length > 12) fail("options", "请填写 2–12 个选项");
54
+ const options = rawOptions.map((option, index) => {
55
+ if (!option || typeof option !== "object" || Array.isArray(option)) fail(`options[${index}]`, "选项必须是对象");
56
+ for (const key of Object.keys(option)) if (!["id", "text", "imageId", "mediaIds", "score"].includes(key)) fail(`options[${index}].${key}`, "不支持此选项字段");
57
+ const id = string(option.id ?? letters[index], `options[${index}].id`, 100);
58
+ const text = string(option.text ?? "", `options[${index}].text`, 4000);
59
+ const imageId = option.imageId ? string(option.imageId, `options[${index}].imageId`, 100) : undefined;
60
+ const mediaIds = list(option.mediaIds, `options[${index}].mediaIds`);
61
+ if (!id || (!text && !imageId && !mediaIds.length)) fail(`options[${index}]`, "每个选项须有编号,以及文字或已上传媒体");
62
+ return {id, text, ...(imageId ? {imageId} : {}), ...(mediaIds.length ? {mediaIds} : {}), ...(option.score !== undefined ? {score: numeric(option.score, `options[${index}].score`)} : {})};
63
+ });
64
+ if (new Set(options.map(o => o.id)).size !== options.length) fail("options", "选项编号不能重复");
65
+ const correct = list(input.correct, "correct");
66
+ if (correct.some(id => !options.some(o => o.id === id))) fail("correct", "正确答案必须引用选项 id");
67
+ if (kind === "multiple" ? correct.length < 2 : correct.length !== 1) fail("correct", kind === "multiple" ? "多选至少两个正确项" : "单选和判断须选择一个正确项");
68
+ if (kind === "boolean" && (options.length !== 2 || options[0].text !== "正确" || options[1].text !== "错误")) fail("options", "判断题固定两个选项:正确、错误");
69
+ const explicitScores = options.filter(o => correct.includes(o.id) && o.score !== undefined);
70
+ if (explicitScores.length && explicitScores.length !== correct.length) fail("options.score", "所有正确项须一起填分值,或全部不填以自动均分");
71
+ const scores = equalScores(points, correct.length);
72
+ const scoredOptions = options.map(o => ({...o, score: correct.includes(o.id) ? o.score ?? scores[correct.indexOf(o.id)] : o.score ?? 0}));
73
+ if (scoredOptions.some(o => o.score < 0 || (!correct.includes(o.id) && o.score !== 0))) fail("options.score", "分值须为非负数,错误项分值须为 0");
74
+ if (Math.round(scoredOptions.reduce((n, o) => n + o.score, 0) * 100) !== Math.round(points * 100)) fail("options.score", "正确项分值合计须等于题目总分");
75
+ return commonQuestion(input, {kind, points, options: scoredOptions, correct});
76
+ }
77
+ function commonQuestion(input, base) {
78
+ const content = string(input.content ?? "", "content"), explanation = string(input.explanation ?? "", "explanation");
79
+ const mediaIds = list(input.mediaIds, "mediaIds"), answerMediaIds = list(input.answerMediaIds, "answerMediaIds"), explanationMediaIds = list(input.explanationMediaIds, "explanationMediaIds");
80
+ if ((!content && !mediaIds.length) || (!explanation && !explanationMediaIds.length)) fail(!content && !mediaIds.length ? "content" : "explanation", "题干和解析各需提供文字或媒体");
81
+ const difficulty = input.difficulty ?? "medium", status = input.status ?? "active";
82
+ if (!["easy", "medium", "hard"].includes(difficulty)) fail("difficulty", "难度仅支持 easy / medium / hard");
83
+ if (!["active", "disabled"].includes(status)) fail("status", "状态仅支持 active / disabled");
84
+ const result = {bankId: string(input.bankId, "bankId", 100), ...base, content, explanation, difficulty,
85
+ knowledge: list(input.knowledge, "knowledge"), tags: list(input.tags, "tags"), mediaIds, answerMediaIds, explanationMediaIds, status};
86
+ if (!result.bankId) fail("bankId", "请选择所属题库");
87
+ if (input.id !== undefined) {
88
+ result.id = string(input.id, "id", 100);
89
+ if (!result.id || !Number.isInteger(input.version) || input.version < 1) fail("version", "更新题目须带 id 和原 version");
90
+ result.version = input.version;
91
+ } else if (input.version !== undefined) fail("version", "新建题目不填写 version");
92
+ return result;
93
+ }
94
+ export function validateQuestions(input) {
95
+ const items = Array.isArray(input) ? input : [input];
96
+ if (!items.length || items.length > 100) return {valid: false, items: [], errors: [{index: 0, path: "$", message: "每批须为 1–100 道题"}]};
97
+ const normalized = [], errors = [];
98
+ items.forEach((item, index) => {
99
+ try {normalized.push(normalizeQuestion(item));}
100
+ catch (error) {errors.push({index, path: error.path || "$", message: error.message});}
101
+ });
102
+ return {valid: errors.length === 0, items: normalized, errors};
103
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://study.aimianshi.online/agent/question.schema.json",
4
+ "title": "考试中心题目 v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["bankId", "kind"],
8
+ "$defs": {
9
+ "media": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}}
10
+ },
11
+ "properties": {
12
+ "id": {"type": "string", "minLength": 1, "maxLength": 100},
13
+ "version": {"type": "integer", "minimum": 1},
14
+ "bankId": {"type": "string", "minLength": 1, "maxLength": 100},
15
+ "kind": {"enum": ["single", "multiple", "boolean", "fill", "short", "coding"]},
16
+ "content": {"type": "string", "maxLength": 20000, "description": "题干,支持 Markdown、GFM 表格;文字或 mediaIds 至少一项"},
17
+ "options": {
18
+ "type": "array", "maxItems": 12,
19
+ "items": {
20
+ "type": "object", "additionalProperties": false,
21
+ "properties": {
22
+ "id": {"type": "string", "minLength": 1, "maxLength": 100, "description": "省略时按顺序生成 A–L"},
23
+ "text": {"type": "string", "maxLength": 4000},
24
+ "imageId": {"type": "string", "maxLength": 100, "description": "上传接口返回的 id;仅图片"},
25
+ "mediaIds": {"$ref": "#/$defs/media", "description": "选项图片、视频及附件,使用上传后的编号"},
26
+ "score": {"type": "number", "minimum": 0, "multipleOf": 0.01, "description": "正确项一起填写或一起省略自动均分;错误项须为 0"}
27
+ }
28
+ }
29
+ },
30
+ "correct": {"type": "array", "maxItems": 12, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "description": "选择题引用 options.id;填空和主观题为空数组或省略"},
31
+ "blankAnswers": {"type": "array", "minItems": 1, "maxItems": 20, "items": {"type": "array", "minItems": 1, "maxItems": 20, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 2000}}, "description": "填空逐空可接受答案,与题干 {{1}}、{{2}} 顺序一致"},
32
+ "referenceAnswer": {"type": "string", "maxLength": 20000},
33
+ "rubric": {"type": "string", "minLength": 1, "maxLength": 20000, "description": "简答和编程题人工评分标准"},
34
+ "points": {"type": "number", "exclusiveMinimum": 0, "maximum": 10000, "multipleOf": 0.01, "default": 10},
35
+ "explanation": {"type": "string", "maxLength": 20000},
36
+ "difficulty": {"enum": ["easy", "medium", "hard"], "default": "medium"},
37
+ "knowledge": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
38
+ "tags": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
39
+ "mediaIds": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
40
+ "answerMediaIds": {"$ref": "#/$defs/media", "description": "标准答案媒体,仅答案开放后向本人展示"},
41
+ "explanationMediaIds": {"$ref": "#/$defs/media", "description": "解析媒体,仅答案开放后向本人展示"},
42
+ "status": {"enum": ["active", "disabled"], "default": "active"},
43
+ "ownerId": {"type": "string", "description": "get 返回的只读字段,输入时忽略"},
44
+ "createdAt": {"type": "string", "description": "只读"},
45
+ "updatedAt": {"type": "string", "description": "只读"}
46
+ },
47
+ "dependentRequired": {"id": ["version"], "version": ["id"]},
48
+ "allOf": [
49
+ {"anyOf": [{"required": ["content"], "properties": {"content": {"minLength": 1}}}, {"required": ["mediaIds"], "properties": {"mediaIds": {"minItems": 1}}}]},
50
+ {"anyOf": [{"required": ["explanation"], "properties": {"explanation": {"minLength": 1}}}, {"required": ["explanationMediaIds"], "properties": {"explanationMediaIds": {"minItems": 1}}}]},
51
+ {"if": {"properties": {"kind": {"enum": ["single", "multiple"]}}}, "then": {"required": ["options"], "properties": {"options": {"minItems": 2}}}},
52
+ {"if": {"properties": {"kind": {"enum": ["single", "multiple", "boolean"]}}}, "then": {"required": ["correct"], "properties": {"correct": {"minItems": 1}}}},
53
+ {"if": {"properties": {"kind": {"const": "multiple"}}}, "then": {"properties": {"correct": {"minItems": 2}}}},
54
+ {"if": {"properties": {"kind": {"enum": ["single", "boolean"]}}}, "then": {"properties": {"correct": {"maxItems": 1}}}},
55
+ {"if": {"properties": {"kind": {"const": "boolean"}}}, "then": {"properties": {"options": {"minItems": 2, "maxItems": 2}}}},
56
+ {"if": {"properties": {"kind": {"const": "fill"}}}, "then": {"required": ["blankAnswers"]}},
57
+ {"if": {"properties": {"kind": {"enum": ["short", "coding"]}}}, "then": {"required": ["rubric"], "anyOf": [{"required": ["referenceAnswer"], "properties": {"referenceAnswer": {"minLength": 1}}}, {"required": ["answerMediaIds"], "properties": {"answerMediaIds": {"minItems": 1}}}]}},
58
+ {"if": {"properties": {"kind": {"enum": ["fill", "short", "coding"]}}}, "then": {"properties": {"options": {"maxItems": 0}, "correct": {"maxItems": 0}}}}
59
+ ]
60
+ }
@@ -0,0 +1,59 @@
1
+ [
2
+ {
3
+ "bankId": "请替换为题库编号",
4
+ "kind": "single",
5
+ "content": "收到客户反馈后,第一步应做什么?",
6
+ "options": [{"id": "A", "text": "记录并确认诉求"}, {"id": "B", "text": "直接关闭工单"}],
7
+ "correct": ["A"],
8
+ "points": 10,
9
+ "explanation": "先确认问题与诉求,再安排处理。",
10
+ "knowledge": ["客户沟通"]
11
+ },
12
+ {
13
+ "bankId": "请替换为题库编号",
14
+ "kind": "multiple",
15
+ "content": "哪些操作符合信息安全要求?",
16
+ "options": [{"id": "A", "text": "按授权访问", "score": 4}, {"id": "B", "text": "共享密码", "score": 0}, {"id": "C", "text": "离开工位时锁屏", "score": 6}],
17
+ "correct": ["A", "C"],
18
+ "points": 10,
19
+ "explanation": "A、C 符合要求;B 会暴露账号权限。漏选累计得分,选错整题 0 分。",
20
+ "knowledge": ["信息安全"]
21
+ },
22
+ {
23
+ "bankId": "请替换为题库编号",
24
+ "kind": "boolean",
25
+ "content": "未经确认的规则评分可以直接发布。",
26
+ "correct": ["B"],
27
+ "points": 10,
28
+ "explanation": "成绩须经人工确认后发布。"
29
+ },
30
+ {
31
+ "bankId": "请替换为题库编号",
32
+ "kind": "fill",
33
+ "content": "中国首都是 {{1}},中华人民共和国成立于 {{2}} 年。",
34
+ "blankAnswers": [["北京", "北京市"], ["1949"]],
35
+ "points": 10,
36
+ "explanation": "逐空均分,每空 5 分。",
37
+ "knowledge": ["常识"]
38
+ },
39
+ {
40
+ "bankId": "请替换为题库编号",
41
+ "kind": "short",
42
+ "content": "发现账号异常登录后,应如何处理?",
43
+ "referenceAnswer": "修改密码,终止异常会话,报告管理员并保留记录。",
44
+ "rubric": "修改密码3分;终止会话3分;报告2分;保留记录2分。",
45
+ "points": 10,
46
+ "explanation": "先阻断访问,再报告和留痕。",
47
+ "knowledge": ["信息安全"]
48
+ },
49
+ {
50
+ "bankId": "请替换为题库编号",
51
+ "kind": "coding",
52
+ "content": "用 Python 实现两个数相加的函数。",
53
+ "referenceAnswer": "```python\ndef add(a, b):\n return a + b\n```",
54
+ "rubric": "参数2分;返回正确结果6分;格式与说明2分。人工评阅代码。",
55
+ "points": 10,
56
+ "explanation": "接收两个参数,返回相加结果。",
57
+ "knowledge": ["Python"]
58
+ }
59
+ ]
package/study.mjs ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import {validateQuestions} from "./question-format.mjs";
6
+
7
+ const help = `考试中心 CLI(Node.js 22+)
8
+
9
+ 环境变量:
10
+ STUDY_URL 服务地址,默认 https://study.aimianshi.online
11
+ STUDY_TOKEN 在网页“题库管理 → Agent 接入”创建的访问凭证
12
+
13
+ 命令(默认输出 JSON):
14
+ version 查看 CLI 版本
15
+ help [--json] 查看帮助
16
+ whoami 查看凭证权限与授权题库
17
+ banks 列出授权题库
18
+ questions [--bank ID] [--kind single|multiple|boolean|fill|short|coding]
19
+ [--q 文字] [--knowledge 名称] [--status active|disabled]
20
+ [--offset 0] [--limit 50]
21
+ get --id ID [--out question.json] 读取题目及版本
22
+ validate --file FILE [--bank ID] [--remote]
23
+ 本地校验;--remote 另校验授权、素材和版本
24
+ create --file FILE [--bank ID] [--request-id KEY]
25
+ import --file FILE [--bank ID] [--dry-run] [--request-id KEY]
26
+ 批量新建,1–100 道,全部成功或全部回滚
27
+ update --file FILE [--id ID] [--request-id KEY]
28
+ 使用 get 返回的完整题目,须保留 id 与 version
29
+ resources 列出可访问素材
30
+ upload --file FILE [--request-id KEY]
31
+ resource --id ID --out FILE 下载素材
32
+ access --id ID 获取素材短时访问链接
33
+ delete --id ID --request-id KEY 删除题目(保留历史答卷)
34
+ capabilities 查看业务操作与权限说明
35
+ call --path PATH [--method GET|POST|DELETE] [--file JSON]
36
+ [--upload FILE] [--out FILE] [--request-id KEY]
37
+ 操作项目、部门、人员、试卷、考试、名单、
38
+ 答卷、成绩、复核、配置、凭证、任务与导出
39
+ schema [--out FILE] 获取本地 JSON Schema
40
+
41
+ 通用参数:--url URL、--token-file FILE、--help、--json。
42
+ --file - 从 stdin 读取 JSON;上传必须指定真实文件。
43
+ 写入自动生成幂等标识,可用 --request-id 固定标识后安全重试。
44
+ 完整说明见 README.md;退出码:0 成功,1 参数/校验失败,2 HTTP/网络失败。
45
+ `;
46
+ const output = value => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
47
+ const error = (message, code = "CLI_ERROR", exit = 1) => {const e = new Error(message); e.code = code; e.exit = exit; throw e;};
48
+ const valueOptions = new Set(["url", "token-file", "request-id", "file", "id", "out", "bank", "kind", "status", "knowledge", "q", "limit", "offset", "path", "method", "upload"]);
49
+ const flags = new Set(["dry-run", "remote", "help", "json"]);
50
+ function parse(args) {
51
+ let command; const options = {};
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i];
54
+ if (arg.startsWith("--")) {
55
+ const key = arg.slice(2);
56
+ if (flags.has(key)) options[key] = true;
57
+ else if (valueOptions.has(key)) {
58
+ if (args[i + 1] === undefined || args[i + 1].startsWith("--")) error(`参数 --${key} 缺少值`);
59
+ options[key] = args[++i];
60
+ } else error(`不支持参数 ${arg}`);
61
+ } else if (!command) command = arg;
62
+ else error(`多余参数 ${arg}`);
63
+ }
64
+ return {command: command || "help", options};
65
+ }
66
+ async function readJson(filename) {
67
+ if (!filename) error("请使用 --file 指定 JSON 文件;--file - 可读 stdin");
68
+ let raw;
69
+ if (filename === "-") {const chunks = []; let size = 0;for await (const chunk of process.stdin) {size += chunk.length;if(size > 2 * 1024 * 1024) error("JSON 超过 2MB");chunks.push(chunk);}raw = Buffer.concat(chunks).toString("utf8");}
70
+ else {if ((await fs.stat(filename)).size > 2 * 1024 * 1024) error("JSON 超过 2MB");raw = await fs.readFile(filename, "utf8");}
71
+ try {return JSON.parse(raw);} catch {error("文件不是有效 JSON", "INVALID_JSON");}
72
+ }
73
+ const requireId = value => {if (!value || typeof value !== "string") error("请使用 --id 指定编号");return encodeURIComponent(value);};
74
+ async function run() {
75
+ const {command, options} = parse(process.argv.slice(2));
76
+ if (command === "version") {output({name: "考试中心 CLI", version: "1.2.0", node: process.versions.node});return;}
77
+ if (command === "help" || options.help) {if(options.json) output({name: "考试中心 CLI", version: "1.2.0", help}); else process.stdout.write(help);return;}
78
+ if (command === "schema") {
79
+ const value = JSON.parse(await fs.readFile(new URL("./question.schema.json", import.meta.url), "utf8"));
80
+ if (options.out) {await fs.writeFile(options.out, JSON.stringify(value, null, 2) + "\n");output({ok: true, file: path.resolve(options.out)});}else output(value);return;
81
+ }
82
+ if (!["whoami", "banks", "questions", "get", "validate", "create", "import", "update", "resources", "upload", "resource", "access", "delete", "call", "capabilities"].includes(command)) error("未知命令:" + command);
83
+ if (options["dry-run"] && !["create", "import", "update"].includes(command)) error("--dry-run 仅支持 create / import / update;本命令未执行");
84
+ if (options.remote && command !== "validate") error("--remote 仅支持 validate;本命令未执行");
85
+ let items;
86
+ if (["validate", "create", "import", "update"].includes(command)) {
87
+ const raw = await readJson(options.file);
88
+ items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : [raw];
89
+ if (options.bank) items = items.map(item => ({...item, bankId: options.bank}));
90
+ if (command === "update" && options.id && items.length === 1) {if (items[0].id && items[0].id !== options.id) error("--id 与文件 id 不一致");items[0] = {...items[0], id: options.id};}
91
+ const checked = validateQuestions(items);
92
+ if (!checked.valid) {output(checked);process.exitCode = 1;return;}
93
+ items = checked.items;
94
+ if (command === "validate" && !options.remote) {output(checked);return;}
95
+ if (["create", "update"].includes(command) && items.length !== 1) error("create / update 每次处理一道题目");
96
+ if (["create", "import"].includes(command) && items.some(item => item.id)) error("新建不填写 id / version;更新请用 update");
97
+ if (command === "update" && !items[0].id) error("更新须保留 get 返回的 id 和 version");
98
+ }
99
+ const base = new URL(options.url || process.env.STUDY_URL || "https://study.aimianshi.online");
100
+ if (base.username || base.password || base.search || base.hash || (base.pathname !== "/" && base.pathname !== "")) error("服务地址须为站点根地址,不含账号、查询参数或子路径");
101
+ if (base.protocol !== "https:" && !(base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) error("服务地址须使用 HTTPS;本机测试可用 HTTP");
102
+ const token = (options["token-file"] ? await fs.readFile(options["token-file"], "utf8") : process.env.STUDY_TOKEN || "").trim();
103
+ if (!/^study_[A-Za-z0-9_-]{43}$/.test(token)) error("请设置有效 STUDY_TOKEN 或 --token-file", "MISSING_CREDENTIAL");
104
+ const requestId = options["request-id"] || crypto.randomUUID();
105
+ async function request(endpoint, method = "GET", body, binary = false) {
106
+ const headers = {"Authorization": "Bearer " + token};
107
+ if (body !== undefined) {headers["Idempotency-Key"] = requestId;if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";}
108
+ let response;
109
+ try {response = await fetch(new URL("/api/agent/v1/" + endpoint, base), {method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(180000)});}
110
+ catch {error("网络请求未完成;写入重试请复用 --request-id " + requestId, "NETWORK_ERROR", 2);}
111
+ if (binary && response.ok) return new Uint8Array(await response.arrayBuffer());
112
+ let value;try {value = await response.json();}catch {error("服务未返回有效 JSON(HTTP " + response.status + ")", "INVALID_RESPONSE", 2);}
113
+ if (!response.ok) {const e = new Error(value.error?.message || value.error || "请求失败");e.code = value.error?.code || "HTTP_ERROR";e.status = response.status;e.requestId = value.error?.requestId;e.idempotencyKey = requestId;e.exit = 2;throw e;}
114
+ return value;
115
+ }
116
+ if (command === "capabilities") return output(await request("capabilities"));
117
+ if (command === "call" || command === "delete") {
118
+ const method = command === "delete" ? "DELETE" : (options.method || "GET").toUpperCase();
119
+ const endpoint = command === "delete" ? "questions/" + requireId(options.id) : options.path;
120
+ if (!endpoint || !["GET", "POST", "DELETE"].includes(method)) error("call 须指定 --path 和有效 --method");
121
+ let body = options.file ? await readJson(options.file) : undefined;
122
+ let payload = {method, path: endpoint, ...(body ? {body} : {})};
123
+ if (options.upload) {
124
+ const stat = await fs.stat(options.upload);if(!stat.size || stat.size > 100 * 1024 * 1024) error("上传文件须为 1 字节至 100MB");
125
+ const form = new FormData();form.set("operation", JSON.stringify(payload));form.set("file", new File([await fs.readFile(options.upload)], path.basename(options.upload)));payload = form;
126
+ }
127
+ const response = await request("operations", "POST", payload);
128
+ if (options.out && response.result?.binary) {
129
+ const bytes = Buffer.from(response.result.binary, "base64");await fs.writeFile(options.out, bytes);return output({ok: true, file: path.resolve(options.out), size: bytes.length});
130
+ }
131
+ if (response.result?.binary && !options.out) error("下载或导出请指定 --out;操作已返回结果", "OUTPUT_REQUIRED");
132
+ if (options.out) {await fs.writeFile(options.out, JSON.stringify(response.result ?? response, null, 2) + "\n");return output({ok: true, file: path.resolve(options.out)});}
133
+ return output(response);
134
+ }
135
+ if (["whoami", "banks", "resources"].includes(command)) return output(await request(command === "whoami" ? "me" : command));
136
+ if (command === "questions") {
137
+ const params = new URLSearchParams();
138
+ for (const key of ["kind", "q", "knowledge", "status", "offset", "limit"]) if(options[key]) params.set(key, options[key]);
139
+ if (options.bank) params.set("bankId", options.bank);
140
+ return output(await request("questions?" + params));
141
+ }
142
+ if (command === "get") {
143
+ const result = await request("questions/" + requireId(options.id));
144
+ if(options.out) {await fs.writeFile(options.out, JSON.stringify(result, null, 2) + "\n");return output({ok: true, file: path.resolve(options.out), id: result.id, version: result.version});}
145
+ return output(result);
146
+ }
147
+ if (command === "validate" || (["create", "import", "update"].includes(command) && options["dry-run"])) {
148
+ const result = await request("validate", "POST", {items});output(result);if(!result.valid) process.exitCode = 1;return;
149
+ }
150
+ if (command === "create") return output(await request("questions", "POST", items[0]));
151
+ if (command === "import") return output(await request("imports", "POST", {items}));
152
+ if (command === "update") return output(await request("questions/" + requireId(items[0].id), "PUT", items[0]));
153
+ if (command === "upload") {
154
+ if(!options.file || options.file === "-") error("上传请指定真实文件");
155
+ const stat = await fs.stat(options.file);if(!stat.size || stat.size > 100 * 1024 * 1024) error("上传文件须为 1 字节至 100MB");
156
+ const form = new FormData();form.set("file", new File([await fs.readFile(options.file)], path.basename(options.file)));
157
+ return output({...await request("resources", "POST", form), requestId});
158
+ }
159
+ if (command === "access") return output(await request("resources/" + requireId(options.id) + "/access"));
160
+ if (command === "resource") {
161
+ if(!options.out) error("下载请使用 --out 指定保存路径");
162
+ const access = await request("resources/" + requireId(options.id) + "/access");
163
+ let bytes;
164
+ if (access.url.startsWith("/")) bytes = await request("resources/" + requireId(options.id), "GET", undefined, true);
165
+ else {
166
+ const url = new URL(access.url);
167
+ if (url.protocol !== "https:" || url.username || url.password || !url.hostname.endsWith(".volces.com")) error("媒体访问地址不符合预期", "INVALID_MEDIA_URL", 2);
168
+ const response = await fetch(url, {redirect: "error", signal: AbortSignal.timeout(180000)});
169
+ if (!response.ok) error("媒体下载失败,请重新获取授权链接", "MEDIA_ERROR", 2);
170
+ bytes = new Uint8Array(await response.arrayBuffer());
171
+ }
172
+ await fs.writeFile(options.out, bytes);output({ok: true, file: path.resolve(options.out), size: bytes.length});return;
173
+ }
174
+ }
175
+ run().catch(e => {
176
+ output({error: {code: e.code || "CLI_ERROR", message: String(e.message).replace(/study_[A-Za-z0-9_-]{43}/g, "[凭证已隐藏]"), ...(e.status ? {status: e.status} : {}), ...(e.requestId ? {requestId: e.requestId} : {}), ...(e.idempotencyKey ? {idempotencyKey: e.idempotencyKey} : {})}});
177
+ process.exitCode = e.exit || 1;
178
+ });