From ce7bed5f19c653d8d2785b523d23a7a0308cfef7 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 03:30:31 +0800 Subject: [PATCH] fix: harden twenty four game submissions --- backend/common/test_frontend_assets.py | 2 + backend/contest/game_services.py | 24 ++++++++++- backend/contest/test_game_services.py | 56 ++++++++++++++++++++++++++ backend/static/js/games.js | 11 ++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index 336ff3c..1beae50 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -63,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/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; }