Skip to content

Commit 60e3794

Browse files
committed
feat(dev): 添加 mock-heng 评测机服务(支持 AC/WA/CE/TLE/RE/交互模式)
1 parent 0547675 commit 60e3794

12 files changed

Lines changed: 273 additions & 50 deletions

File tree

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d data-source.ts",
3030
"migration:show": "typeorm-ts-node-commonjs migration:show -d data-source.ts",
3131
"load-test": "k6 run load-tests/auth.k6.js",
32-
"load-test:all": "for f in load-tests/*.k6.js; do k6 run $f; done"
32+
"load-test:all": "for f in load-tests/*.k6.js; do k6 run $f; done",
33+
"mock:heng": "node scripts/mock-heng.js",
34+
"mock:heng:wa": "node scripts/mock-heng.js --result=WA",
35+
"mock:heng:ce": "node scripts/mock-heng.js --result=CE",
36+
"mock:heng:interactive": "node scripts/mock-heng.js --interactive=true"
3337
},
3438
"dependencies": {
3539
"@bull-board/api": "^6.20.3",

scripts/mock-heng.js

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Mock Heng Controller
4+
* 模拟 heng-controller 的 HTTP 行为,用于本地开发测试
5+
*
6+
* 行为:
7+
* 1. 接受 POST /c/v1/judges → 返回 judgeId
8+
* 2. 2 秒后模拟 update 回调(JUDGING 状态)
9+
* 3. 再 1 秒后模拟 finish 回调(默认 AC,或根据代码内容决定结果)
10+
*
11+
* 用法:
12+
* node scripts/mock-heng.js
13+
* node scripts/mock-heng.js --result=WA # 全部返回 WA
14+
* node scripts/mock-heng.js --result=CE # 全部返回 CE
15+
* node scripts/mock-heng.js --result=TLE # 全部返回 TLE
16+
* node scripts/mock-heng.js --delay=500 # 500ms 后返回结果(默认 2000ms)
17+
*
18+
* 配置 .env:
19+
* HENG_BASE_URL=http://localhost:5010
20+
* HENG_AK=mock-ak
21+
* HENG_SK=mock-sk
22+
*/
23+
24+
const http = require('http')
25+
const { randomUUID } = require('crypto')
26+
27+
// ─── CLI 参数解析 ──────────────────────────────────────────────────────────────
28+
const args = process.argv.slice(2).reduce((acc, arg) => {
29+
const [key, val] = arg.replace('--', '').split('=')
30+
acc[key] = val
31+
return acc
32+
}, {})
33+
34+
const DEFAULT_RESULT = args.result || 'AC'
35+
const DELAY_MS = parseInt(args.delay || '2000', 10)
36+
const PORT = parseInt(args.port || '5010', 10)
37+
38+
// ─── 预设结果 ──────────────────────────────────────────────────────────────────
39+
const RESULT_MAP = {
40+
AC: {
41+
cases: [
42+
{ kind: 'Accepted', time: 42, memory: 3145728 },
43+
],
44+
},
45+
WA: {
46+
cases: [
47+
{ kind: 'Accepted', time: 38, memory: 2097152 },
48+
{ kind: 'WrongAnswer', time: 45, memory: 2359296, extraMessage: 'expected 42, got 43' },
49+
],
50+
},
51+
TLE: {
52+
cases: [
53+
{ kind: 'Accepted', time: 40, memory: 2097152 },
54+
{ kind: 'TimeLimitExceeded', time: 2000, memory: 2097152 },
55+
],
56+
},
57+
MLE: {
58+
cases: [
59+
{ kind: 'MemoryLimitExceeded', time: 120, memory: 268435456 },
60+
],
61+
},
62+
RE: {
63+
cases: [
64+
{ kind: 'RuntimeError', time: 10, memory: 1048576, extraMessage: 'Segmentation fault (core dumped)' },
65+
],
66+
},
67+
CE: {
68+
cases: [],
69+
extra: {
70+
user: {
71+
compileMessage: "error: 'cout' was not declared in this scope\n cout << \"hello\";\n ^\ncompilation terminated.",
72+
compileTime: 1200,
73+
},
74+
},
75+
},
76+
PE: {
77+
cases: [
78+
{ kind: 'PresentationError', time: 35, memory: 2097152 },
79+
],
80+
},
81+
}
82+
83+
// ─── HTTP 工具 ─────────────────────────────────────────────────────────────────
84+
function readBody(req) {
85+
return new Promise((resolve) => {
86+
let data = ''
87+
req.on('data', chunk => data += chunk)
88+
req.on('end', () => {
89+
try { resolve(JSON.parse(data || '{}')) }
90+
catch { resolve({}) }
91+
})
92+
})
93+
}
94+
95+
function sendJson(res, status, body) {
96+
const json = JSON.stringify(body)
97+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json) })
98+
res.end(json)
99+
}
100+
101+
async function postJson(url, body) {
102+
return new Promise((resolve, reject) => {
103+
const urlObj = new URL(url)
104+
const data = JSON.stringify(body)
105+
const req = http.request({
106+
hostname: urlObj.hostname,
107+
port: urlObj.port || 80,
108+
path: urlObj.pathname,
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
111+
}, (res) => {
112+
res.resume()
113+
resolve(res.statusCode)
114+
})
115+
req.on('error', reject)
116+
req.write(data)
117+
req.end()
118+
})
119+
}
120+
121+
// ─── 回调模拟 ──────────────────────────────────────────────────────────────────
122+
async function simulateJudge(submissionId, judgeId, callbackUrls, result) {
123+
const updateUrl = callbackUrls.update.replace(':submissionId', submissionId).replace(':judgeId', judgeId)
124+
const finishUrl = callbackUrls.finish.replace(':submissionId', submissionId).replace(':judgeId', judgeId)
125+
126+
// 第一步:JUDGING 状态更新
127+
await new Promise(r => setTimeout(r, DELAY_MS * 0.5))
128+
console.log(`[mock-heng] → update(JUDGING) submissionId=${submissionId}`)
129+
try {
130+
await postJson(updateUrl, { state: 'judging' })
131+
} catch (e) {
132+
console.error(`[mock-heng] update callback failed: ${e.message}`)
133+
}
134+
135+
// 第二步:finish 最终结果
136+
await new Promise(r => setTimeout(r, DELAY_MS * 0.5))
137+
console.log(`[mock-heng] → finish(${result}) submissionId=${submissionId}`)
138+
try {
139+
const finishBody = { ...RESULT_MAP[result], judger: 'mock-heng' }
140+
await postJson(finishUrl, finishBody)
141+
} catch (e) {
142+
console.error(`[mock-heng] finish callback failed: ${e.message}`)
143+
}
144+
}
145+
146+
// ─── 交互式模式(每次提交前询问结果)──────────────────────────────────────────
147+
const pendingJobs = [] // { submissionId, judgeId, callbackUrls }
148+
149+
function promptForResult() {
150+
if (pendingJobs.length === 0) return
151+
const job = pendingJobs.shift()
152+
const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout })
153+
readline.question(`\n[mock-heng] submissionId=${job.submissionId} 结果? (AC/WA/TLE/MLE/RE/CE/PE, 默认${DEFAULT_RESULT}): `, (answer) => {
154+
readline.close()
155+
const result = RESULT_MAP[answer?.toUpperCase()] ? answer.toUpperCase() : DEFAULT_RESULT
156+
simulateJudge(job.submissionId, job.judgeId, job.callbackUrls, result)
157+
.then(() => promptForResult())
158+
})
159+
}
160+
161+
// ─── HTTP Server ───────────────────────────────────────────────────────────────
162+
const INTERACTIVE = args.interactive === 'true' || args.i === 'true'
163+
164+
const server = http.createServer(async (req, res) => {
165+
if (req.method === 'POST' && req.url === '/c/v1/judges') {
166+
const body = await readBody(req)
167+
const judgeId = `mock-${randomUUID().slice(0, 8)}`
168+
169+
// 解析 submissionId(从 callbackUrls 路径提取)
170+
let submissionId = 'unknown'
171+
if (body.callbackUrls?.finish) {
172+
const match = body.callbackUrls.finish.match(/\/heng\/finish\/(\d+)\//)
173+
if (match) submissionId = match[1]
174+
}
175+
176+
console.log(`[mock-heng] ← createJudge submissionId=${submissionId} judgeId=${judgeId} lang=${body.judge?.user?.environment?.language || '?'}`)
177+
178+
sendJson(res, 200, { judgeId })
179+
180+
if (INTERACTIVE) {
181+
pendingJobs.push({ submissionId, judgeId, callbackUrls: body.callbackUrls })
182+
if (pendingJobs.length === 1) setTimeout(promptForResult, 100)
183+
} else {
184+
// 自动模式:直接返回预设结果
185+
simulateJudge(submissionId, judgeId, body.callbackUrls, DEFAULT_RESULT).catch(console.error)
186+
}
187+
} else {
188+
sendJson(res, 404, { error: 'Not found' })
189+
}
190+
})
191+
192+
server.listen(PORT, () => {
193+
console.log(`
194+
╔══════════════════════════════════════════════════════╗
195+
║ 🎭 Mock Heng Controller ║
196+
╠══════════════════════════════════════════════════════╣
197+
║ 监听端口: ${PORT}
198+
║ 默认结果: ${DEFAULT_RESULT}
199+
║ 延迟时间: ${DELAY_MS}ms ║
200+
║ 交互模式: ${INTERACTIVE ? '✅ 每次提交前询问' : '❌ 全部返回预设结果'}
201+
╠══════════════════════════════════════════════════════╣
202+
║ .env 配置: ║
203+
║ HENG_BASE_URL=http://localhost:${PORT}
204+
║ HENG_AK=mock-ak ║
205+
║ HENG_SK=mock-sk ║
206+
╚══════════════════════════════════════════════════════╝
207+
`)
208+
})
209+
210+
server.on('error', (e) => {
211+
console.error(`[mock-heng] Server error: ${e.message}`)
212+
process.exit(1)
213+
})

src/modules/auth/auth.service.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,11 @@ export class AuthService {
170170
'15m',
171171
);
172172

173-
return this.jwtService.sign(payload as any, {
173+
return this.jwtService.sign(payload as object, {
174174
secret: this.configService.get<string>('jwt.accessSecret'),
175-
expiresIn: expiresIn as any,
175+
expiresIn: expiresIn as
176+
| `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}`
177+
| undefined,
176178
});
177179
}
178180

@@ -182,9 +184,11 @@ export class AuthService {
182184
'7d',
183185
);
184186

185-
return this.jwtService.sign(payload as any, {
187+
return this.jwtService.sign(payload as object, {
186188
secret: this.configService.get<string>('jwt.refreshSecret'),
187-
expiresIn: expiresIn as any,
189+
expiresIn: expiresIn as
190+
| `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}`
191+
| undefined,
188192
});
189193
}
190194

@@ -194,9 +198,11 @@ export class AuthService {
194198
'15m',
195199
);
196200

197-
return this.jwtService.sign(payload as any, {
201+
return this.jwtService.sign(payload as object, {
198202
secret: this.configService.get<string>('jwt.accessSecret'),
199-
expiresIn: expiresIn as any,
203+
expiresIn: expiresIn as
204+
| `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}`
205+
| undefined,
200206
});
201207
}
202208

src/modules/compete/compete.service.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,13 @@ export class CompeteService {
381381
.where('m.status = :status', { status: MatchStatus.FINISHED })
382382
.groupBy('mgl.gamerId')
383383
.orderBy('wins / total', 'DESC')
384-
.getRawMany();
384+
.getRawMany<{ gamerId: number; wins: string; total: string }>();
385385

386386
return results.map((r) => ({
387387
gamerId: r.gamerId,
388388
wins: Number(r.wins),
389389
total: Number(r.total),
390-
winRate: r.total > 0 ? Number(r.wins) / Number(r.total) : 0,
390+
winRate: Number(r.total) > 0 ? Number(r.wins) / Number(r.total) : 0,
391391
}));
392392
}
393393

@@ -441,10 +441,9 @@ export class CompeteService {
441441
if (!game) throw new NotFoundException(`游戏 #${dto.gameId} 不存在`);
442442
if (game.disabled && !isAdmin) throw new NotFoundException();
443443

444-
const userResult = await this.dataSource.query(
445-
'SELECT id, username FROM `user` WHERE id = ?',
446-
[userId],
447-
);
444+
const userResult = await this.dataSource.query<
445+
{ id: number; username: string }[]
446+
>('SELECT id, username FROM `user` WHERE id = ?', [userId]);
448447
const user = userResult[0];
449448
if (!user) throw new NotFoundException('用户不存在');
450449

@@ -523,14 +522,14 @@ export class CompeteService {
523522

524523
// 已开始的对局
525524
if (startRes && startRes[0] === null && startRes[1]) {
526-
return { matchId: parseInt(String(startRes[1])) };
525+
return { matchId: parseInt(startRes[1] as string) };
527526
}
528527

529528
if (!infoRes || infoRes[0] !== null || !infoRes[1]) {
530529
throw new InternalServerErrorException('no such room');
531530
}
532531

533-
const info: RoomInfo = JSON.parse(String(infoRes[1]));
532+
const info = JSON.parse(infoRes[1] as string) as RoomInfo;
534533
if (openRes && openRes[0] === null) {
535534
info.open = !!openRes[1];
536535
}
@@ -572,10 +571,9 @@ export class CompeteService {
572571
if (!gamer) throw new NotFoundException(`Bot #${dto.gamerId} 不存在`);
573572
if (gamer.userId !== userId) throw new ForbiddenException();
574573

575-
const userResult = await this.dataSource.query(
576-
'SELECT id, username FROM `user` WHERE id = ?',
577-
[userId],
578-
);
574+
const userResult = await this.dataSource.query<
575+
{ id: number; username: string }[]
576+
>('SELECT id, username FROM `user` WHERE id = ?', [userId]);
579577
const user = userResult[0];
580578

581579
const submitterKey = this.submittedGamerKey(roomId);
@@ -629,7 +627,7 @@ export class CompeteService {
629627
const gamerIds: number[] = [];
630628
for (let i = 0; i < gamerQuantity; i++) {
631629
if (players[String(i)]) {
632-
const gamer = JSON.parse(players[String(i)]);
630+
const gamer = JSON.parse(players[String(i)]) as { id: number };
633631
gamerIds.push(gamer.id);
634632
} else {
635633
throw new BadRequestException(`位置 ${i} 还没有选手`);

src/modules/contest/contest.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
NotFoundException,
66
} from '@nestjs/common';
77
import { InjectRepository } from '@nestjs/typeorm';
8-
import { LessThan, MoreThan, Repository } from 'typeorm';
8+
import { Repository } from 'typeorm';
99
import { randomBytes } from 'crypto';
1010
import { Contest } from '../../database/entities/contest.entity';
1111
import { ContestProblem } from '../../database/entities/contest-problem.entity';
@@ -16,7 +16,7 @@ import { RedisService } from '../redis/redis.service';
1616
import { hashPassword } from '../../common/utils/crypto.util';
1717
import { CreateContestDto } from './dto/create-contest.dto';
1818
import { UpdateContestDto } from './dto/update-contest.dto';
19-
import { ContestQueryDto, ContestStatus } from './dto/contest-query.dto';
19+
import { ContestQueryDto } from './dto/contest-query.dto';
2020
import { ContestUserDto } from './dto/contest-user.dto';
2121

2222
export interface RankItem {

src/modules/heng/workers/judge-rx.worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Job, Queue } from 'bull';
44
import { JUDGE_RX_QUEUE } from '../../queue/queue.constants';
55
import { RedisService } from '../../redis/redis.service';
66
import { ReceiveService } from '../../receive/receive.service';
7-
import { JudgeResult, JudgeRxPayload } from '../heng.types';
7+
import type { JudgeRxPayload } from '../heng.types';
88

99
/**
1010
* JudgeRxWorker

src/modules/problem/problem.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export class ProblemController {
212212
noMarkdown: boolean;
213213
};
214214
try {
215-
params = JSON.parse(paramsStr);
215+
params = JSON.parse(paramsStr) as typeof params;
216216
} catch {
217217
throw new BadRequestException('params 必须是合法 JSON');
218218
}

src/modules/statistics/statistics.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class StatisticsService {
3434
.select('COUNT(*)', 'total')
3535
.addSelect('SUM(CASE WHEN s.status = 1 THEN 1 ELSE 0 END)', 'accepted')
3636
.where('s.problemId = :problemId', { problemId })
37-
.getRawOne();
37+
.getRawOne<{ accepted: string; total: string }>();
3838

3939
return {
4040
accepted: Number(result?.accepted ?? 0),
@@ -54,7 +54,7 @@ export class StatisticsService {
5454
.andWhere('s.createdAt >= DATE_SUB(NOW(), INTERVAL 365 DAY)')
5555
.groupBy('DATE(s.createdAt)')
5656
.orderBy('date', 'ASC')
57-
.getRawMany();
57+
.getRawMany<{ date: string; count: string }>();
5858

5959
return results.map((r) => ({
6060
date: r.date,

0 commit comments

Comments
 (0)