feat: add realtime challenge client and local smoke test
CI / test (pull_request) Successful in 2m53s
PR合并自动部署 / release-check (pull_request) Successful in 1m34s
PR合并自动部署 / deploy (pull_request) Successful in 12s

This commit is contained in:
2026-08-09 03:08:01 +08:00
parent 821311f4ad
commit 1dd828609e
9 changed files with 872 additions and 7 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install migrate seed run test check
.PHONY: install migrate seed run run-asgi test check
install:
python3 -m venv .venv
@@ -14,6 +14,9 @@ seed:
run:
cd backend && ../.venv/bin/python manage.py runserver
run-asgi:
cd backend && ../.venv/bin/uvicorn config.asgi:application --host 127.0.0.1 --port 8000 --reload
test:
.venv/bin/python -m pytest -q
+1
View File
@@ -76,6 +76,7 @@ Gitea 会在 PR 合并到 `main` 后自动测试和部署:
- [Ubuntu + 宝塔面板从零部署](docs/BAOTA_UBUNTU_FROM_ZERO.md)
- [自动发布机制与运维说明](docs/DEPLOYMENT.md)
- [MySQL 8 数据迁移说明](docs/MYSQL8_MIGRATION.md)
- [本地实时 1v1 与联机码约战测试](docs/LOCAL_REALTIME_TEST.md)
## 目录
+15
View File
@@ -55,3 +55,18 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
assert ".calculator-controls [hidden]" in styles
assert ".sudoku-board" in styles
assert "@media (max-width: 700px)" in styles
def test_realtime_match_联机码与_websocket_前端资源存在():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
assert 'id="challenge-create"' in template
assert 'id="challenge-join-form"' in template
assert template.count("js/realtime.js") == 1
assert "new WebSocket" in realtime
assert "setInterval(refreshMatch, 2000)" in realtime
assert "Idempotency-Key" in realtime
assert ".challenge-panel" in styles
assert ".realtime-progress-panel" in styles
+9
View File
@@ -132,6 +132,14 @@ button { color: inherit; }
.track-switch { display: flex; gap: 5px; margin-bottom: 22px; }
.track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; }
.track-switch button.active { background: var(--ink); color: white; }
.challenge-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(380px, .9fr); gap: 24px; align-items: center; margin-bottom: 22px; padding: 24px 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(120deg, rgba(25,101,72,.07), rgba(204,232,91,.12)); }.challenge-panel h2 { margin: 7px 0 6px; font: 27px Georgia, serif; }.challenge-panel p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.challenge-actions { display: grid; gap: 9px; }.challenge-actions > .primary-button { width: 100%; }.challenge-actions form { display: grid; grid-template-columns: 1fr auto; gap: 8px; }.challenge-actions input { min-width: 0; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: white; text-transform: uppercase; letter-spacing: .18em; font-weight: 700; }.challenge-actions .dark-button { width: auto; margin: 0; white-space: nowrap; }
.realtime-status-line { display: flex; justify-content: space-between; gap: 15px; margin: 10px 0 18px; color: var(--muted); font-size: 11px; }.realtime-status-line strong { color: var(--green); }
.realtime-waiting { display: grid; justify-items: center; gap: 16px; padding: 35px 20px; border-radius: 18px; background: #eef1eb; text-align: center; }.realtime-waiting p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.7; }.realtime-pulse { width: 18px; height: 18px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(25,101,72,.35); animation: realtime-pulse 1.5s infinite; }.challenge-code { border: 1px dashed var(--green); border-radius: 14px; padding: 14px 24px; background: white; color: var(--green); font: 700 31px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .22em; cursor: pointer; }
@keyframes realtime-pulse { 70% { box-shadow: 0 0 0 15px rgba(25,101,72,0); } 100% { box-shadow: 0 0 0 0 rgba(25,101,72,0); } }
.realtime-progress-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }.realtime-progress-panel > div { padding: 13px 15px; border-radius: 12px; background: #eef1eb; }.realtime-progress-panel span, .realtime-progress-panel b { display: block; }.realtime-progress-panel span { color: var(--muted); font-size: 10px; }.realtime-progress-panel b { margin-top: 5px; color: var(--green); }
.realtime-answer-form input { margin-top: 7px; width: 100%; padding: 12px; border: 1px solid #d6d8d1; border-radius: 9px; }.realtime-submitted { margin-top: 20px; padding: 30px; border-radius: 16px; background: var(--ink); color: white; text-align: center; }.realtime-submitted strong { color: var(--lime); font: 27px Georgia, serif; }.realtime-submitted p { margin-bottom: 0; color: #b9c2bc; }
.realtime-result { margin: 20px 0; padding: 28px; border-radius: 17px; background: var(--ink); color: white; text-align: center; }.realtime-result > strong { color: var(--lime); font: 38px Georgia, serif; }.realtime-result p { color: #c5cec8; }.realtime-result > b { display: inline-block; padding: 6px 10px; border-radius: 99px; background: rgba(204,232,91,.13); color: var(--lime); }.result-opponent > strong { color: #ef947c; }
.realtime-review { display: grid; gap: 9px; }.realtime-review article { padding: 15px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 11px; background: white; }.realtime-review article.incorrect { border-left-color: #c05245; }.realtime-review p { margin: 7px 0; color: #3e4942; }.realtime-review small { color: var(--muted); }
.math-games-section { margin-top: 60px; }
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; }
@@ -262,6 +270,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
.map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; }
.map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; }
}
+3 -5
View File
@@ -308,12 +308,8 @@ async function beginContest(contest) {
if (!requireAuth()) return;
try {
if (contest.kind === "realtime") {
const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} });
if (match.status === "waiting") {
showToast("已进入匹配队列,等待同赛道对手");
await window.HuluRealtime.startRandom(contest);
return;
}
renderAttempt(match.attempt);
} else {
renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} }));
}
@@ -951,6 +947,7 @@ function bindUI() {
$("#auth-button").addEventListener("click", async () => {
if (!state.user) return openAuth();
await api("accounts/logout/", { method: "POST", body: {} });
window.HuluRealtime?.reset();
state.user = null;
updateUserUI();
showToast("已退出登录");
@@ -996,6 +993,7 @@ function bindUI() {
async function boot() {
bindUI();
window.HuluToolbox?.init();
window.HuluRealtime?.init();
renderSymbols();
await Promise.all([
loadUser(),
+451
View File
@@ -0,0 +1,451 @@
(function () {
const realtime = {
match: null,
socket: null,
pollTimer: null,
clockTimer: null,
opponentProgress: 0,
reconnectTimer: null,
};
function stopTimers() {
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
if (realtime.clockTimer) window.clearInterval(realtime.clockTimer);
realtime.pollTimer = null;
realtime.clockTimer = null;
}
function closeSocket() {
if (realtime.reconnectTimer) window.clearTimeout(realtime.reconnectTimer);
realtime.reconnectTimer = null;
if (realtime.socket) {
realtime.socket.onclose = null;
realtime.socket.close();
}
realtime.socket = null;
}
function websocketUrl(path) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${path}`;
}
function connectSocket() {
closeSocket();
if (!realtime.match?.websocket_path) return;
const socket = new WebSocket(websocketUrl(realtime.match.websocket_path));
realtime.socket = socket;
socket.addEventListener("open", () => {
updateConnectionStatus("实时连接已建立");
socket.send(JSON.stringify({ type: "ping" }));
});
socket.addEventListener("message", async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "state") {
await refreshMatch();
} else if (
message.type === "progress" &&
message.user_id !== String(state.user?.id)
) {
realtime.opponentProgress = message.answered_count;
updateProgressUI();
}
});
socket.addEventListener("close", () => {
updateConnectionStatus("实时连接中断,正在使用轮询");
if (["waiting", "active"].includes(realtime.match?.status)) {
realtime.reconnectTimer = window.setTimeout(connectSocket, 2000);
}
});
socket.addEventListener("error", () => {
updateConnectionStatus("WebSocket 不可用,轮询仍在工作");
});
}
function updateConnectionStatus(message) {
const node = document.querySelector("#realtime-connection");
if (node) node.textContent = message;
}
async function refreshMatch() {
if (!realtime.match?.match_id) return;
try {
const match = await api(`contests/matches/${realtime.match.match_id}/`);
const previous = realtime.match;
const previousStatus = previous.status;
realtime.match = match;
const shouldRender =
previous.status !== match.status ||
previous.attempt?.status !== match.attempt?.status;
if (shouldRender) renderMatch();
else {
updateClock();
if (
previous.opponent?.status !== match.opponent?.status &&
match.opponent?.status &&
match.opponent.status !== "active"
) {
realtime.opponentProgress = match.attempt?.questions.length || 0;
updateProgressUI();
updateConnectionStatus("对手已提交,完成后将立即结算");
}
}
if (previousStatus === "waiting" && match.status === "active") {
showToast(`已匹配到 ${match.opponent.nickname}`);
}
if (!["waiting", "active"].includes(match.status)) {
stopTimers();
closeSocket();
}
} catch (error) {
if (error.status === 404) {
stopTimers();
closeSocket();
}
}
}
function startPolling() {
stopTimers();
realtime.pollTimer = window.setInterval(refreshMatch, 2000);
realtime.clockTimer = window.setInterval(updateClock, 250);
}
function openMatch(match) {
realtime.match = match;
realtime.opponentProgress = 0;
renderMatch();
const dialog = document.querySelector("#experience-dialog");
if (!dialog.open) dialog.showModal();
connectSocket();
if (["waiting", "active"].includes(match.status)) startPolling();
}
function header(root, match) {
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`;
const title = document.createElement("h2");
title.textContent = match.contest;
const status = document.createElement("div");
status.className = "realtime-status-line";
const connection = document.createElement("span");
connection.id = "realtime-connection";
connection.textContent = "正在建立实时连接…";
const clock = document.createElement("strong");
clock.id = "realtime-clock";
status.append(connection, clock);
root.append(label, title, status);
}
function updateClock() {
const clock = document.querySelector("#realtime-clock");
if (!clock || !realtime.match) return;
if (realtime.match.status === "waiting") {
const expiresAt = new Date(realtime.match.expires_at).getTime();
const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
clock.textContent = `联机码 ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} 后失效`;
return;
}
if (realtime.match.status === "active") {
const startedAt = new Date(realtime.match.started_at).getTime();
const elapsed = (Date.now() - startedAt) / 1000;
const remaining = Math.max(0, Math.ceil(realtime.match.duration_seconds - elapsed));
clock.textContent = `剩余 ${remaining}`;
if (remaining === 0) refreshMatch();
return;
}
clock.textContent = "";
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code);
showToast(`联机码 ${code} 已复制`);
} catch {
showToast(`联机码:${code}`);
}
}
function renderWaiting(root, match) {
const panel = document.createElement("div");
panel.className = "realtime-waiting";
const pulse = document.createElement("span");
pulse.className = "realtime-pulse";
const message = document.createElement("p");
message.textContent =
match.match_type === "challenge"
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
: "正在寻找同赛道、相近 Rating 的玩家。";
panel.append(pulse, message);
if (match.challenge_code) {
const code = document.createElement("button");
code.className = "challenge-code";
code.textContent = match.challenge_code;
code.title = "点击复制联机码";
code.addEventListener("click", () => copyCode(match.challenge_code));
panel.append(code);
}
if (match.is_owner) {
const cancel = document.createElement("button");
cancel.className = "ghost-button";
cancel.textContent = "取消等待";
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try {
realtime.match = await api(`contests/matches/${match.match_id}/cancel/`, {
method: "POST",
body: {},
});
renderMatch();
stopTimers();
closeSocket();
} catch (error) {
showToast(error.message);
cancel.disabled = false;
}
});
panel.append(cancel);
}
root.append(panel);
updateClock();
}
function progressPanel(root, match) {
const panel = document.createElement("div");
panel.className = "realtime-progress-panel";
const self = document.createElement("div");
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${match.attempt.questions.length}</b>`;
const opponent = document.createElement("div");
opponent.innerHTML =
`<span>${match.opponent?.nickname || "对手"}</span>` +
`<b id="opponent-progress">${realtime.opponentProgress} / ${match.attempt.questions.length}</b>`;
panel.append(self, opponent);
root.append(panel);
}
function updateProgressUI(selfCount) {
if (Number.isInteger(selfCount)) {
const self = document.querySelector("#self-progress");
if (self && realtime.match?.attempt) {
self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`;
}
}
const opponent = document.querySelector("#opponent-progress");
if (opponent && realtime.match?.attempt) {
opponent.textContent =
`${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`;
}
}
function sendProgress(answeredCount) {
if (realtime.socket?.readyState === WebSocket.OPEN) {
realtime.socket.send(
JSON.stringify({ type: "progress", answered_count: answeredCount })
);
}
}
function renderActive(root, match) {
progressPanel(root, match);
const attempt = match.attempt;
if (attempt.status !== "active") {
const waiting = document.createElement("div");
waiting.className = "realtime-submitted";
waiting.innerHTML =
"<strong>答案已锁定</strong><p>等待对手提交。双方完成后才会公开答案和 Rating 变化。</p>";
root.append(waiting);
updateClock();
return;
}
const form = document.createElement("form");
form.className = "choice-list realtime-answer-form";
attempt.questions.forEach((question) => {
const field = document.createElement("label");
field.textContent = `${question.order}. ${question.prompt}`;
const input = document.createElement("input");
input.name = String(question.order);
input.inputMode = "decimal";
input.autocomplete = "off";
input.addEventListener("input", () => {
const answered = [...form.querySelectorAll("input")].filter(
(item) => item.value.trim()
).length;
updateProgressUI(answered);
sendProgress(answered);
});
field.append(input);
form.append(field);
});
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "提交并等待对手";
form.append(submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
const data = new FormData(form);
try {
realtime.match = await api(
`contests/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: {
answers: attempt.questions.map((question) => ({
order: question.order,
answer: data.get(String(question.order)) || "",
})),
},
},
);
renderMatch();
} catch (error) {
showToast(error.message);
submit.disabled = false;
}
});
root.append(form);
updateClock();
}
function renderCompleted(root, match) {
const result = document.createElement("section");
result.className = `realtime-result result-${match.result.winner}`;
const outcome = document.createElement("strong");
outcome.textContent =
match.result.winner === "self"
? "获胜"
: match.result.winner === "opponent"
? "本局惜败"
: "平局";
const score = document.createElement("p");
score.textContent =
`${match.attempt.score} 分 · ${match.opponent.score}${match.opponent.nickname}`;
const rating = document.createElement("b");
const sign = match.result.rating_delta > 0 ? "+" : "";
rating.textContent =
`Rating ${sign}${match.result.rating_delta}${match.result.rating_after}`;
result.append(outcome, score, rating);
root.append(result);
const review = document.createElement("div");
review.className = "realtime-review";
match.attempt.questions.forEach((question) => {
const item = document.createElement("article");
const title = document.createElement("b");
title.textContent = `${question.order}. ${question.prompt}`;
const answer = document.createElement("p");
answer.textContent =
`你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`;
const explanation = document.createElement("small");
explanation.textContent = question.explanation || "";
item.className = question.is_correct ? "correct" : "incorrect";
item.append(title, answer, explanation);
review.append(item);
});
root.append(review);
}
function renderMatch() {
const root = document.querySelector("#experience-content");
const match = realtime.match;
root.replaceChildren();
header(root, match);
if (match.status === "waiting") renderWaiting(root, match);
else if (match.status === "active") renderActive(root, match);
else if (match.status === "completed") renderCompleted(root, match);
else {
const message = document.createElement("p");
message.className = "scene";
message.textContent = "这场匹配已取消或联机码已过期。";
root.append(message);
}
}
function currentRealtimeContest() {
return state.contests.find(
(contest) => contest.kind === "realtime" && contest.track === state.track
);
}
async function startRandom(contest) {
const match = await api(`contests/${contest.slug}/matchmaking/`, {
method: "POST",
body: {},
});
openMatch(match);
}
async function createChallenge() {
if (!requireAuth()) return;
const contest = currentRealtimeContest();
if (!contest) {
showToast("当前赛道没有可用的实时比赛");
return;
}
try {
const match = await api(`contests/${contest.slug}/challenges/`, {
method: "POST",
body: {},
});
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
async function joinChallenge(event) {
event.preventDefault();
if (!requireAuth()) return;
const input = document.querySelector("#challenge-code-input");
const challengeCode = input.value.trim().toUpperCase();
if (challengeCode.length !== 6) {
showToast("请输入 6 位联机码");
return;
}
try {
const match = await api("contests/challenges/join/", {
method: "POST",
body: { challenge_code: challengeCode },
});
input.value = "";
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
function init() {
document
.querySelector("#challenge-create")
.addEventListener("click", createChallenge);
document
.querySelector("#challenge-join-form")
.addEventListener("submit", joinChallenge);
document
.querySelector("#challenge-code-input")
.addEventListener("input", (event) => {
event.target.value = event.target.value
.toUpperCase()
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
.slice(0, 6);
});
}
function reset() {
stopTimers();
closeSocket();
realtime.match = null;
realtime.opponentProgress = 0;
}
window.HuluRealtime = { init, startRandom, refreshMatch, reset };
})();
+15
View File
@@ -89,6 +89,20 @@
<div class="track-switch" id="track-switch">
<button class="active" data-track="standard">标准</button><button data-track="beginner">入门</button><button data-track="advanced">进阶</button>
</div>
<section class="challenge-panel">
<div>
<span class="kicker">FRIEND CHALLENGE</span>
<h2>联机码约战</h2>
<p>创建 6 位联机码发给朋友,或输入朋友的联机码加入同一场对局。</p>
</div>
<div class="challenge-actions">
<button id="challenge-create" class="primary-button">创建当前赛道约战</button>
<form id="challenge-join-form">
<input id="challenge-code-input" maxlength="6" autocomplete="off" placeholder="输入 6 位联机码" aria-label="联机码">
<button class="dark-button" type="submit">加入约战</button>
</form>
</div>
</section>
<div id="contest-list" class="content-grid loading">正在读取比赛…</div>
<section class="math-games-section">
<div class="section-heading">
@@ -305,6 +319,7 @@
<div class="csrf-token">{% csrf_token %}</div>
<script src="{% static 'js/toolbox.js' %}" defer></script>
<script src="{% static 'js/games.js' %}" defer></script>
<script src="{% static 'js/realtime.js' %}" defer></script>
<script src="{% static 'js/app.js' %}" defer></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
# 本地实时 1v1 与联机码约战测试
本指南用于在一台电脑上使用两个浏览器会话验证完整联机流程。
## 1. 准备数据
```bash
make install
make migrate
make seed
```
种子数据会创建三个赛道的实时 1v1 比赛。本地邀请码为:
```text
HULU2026
```
## 2. 使用 ASGI 启动
实时比赛依赖 WebSocket。不要使用普通 WSGI 服务测试联机。
```bash
make run-asgi
```
访问:
```text
http://127.0.0.1:8000/
```
本地没有配置 `REDIS_URL` 时会使用进程内 Channel Layer,适合单进程开发测试。生产环境必须使用 Redis。
### 一键端到端验证
保持 `make run-asgi` 运行,另开终端执行:
```bash
.venv/bin/python scripts/test_realtime_local.py
```
脚本会临时创建两个本地账号,通过真实 HTTP Session 和两个真实 WebSocket 完成:
```text
创建联机码
→ 第二位玩家加入
→ matched 状态推送
→ 答题进度同步
→ 第一位提交且不泄露答案
→ 第二位提交
→ completed 推送
→ 胜负、Rating 和解析检查
```
结束后脚本自动清理临时用户和比赛记录。
## 3. 准备两个独立登录会话
使用下列任一组合:
- Chrome 普通窗口 + 无痕窗口
- Chrome + Safari
- 两个不同浏览器 Profile
两个窗口分别使用邀请码 `HULU2026` 注册不同账号。不要在同一浏览器 Profile 的两个普通标签页登录不同账号,因为它们会共享 Session Cookie。
## 4. 联机码约战
玩家 A
1. 打开“比赛”。
2. 选择双方约定的赛道。
3. 点击“创建当前赛道约战”。
4. 复制 6 位联机码。
玩家 B
1. 打开“比赛”。
2. 输入联机码。
3. 点击“加入约战”。
预期结果:
- 玩家 A 无需再次点击,自动进入答题。
- 双方显示相同题目和同一个倒计时。
- 任一方填写答案时,另一方看到答题数量变化。
- 第一位提交者只看到“答案已锁定”,看不到正确答案。
- 双方提交或倒计时结束后,同时展示胜负、双方分数、Rating 变化和题目解析。
## 5. 随机匹配
双方选择同一赛道并点击“开始匹配”。
预期结果:
- 第一位玩家进入等待状态。
- 第二位玩家加入后,第一位玩家自动进入答题。
- 私人联机码房间不会被随机匹配玩家加入。
## 6. 断线与超时
验证以下场景:
1. 答题时短暂关闭网络,再恢复。
2. WebSocket 断开后页面仍每 2 秒轮询比赛状态。
3. 关闭其中一个窗口,另一方等待倒计时结束。
4. 服务端到时后将未提交 Attempt 标记为过期并完成结算。
5. 等待中的联机码 10 分钟后失效。
## 7. 排查
浏览器开发者工具应看到:
```text
WS /ws/v1/contest/matches/<match_id>/
GET /api/v1/contests/matches/<match_id>/
```
检查 Redis
```bash
redis-cli ping
```
检查 ASGI
```bash
curl http://127.0.0.1:8000/health/
```
生产 Nginx 必须为 `/ws/` 设置 `Upgrade``Connection` 请求头。详见 `docs/DEPLOYMENT.md`
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
import argparse
import asyncio
import http.cookiejar
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
from django.db.models import Q
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "backend"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
import django # noqa: E402
django.setup()
from django.conf import settings # noqa: E402
from websockets.asyncio.client import connect # noqa: E402
from accounts.models import User # noqa: E402
from contest.models import ( # noqa: E402
Contest,
ContestAttempt,
RatingHistory,
RealtimeMatch,
)
class ApiClient:
def __init__(self, base_url):
self.base_url = base_url.rstrip("/")
self.cookies = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cookies)
)
def request(self, method, path, payload=None, extra_headers=None):
body = json.dumps(payload).encode() if payload is not None else None
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
csrf_token = self.cookie("csrftoken")
if csrf_token:
headers["X-CSRFToken"] = csrf_token
headers.update(extra_headers or {})
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
headers=headers,
method=method,
)
try:
with self.opener.open(request, timeout=10) as response:
content = response.read()
if not content:
return None
if "application/json" in response.headers.get("Content-Type", ""):
return json.loads(content)
return content.decode()
except urllib.error.HTTPError as exc:
content = exc.read().decode()
raise RuntimeError(f"{method} {path} -> HTTP {exc.code}: {content}") from exc
def get(self, path):
return self.request("GET", path)
def post(self, path, payload, extra_headers=None):
return self.request("POST", path, payload, extra_headers)
def cookie(self, name):
return next((item.value for item in self.cookies if item.name == name), "")
@property
def cookie_header(self):
return "; ".join(f"{item.name}={item.value}" for item in self.cookies)
async def receive_until(websocket, event_type, reason=None):
for _ in range(10):
payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5))
if payload.get("type") == event_type and (
reason is None or payload.get("reason") == reason
):
return payload
raise RuntimeError(f"未收到 WebSocket 事件: type={event_type}, reason={reason}")
def websocket_url(base_url, path):
parsed = urlparse(base_url)
scheme = "wss" if parsed.scheme == "https" else "ws"
return f"{scheme}://{parsed.netloc}{path}"
async def run_flow(base_url, first_client, second_client, contest):
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
)
code = created["challenge_code"]
print(f"[1/7] 玩家 A 创建联机码: {code}")
async with connect(
websocket_url(base_url, created["websocket_path"]),
additional_headers={"Cookie": first_client.cookie_header},
open_timeout=10,
) as first_socket:
await receive_until(first_socket, "connected")
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code},
)
await receive_until(first_socket, "state", "matched")
print("[2/7] 玩家 B 加入,玩家 A 收到 matched 事件")
first_state = first_client.get(
f"/api/v1/contests/matches/{created['match_id']}/"
)
if first_state["status"] != "active":
raise RuntimeError("匹配后状态不是 active")
async with connect(
websocket_url(base_url, joined["websocket_path"]),
additional_headers={"Cookie": second_client.cookie_header},
open_timeout=10,
) as second_socket:
await receive_until(second_socket, "connected")
await first_socket.send(
json.dumps({"type": "progress", "answered_count": 1})
)
progress = await receive_until(second_socket, "progress")
if progress["answered_count"] != 1:
raise RuntimeError("答题进度同步失败")
print("[3/7] 两个 WebSocket 已连接,答题进度同步成功")
first_attempt = first_state["attempt"]
second_attempt = joined["attempt"]
first_answers = [
{"order": item["order"], "answer": "0"}
for item in first_attempt["questions"]
]
second_answers = [
{"order": item["order"], "answer": "0"}
for item in second_attempt["questions"]
]
first_result = first_client.post(
f"/api/v1/contests/attempts/{first_attempt['attempt_id']}/submit/",
{"answers": first_answers},
{"Idempotency-Key": f"local-first-{created['match_id']}"},
)
if first_result["status"] != "active":
raise RuntimeError("首位玩家提交后比赛不应立即完成")
if "correct_answer" in first_result["attempt"]["questions"][0]:
raise RuntimeError("首位玩家提前看到了正确答案")
await receive_until(second_socket, "state", "submitted")
print("[4/7] 玩家 A 提交后答案锁定,未提前泄露正确答案")
second_result = second_client.post(
f"/api/v1/contests/attempts/{second_attempt['attempt_id']}/submit/",
{"answers": second_answers},
{"Idempotency-Key": f"local-second-{created['match_id']}"},
)
if second_result["status"] != "completed":
raise RuntimeError("双方提交后比赛没有完成")
await receive_until(first_socket, "state", "completed")
print("[5/7] 玩家 B 提交后双方收到 completed 事件")
final_state = first_client.get(
f"/api/v1/contests/matches/{created['match_id']}/"
)
if "correct_answer" not in final_state["attempt"]["questions"][0]:
raise RuntimeError("完成后没有公开题目解析")
if final_state["result"] is None:
raise RuntimeError("完成后没有胜负与 Rating 结果")
print("[6/7] 最终比分、Rating 和题目解析均可读取")
print("[7/7] 本地联机码约战端到端测试通过")
def cleanup(users):
user_ids = [user.id for user in users]
matches = RealtimeMatch.objects.filter(
Q(player_one_id__in=user_ids) | Q(player_two_id__in=user_ids)
)
match_ids = list(matches.values_list("id", flat=True))
ContestAttempt.objects.filter(
Q(user_id__in=user_ids) | Q(match_id__in=match_ids)
).delete()
RatingHistory.objects.filter(match_id__in=match_ids).delete()
matches.delete()
User.objects.filter(id__in=user_ids).delete()
def main():
parser = argparse.ArgumentParser(description="本地实时联机码约战端到端测试")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
args = parser.parse_args()
hostname = urlparse(args.base_url).hostname
if not settings.DEBUG or hostname not in {"127.0.0.1", "localhost"}:
raise RuntimeError("该脚本只允许在 DEBUG=true 的本机地址运行")
stamp = str(int(time.time() * 1000))
password = "LocalRealtime2026!"
users = [
User.objects.create_user(
username=f"local_ws_a_{stamp}",
password=password,
nickname="本地联机 A",
),
User.objects.create_user(
username=f"local_ws_b_{stamp}",
password=password,
nickname="本地联机 B",
),
]
try:
contest = Contest.objects.filter(
kind=Contest.Kind.REALTIME,
status=Contest.Status.PUBLISHED,
track="standard",
).first()
if contest is None:
raise RuntimeError("缺少标准赛道实时比赛,请先执行 make seed")
clients = [ApiClient(args.base_url), ApiClient(args.base_url)]
for client, user in zip(clients, users):
client.get("/")
client.post(
"/api/v1/accounts/login/",
{"username": user.username, "password": password},
)
asyncio.run(run_flow(args.base_url, clients[0], clients[1], contest))
finally:
cleanup(users)
if __name__ == "__main__":
main()