diff --git a/backend/common/api.py b/backend/common/api.py index 05328aa..43c98b4 100644 --- a/backend/common/api.py +++ b/backend/common/api.py @@ -1,17 +1,65 @@ +import logging + from rest_framework.views import exception_handler as drf_exception_handler +logger = logging.getLogger(__name__) + + +def _error_messages(details): + messages = [] + + def collect(value): + if isinstance(value, dict): + for item in value.values(): + collect(item) + elif isinstance(value, (list, tuple)): + for item in value: + collect(item) + elif value is not None: + message = str(value).strip() + if message and message not in messages: + messages.append(message) + + collect(details) + return messages + + +def _error_message(details): + messages = _error_messages(details) + if not messages: + return "请求未能完成" + return ";".join(messages)[:300] + def exception_handler(exc, context): response = drf_exception_handler(exc, context) + request = context.get("request") if response is None: + logger.exception( + "api_unhandled_error method=%s path=%s", + getattr(request, "method", "-"), + getattr(request, "path", "-"), + ) return response - request = context.get("request") + details = response.data + code = getattr(exc, "default_code", "request_error") + message = _error_message(details) + fields = ",".join(details.keys()) if isinstance(details, dict) else "-" + logger.info( + "api_request_error method=%s path=%s status=%s code=%s fields=%s message=%s", + getattr(request, "method", "-"), + getattr(request, "path", "-"), + response.status_code, + code, + fields, + message, + ) response.data = { "error": { - "code": getattr(exc, "default_code", "request_error"), - "message": "请求未能完成", - "details": response.data, + "code": code, + "message": message, + "details": details, "request_id": getattr(request, "request_id", None), } } diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index 03e1df0..1beae50 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -13,6 +13,18 @@ def test_app_js_幂等键兼容非安全上下文(): assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2 +def test_app_js_api_错误优先显示详情并输出安全诊断日志(): + source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + + assert "function collectApiErrorMessages(" in source + assert "const baseMessage = detailMessages.length" in source + assert 'console.error("[Hulumath API]", {' in source + assert "requestId: error.requestId" in source + assert "error.details = details || null" in source + assert "payload.error?.message || payload.detail ||" in source + assert "payload.error?.message || payload.detail ||\n (details ?" not in source + + def test_app_css_答题提交按钮可见且长弹窗可滚动(): source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") @@ -51,6 +63,8 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在(): assert "pointerdown" in toolbox assert "window.HuluGames" in games assert "sudoku-board" in games + assert 'errorMessage.className = "form-error game-form-error"' in games + assert "errorMessage.textContent = error.message" in games assert "touch-action: none" in styles assert ".calculator-controls [hidden]" in styles assert ".sudoku-board" in styles diff --git a/backend/contest/game_services.py b/backend/contest/game_services.py index aaa2549..7a8cdee 100644 --- a/backend/contest/game_services.py +++ b/backend/contest/game_services.py @@ -1,5 +1,6 @@ import ast import secrets +import unicodedata from collections import Counter from fractions import Fraction @@ -36,6 +37,18 @@ TWENTY_FOUR_PUZZLES = { MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)], } +TWENTY_FOUR_SYMBOLS = str.maketrans( + { + "×": "*", + "·": "*", + "∙": "*", + "÷": "/", + "−": "-", + "–": "-", + "—": "-", + } +) + def _grid_from_text(value): return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)] @@ -80,7 +93,10 @@ def start_game(user, kind, difficulty): def _validate_twenty_four_expression(source, numbers): - source = str(source or "").strip() + source = unicodedata.normalize("NFKC", str(source or "")).translate( + TWENTY_FOUR_SYMBOLS + ) + source = source.strip() if not source or len(source) > 120: raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"}) try: @@ -90,7 +106,11 @@ def _validate_twenty_four_expression(source, numbers): used = [] def evaluate(node): - if isinstance(node, ast.Constant) and isinstance(node.value, int): + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, int) + and not isinstance(node.value, bool) + ): used.append(node.value) return Fraction(node.value) if isinstance(node, ast.BinOp) and isinstance( diff --git a/backend/contest/test_game_services.py b/backend/contest/test_game_services.py index 588e768..a9651df 100644 --- a/backend/contest/test_game_services.py +++ b/backend/contest/test_game_services.py @@ -47,6 +47,55 @@ def test_twenty_four_服务端校验数字使用与幂等提交(game_user): assert replay["score"] == result["score"] +@pytest.mark.django_db +def test_twenty_four_api_兼容常见数学符号并完成计分(client, game_user): + attempt = MathGameAttempt.objects.create( + user=game_user, + kind=MathGameAttempt.Kind.TWENTY_FOUR, + puzzle={"numbers": [1, 3, 4, 6]}, + solution={"target": 24}, + ) + client.force_login(game_user) + + response = client.post( + f"/api/v1/contests/games/attempts/{attempt.id}/submit/", + {"expression": "6÷(1−3÷4)"}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="unicode-game-submit", + ) + + assert response.status_code == 200 + assert response.json()["status"] == MathGameAttempt.Status.COMPLETED + assert response.json()["score"] >= 100 + attempt.refresh_from_db() + assert attempt.submission == {"expression": "6/(1-3/4)"} + + +@pytest.mark.django_db +def test_twenty_four_api_答案错误时返回具体原因和请求编号(client, game_user): + attempt = MathGameAttempt.objects.create( + user=game_user, + kind=MathGameAttempt.Kind.TWENTY_FOUR, + puzzle={"numbers": [1, 3, 4, 6]}, + solution={"target": 24}, + ) + client.force_login(game_user) + + response = client.post( + f"/api/v1/contests/games/attempts/{attempt.id}/submit/", + {"expression": "1 + 3 + 4 + 6"}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="incorrect-game-submit", + HTTP_X_REQUEST_ID="twenty-four-invalid-test", + ) + + assert response.status_code == 400 + assert response.json()["error"]["message"] == "当前结果是 14,还没有得到 24" + assert response.json()["error"]["request_id"] == "twenty-four-invalid-test" + attempt.refresh_from_db() + assert attempt.status == MathGameAttempt.Status.ACTIVE + + @pytest.mark.django_db def test_twenty_four_拒绝额外数字和非四则表达式(game_user): attempt = MathGameAttempt.objects.create( @@ -70,6 +119,13 @@ def test_twenty_four_拒绝额外数字和非四则表达式(game_user): {"expression": "pow(2, 3) * 3"}, "invalid-function", ) + with pytest.raises(ValidationError, match="只允许"): + submit_game( + game_user, + attempt.id, + {"expression": "6 / (True - 3 / 4)"}, + "invalid-boolean", + ) @pytest.mark.django_db diff --git a/backend/contest/test_realtime_api.py b/backend/contest/test_realtime_api.py index 9f72438..67341fc 100644 --- a/backend/contest/test_realtime_api.py +++ b/backend/contest/test_realtime_api.py @@ -1,8 +1,17 @@ +from datetime import timedelta + import pytest from django.test import Client +from django.utils import timezone from accounts.models import User -from contest.models import Contest, ContestQuestion, Question, QuestionVersion +from contest.models import ( + Contest, + ContestQuestion, + Question, + QuestionVersion, + RealtimeMatch, +) @pytest.fixture @@ -94,3 +103,103 @@ def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_s final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json() assert final_state["result"]["winner"] == "self" assert final_state["attempt"]["questions"][0]["correct_answer"] == "42" + + +@pytest.mark.django_db +def test_challenge_api_无效联机码返回具体原因_request_id_和诊断日志( + realtime_api_setup, + caplog, +): + _, _, second_client = realtime_api_setup + caplog.set_level("INFO", logger="common.api") + + response = second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": "ABC234"}, + content_type="application/json", + HTTP_X_REQUEST_ID="challenge-invalid-test", + ) + + assert response.status_code == 400 + assert response["X-Request-ID"] == "challenge-invalid-test" + assert response.json()["error"] == { + "code": "invalid", + "message": "联机码不存在", + "details": {"challenge_code": "联机码不存在"}, + "request_id": "challenge-invalid-test", + } + assert "api_request_error method=POST" in caplog.text + assert "path=/api/v1/contests/challenges/join/" in caplog.text + assert "fields=challenge_code message=联机码不存在" in caplog.text + + +@pytest.mark.django_db +def test_challenge_api_创建者不能加入自己的联机码(realtime_api_setup): + contest, first_client, _ = realtime_api_setup + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {}, + content_type="application/json", + ) + + response = first_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": created.json()["challenge_code"]}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["error"]["message"] == "不能加入自己创建的约战" + + +@pytest.mark.django_db +def test_challenge_api_过期联机码返回具体原因(realtime_api_setup): + contest, first_client, second_client = realtime_api_setup + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {}, + content_type="application/json", + ) + RealtimeMatch.objects.filter(id=created.json()["match_id"]).update( + expires_at=timezone.now() - timedelta(seconds=1) + ) + + response = second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": created.json()["challenge_code"]}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["error"]["message"] == "联机码已失效或已被使用" + + +@pytest.mark.django_db +def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup): + contest, first_client, second_client = realtime_api_setup + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {}, + content_type="application/json", + ) + second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": created.json()["challenge_code"]}, + content_type="application/json", + ) + third = User.objects.create_user( + username="api_player_three", + password="StrongPass_2026", + nickname="API 玩家三", + ) + third_client = Client() + third_client.force_login(third) + + response = third_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": created.json()["challenge_code"]}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["error"]["message"] == "联机码已失效或已被使用" diff --git a/backend/static/js/app.js b/backend/static/js/app.js index fffc68f..6f97f9b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -70,22 +70,71 @@ function createIdempotencyKey() { ].join("-"); } +function collectApiErrorMessages(value, messages = []) { + if (Array.isArray(value)) { + value.forEach((item) => collectApiErrorMessages(item, messages)); + } else if (value && typeof value === "object") { + Object.values(value).forEach((item) => collectApiErrorMessages(item, messages)); + } else if (value !== undefined && value !== null) { + const message = String(value).trim(); + if (message && !messages.includes(message)) messages.push(message); + } + return messages; +} + +function logApiError(error) { + console.error("[Hulumath API]", { + method: error.method, + path: error.path, + status: error.status, + code: error.code, + requestId: error.requestId, + details: error.details, + }); +} + async function api(path, options = {}) { + const method = (options.method || "GET").toUpperCase(); + const requestPath = `/api/v1/${path}`; const headers = { Accept: "application/json", ...(options.headers || {}) }; if (options.body && typeof options.body !== "string") { headers["Content-Type"] = "application/json"; options.body = JSON.stringify(options.body); } - if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken(); - const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers }); + if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken(); + let response; + try { + response = await fetch(requestPath, { credentials: "same-origin", ...options, headers }); + } catch (cause) { + const error = new Error("网络连接失败,请检查连接后重试"); + error.status = null; + error.code = "network_error"; + error.requestId = null; + error.details = null; + error.path = requestPath; + error.method = method; + error.cause = cause; + logApiError(error); + throw error; + } if (response.status === 204) return null; const payload = await response.json().catch(() => ({})); if (!response.ok) { const details = payload.error?.details; - const message = payload.error?.message || payload.detail || - (details ? Object.values(details).flat().join(" ") : "请求失败"); + const detailMessages = collectApiErrorMessages(details); + const requestId = payload.error?.request_id || response.headers.get("X-Request-ID"); + const baseMessage = detailMessages.length + ? detailMessages.join(";") + : payload.error?.message || payload.detail || `请求失败(HTTP ${response.status})`; + const message = requestId ? `${baseMessage}(请求编号:${requestId})` : baseMessage; const error = new Error(message); error.status = response.status; + error.code = payload.error?.code || "request_error"; + error.requestId = requestId; + error.details = details || null; + error.path = requestPath; + error.method = method; + logApiError(error); throw error; } return payload; diff --git a/backend/static/js/games.js b/backend/static/js/games.js index ee153b2..e5d5192 100644 --- a/backend/static/js/games.js +++ b/backend/static/js/games.js @@ -143,6 +143,13 @@ input.placeholder = "例如:6 / (1 - 3 / 4)"; input.autocomplete = "off"; input.inputMode = "text"; + input.spellcheck = false; + const errorMessage = document.createElement("p"); + errorMessage.className = "form-error game-form-error"; + errorMessage.setAttribute("role", "alert"); + input.addEventListener("input", () => { + errorMessage.textContent = ""; + }); const keypad = document.createElement("div"); keypad.className = "game-keypad"; ["+", "-", "*", "/", "(", ")"].forEach((operator) => { @@ -159,10 +166,11 @@ submit.className = "primary-button"; submit.type = "submit"; submit.textContent = "验证并计分"; - form.append(input, keypad, submit); + form.append(input, keypad, errorMessage, submit); form.addEventListener("submit", async (event) => { event.preventDefault(); submit.disabled = true; + errorMessage.textContent = ""; try { const result = await api( `contests/games/attempts/${attempt.attempt_id}/submit/`, @@ -175,6 +183,7 @@ renderTwentyFour(result); showToast("得到 24,成绩已记录"); } catch (error) { + errorMessage.textContent = error.message; showToast(error.message); submit.disabled = false; }