From b8916180a995e54075c589d086341407591028d6 Mon Sep 17 00:00:00 2001 From: Jacky Date: Mon, 10 Aug 2026 00:47:03 +0800 Subject: [PATCH] feat: add collaborative math board and draw guess mode --- backend/common/test_frontend_assets.py | 7 + backend/config/asgi.py | 2 + backend/static/css/app.css | 5 + backend/static/js/toolbox.js | 254 ++++++++++++++++++++- backend/templates/index.html | 94 +++++--- backend/toolbox/admin.py | 20 ++ backend/toolbox/board_services.py | 147 ++++++++++++ backend/toolbox/consumers.py | 100 ++++++++ backend/toolbox/migrations/0001_initial.py | 38 +++ backend/toolbox/migrations/__init__.py | 0 backend/toolbox/models.py | 53 +++++ backend/toolbox/routing.py | 10 + backend/toolbox/test_consumers.py | 78 +++++++ backend/toolbox/test_views.py | 54 +++++ backend/toolbox/urls.py | 16 +- backend/toolbox/views.py | 48 ++++ 16 files changed, 890 insertions(+), 36 deletions(-) create mode 100644 backend/toolbox/admin.py create mode 100644 backend/toolbox/board_services.py create mode 100644 backend/toolbox/consumers.py create mode 100644 backend/toolbox/migrations/0001_initial.py create mode 100644 backend/toolbox/migrations/__init__.py create mode 100644 backend/toolbox/models.py create mode 100644 backend/toolbox/routing.py create mode 100644 backend/toolbox/test_consumers.py diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index b419477..bdde153 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -61,9 +61,16 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在(): assert "js/games.js" in template assert 'id="whiteboard-canvas"' in template assert 'id="geometry-canvas"' in template + assert template.count('data-tool="whiteboard"') == 1 + assert 'data-tool="geometry"' not in template + assert 'id="board-join-form"' in template + assert "函数书写规则" in template assert 'id="math-game-list"' in template assert "window.HuluToolbox" in toolbox assert "pointerdown" in toolbox + assert "class BoardRealtime" in toolbox + assert 'boardRealtime.send("canvas", payload)' in toolbox + assert 'boardRealtime.send("geometry", payload)' in toolbox assert "window.HuluGames" in games assert "sudoku-board" in games assert 'errorMessage.className = "form-error game-form-error"' in games diff --git a/backend/config/asgi.py b/backend/config/asgi.py index f1784c0..0497c3d 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application() from common.consumers import HealthConsumer from contest.routing import websocket_urlpatterns +from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns application = ProtocolTypeRouter( { @@ -20,6 +21,7 @@ application = ProtocolTypeRouter( [ path("ws/health/", HealthConsumer.as_asgi()), *websocket_urlpatterns, + *toolbox_websocket_urlpatterns, ] ) ), diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 4e056c9..1474464 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -214,12 +214,16 @@ button { color: inherit; } .symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; } .graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; } .graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; } +.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); } .check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; } .graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; } #graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; } .drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; } .toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; } .canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; } +.board-mode-switch { display: flex; gap: 8px; margin-bottom: 12px; }.board-mode-switch button { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; background: white; cursor: pointer; }.board-mode-switch button.active { border-color: var(--ink); background: var(--ink); color: white; } +.board-online-panel { display: grid; grid-template-columns: auto minmax(250px, 1fr); gap: 10px 16px; align-items: center; margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(120deg, rgba(25,101,72,.06), rgba(204,232,91,.1)); }.board-online-panel > div, .board-online-panel form { display: flex; gap: 8px; }.board-online-panel select, .board-online-panel input { min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.board-online-panel p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 11px; }.board-online-panel > strong { grid-column: 1 / -1; color: var(--green); }.board-online-panel [hidden] { display: none !important; } +.board-pane { display: none; }.board-pane.active { display: block; } .discover-title { padding-bottom: 24px; } .ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; } .map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; } @@ -290,6 +294,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; } .preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; } .match-mode-switch { grid-template-columns: 1fr; } + .board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; } .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; } diff --git a/backend/static/js/toolbox.js b/backend/static/js/toolbox.js index 05c511f..2924917 100644 --- a/backend/static/js/toolbox.js +++ b/backend/static/js/toolbox.js @@ -273,6 +273,7 @@ this.drawing = false; this.start = null; this.preview = null; + this.onChange = null; this.reset(); canvas.addEventListener("pointerdown", (event) => this.startDrawing(event)); canvas.addEventListener("pointermove", (event) => this.move(event)); @@ -290,6 +291,23 @@ if (this.history.length > 20) this.history.shift(); } + emitSnapshot() { + if (!this.onChange) return; + const data = this.canvas.toDataURL("image/png"); + if (data.length <= 700000) this.onChange(data); + else showToast("当前画布内容较大,已保留本地编辑但暂停联机同步"); + } + + applySnapshot(data) { + if (typeof data !== "string" || !data.startsWith("data:image/")) return; + const image = new Image(); + image.onload = () => { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height); + }; + image.src = data; + } + startDrawing(event) { event.preventDefault(); this.snapshot(); @@ -305,6 +323,7 @@ this.context.fillStyle = $tool("#whiteboard-color").value; this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`; this.context.fillText(text, this.start.x, this.start.y); + this.emitSnapshot(); } this.drawing = false; return; @@ -352,6 +371,7 @@ this.move(event); this.drawing = false; this.preview = null; + this.emitSnapshot(); } cancel() { @@ -366,6 +386,7 @@ image.onload = () => { this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.drawImage(image, 0, 0); + this.emitSnapshot(); }; image.src = source; } @@ -373,6 +394,7 @@ clear() { this.snapshot(); this.reset(); + this.emitSnapshot(); } addGrid() { @@ -400,6 +422,7 @@ context.moveTo(0, this.canvas.height / 2); context.lineTo(this.canvas.width, this.canvas.height / 2); context.stroke(); + this.emitSnapshot(); } addImage(file) { @@ -421,6 +444,7 @@ image.width * scale, image.height * scale, ); + this.emitSnapshot(); }; image.src = reader.result; }; @@ -439,6 +463,7 @@ this.history = []; this.pending = []; this.dragging = null; + this.onChange = null; canvas.addEventListener("pointerdown", (event) => this.pointerDown(event)); canvas.addEventListener("pointermove", (event) => this.pointerMove(event)); canvas.addEventListener("pointerup", (event) => this.pointerUp(event)); @@ -448,6 +473,34 @@ this.draw(); } + serialize() { + return { + points: this.points, + segments: this.segments, + circles: this.circles, + }; + } + + emitState() { + if (this.onChange) this.onChange(this.serialize()); + } + + applyState(payload) { + if ( + !payload || + !Array.isArray(payload.points) || + !Array.isArray(payload.segments) || + !Array.isArray(payload.circles) + ) { + return; + } + this.points = payload.points.slice(0, 300); + this.segments = payload.segments.slice(0, 500); + this.circles = payload.circles.slice(0, 300); + this.pending = []; + this.draw(); + } + save() { this.history.push( JSON.stringify({ @@ -501,6 +554,7 @@ if (this.tool === "move") { this.pointerMove(event); this.dragging = null; + this.emitState(); return; } this.handlePoint(event); @@ -535,6 +589,7 @@ } } this.draw(); + this.emitState(); $tool("#geometry-hint").textContent = this.pending.length ? "再选择一个点完成构造。" : "可继续创建或切换构造工具。"; @@ -624,6 +679,7 @@ this.circles = parsed.circles; this.pending = []; this.draw(); + this.emitState(); } clear() { @@ -633,15 +689,196 @@ this.circles = []; this.pending = []; this.draw(); + this.emitState(); + } + } + + class BoardRealtime { + constructor() { + this.session = null; + this.socket = null; + this.pollTimer = null; + } + + websocketUrl(path) { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${path}`; + } + + close() { + if (this.pollTimer) window.clearInterval(this.pollTimer); + this.pollTimer = null; + if (this.socket) { + this.socket.onclose = null; + this.socket.close(); + } + this.socket = null; + } + + async refresh() { + if (!this.session?.session_id) return; + try { + this.session = await api(`toolbox/boards/${this.session.session_id}/`); + this.render(); + } catch (error) { + if (error.status === 404) this.close(); + } + } + + connect() { + this.close(); + if (!this.session?.websocket_path) return; + const socket = new WebSocket(this.websocketUrl(this.session.websocket_path)); + this.socket = socket; + socket.addEventListener("open", () => { + this.render("实时连接已建立"); + socket.send(JSON.stringify({ type: "ping" })); + }); + socket.addEventListener("message", async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if ( + message.type === "canvas" && + message.user_id !== String(state.user?.id) + ) { + whiteboard.applySnapshot(message.payload); + } else if ( + message.type === "geometry" && + message.user_id !== String(state.user?.id) + ) { + geometry.applyState(message.payload); + } else if (message.type === "state") { + await this.refresh(); + if (message.reason === "joined" && this.session?.role === "host") { + this.send("canvas", whiteboard.canvas.toDataURL("image/png")); + this.send("geometry", geometry.serialize()); + } + } else if (message.type === "error") { + showToast(message.message); + } + }); + socket.addEventListener("close", () => { + this.render("实时连接已断开,正在轮询房间状态"); + }); + this.pollTimer = window.setInterval(() => this.refresh(), 3000); + } + + send(type, payload) { + if (this.socket?.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ type, payload })); + } + + async create() { + if (!requireAuth()) return; + const mode = $tool("#board-session-mode").value; + try { + this.session = await api("toolbox/boards/", { + method: "POST", + body: { mode }, + }); + this.render(); + this.connect(); + } catch (error) { + showToast(error.message); + } + } + + async join(event) { + event.preventDefault(); + if (!requireAuth()) return; + const input = $tool("#board-code-input"); + try { + this.session = await api("toolbox/boards/join/", { + method: "POST", + body: { code: input.value }, + }); + input.value = ""; + this.render(); + this.connect(); + } catch (error) { + showToast(error.message); + } + } + + async guess(event) { + event.preventDefault(); + if (!this.session) return; + const input = $tool("#board-guess-input"); + try { + const result = await api( + `toolbox/boards/${this.session.session_id}/guess/`, + { + method: "POST", + body: { guess: input.value }, + }, + ); + this.session = result; + showToast(result.message); + if (result.correct) input.value = ""; + this.render(); + } catch (error) { + showToast(error.message); + } + } + + render(connectionMessage = "") { + const status = $tool("#board-session-status"); + const target = $tool("#board-target"); + const guessForm = $tool("#board-guess-form"); + if (!this.session) { + status.textContent = "当前为本地画板,创建或加入后开始实时同步。"; + target.hidden = true; + guessForm.hidden = true; + return; + } + const participants = this.session.guest + ? `${this.session.host} 与 ${this.session.guest}` + : `${this.session.host} 正在等待另一位用户`; + status.textContent = + `${this.session.mode_label} · 联机码 ${this.session.code} · ${participants}` + + (connectionMessage ? ` · ${connectionMessage}` : ""); + target.hidden = !this.session.target; + target.textContent = this.session.target + ? `本轮数学对象:${this.session.target}` + : ""; + guessForm.hidden = !( + this.session.mode === "draw_guess" && + this.session.role === "guest" && + this.session.status === "active" + ); + if (this.session.status === "completed") { + status.textContent += + ` · 本轮结束,比分 ${this.session.host_score}:${this.session.guest_score}`; + } } } let whiteboard; let geometry; + let boardRealtime; function initDrawingTools() { whiteboard = new Whiteboard($tool("#whiteboard-canvas")); geometry = new GeometryBoard($tool("#geometry-canvas")); + boardRealtime = new BoardRealtime(); + whiteboard.onChange = (payload) => boardRealtime.send("canvas", payload); + geometry.onChange = (payload) => boardRealtime.send("geometry", payload); + $$tool("[data-board-pane-toggle]").forEach((button) => { + button.addEventListener("click", () => { + const pane = button.dataset.boardPaneToggle; + $$tool("[data-board-pane-toggle]").forEach((item) => { + item.classList.toggle("active", item === button); + }); + $$tool("[data-board-pane]").forEach((item) => { + item.classList.toggle("active", item.dataset.boardPane === pane); + }); + if (pane === "geometry") window.setTimeout(() => geometry.draw(), 30); + }); + }); $$tool("[data-whiteboard-tool]").forEach((button) => { button.addEventListener("click", () => { whiteboard.tool = button.dataset.whiteboardTool; @@ -675,6 +912,21 @@ $tool("#geometry-export").addEventListener("click", () => downloadCanvas(geometry.canvas, "hulumath-geometry") ); + $tool("#board-create").addEventListener("click", () => boardRealtime.create()); + $tool("#board-join-form").addEventListener( + "submit", + (event) => boardRealtime.join(event), + ); + $tool("#board-guess-form").addEventListener( + "submit", + (event) => boardRealtime.guess(event), + ); + $tool("#board-code-input").addEventListener("input", (event) => { + event.target.value = event.target.value + .toUpperCase() + .replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "") + .slice(0, 6); + }); } function init() { @@ -703,7 +955,7 @@ function activate(tool) { if (tool === "graph") window.setTimeout(drawGraph, 30); - if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30); + if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30); } window.HuluToolbox = { init, activate, runCalculator, drawGraph }; diff --git a/backend/templates/index.html b/backend/templates/index.html index 7dafd08..de7491e 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -212,8 +212,7 @@ - - + @@ -284,6 +283,10 @@
FUNCTION PLOTTER

函数图形绘制

每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。

+
+ 函数书写规则 +

每行一个表达式,直接写右侧:sin(x)x^2 - 2*x + 1sqrt(abs(x))。乘法必须写 *,幂可写 ^,参数仅支持 a

+
@@ -298,40 +301,63 @@
-
MATH WHITEBOARD

数学白板

支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG。

-
- - - - - - - - - - - - - +
MATH BOARD

统一数学画板

在白板与几何构造之间切换,可用联机码同步展示或发起数学你画我猜。

+
+ +
-
-
- -
-
GEOMETRY BOARD

几何画板

依次点击构造点、线段和圆;支持中点、长度与坐标测量。

-
- - - - - - - - - +
+
+ + +
+
+ + +
+

当前为本地画板,创建或加入后开始实时同步。

+ + +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + + + + + + + +
+
+

点击画布创建第一个点。

-
-

点击画布创建第一个点。

diff --git a/backend/toolbox/admin.py b/backend/toolbox/admin.py new file mode 100644 index 0000000..2b0a326 --- /dev/null +++ b/backend/toolbox/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from .models import BoardSession + + +@admin.register(BoardSession) +class BoardSessionAdmin(admin.ModelAdmin): + list_display = ( + "code", + "mode", + "host", + "guest", + "status", + "guest_score", + "created_at", + ) + list_filter = ("mode", "status") + search_fields = ("code", "host__username", "guest__username") + readonly_fields = ("code", "target", "created_at", "completed_at") + ordering = ("-created_at",) diff --git a/backend/toolbox/board_services.py b/backend/toolbox/board_services.py new file mode 100644 index 0000000..0d48426 --- /dev/null +++ b/backend/toolbox/board_services.py @@ -0,0 +1,147 @@ +import secrets +from datetime import timedelta + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from .models import BoardSession + +BOARD_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +BOARD_TTL = timedelta(minutes=30) +DRAW_GUESS_TARGETS = ( + "抛物线", + "三角形", + "勾股定理", + "质数", + "圆", + "正弦函数", + "分数", + "坐标系", +) + + +def _new_board_code(): + for _ in range(20): + code = "".join(secrets.choice(BOARD_CODE_ALPHABET) for _ in range(6)) + if not BoardSession.objects.filter(code=code).exists(): + return code + raise ValidationError("暂时无法生成画板联机码,请稍后重试") + + +def broadcast_board(session_id, reason): + channel_layer = get_channel_layer() + if channel_layer is None: + return + async_to_sync(channel_layer.group_send)( + f"board_{session_id}", + { + "type": "board.state", + "reason": reason, + }, + ) + + +def board_payload(session, user): + is_host = session.host_id == user.id + reveal_target = ( + session.mode == BoardSession.Mode.DRAW_GUESS + and (is_host or session.status == BoardSession.Status.COMPLETED) + ) + return { + "session_id": session.id, + "code": session.code, + "mode": session.mode, + "mode_label": session.get_mode_display(), + "status": session.status, + "role": "host" if is_host else "guest", + "target": session.target if reveal_target else None, + "host": session.host.nickname, + "guest": session.guest.nickname if session.guest else None, + "host_score": session.host_score, + "guest_score": session.guest_score, + "expires_at": session.expires_at, + "websocket_path": f"/ws/v1/toolbox/boards/{session.id}/", + } + + +@transaction.atomic +def create_board(user, mode): + if mode not in BoardSession.Mode.values: + raise ValidationError({"mode": "不支持的画板联机模式"}) + BoardSession.objects.filter( + host=user, + status=BoardSession.Status.WAITING, + ).update(status=BoardSession.Status.CANCELLED) + return BoardSession.objects.create( + code=_new_board_code(), + mode=mode, + host=user, + target=( + secrets.choice(DRAW_GUESS_TARGETS) + if mode == BoardSession.Mode.DRAW_GUESS + else "" + ), + expires_at=timezone.now() + BOARD_TTL, + ) + + +@transaction.atomic +def join_board(user, code): + normalized = str(code or "").strip().upper() + if len(normalized) != 6 or any( + character not in BOARD_CODE_ALPHABET for character in normalized + ): + raise ValidationError({"code": "画板联机码应为 6 位大写字母或数字"}) + try: + session = ( + BoardSession.objects.select_for_update() + .select_related("host", "guest") + .get(code=normalized) + ) + except BoardSession.DoesNotExist as exc: + raise ValidationError({"code": "画板联机码不存在"}) from exc + if session.host_id == user.id: + raise ValidationError({"code": "不能加入自己创建的画板"}) + if session.status != BoardSession.Status.WAITING: + raise ValidationError({"code": "画板联机码已失效或已被使用"}) + if session.expires_at <= timezone.now(): + session.status = BoardSession.Status.CANCELLED + session.save(update_fields=["status"]) + raise ValidationError({"code": "画板联机码已经过期"}) + session.guest = user + session.status = BoardSession.Status.ACTIVE + session.save(update_fields=["guest", "status"]) + transaction.on_commit(lambda: broadcast_board(session.id, "joined")) + return session + + +@transaction.atomic +def submit_guess(user, session_id, raw_guess): + session = ( + BoardSession.objects.select_for_update() + .select_related("host", "guest") + .get(id=session_id) + ) + if session.guest_id != user.id: + raise ValidationError("只有猜题方可以提交答案") + if ( + session.mode != BoardSession.Mode.DRAW_GUESS + or session.status != BoardSession.Status.ACTIVE + ): + raise ValidationError("当前画板不接受猜题") + guess = str(raw_guess or "").strip() + if not guess or len(guess) > 40: + raise ValidationError({"guess": "请输入不超过 40 个字符的数学对象"}) + correct = guess.replace(" ", "").lower() == session.target.replace(" ", "").lower() + if correct: + session.guest_score += 1 + session.status = BoardSession.Status.COMPLETED + session.completed_at = timezone.now() + session.save( + update_fields=["guest_score", "status", "completed_at"] + ) + transaction.on_commit(lambda: broadcast_board(session.id, "completed")) + return session, correct diff --git a/backend/toolbox/consumers.py b/backend/toolbox/consumers.py new file mode 100644 index 0000000..38e595e --- /dev/null +++ b/backend/toolbox/consumers.py @@ -0,0 +1,100 @@ +import json + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from .models import BoardSession + + +class BoardConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + self.session_id = self.scope["url_route"]["kwargs"]["session_id"] + self.group_name = f"board_{self.session_id}" + user = self.scope["user"] + if not user.is_authenticated or not await self._is_participant(user.id): + await self.close(code=4403) + return + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + await self.send_json( + { + "type": "connected", + "session_id": str(self.session_id), + } + ) + + async def disconnect(self, close_code): + if hasattr(self, "group_name"): + await self.channel_layer.group_discard( + self.group_name, + self.channel_name, + ) + + async def receive_json(self, content, **kwargs): + event_type = content.get("type") + if event_type == "ping": + await self.send_json({"type": "pong"}) + return + if event_type not in {"canvas", "geometry"}: + await self.send_json({"type": "error", "message": "不支持的画板消息"}) + return + if not await self._is_active(): + await self.send_json({"type": "error", "message": "画板尚未开始或已经结束"}) + return + payload = content.get("payload") + if event_type == "canvas": + valid = ( + isinstance(payload, str) + and payload.startswith("data:image/") + and len(payload) <= 700_000 + ) + else: + valid = isinstance(payload, dict) and len( + json.dumps(payload, ensure_ascii=False) + ) <= 100_000 + if not valid: + await self.send_json({"type": "error", "message": "画板消息无效或过大"}) + return + await self.channel_layer.group_send( + self.group_name, + { + "type": "board.update", + "event_type": event_type, + "payload": payload, + "user_id": str(self.scope["user"].id), + }, + ) + + async def board_update(self, event): + await self.send_json( + { + "type": event["event_type"], + "payload": event["payload"], + "user_id": event["user_id"], + } + ) + + async def board_state(self, event): + await self.send_json( + { + "type": "state", + "reason": event["reason"], + "session_id": str(self.session_id), + } + ) + + @database_sync_to_async + def _is_participant(self, user_id): + return BoardSession.objects.filter(id=self.session_id).filter( + host_id=user_id + ).exists() or BoardSession.objects.filter( + id=self.session_id, + guest_id=user_id, + ).exists() + + @database_sync_to_async + def _is_active(self): + return BoardSession.objects.filter( + id=self.session_id, + status=BoardSession.Status.ACTIVE, + ).exists() diff --git a/backend/toolbox/migrations/0001_initial.py b/backend/toolbox/migrations/0001_initial.py new file mode 100644 index 0000000..4ac8131 --- /dev/null +++ b/backend/toolbox/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.23 on 2026-08-09 16:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BoardSession', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=6, unique=True)), + ('mode', models.CharField(choices=[('collaborate', '协作展示'), ('draw_guess', '数学你画我猜')], default='collaborate', max_length=16)), + ('target', models.CharField(blank=True, max_length=40)), + ('host_score', models.PositiveSmallIntegerField(default=0)), + ('guest_score', models.PositiveSmallIntegerField(default=0)), + ('status', models.CharField(choices=[('waiting', '等待加入'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('guest', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='joined_board_sessions', to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hosted_board_sessions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/toolbox/migrations/__init__.py b/backend/toolbox/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/toolbox/models.py b/backend/toolbox/models.py new file mode 100644 index 0000000..6350f09 --- /dev/null +++ b/backend/toolbox/models.py @@ -0,0 +1,53 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class BoardSession(models.Model): + class Mode(models.TextChoices): + COLLABORATE = "collaborate", "协作展示" + DRAW_GUESS = "draw_guess", "数学你画我猜" + + class Status(models.TextChoices): + WAITING = "waiting", "等待加入" + ACTIVE = "active", "进行中" + COMPLETED = "completed", "已完成" + CANCELLED = "cancelled", "已取消" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=6, unique=True) + mode = models.CharField( + max_length=16, + choices=Mode.choices, + default=Mode.COLLABORATE, + ) + host = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="hosted_board_sessions", + ) + guest = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="joined_board_sessions", + ) + target = models.CharField(max_length=40, blank=True) + host_score = models.PositiveSmallIntegerField(default=0) + guest_score = models.PositiveSmallIntegerField(default=0) + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.WAITING, + ) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.code} · {self.get_mode_display()}" diff --git a/backend/toolbox/routing.py b/backend/toolbox/routing.py new file mode 100644 index 0000000..35de309 --- /dev/null +++ b/backend/toolbox/routing.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .consumers import BoardConsumer + +websocket_urlpatterns = [ + path( + "ws/v1/toolbox/boards//", + BoardConsumer.as_asgi(), + ), +] diff --git a/backend/toolbox/test_consumers.py b/backend/toolbox/test_consumers.py new file mode 100644 index 0000000..9eaebdf --- /dev/null +++ b/backend/toolbox/test_consumers.py @@ -0,0 +1,78 @@ +from datetime import timedelta + +import pytest +from asgiref.sync import async_to_sync +from channels.routing import URLRouter +from channels.testing import WebsocketCommunicator +from django.urls import path +from django.utils import timezone + +from accounts.models import User +from toolbox.consumers import BoardConsumer +from toolbox.models import BoardSession + + +@pytest.mark.django_db(transaction=True) +def test_board_consumer_参与者同步画布并拒绝局外人(): + host = User.objects.create_user( + username="board_socket_host", + password="StrongPass_2026", + nickname="WS 房主", + ) + guest = User.objects.create_user( + username="board_socket_guest", + password="StrongPass_2026", + nickname="WS 访客", + ) + outsider = User.objects.create_user( + username="board_socket_outsider", + password="StrongPass_2026", + nickname="WS 局外人", + ) + session = BoardSession.objects.create( + code="ABC234", + host=host, + guest=guest, + status=BoardSession.Status.ACTIVE, + expires_at=timezone.now() + timedelta(minutes=30), + ) + application = URLRouter( + [ + path( + "ws/test//", + BoardConsumer.as_asgi(), + ) + ] + ) + + async def scenario(): + outsider_socket = WebsocketCommunicator( + application, + f"/ws/test/{session.id}/", + ) + outsider_socket.scope["user"] = outsider + connected, close_code = await outsider_socket.connect() + assert not connected + assert close_code == 4403 + + host_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/") + guest_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/") + host_socket.scope["user"] = host + guest_socket.scope["user"] = guest + assert (await host_socket.connect())[0] + assert (await guest_socket.connect())[0] + assert (await host_socket.receive_json_from())["type"] == "connected" + assert (await guest_socket.receive_json_from())["type"] == "connected" + + payload = "data:image/png;base64,AAAA" + await host_socket.send_json_to({"type": "canvas", "payload": payload}) + host_event = await host_socket.receive_json_from() + guest_event = await guest_socket.receive_json_from() + assert host_event["payload"] == payload + assert guest_event["payload"] == payload + assert guest_event["user_id"] == str(host.id) + + await host_socket.disconnect() + await guest_socket.disconnect() + + async_to_sync(scenario)() diff --git a/backend/toolbox/test_views.py b/backend/toolbox/test_views.py index bb92f10..91137a1 100644 --- a/backend/toolbox/test_views.py +++ b/backend/toolbox/test_views.py @@ -1,5 +1,8 @@ import pytest +from accounts.models import User +from toolbox.models import BoardSession + @pytest.mark.django_db def test_calculator_api_公开访问并返回精确值(client): @@ -23,3 +26,54 @@ def test_calculator_api_危险表达式返回四百(client): assert response.status_code == 400 assert "error" in response.json() + + +@pytest.mark.django_db +def test_board_api_联机码加入你画我猜并服务端计分(client): + host = User.objects.create_user( + username="board_host", + password="StrongPass_2026", + nickname="画板房主", + ) + guest = User.objects.create_user( + username="board_guest", + password="StrongPass_2026", + nickname="猜题玩家", + ) + client.force_login(host) + created = client.post( + "/api/v1/toolbox/boards/", + {"mode": "draw_guess"}, + content_type="application/json", + ) + target = created.json()["target"] + code = created.json()["code"] + session_id = created.json()["session_id"] + + client.force_login(guest) + joined = client.post( + "/api/v1/toolbox/boards/join/", + {"code": code.lower()}, + content_type="application/json", + ) + wrong = client.post( + f"/api/v1/toolbox/boards/{session_id}/guess/", + {"guess": "不是答案"}, + content_type="application/json", + ) + correct = client.post( + f"/api/v1/toolbox/boards/{session_id}/guess/", + {"guess": target}, + content_type="application/json", + ) + + assert created.status_code == 201 + assert created.json()["role"] == "host" + assert target + assert joined.status_code == 200 + assert joined.json()["target"] is None + assert joined.json()["status"] == BoardSession.Status.ACTIVE + assert wrong.json()["correct"] is False + assert correct.json()["correct"] is True + assert correct.json()["guest_score"] == 1 + assert correct.json()["status"] == BoardSession.Status.COMPLETED diff --git a/backend/toolbox/urls.py b/backend/toolbox/urls.py index 18f34de..0d66295 100644 --- a/backend/toolbox/urls.py +++ b/backend/toolbox/urls.py @@ -1,7 +1,21 @@ from django.urls import path -from .views import CalculatorView +from .views import ( + BoardCreateView, + BoardGuessView, + BoardJoinView, + BoardStateView, + CalculatorView, +) urlpatterns = [ path("calculate/", CalculatorView.as_view(), name="toolbox-calculate"), + path("boards/", BoardCreateView.as_view(), name="board-create"), + path("boards/join/", BoardJoinView.as_view(), name="board-join"), + path("boards//", BoardStateView.as_view(), name="board-state"), + path( + "boards//guess/", + BoardGuessView.as_view(), + name="board-guess", + ), ] diff --git a/backend/toolbox/views.py b/backend/toolbox/views.py index 38d359c..b908b7a 100644 --- a/backend/toolbox/views.py +++ b/backend/toolbox/views.py @@ -1,10 +1,19 @@ +from django.db.models import Q +from django.shortcuts import get_object_or_404 from rest_framework import permissions from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +from .board_services import ( + board_payload, + create_board, + join_board, + submit_guess, +) from .engine import calculate +from .models import BoardSession class CalculatorView(APIView): @@ -20,3 +29,42 @@ class CalculatorView(APIView): except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc: raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc return Response(payload) + + +class BoardCreateView(APIView): + def post(self, request): + session = create_board( + request.user, + request.data.get("mode", BoardSession.Mode.COLLABORATE), + ) + return Response(board_payload(session, request.user), status=201) + + +class BoardJoinView(APIView): + def post(self, request): + session = join_board(request.user, request.data.get("code")) + return Response(board_payload(session, request.user)) + + +class BoardStateView(APIView): + def get(self, request, session_id): + session = get_object_or_404( + BoardSession.objects.select_related("host", "guest").filter( + Q(host=request.user) | Q(guest=request.user) + ), + id=session_id, + ) + return Response(board_payload(session, request.user)) + + +class BoardGuessView(APIView): + def post(self, request, session_id): + session, correct = submit_guess( + request.user, + session_id, + request.data.get("guess"), + ) + payload = board_payload(session, request.user) + payload["correct"] = correct + payload["message"] = "猜对了,得 1 分" if correct else "还不对,再观察一下画板" + return Response(payload)