From 998e6bb5ddf4a886892f83bc7bb173317e844a90 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 16:27:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=BB=E6=8E=89=20Judge0=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=EF=BC=8CSQL=20=E6=94=B9=E4=B8=BA=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=86=85=E6=89=A7=E8=A1=8C=EF=BC=8CC/C++=20=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E5=90=8E=E7=AB=AF=20go-judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL 用 sql.js 在 Web Worker 中执行,带 5 秒超时 - C/C++ 改为请求后端 /run,请求失败时显示错误而不是一直卡在运行中 - 删除 Judge0 相关代码和 PUBLIC_JUDGE0API_URL 配置 Co-Authored-By: Claude Opus 5.5 --- .env | 1 - .env.production | 1 - .env.staging | 1 - package-lock.json | 49 ++++++++++++++++++++-- package.json | 2 + src/api.ts | 80 +++++++++++++++++++----------------- src/composables/code.ts | 17 ++++---- src/composables/sqlTable.ts | 25 ++++++----- src/rsbuild-env.d.ts | 1 - src/templates.ts | 8 ---- src/types.ts | 14 ------- src/workers/sqlite.worker.ts | 73 ++++++++++++++++++++++++++++++++ 12 files changed, 185 insertions(+), 87 deletions(-) create mode 100644 src/workers/sqlite.worker.ts diff --git a/.env b/.env index a30894a..54edd9e 100644 --- a/.env +++ b/.env @@ -1,3 +1,2 @@ -PUBLIC_JUDGE0API_URL=https://judge0api.xuyue.cc PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=dd37457027c40b39 PUBLIC_CODEAPI_URL=http://localhost:8080 \ No newline at end of file diff --git a/.env.production b/.env.production index 19296b4..cabf0ad 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,3 @@ -PUBLIC_JUDGE0API_URL=https://judge0api.xuyue.cc PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=dd37457027c40b39 PUBLIC_CODEAPI_URL=https://code.xuyue.cc/api PUBLIC_ICONIFY_URL=https://icon.xuyue.cc \ No newline at end of file diff --git a/.env.staging b/.env.staging index 9e1fef4..3b3611a 100644 --- a/.env.staging +++ b/.env.staging @@ -1,4 +1,3 @@ -PUBLIC_JUDGE0API_URL=http://10.13.114.114:8082 PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39 PUBLIC_CODEAPI_URL=http://10.13.114.114:82/api PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e1549eb..a4f038f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "normalize.css": "^8.0.1", "query-string": "^9.3.1", "skulpt": "^1.2.0", + "sql.js": "^1.15.1", "vue": "^3.5.34", "vue-codemirror": "^6.1.1" }, @@ -32,6 +33,7 @@ "@rsbuild/core": "^2.0.5", "@rsbuild/plugin-vue": "^1.2.7", "@types/file-saver": "^2.0.7", + "@types/sql.js": "^1.4.11", "core-js": "^3.49.0", "prettier": "^3.8.3", "typescript": "^6.0.3" @@ -220,9 +222,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -634,6 +636,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/file-saver": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", @@ -656,6 +665,27 @@ "@types/lodash": "*" } }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -1539,6 +1569,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sql.js": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.15.1.tgz", + "integrity": "sha512-i0M/6s+lcl67Flm/1m8/AeVU5caDramlILXU/udUwpTQym2qKMSe2sJiXB5d5WXFB272aj4B2v1JbtQ93ID3Pg==", + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -1586,6 +1622,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/vdirs": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", diff --git a/package.json b/package.json index f552c6e..ea4f0af 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "normalize.css": "^8.0.1", "query-string": "^9.3.1", "skulpt": "^1.2.0", + "sql.js": "^1.15.1", "vue": "^3.5.34", "vue-codemirror": "^6.1.1" }, @@ -34,6 +35,7 @@ "@rsbuild/core": "^2.0.5", "@rsbuild/plugin-vue": "^1.2.7", "@types/file-saver": "^2.0.7", + "@types/sql.js": "^1.4.11", "core-js": "^3.49.0", "prettier": "^3.8.3", "typescript": "^6.0.3" diff --git a/src/api.ts b/src/api.ts index eabfe68..f3020f8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,21 +1,7 @@ import axios from "axios" -import { languageToId } from "./templates" -import { Code, LANGUAGE, Submission } from "./types" +import type { SqlJob } from "./composables/sqlTable" +import { Code, LANGUAGE } from "./types" -function encode(string?: string) { - return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? ""))) -} - -function decode(bytes?: string) { - const latin = atob(bytes ?? "") - return new TextDecoder("utf-8").decode( - Uint8Array.from({ length: latin.length }, (_, index) => - latin.charCodeAt(index), - ), - ) -} - -const judge = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0API_URL }) const api = axios.create({ baseURL: import.meta.env.PUBLIC_CODEAPI_URL }) type PythonWorkerRequest = { @@ -78,32 +64,52 @@ async function runPythonInWorker(source: string, stdin: string) { }) } +// SQL 在浏览器里用 sql.js 执行;每次新开一个 worker,跑完或超时就销毁 +export function runSql(job: SqlJob) { + return new Promise<{ status: number; output: string }>((resolve) => { + const worker = new Worker( + new URL("./workers/sqlite.worker.ts", import.meta.url), + { type: "module" }, + ) + let timeout = 0 + const finish = (result: { status: number; output: string }) => { + clearTimeout(timeout) + worker.terminate() + resolve(result) + } + worker.onerror = () => finish({ status: 11, output: "SQL 引擎加载失败" }) + worker.onmessage = (event) => { + // 首次加载 wasm 不计入超时,引擎就绪后再提交并开始计时 + if (event.data?.ready) { + timeout = window.setTimeout( + () => finish({ status: 11, output: "运行超时" }), + 5000, + ) + worker.postMessage(job) + return + } + finish(event.data) + } + }) +} + export async function submit(code: Code, input: string) { if (code.language === "python") { return runPythonInWorker(code.value, input) } - const encodedCode = encode(code.value) - - const id = languageToId[code.language] - let compilerOptions = "" - if (id === 50) compilerOptions = "-lm" // 解决 GCC 的链接问题 - const payload = { - source_code: encodedCode, - language_id: id, - stdin: encode(input), - redirect_stderr_to_stdout: true, - compiler_options: compilerOptions, - } - const response = await judge.post("/submissions", payload, { - params: { base64_encoded: true, wait: true }, - }) - const data = response.data - return { - status: data.status && data.status.id, - output: [decode(data.compile_output), decode(data.stdout)] - .join("\n") - .trim(), + try { + const res = await api.post<{ status: number; output: string }>("/run", { + code: code.value, + language: code.language, + stdin: input, + }) + return res.data + } catch (err: any) { + return { + status: 11, + output: err?.response?.data?.detail ?? "运行服务不可用,请稍后再试", + } } } diff --git a/src/composables/code.ts b/src/composables/code.ts index 252f608..21dd53d 100644 --- a/src/composables/code.ts +++ b/src/composables/code.ts @@ -2,14 +2,14 @@ import { useStorage } from "@vueuse/core" import copyTextToClipboard from "copy-text-to-clipboard" import qs from "query-string" import { nextTick, reactive, ref, watch } from "vue" -import { formatCode, getCodeByQuery, submit } from "../api" +import { formatCode, getCodeByQuery, runSql, submit } from "../api" import { sources } from "../templates" import { Cache, Code, LANGUAGE, Status } from "../types" import { atou, utoa } from "../utils" import { isMobile } from "./breakpoints" import { dialog, notice } from "./notice" import { - buildSqlScript, + buildSqlJob, resetSqlTableSelection, selectSqlTable, selectedTableId, @@ -177,12 +177,13 @@ export async function run() { } else { output.value = "" status.value = Status.NotStarted - const sourceCode = - code.language === "sql" ? buildSqlScript(cleanCode) : cleanCode - const result = await submit( - { value: sourceCode, language: code.language }, - input.value.trim(), - ) + const result = + code.language === "sql" + ? await runSql(buildSqlJob(cleanCode)) + : await submit( + { value: cleanCode, language: code.language }, + input.value.trim(), + ) output.value = result.output || "" status.value = result.status } diff --git a/src/composables/sqlTable.ts b/src/composables/sqlTable.ts index c81ed85..96b18da 100644 --- a/src/composables/sqlTable.ts +++ b/src/composables/sqlTable.ts @@ -19,21 +19,20 @@ function isQuery(sql: string): boolean { return /^\s*(SELECT|WITH)\b/i.test(sql) } -export function buildSqlScript(studentSql: string) { +export interface SqlJob { + setup: string + sql: string + echoTable: string | null +} + +export function buildSqlJob(studentSql: string): SqlJob { const table = sqlTables.find((item) => item.id === selectedTableId.value) ?? sqlTables[0] - const normalizedSql = studentSql.trim().replace(/;?\s*$/, ";") - if (isQuery(studentSql.trim())) { - return [buildSetupSql(table), ".headers on", normalizedSql].join("\n\n") + return { + setup: buildSetupSql(table), + sql: studentSql, + echoTable: isQuery(studentSql.trim()) ? null : table.tableName, } - return [ - buildSetupSql(table), - ".output /dev/null", - normalizedSql, - ".output stdout", - ".headers on", - `SELECT * FROM ${table.tableName};`, - ].join("\n\n") } export interface SqlResult { @@ -41,7 +40,7 @@ export interface SqlResult { rows: Record[] } -// 输出为 sqlite CLI 的 list 模式(| 分隔),开启 .headers on 后首行是列名 +// 输出为 sqlite CLI 的 list 模式(| 分隔),首行是列名 export function parseResult(output: string): SqlResult { const lines = output .split("\n") diff --git a/src/rsbuild-env.d.ts b/src/rsbuild-env.d.ts index cf17709..e0042b8 100644 --- a/src/rsbuild-env.d.ts +++ b/src/rsbuild-env.d.ts @@ -1,5 +1,4 @@ interface ImportMetaEnv { - readonly PUBLIC_JUDGE0API_URL: string readonly PUBLIC_MAXKB_URL: string readonly PUBLIC_CODEAPI_URL: string readonly PUBLIC_ICONIFY_URL: string diff --git a/src/templates.ts b/src/templates.ts index 16c12fc..53b001c 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -19,14 +19,6 @@ turtle.done()` const sqlSource = "" -export const languageToId: { [key in string]: number } = { - c: 50, - cpp: 54, - java: 62, - python: 71, - sql: 82, -} - export const sources = { c: cSource, cpp: cppSource, diff --git a/src/types.ts b/src/types.ts index fd5cb59..59999b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,20 +21,6 @@ export enum Status { RuntimeError = 11, } -export interface Submission { - stdout: string - time: string - memory: number - stderr: string - token: string - compile_output: string - message: string - status: { - id: number - description: string - } -} - export interface File { in: string out: string diff --git a/src/workers/sqlite.worker.ts b/src/workers/sqlite.worker.ts new file mode 100644 index 0000000..c2d91dd --- /dev/null +++ b/src/workers/sqlite.worker.ts @@ -0,0 +1,73 @@ +/// + +import initSqlJs, { type Database } from "sql.js" + +type RunRequest = { + setup: string + sql: string + // 增删改语句执行后回显的表名;查询语句为 null,直接展示查询结果 + echoTable: string | null +} + +type RunResponse = { + status: number + output: string +} + +// 取最后一条有结果列的语句(没有数据行时也保留列名) +function lastResult(db: Database, sql: string) { + let result: { columns: string[]; rows: unknown[][] } | null = null + for (const stmt of db.iterateStatements(sql)) { + const columns = stmt.getColumnNames() + if (columns.length === 0) { + stmt.step() + continue + } + const rows: unknown[][] = [] + while (stmt.step()) rows.push(stmt.get()) + result = { columns, rows } + } + return result +} + +// 与 sqlite CLI 的 list 模式 + .headers on 一致:首行列名,| 分隔 +function format(result: { columns: string[]; rows: unknown[][] } | null) { + if (!result) return "" + return [result.columns, ...result.rows] + .map((row) => row.map((value) => value ?? "").join("|")) + .join("\n") +} + +const wasmUrl = new URL("sql.js/dist/sql-wasm-browser.wasm", import.meta.url) + .href +const ready = initSqlJs({ locateFile: () => wasmUrl }) +ready.then( + () => (self as any).postMessage({ ready: true }), + () => + (self as any).postMessage({ + status: 11, + output: "SQL 引擎加载失败", + } satisfies RunResponse), +) + +self.onmessage = async (event: MessageEvent) => { + const { setup, sql, echoTable } = event.data + const SQL = await ready + const db = new SQL.Database() + try { + db.run(setup) + let result = lastResult(db, sql) + if (echoTable) result = lastResult(db, `SELECT * FROM ${echoTable};`) + ;(self as any).postMessage({ + status: 3, + output: format(result), + } satisfies RunResponse) + } catch (err: any) { + ;(self as any).postMessage({ + status: 11, + output: String(err?.message ?? err), + } satisfies RunResponse) + } finally { + db.close() + } +}