@quantacenter/quanta-problem-cli 0.0.0-stage → 1.0.1
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/README.md +337 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4134 -0
- package/dist/index.js.map +1 -0
- package/dist/program.d.ts +134 -0
- package/dist/program.js +125 -0
- package/dist/program.js.map +1 -0
- package/package.json +60 -5
- package/templates/backend_service/README.md +55 -0
- package/templates/backend_service/answer/package.json +9 -0
- package/templates/backend_service/answer/pnpm-lock.yaml +9 -0
- package/templates/backend_service/answer/server.js +61 -0
- package/templates/backend_service/detail.md +30 -0
- package/templates/backend_service/gitignore +5 -0
- package/templates/backend_service/judge.js +43 -0
- package/templates/backend_service/problem.config.ts +37 -0
- package/templates/backend_service/template/package.json +9 -0
- package/templates/backend_service/template/pnpm-lock.yaml +9 -0
- package/templates/backend_service/template/server.js +28 -0
- package/templates/frontend/README.md +34 -0
- package/templates/frontend/answer/index.html +97 -0
- package/templates/frontend/gitignore +5 -0
- package/templates/frontend/judge.js +41 -0
- package/templates/frontend/problem.config.ts +34 -0
- package/templates/frontend/template/index.html +74 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** 与 packages/challenge-web-app/server/trpc/schemas/publish-schema.ts 保持一致 */
|
|
4
|
+
declare const DIFFICULTIES: readonly ["easy", "medium", "hard", "very_hard"];
|
|
5
|
+
type Difficulty = (typeof DIFFICULTIES)[number];
|
|
6
|
+
/** 默认站点根目录:live-server 镜像与 initCommand 都以 `project` 为根 */
|
|
7
|
+
declare const DEFAULT_JUDGE_UPLOAD_PATH = "project";
|
|
8
|
+
declare const DEFAULT_INIT_COMMAND = "npx serve -l 3000 project";
|
|
9
|
+
declare const problemConfigSchema: z.ZodObject<{
|
|
10
|
+
title: z.ZodString;
|
|
11
|
+
problemId: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
13
|
+
frontend: "frontend";
|
|
14
|
+
backend_service: "backend_service";
|
|
15
|
+
}>>;
|
|
16
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
17
|
+
detailFile: z.ZodOptional<z.ZodString>;
|
|
18
|
+
difficulty: z.ZodEnum<{
|
|
19
|
+
easy: "easy";
|
|
20
|
+
medium: "medium";
|
|
21
|
+
hard: "hard";
|
|
22
|
+
very_hard: "very_hard";
|
|
23
|
+
}>;
|
|
24
|
+
tagIds: z.ZodArray<z.ZodNumber>;
|
|
25
|
+
totalScore: z.ZodOptional<z.ZodNumber>;
|
|
26
|
+
cover: z.ZodDefault<z.ZodUnion<readonly [z.ZodObject<{
|
|
27
|
+
mode: z.ZodLiteral<"default">;
|
|
28
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
29
|
+
mode: z.ZodLiteral<"custom">;
|
|
30
|
+
imageId: z.ZodOptional<z.ZodString>;
|
|
31
|
+
generate: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
bg: z.ZodDefault<z.ZodString>;
|
|
33
|
+
bgTo: z.ZodOptional<z.ZodString>;
|
|
34
|
+
accent: z.ZodDefault<z.ZodString>;
|
|
35
|
+
text: z.ZodDefault<z.ZodString>;
|
|
36
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
37
|
+
footer: z.ZodOptional<z.ZodString>;
|
|
38
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
out: z.ZodDefault<z.ZodString>;
|
|
41
|
+
}, z.core.$strip>>;
|
|
42
|
+
}, z.core.$strip>]>>;
|
|
43
|
+
runtime: z.ZodDefault<z.ZodObject<{
|
|
44
|
+
judgeUploadPath: z.ZodDefault<z.ZodString>;
|
|
45
|
+
initCommand: z.ZodDefault<z.ZodString>;
|
|
46
|
+
buildCommand: z.ZodOptional<z.ZodString>;
|
|
47
|
+
bootCommand: z.ZodOptional<z.ZodString>;
|
|
48
|
+
serviceImage: z.ZodOptional<z.ZodEnum<{
|
|
49
|
+
"node-24": "node-24";
|
|
50
|
+
}>>;
|
|
51
|
+
servicePort: z.ZodDefault<z.ZodNumber>;
|
|
52
|
+
}, z.core.$strip>>;
|
|
53
|
+
paths: z.ZodDefault<z.ZodObject<{
|
|
54
|
+
template: z.ZodDefault<z.ZodString>;
|
|
55
|
+
answer: z.ZodDefault<z.ZodString>;
|
|
56
|
+
judge: z.ZodDefault<z.ZodString>;
|
|
57
|
+
}, z.core.$strip>>;
|
|
58
|
+
}, z.core.$strip>;
|
|
59
|
+
type ProblemConfigInput = z.input<typeof problemConfigSchema>;
|
|
60
|
+
type ProblemConfig = z.output<typeof problemConfigSchema>;
|
|
61
|
+
|
|
62
|
+
/** 判题脚本的静态分析:把常见坑(检查点不 return、点击 disabled 按钮、断言消息缺信息)在本地毫秒级挡住。策略是先把注释/字符串"打码"成等长 mask,再做括号配对与调用点查找,避免被字符串里的括号带偏。 */
|
|
63
|
+
interface Checkpoint {
|
|
64
|
+
name: string;
|
|
65
|
+
score: number;
|
|
66
|
+
/** 1-based 行号 */
|
|
67
|
+
line: number;
|
|
68
|
+
/** 是否 return 了具体分数(return; / 无 return 都是 false) */
|
|
69
|
+
returnsValue: boolean;
|
|
70
|
+
}
|
|
71
|
+
interface Located<T> {
|
|
72
|
+
line: number;
|
|
73
|
+
value: T;
|
|
74
|
+
}
|
|
75
|
+
interface JudgeScriptAnalysis {
|
|
76
|
+
/** 是否写了 `export default ...`(判题机靠文本替换 `export default ` → `const run = `) */
|
|
77
|
+
hasDefaultExport: boolean;
|
|
78
|
+
hasDefineTestHandler: boolean;
|
|
79
|
+
/** 除 default 之外的导出,会残留在沙箱模块里,属于可疑写法 */
|
|
80
|
+
namedExports: string[];
|
|
81
|
+
checkpoints: Checkpoint[];
|
|
82
|
+
/** 各检查点分值之和(解析不到的一律计 0 并单独报告) */
|
|
83
|
+
checkpointTotal: number;
|
|
84
|
+
unparsedCheckpoints: Located<string>[];
|
|
85
|
+
clickWithoutTimeout: Located<string>[];
|
|
86
|
+
/** 断言消息缺少"期望 vs 实际"(学生看不懂失败原因) */
|
|
87
|
+
vagueExpectations: Located<string>[];
|
|
88
|
+
expectationCount: number;
|
|
89
|
+
lineCount: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Severity = 'error' | 'warn' | 'info';
|
|
93
|
+
interface Finding {
|
|
94
|
+
/** 规则编号,可与 docs/RULES.md 对照;CI 里可以按编号豁免 */
|
|
95
|
+
rule: string;
|
|
96
|
+
severity: Severity;
|
|
97
|
+
message: string;
|
|
98
|
+
/** 相对题目目录的路径 */
|
|
99
|
+
file?: string;
|
|
100
|
+
line?: number;
|
|
101
|
+
hint?: string;
|
|
102
|
+
}
|
|
103
|
+
interface ValidationReport {
|
|
104
|
+
findings: Finding[];
|
|
105
|
+
errors: number;
|
|
106
|
+
warnings: number;
|
|
107
|
+
/** 无 error 即视为通过(warning 默认不阻塞,--strict 时才阻塞) */
|
|
108
|
+
ok: boolean;
|
|
109
|
+
totalScore: number;
|
|
110
|
+
stats: {
|
|
111
|
+
checkpointCount: number;
|
|
112
|
+
judgeLines: number;
|
|
113
|
+
template: ArtifactStats;
|
|
114
|
+
answer: ArtifactStats;
|
|
115
|
+
};
|
|
116
|
+
analysis: JudgeScriptAnalysis;
|
|
117
|
+
templateSnapshot: Record<string, string>;
|
|
118
|
+
answerSnapshot: Record<string, string>;
|
|
119
|
+
}
|
|
120
|
+
interface ArtifactStats {
|
|
121
|
+
fileCount: number;
|
|
122
|
+
totalBytes: number;
|
|
123
|
+
ignored: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
declare const CLI_NAME = "qpc";
|
|
127
|
+
declare const CLI_VERSION: string;
|
|
128
|
+
|
|
129
|
+
/** 包的程序化入口(exports/main 指向这里):零副作用,与 bin 入口分开以避免 jiti 加载配置时把 CLI 跑一遍;只依赖 problem-config-schema(纯 zod)。 */
|
|
130
|
+
|
|
131
|
+
/** 出题配置的类型助手(运行时恒等函数);返回值刻意声明为 ProblemConfigInput 而非泛型 T,让拼错的字段名触发多余属性检查。 */
|
|
132
|
+
declare const defineProblemConfig: (config: ProblemConfigInput) => ProblemConfigInput;
|
|
133
|
+
|
|
134
|
+
export { CLI_NAME, CLI_VERSION, DEFAULT_INIT_COMMAND, DEFAULT_JUDGE_UPLOAD_PATH, DIFFICULTIES, type Difficulty, type Finding, type ProblemConfig, type ProblemConfigInput, type Severity, type ValidationReport, defineProblemConfig, problemConfigSchema };
|
package/dist/program.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
// ../shared/service/backend-service-image.ts
|
|
5
|
+
var PNPM_VERSION = "9.15.9";
|
|
6
|
+
var BACKEND_SERVICE_IMAGES = {
|
|
7
|
+
"node-24": {
|
|
8
|
+
id: "node-24",
|
|
9
|
+
label: "Node.js 24",
|
|
10
|
+
image: "challenge-backend-service-agent",
|
|
11
|
+
editorBootCommand: (mountPath) => [
|
|
12
|
+
`[install-pnpm] npm install --global pnpm@${PNPM_VERSION}`,
|
|
13
|
+
`[install] pnpm --dir ./${mountPath} install`
|
|
14
|
+
].join("\n"),
|
|
15
|
+
// 入口由题目的 package.json 决定;npm 在没有 scripts.start 时回退到 node server.js。
|
|
16
|
+
initCommand: (mountPath) => `[dev-server] cd ./${mountPath} && npm start`,
|
|
17
|
+
defaultPort: 3e3
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var BACKEND_SERVICE_IMAGE_IDS = Object.keys(
|
|
21
|
+
BACKEND_SERVICE_IMAGES
|
|
22
|
+
);
|
|
23
|
+
Object.values(
|
|
24
|
+
BACKEND_SERVICE_IMAGES
|
|
25
|
+
).map((item) => item.image);
|
|
26
|
+
|
|
27
|
+
// src/domain/cover.ts
|
|
28
|
+
var COVER_DEFAULTS = {
|
|
29
|
+
bg: "#0F172A",
|
|
30
|
+
accent: "#A6FB1D",
|
|
31
|
+
text: "#F8FAFC",
|
|
32
|
+
width: 1200,
|
|
33
|
+
height: 675
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/domain/problem-config-schema.ts
|
|
37
|
+
var DIFFICULTIES = ["easy", "medium", "hard", "very_hard"];
|
|
38
|
+
var DEFAULT_JUDGE_UPLOAD_PATH = "project";
|
|
39
|
+
var DEFAULT_INIT_COMMAND = "npx serve -l 3000 project";
|
|
40
|
+
var PROBLEM_TYPES = ["frontend", "backend_service"];
|
|
41
|
+
var coverGenerateSchema = z.object({
|
|
42
|
+
/** 背景色(纯色;配合 bgTo 变成对角线渐变) */
|
|
43
|
+
bg: z.string().default(COVER_DEFAULTS.bg),
|
|
44
|
+
/** 渐变终止色,留空 = 纯色背景 */
|
|
45
|
+
bgTo: z.string().optional(),
|
|
46
|
+
/** 强调色:左侧竖条 + 副标题 */
|
|
47
|
+
accent: z.string().default(COVER_DEFAULTS.accent),
|
|
48
|
+
/** 主标题颜色 */
|
|
49
|
+
text: z.string().default(COVER_DEFAULTS.text),
|
|
50
|
+
/** 副标题(建议写接口清单或一句话简介),留空则不渲染 */
|
|
51
|
+
subtitle: z.string().optional(),
|
|
52
|
+
/** 左下角页脚,默认 “Quanta Challenge” */
|
|
53
|
+
footer: z.string().optional(),
|
|
54
|
+
width: z.number().int().min(200).max(4096).default(COVER_DEFAULTS.width),
|
|
55
|
+
height: z.number().int().min(200).max(4096).default(COVER_DEFAULTS.height),
|
|
56
|
+
/** 输出文件(相对题目目录) */
|
|
57
|
+
out: z.string().default("cover.png")
|
|
58
|
+
});
|
|
59
|
+
var coverSchema = z.union([
|
|
60
|
+
z.object({ mode: z.literal("default") }),
|
|
61
|
+
z.object({
|
|
62
|
+
mode: z.literal("custom"),
|
|
63
|
+
/** 平台上的图片 id;`qpc cover --upload --write` 会把生成结果写回这里 */
|
|
64
|
+
imageId: z.string().min(1).optional(),
|
|
65
|
+
/** 配置了就能用 `qpc cover` 一键生成(后端题型必须自定义封面) */
|
|
66
|
+
generate: coverGenerateSchema.optional()
|
|
67
|
+
})
|
|
68
|
+
]);
|
|
69
|
+
var problemConfigSchema = z.object({
|
|
70
|
+
title: z.string().min(1, "title \u4E0D\u80FD\u4E3A\u7A7A"),
|
|
71
|
+
/** 平台题目 id(qpc upload 返回的 pid):填了它就在该题目下追加新版本,否则每次新建 */
|
|
72
|
+
problemId: z.number().int().positive().optional(),
|
|
73
|
+
/** 题型;缺省为前端,老题目不需要改配置 */
|
|
74
|
+
type: z.enum(PROBLEM_TYPES).default("frontend"),
|
|
75
|
+
/** 题面正文;与 detailFile 二选一 */
|
|
76
|
+
detail: z.string().min(1).optional(),
|
|
77
|
+
/** 题面文件(相对题目目录),便于把长题面单独放一个 markdown */
|
|
78
|
+
detailFile: z.string().min(1).optional(),
|
|
79
|
+
difficulty: z.enum(DIFFICULTIES),
|
|
80
|
+
tagIds: z.array(z.number().int().positive()).min(1, "tagIds \u81F3\u5C11\u9700\u8981\u4E00\u4E2A\u6807\u7B7E\uFF08\u7528 `qpc tag` \u67E5\u770B\u53EF\u7528 id\uFF09"),
|
|
81
|
+
/** 留空表示"以判题脚本里各检查点之和为准";填写则必须与之和相等 */
|
|
82
|
+
totalScore: z.number().int().positive().optional(),
|
|
83
|
+
cover: coverSchema.default({ mode: "default" }),
|
|
84
|
+
runtime: z.object({
|
|
85
|
+
/** 判题时打包上传的目录,同时决定快照挂载路径(快照键为 `/<judgeUploadPath>/...`) */
|
|
86
|
+
judgeUploadPath: z.string().min(1).default(DEFAULT_JUDGE_UPLOAD_PATH),
|
|
87
|
+
initCommand: z.string().min(1).default(DEFAULT_INIT_COMMAND),
|
|
88
|
+
buildCommand: z.string().min(1).optional(),
|
|
89
|
+
bootCommand: z.string().min(1).optional(),
|
|
90
|
+
/** 后端服务:初始镜像 id(不是 docker 镜像名,取值见 @challenge/shared/backend-service-image) */
|
|
91
|
+
serviceImage: z.enum(BACKEND_SERVICE_IMAGE_IDS).optional(),
|
|
92
|
+
/** 后端服务:服务监听端口(也是判题机请求的端口) */
|
|
93
|
+
servicePort: z.number().int().positive().max(65535).default(3e3)
|
|
94
|
+
}).default({
|
|
95
|
+
judgeUploadPath: DEFAULT_JUDGE_UPLOAD_PATH,
|
|
96
|
+
initCommand: DEFAULT_INIT_COMMAND,
|
|
97
|
+
servicePort: 3e3
|
|
98
|
+
}),
|
|
99
|
+
paths: z.object({
|
|
100
|
+
template: z.string().min(1).default("template"),
|
|
101
|
+
answer: z.string().min(1).default("answer"),
|
|
102
|
+
judge: z.string().min(1).default("judge.js")
|
|
103
|
+
}).default({ template: "template", answer: "answer", judge: "judge.js" })
|
|
104
|
+
});
|
|
105
|
+
var CLI_NAME = "qpc";
|
|
106
|
+
var readVersion = () => {
|
|
107
|
+
try {
|
|
108
|
+
const raw = readFileSync(
|
|
109
|
+
new URL("../package.json", import.meta.url),
|
|
110
|
+
"utf8"
|
|
111
|
+
);
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
return parsed.version ?? "0.0.0";
|
|
114
|
+
} catch {
|
|
115
|
+
return "0.0.0";
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var CLI_VERSION = readVersion();
|
|
119
|
+
|
|
120
|
+
// src/program.ts
|
|
121
|
+
var defineProblemConfig = (config) => config;
|
|
122
|
+
|
|
123
|
+
export { CLI_NAME, CLI_VERSION, DEFAULT_INIT_COMMAND, DEFAULT_JUDGE_UPLOAD_PATH, DIFFICULTIES, defineProblemConfig, problemConfigSchema };
|
|
124
|
+
//# sourceMappingURL=program.js.map
|
|
125
|
+
//# sourceMappingURL=program.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../shared/service/backend-service-image.ts","../src/domain/cover.ts","../src/domain/problem-config-schema.ts","../src/version.ts","../src/program.ts"],"names":[],"mappings":"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","file":"program.js","sourcesContent":["export interface BackendServiceImage {\n /** 存进数据库的稳定 id,也是下拉选项的 value(不要改,改了要写数据迁移) */\n id: string;\n /** 展示名,带版本号 */\n label: string;\n /** 真正拉起容器的 docker 镜像名 */\n image: string;\n /** 编辑器(WebContainer)里的一次性准备步骤,`mountPath` = 题目的 judgeUploadPath */\n editorBootCommand: (mountPath: string) => string;\n /** 服务启动命令,编辑器与判题容器共用 */\n initCommand: (mountPath: string) => string;\n defaultPort: number;\n}\n\nconst PNPM_VERSION = '9.15.9';\n\nexport const BACKEND_SERVICE_IMAGES = {\n 'node-24': {\n id: 'node-24',\n label: 'Node.js 24',\n image: 'challenge-backend-service-agent',\n editorBootCommand: (mountPath: string) =>\n [\n `[install-pnpm] npm install --global pnpm@${PNPM_VERSION}`,\n `[install] pnpm --dir ./${mountPath} install`,\n ].join('\\n'),\n // 入口由题目的 package.json 决定;npm 在没有 scripts.start 时回退到 node server.js。\n initCommand: (mountPath: string) =>\n `[dev-server] cd ./${mountPath} && npm start`,\n defaultPort: 3000,\n },\n} as const satisfies Record<string, BackendServiceImage>;\n\nexport type BackendServiceImageId = keyof typeof BACKEND_SERVICE_IMAGES;\n\nexport const BACKEND_SERVICE_IMAGE_IDS = Object.keys(\n BACKEND_SERVICE_IMAGES\n) as BackendServiceImageId[];\n\nexport const DEFAULT_BACKEND_SERVICE_IMAGE_ID: BackendServiceImageId =\n 'node-24';\n\nconst isBackendServiceImageId = (\n value: unknown\n): value is BackendServiceImageId =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(BACKEND_SERVICE_IMAGES, value);\n\n/** 只认注册表 id:空值 / 未知值一律收敛成默认镜像。 */\nexport const normalizeBackendServiceImageId = (\n id?: string | null\n): BackendServiceImageId =>\n isBackendServiceImageId(id) ? id : DEFAULT_BACKEND_SERVICE_IMAGE_ID;\n\nconst getBackendServiceImage = (\n id?: string | null\n): BackendServiceImage =>\n BACKEND_SERVICE_IMAGES[normalizeBackendServiceImageId(id)];\n\n/** 库里的 id → 真正拉起的 docker 镜像名。 */\nexport const resolveBackendServiceImageName = (id?: string | null): string =>\n getBackendServiceImage(id).image;\n\nexport const buildBackendBootCommand = (\n id: string | null | undefined,\n mountPath: string\n): string => getBackendServiceImage(id).editorBootCommand(mountPath);\n\nexport const buildBackendInitCommand = (\n id: string | null | undefined,\n mountPath: string\n): string => getBackendServiceImage(id).initCommand(mountPath);\n\nexport const getBackendServiceDefaultPort = (id?: string | null): number =>\n getBackendServiceImage(id).defaultPort;\n\n/** 所有后端题容器镜像(调度器清扫器用来回收泄漏容器)。 */\nexport const BACKEND_SERVICE_CONTAINER_IMAGES = Object.values(\n BACKEND_SERVICE_IMAGES\n).map((item) => item.image);\n","import { UsageError } from '../core/errors';\n\nexport interface CoverSpec {\n /** 主标题(必填)。过长会自动换行并缩小字号 */\n title: string;\n /** 强调色副标题,例如接口清单或一句话简介 */\n subtitle?: string;\n /** 左下角页脚,默认 \"Quanta Challenge\" */\n footer?: string;\n /** 背景色(`#rgb` / `#rrggbb`) */\n bg: string;\n /** 渐变终止色;给了就画对角线渐变,否则纯色 */\n bgTo?: string;\n /** 强调色:竖条 + 副标题 */\n accent: string;\n /** 主标题颜色 */\n text: string;\n /** 画布宽高(像素) */\n width: number;\n height: number;\n}\n\nexport const COVER_DEFAULTS = {\n bg: '#0F172A',\n accent: '#A6FB1D',\n text: '#F8FAFC',\n footer: 'Quanta Challenge',\n width: 1200,\n height: 675,\n} as const;\n\n/** 手写那版封面的渐变终止色,作为 `--bg-to` 的推荐值写进文档 */\nexport const COVER_SUGGESTED_BG_TO = '#14432A';\n\nconst HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;\n\nexport const isHexColor = (value: string): boolean => HEX_COLOR.test(value);\n\nexport const assertHexColor = (value: string, field: string): string => {\n if (!isHexColor(value)) {\n throw new UsageError(\n `${field} 必须是十六进制颜色(#rgb / #rrggbb),当前为 \"${value}\"`,\n { hint: '例如 --bg \"#0F172A\" --accent \"#A6FB1D\"。' },\n );\n }\n return value;\n};\n\nconst XML_ESCAPES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": ''',\n};\n\nexport const escapeXml = (value: string): string =>\n value.replace(/[&<>\"']/g, (char) => XML_ESCAPES[char] ?? char);\n\n/** 全角/宽字符判定(与 logger 的显示宽度判定同源,用于估算排版宽度) */\nconst isWideChar = (code: number): boolean =>\n (code >= 0x1100 && code <= 0x115f) ||\n (code >= 0x2e80 && code <= 0xa4cf) ||\n (code >= 0xac00 && code <= 0xd7a3) ||\n (code >= 0xf900 && code <= 0xfaff) ||\n (code >= 0xfe30 && code <= 0xfe6f) ||\n (code >= 0xff00 && code <= 0xff60) ||\n (code >= 0xffe0 && code <= 0xffe6);\n\n/** 单字符宽度,单位是 em(相对当前字号) */\nconst charWidthEm = (char: string, bold: boolean): number => {\n const code = char.codePointAt(0) ?? 0;\n if (isWideChar(code)) return 1;\n if (char === ' ') return 0.28;\n if (\"ilj.,:;'|!\".includes(char)) return 0.3;\n if (/[A-Z]/.test(char)) return bold ? 0.68 : 0.66;\n if (/[0-9]/.test(char)) return 0.58;\n return bold ? 0.6 : 0.56;\n};\n\nconst textWidthEm = (text: string, bold: boolean): number =>\n [...text].reduce((sum, char) => sum + charWidthEm(char, bold), 0);\n\n/** 可以在其后断行的位置:空格之后,或任意宽字符之后(中文没有空格) */\nconst isBreakAfter = (char: string): boolean =>\n char === ' ' || isWideChar(char.codePointAt(0) ?? 0);\n\nexport const wrapByWidth = (\n text: string,\n maxWidthEm: number,\n bold = true,\n): string[] => {\n const lines: string[] = [];\n let current = '';\n let currentEm = 0;\n let breakAt = -1;\n let breakEm = 0;\n\n const flush = (at: number, em: number) => {\n const line = (at >= 0 ? current.slice(0, at) : current).trim();\n if (line) lines.push(line);\n current = at >= 0 ? current.slice(at).trimStart() : '';\n currentEm = at >= 0 ? currentEm - em : 0;\n breakAt = -1;\n breakEm = 0;\n };\n\n for (const char of text) {\n const width = charWidthEm(char, bold);\n if (currentEm + width > maxWidthEm && current) {\n // 只有断点足够靠后(>40% 行宽)才用它,否则宁可硬切,避免出现极短的尾行\n if (breakAt > 0 && breakEm > maxWidthEm * 0.4) {\n flush(breakAt, breakEm);\n } else {\n flush(-1, currentEm);\n }\n }\n current += char;\n currentEm += width;\n if (isBreakAfter(char)) {\n breakAt = current.length;\n breakEm = currentEm;\n }\n }\n if (current.trim()) lines.push(current.trim());\n return lines.length > 0 ? lines : [''];\n};\n\nexport interface CoverLayout {\n lines: string[];\n fontSize: number;\n lineHeight: number;\n barX: number;\n barY: number;\n barWidth: number;\n barHeight: number;\n textX: number;\n firstBaseline: number;\n subtitleBaseline: number;\n subtitleFontSize: number;\n footerBaseline: number;\n footerFontSize: number;\n}\n\n/** 字号从基准值往下收敛直到能塞进 maxLines 行,下限 0.5 × 基准(再小不可读,宁可标题多占一行)。 */\nexport const layoutCover = (spec: CoverSpec): CoverLayout => {\n const scale = spec.width / COVER_DEFAULTS.width;\n const pad = 72 * scale;\n const barWidth = 6 * scale;\n const textX = pad + barWidth + 26 * scale;\n const contentWidth = spec.width - textX - pad;\n const baseFontSize = 58 * scale;\n const minFontSize = Math.max(20, baseFontSize * 0.5);\n const maxLines = 3;\n\n let fontSize = baseFontSize;\n let lines = wrapByWidth(spec.title, contentWidth / fontSize, true);\n for (let size = baseFontSize; size >= minFontSize; size -= 2) {\n const candidate = wrapByWidth(spec.title, contentWidth / size, true);\n const fits = candidate.every(\n (line) => textWidthEm(line, true) * size <= contentWidth + 0.5,\n );\n if (fits && candidate.length <= maxLines) {\n fontSize = size;\n lines = candidate;\n break;\n }\n fontSize = size;\n lines = candidate;\n }\n\n const lineHeight = fontSize * 1.22;\n const blockTop = pad + 24 * scale;\n const textBaseline = blockTop + fontSize;\n const barHeight = lineHeight * (lines.length - 1) + fontSize * 1.02;\n const subtitleFontSize = 26 * scale;\n const subtitleBaseline =\n blockTop + lineHeight * (lines.length - 1) + fontSize * 0.62 + 72 * scale;\n\n return {\n lines,\n fontSize,\n lineHeight,\n barX: pad,\n barY: blockTop + fontSize * 0.06,\n barWidth,\n barHeight,\n textX,\n firstBaseline: textBaseline,\n subtitleBaseline,\n subtitleFontSize,\n footerBaseline: spec.height - 60 * scale,\n footerFontSize: 24 * scale,\n };\n};\n\nconst fontStack = (mono: boolean): string =>\n mono\n ? \"'Menlo','Monaco','Courier New',monospace\"\n : \"'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans CJK SC','Source Han Sans SC',sans-serif\";\n\nexport const buildCoverSvg = (spec: CoverSpec): string => {\n assertHexColor(spec.bg, '背景色');\n assertHexColor(spec.accent, '强调色');\n assertHexColor(spec.text, '标题颜色');\n if (spec.bgTo !== undefined) assertHexColor(spec.bgTo, '渐变终止色');\n\n const layout = layoutCover(spec);\n const background =\n spec.bgTo === undefined\n ? `<rect width=\"${spec.width}\" height=\"${spec.height}\" fill=\"${spec.bg}\"/>`\n : `<defs><linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">` +\n `<stop offset=\"0%\" stop-color=\"${spec.bg}\"/>` +\n `<stop offset=\"100%\" stop-color=\"${spec.bgTo}\"/></linearGradient></defs>` +\n `<rect width=\"${spec.width}\" height=\"${spec.height}\" fill=\"url(#bg)\"/>`;\n\n const titleLines = layout.lines\n .map(\n (line, index) =>\n `<text x=\"${layout.textX}\" y=\"${round(layout.firstBaseline + index * layout.lineHeight)}\" ` +\n `font-family=\"${fontStack(false)}\" font-size=\"${round(layout.fontSize)}\" ` +\n `font-weight=\"700\" fill=\"${spec.text}\">${escapeXml(line)}</text>`,\n )\n .join('\\n');\n\n const subtitle = spec.subtitle\n ? `<text x=\"${layout.textX}\" y=\"${round(layout.subtitleBaseline)}\" ` +\n `font-family=\"${fontStack(true)}\" font-size=\"${round(layout.subtitleFontSize)}\" ` +\n `font-weight=\"700\" fill=\"${spec.accent}\">${escapeXml(spec.subtitle)}</text>`\n : '';\n\n const footer =\n `<text x=\"${layout.barX}\" y=\"${round(layout.footerBaseline)}\" ` +\n `font-family=\"${fontStack(false)}\" font-size=\"${round(layout.footerFontSize)}\" ` +\n `fill=\"#64748B\">${escapeXml(spec.footer ?? COVER_DEFAULTS.footer)}</text>`;\n\n return [\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${spec.width}\" height=\"${spec.height}\" viewBox=\"0 0 ${spec.width} ${spec.height}\">`,\n background,\n `<rect x=\"${layout.barX}\" y=\"${round(layout.barY)}\" width=\"${layout.barWidth}\" height=\"${round(layout.barHeight)}\" rx=\"${layout.barWidth / 2}\" fill=\"${spec.accent}\"/>`,\n titleLines,\n subtitle,\n footer,\n '</svg>',\n ]\n .filter(Boolean)\n .join('\\n');\n};\n\nconst round = (value: number): number => Math.round(value * 100) / 100;\n","import {\n BACKEND_SERVICE_IMAGE_IDS,\n buildBackendBootCommand,\n buildBackendInitCommand,\n getBackendServiceDefaultPort,\n normalizeBackendServiceImageId,\n} from '@challenge/shared/backend-service-image';\nimport { z } from 'zod';\n\nimport { COVER_DEFAULTS } from './cover';\n\n/** 与 packages/challenge-web-app/server/trpc/schemas/publish-schema.ts 保持一致 */\nexport const DIFFICULTIES = ['easy', 'medium', 'hard', 'very_hard'] as const;\nexport type Difficulty = (typeof DIFFICULTIES)[number];\n\nexport const DEFAULT_CONFIG_FILES = [\n 'problem.config.ts',\n 'problem.config.mts',\n 'problem.config.js',\n 'problem.config.mjs',\n 'problem.config.json',\n] as const;\n\n/** 默认站点根目录:live-server 镜像与 initCommand 都以 `project` 为根 */\nexport const DEFAULT_JUDGE_UPLOAD_PATH = 'project';\nexport const DEFAULT_INIT_COMMAND = 'npx serve -l 3000 project';\n\n/** 题型:前端(静态站点 + Playwright)或后端服务(常驻进程 + HTTP 判题) */\nexport const PROBLEM_TYPES = ['frontend', 'backend_service'] as const;\nexport type ProblemType = (typeof PROBLEM_TYPES)[number];\n\n/** 本地生成封面的配色与排版:不随上传发给服务端(服务端只认 imageId),`qpc cover` 渲染 PNG 后由 --write 写回 imageId。 */\nexport const coverGenerateSchema = z.object({\n /** 背景色(纯色;配合 bgTo 变成对角线渐变) */\n bg: z.string().default(COVER_DEFAULTS.bg),\n /** 渐变终止色,留空 = 纯色背景 */\n bgTo: z.string().optional(),\n /** 强调色:左侧竖条 + 副标题 */\n accent: z.string().default(COVER_DEFAULTS.accent),\n /** 主标题颜色 */\n text: z.string().default(COVER_DEFAULTS.text),\n /** 副标题(建议写接口清单或一句话简介),留空则不渲染 */\n subtitle: z.string().optional(),\n /** 左下角页脚,默认 “Quanta Challenge” */\n footer: z.string().optional(),\n width: z.number().int().min(200).max(4096).default(COVER_DEFAULTS.width),\n height: z.number().int().min(200).max(4096).default(COVER_DEFAULTS.height),\n /** 输出文件(相对题目目录) */\n out: z.string().default('cover.png'),\n});\nexport type CoverGenerateOptions = z.output<typeof coverGenerateSchema>;\n\nexport const coverSchema = z.union([\n z.object({ mode: z.literal('default') }),\n z.object({\n mode: z.literal('custom'),\n /** 平台上的图片 id;`qpc cover --upload --write` 会把生成结果写回这里 */\n imageId: z.string().min(1).optional(),\n /** 配置了就能用 `qpc cover` 一键生成(后端题型必须自定义封面) */\n generate: coverGenerateSchema.optional(),\n }),\n]);\n\nexport const problemConfigSchema = z.object({\n title: z.string().min(1, 'title 不能为空'),\n /** 平台题目 id(qpc upload 返回的 pid):填了它就在该题目下追加新版本,否则每次新建 */\n problemId: z.number().int().positive().optional(),\n /** 题型;缺省为前端,老题目不需要改配置 */\n type: z.enum(PROBLEM_TYPES).default('frontend'),\n /** 题面正文;与 detailFile 二选一 */\n detail: z.string().min(1).optional(),\n /** 题面文件(相对题目目录),便于把长题面单独放一个 markdown */\n detailFile: z.string().min(1).optional(),\n difficulty: z.enum(DIFFICULTIES),\n tagIds: z\n .array(z.number().int().positive())\n .min(1, 'tagIds 至少需要一个标签(用 `qpc tag` 查看可用 id)'),\n /** 留空表示\"以判题脚本里各检查点之和为准\";填写则必须与之和相等 */\n totalScore: z.number().int().positive().optional(),\n cover: coverSchema.default({ mode: 'default' }),\n runtime: z\n .object({\n /** 判题时打包上传的目录,同时决定快照挂载路径(快照键为 `/<judgeUploadPath>/...`) */\n judgeUploadPath: z.string().min(1).default(DEFAULT_JUDGE_UPLOAD_PATH),\n initCommand: z.string().min(1).default(DEFAULT_INIT_COMMAND),\n buildCommand: z.string().min(1).optional(),\n bootCommand: z.string().min(1).optional(),\n /** 后端服务:初始镜像 id(不是 docker 镜像名,取值见 @challenge/shared/backend-service-image) */\n serviceImage: z.enum(BACKEND_SERVICE_IMAGE_IDS).optional(),\n /** 后端服务:服务监听端口(也是判题机请求的端口) */\n servicePort: z.number().int().positive().max(65535).default(3000),\n })\n .default({\n judgeUploadPath: DEFAULT_JUDGE_UPLOAD_PATH,\n initCommand: DEFAULT_INIT_COMMAND,\n servicePort: 3000,\n }),\n paths: z\n .object({\n template: z.string().min(1).default('template'),\n answer: z.string().min(1).default('answer'),\n judge: z.string().min(1).default('judge.js'),\n })\n .default({ template: 'template', answer: 'answer', judge: 'judge.js' }),\n});\n\nexport type ProblemConfigInput = z.input<typeof problemConfigSchema>;\nexport type ProblemConfig = z.output<typeof problemConfigSchema>;\n\n/** 后端题的镜像与 bootCommand 由镜像决定(覆盖作者写的值),initCommand 仅缺省时填默认值;回报被覆盖字段供 `qpc check` 提示。 */\nexport const applyBackendServiceImageDefaults = (\n config: ProblemConfig,\n): { config: ProblemConfig; overridden: string[] } => {\n if (config.type !== 'backend_service') {\n return { config, overridden: [] };\n }\n const mountPath =\n config.runtime.judgeUploadPath || DEFAULT_JUDGE_UPLOAD_PATH;\n const serviceImage = normalizeBackendServiceImageId(\n config.runtime.serviceImage,\n );\n const bootCommand = buildBackendBootCommand(serviceImage, mountPath);\n const defaultInitCommand = buildBackendInitCommand(serviceImage, mountPath);\n\n const overridden: string[] = [];\n if (\n config.runtime.bootCommand &&\n config.runtime.bootCommand !== bootCommand\n ) {\n overridden.push('runtime.bootCommand');\n }\n // initCommand 的 schema 默认值是静态服务器命令(前端用),后端题没写时会落到它上面;\n // 只有这种情况才用镜像默认值替掉它,作者写过的保留。\n const initCommand =\n !config.runtime.initCommand ||\n config.runtime.initCommand === DEFAULT_INIT_COMMAND\n ? defaultInitCommand\n : config.runtime.initCommand;\n\n return {\n config: {\n ...config,\n runtime: {\n ...config.runtime,\n serviceImage,\n servicePort:\n config.runtime.servicePort ??\n getBackendServiceDefaultPort(serviceImage),\n bootCommand,\n initCommand,\n },\n },\n overridden,\n };\n};\n","import { readFileSync } from 'node:fs';\n\nexport const CLI_NAME = 'qpc';\nexport const CLI_DESCRIPTION = 'Quanta 出题工具:脚手架、离线预检、上传与发布';\n\nconst readVersion = (): string => {\n try {\n const raw = readFileSync(\n new URL('../package.json', import.meta.url),\n 'utf8',\n );\n const parsed = JSON.parse(raw) as { version?: string };\n return parsed.version ?? '0.0.0';\n } catch {\n return '0.0.0';\n }\n};\n\nexport const CLI_VERSION = readVersion();\n","/** 包的程序化入口(exports/main 指向这里):零副作用,与 bin 入口分开以避免 jiti 加载配置时把 CLI 跑一遍;只依赖 problem-config-schema(纯 zod)。 */\nimport type { ProblemConfigInput } from './domain/problem-config-schema';\n\nexport type {\n Difficulty,\n ProblemConfig,\n ProblemConfigInput,\n} from './domain/problem-config-schema';\nexport {\n DEFAULT_INIT_COMMAND,\n DEFAULT_JUDGE_UPLOAD_PATH,\n DIFFICULTIES,\n problemConfigSchema,\n} from './domain/problem-config-schema';\nexport type { Finding, Severity, ValidationReport } from './domain/validate';\nexport { CLI_NAME, CLI_VERSION } from './version';\n\n/** 出题配置的类型助手(运行时恒等函数);返回值刻意声明为 ProblemConfigInput 而非泛型 T,让拼错的字段名触发多余属性检查。 */\nexport const defineProblemConfig = (\n config: ProblemConfigInput,\n): ProblemConfigInput => config;\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@quantacenter/quanta-problem-cli",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Quanta 出题工具链:脚手架、离线预检、上传与发布(qpc)",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/QuantaCenter/quanta-challenge.git",
|
|
9
|
+
"directory": "packages/problem-creator-cli"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.11"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"qpc": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"main": "./dist/program.js",
|
|
22
|
+
"types": "./dist/program.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/program.d.ts",
|
|
26
|
+
"import": "./dist/program.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"templates",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"start": "node dist/index.js",
|
|
39
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.examples.json",
|
|
40
|
+
"lint": "biome check .",
|
|
41
|
+
"lint:fix": "biome check --write .",
|
|
42
|
+
"format": "biome format --write .",
|
|
43
|
+
"verify": "pnpm lint && pnpm typecheck && pnpm build"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@clack/prompts": "^1.8.1",
|
|
47
|
+
"commander": "^14.0.1",
|
|
48
|
+
"jiti": "^2.5.1",
|
|
49
|
+
"picocolors": "^1.1.1",
|
|
50
|
+
"sharp": "^0.34.3",
|
|
51
|
+
"zod": "^4.0.5"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "^2.5.15",
|
|
55
|
+
"@challenge/shared": "workspace:^",
|
|
56
|
+
"@types/node": "^24.0.14",
|
|
57
|
+
"tsup": "^8.5.1",
|
|
58
|
+
"tsx": "^4.7.1",
|
|
59
|
+
"typescript": "^5.8.3"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# {{title}}
|
|
2
|
+
|
|
3
|
+
本目录由 `qpc init --type backend_service` 生成:一道**后端服务题**的骨架。
|
|
4
|
+
判题机不发浏览器请求,而是把参考答案跑起来、向它发 HTTP 请求做断言。
|
|
5
|
+
|
|
6
|
+
## 目录结构
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
problem.config.ts 题目配置(题型、镜像、端口、启动命令)
|
|
10
|
+
detail.md 题面(Markdown,原样展示给学生)
|
|
11
|
+
judge.js 判题脚本(用 http 发请求、断言响应)
|
|
12
|
+
template/ 答案模板:学生起点(含 pnpm-lock.yaml)
|
|
13
|
+
answer/ 参考解:必须拿满分(含 pnpm-lock.yaml)
|
|
14
|
+
cover.png init 已按 cover.generate 渲染好的封面(还没上传)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 标准流程
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 0. 后端题必须自定义封面:生成 → 上传 → 把 imageId 写回配置
|
|
21
|
+
qpc cover --upload --write
|
|
22
|
+
|
|
23
|
+
# 1. 本地预检(毫秒级,先挡掉静态错误)
|
|
24
|
+
qpc check
|
|
25
|
+
|
|
26
|
+
# 2. 上传并等待审计(生成题目版本,审计约 20 秒)
|
|
27
|
+
qpc upload --wait
|
|
28
|
+
|
|
29
|
+
# 3. 审计通过后发布
|
|
30
|
+
qpc publish <problemId>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> 第 0 步之前 `qpc check` 会报 IMG001(custom 封面缺 imageId),这是预期的:
|
|
34
|
+
> 后端题没有首屏可截图,封面只能先生成再上传。
|
|
35
|
+
|
|
36
|
+
## 加依赖
|
|
37
|
+
|
|
38
|
+
`template/` 与 `answer/` 是两份独立工程(学生改 template,判分跑 answer)。
|
|
39
|
+
加了依赖要同步两边,并在**对应目录**重新生成锁文件——容器是断网离线安装,
|
|
40
|
+
没有锁文件或者锁文件与 package.json 对不上都会起不来:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd answer && pnpm add express # 同时更新 package.json 与 pnpm-lock.yaml
|
|
44
|
+
cd ../template && pnpm add express
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 需要人工确认的事(预检覆盖不到)
|
|
48
|
+
|
|
49
|
+
- [ ] 参考解真的能拿满分(看 `qpc upload --wait` 的检查点明细)
|
|
50
|
+
- [ ] 答案模板明显低分(证明判题能抓错)
|
|
51
|
+
- [ ] `initCommand` 指向的入口能启动,服务监听 `servicePort`(默认 3000)
|
|
52
|
+
- [ ] 封面好看(后端题用的是 `cover.png` 生成的图,不是截图)
|
|
53
|
+
|
|
54
|
+
完整链路与镜像约定见仓库 `docs/BACKEND_SERVICE_PROBLEM.md`;
|
|
55
|
+
出题踩坑清单见 `docs/PROBLEM_AUTHORING.md`。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
|
|
3
|
+
// 参考解:零依赖的 node:http 服务;改完在 answer/ 跑 pnpm install --lockfile-only,让锁文件与 package.json 一致。
|
|
4
|
+
const items = [];
|
|
5
|
+
let nextId = 1;
|
|
6
|
+
|
|
7
|
+
const sendJson = (res, status, body) => {
|
|
8
|
+
const payload = JSON.stringify(body);
|
|
9
|
+
res.writeHead(status, {
|
|
10
|
+
'content-type': 'application/json; charset=utf-8',
|
|
11
|
+
'content-length': Buffer.byteLength(payload),
|
|
12
|
+
});
|
|
13
|
+
res.end(payload);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const readJson = (req) =>
|
|
17
|
+
new Promise((resolve) => {
|
|
18
|
+
let raw = '';
|
|
19
|
+
req.on('data', (chunk) => {
|
|
20
|
+
raw += chunk;
|
|
21
|
+
});
|
|
22
|
+
req.on('end', () => {
|
|
23
|
+
try {
|
|
24
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
25
|
+
} catch {
|
|
26
|
+
resolve(undefined);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const server = createServer(async (req, res) => {
|
|
32
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
33
|
+
|
|
34
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
35
|
+
return sendJson(res, 200, { ok: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (req.method === 'GET' && url.pathname === '/items') {
|
|
39
|
+
return sendJson(res, 200, { items });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (req.method === 'POST' && url.pathname === '/items') {
|
|
43
|
+
const body = await readJson(req);
|
|
44
|
+
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
|
45
|
+
if (!title) {
|
|
46
|
+
return sendJson(res, 400, { error: 'title_required' });
|
|
47
|
+
}
|
|
48
|
+
const item = { id: nextId, title };
|
|
49
|
+
nextId += 1;
|
|
50
|
+
items.push(item);
|
|
51
|
+
return sendJson(res, 201, { item });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return sendJson(res, 404, { error: 'not_found' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 端口从环境变量注入:编辑器与判题容器都会传 PORT / QUANTA_PORT。
|
|
58
|
+
const port = Number(process.env.PORT || process.env.QUANTA_PORT || 3000);
|
|
59
|
+
server.listen(port, () => {
|
|
60
|
+
console.log('todo-api listening on ' + port);
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{title}}
|
|
2
|
+
|
|
3
|
+
用 Node.js 写一个**待办清单 HTTP API**:进程必须监听 **3000** 端口,启动后对外提供下面的接口。
|
|
4
|
+
|
|
5
|
+
> 本题不要求任何第三方依赖,用 Node 内置的 `node:http` 就够;想用 Express/Fastify
|
|
6
|
+
> 也行,记得同步 `template/` 与 `answer/` 的依赖和锁文件(见 README)。
|
|
7
|
+
|
|
8
|
+
## 接口要求
|
|
9
|
+
|
|
10
|
+
| 方法 | 路径 | 说明 |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| GET | `/health` | 返回 `200` 与 `{ "ok": true }` |
|
|
13
|
+
| GET | `/items` | 返回全部待办:`{ "items": [{ "id": 1, "title": "写题" }] }`,初始为空 |
|
|
14
|
+
| POST | `/items` | 新建待办,请求体 `{ "title": "写题" }` |
|
|
15
|
+
|
|
16
|
+
约定:
|
|
17
|
+
|
|
18
|
+
- `POST /items` 成功返回 **201**,响应体为 `{ "item": { "id": 1, "title": "写题" } }`,`id` 从 `1` 开始自增。
|
|
19
|
+
- `title` 为空或缺失时返回 **400**,且不创建。
|
|
20
|
+
- 请求与响应都是 JSON。
|
|
21
|
+
|
|
22
|
+
## 怎么自己验证
|
|
23
|
+
|
|
24
|
+
判题机不发浏览器请求,只用 HTTP 断言。你可以在预览面板的终端里自己 curl:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -s localhost:3000/health
|
|
28
|
+
curl -s -X POST localhost:3000/items -H 'content-type: application/json' -d '{"title":"写题"}'
|
|
29
|
+
curl -s localhost:3000/items
|
|
30
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 判题脚本必须写成 `export default defineTestHandler(...)`:判题机会把 "export default " 替换成 "const run = ";后端给的是 http,不是 page。
|
|
2
|
+
export default defineTestHandler(async ({ http, $ }) => {
|
|
3
|
+
$.defineCheckPoint('GET /health 返回 ok', 5, async () => {
|
|
4
|
+
const res = await http.get('/health');
|
|
5
|
+
$.expect(res.status === 200, '期望 /health 返回 200,实际为 ' + res.status);
|
|
6
|
+
$.expect(
|
|
7
|
+
res.json && res.json.ok === true,
|
|
8
|
+
'期望 body.ok 为 true,实际为 ' + JSON.stringify(res.json)
|
|
9
|
+
);
|
|
10
|
+
return 5; // 必须 return 本检查点得分:不返回即使断言全过也记 0 分
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
$.defineCheckPoint('POST /items 创建一条待办', 10, async () => {
|
|
14
|
+
// body 传对象时会自动 JSON.stringify 并带上 content-type
|
|
15
|
+
const res = await http.post('/items', { title: '写题' });
|
|
16
|
+
$.expect(res.status === 201, '期望创建返回 201,实际为 ' + res.status);
|
|
17
|
+
const item = res.json && res.json.item;
|
|
18
|
+
$.expect(
|
|
19
|
+
item !== undefined,
|
|
20
|
+
'期望响应体里有 item,实际为 ' + JSON.stringify(res.json)
|
|
21
|
+
);
|
|
22
|
+
$.expect(
|
|
23
|
+
item && item.title === '写题',
|
|
24
|
+
'期望 item.title 为 "写题",实际为 ' + (item && item.title)
|
|
25
|
+
);
|
|
26
|
+
return 10;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
$.defineCheckPoint('GET /items 能读到刚创建的一条', 5, async () => {
|
|
30
|
+
const res = await http.get('/items');
|
|
31
|
+
$.expect(res.status === 200, '期望 /items 返回 200,实际为 ' + res.status);
|
|
32
|
+
const items = res.json && res.json.items;
|
|
33
|
+
$.expect(
|
|
34
|
+
Array.isArray(items),
|
|
35
|
+
'期望 body.items 是数组,实际为 ' + typeof items
|
|
36
|
+
);
|
|
37
|
+
$.expect(
|
|
38
|
+
Array.isArray(items) && items.length === 1 && items[0].title === '写题',
|
|
39
|
+
'期望只有 1 条 title 为 "写题" 的待办,实际为 ' + JSON.stringify(items)
|
|
40
|
+
);
|
|
41
|
+
return 5;
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineProblemConfig } from '@challenge/problem-creator-cli';
|
|
2
|
+
|
|
3
|
+
// 后端服务题配置。字段语义与踩坑清单见 docs/BACKEND_SERVICE_PROBLEM.md
|
|
4
|
+
export default defineProblemConfig({
|
|
5
|
+
title: '{{title}}',
|
|
6
|
+
type: 'backend_service',
|
|
7
|
+
// 题面单独放一个 markdown:后端题通常要列接口清单,写在配置里太长
|
|
8
|
+
detailFile: 'detail.md',
|
|
9
|
+
difficulty: '{{difficulty}}',
|
|
10
|
+
tagIds: [{{tagIds}}], // TODO: 换成后端相关的标签 id
|
|
11
|
+
// 后端题没有首屏可截图:init 已渲染 cover.png,跑 qpc cover --upload --write 上传并把 imageId 写回这里(否则 IMG001 拦下)。
|
|
12
|
+
cover: {
|
|
13
|
+
mode: 'custom',
|
|
14
|
+
generate: {
|
|
15
|
+
bg: '{{coverBg}}',
|
|
16
|
+
bgTo: '{{coverBgTo}}',
|
|
17
|
+
accent: '{{coverAccent}}',
|
|
18
|
+
subtitle: '{{coverSubtitle}}',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
runtime: {
|
|
22
|
+
// 判题打包目录,同时决定快照键前缀(/project/...)
|
|
23
|
+
judgeUploadPath: '{{mountPath}}',
|
|
24
|
+
// 服务启动命令:编辑器与判题容器共用,入口写在 package.json 的 scripts.start
|
|
25
|
+
initCommand: '{{initCommand}}',
|
|
26
|
+
// 初始镜像(容器 + 语言运行时);bootCommand 由镜像决定,不用手写
|
|
27
|
+
serviceImage: '{{serviceImage}}',
|
|
28
|
+
servicePort: {{servicePort}},
|
|
29
|
+
// 没有构建步骤也要填一个立即退出的命令,否则编辑器点"提交"会空转
|
|
30
|
+
buildCommand: 'node -e ""',
|
|
31
|
+
},
|
|
32
|
+
paths: {
|
|
33
|
+
template: 'template',
|
|
34
|
+
answer: 'answer',
|
|
35
|
+
judge: 'judge.js',
|
|
36
|
+
},
|
|
37
|
+
});
|