5 Commits
Author SHA1 Message Date
Jacky feb8845ec7 Merge pull request 'Fix/api error details' (#14) from fix/api-error-details into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/14
2026-08-09 03:37:16 +08:00
Jacky f177739824 merge: sync latest main
CI / test (pull_request) Successful in 3m3s
PR合并自动部署 / release-check (pull_request) Successful in 1m32s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 03:36:14 +08:00
Jacky aa3246a1cf merge: integrate bug1.0 updates 2026-08-09 03:34:36 +08:00
Jacky ce7bed5f19 fix: harden twenty four game submissions 2026-08-09 03:30:31 +08:00
Jacky b1ac6cef86 fix: expose actionable API error details 2026-08-09 03:26:45 +08:00
7 changed files with 317 additions and 12 deletions
+52 -4
View File
@@ -1,17 +1,65 @@
import logging
from rest_framework.views import exception_handler as drf_exception_handler 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): def exception_handler(exc, context):
response = drf_exception_handler(exc, context) response = drf_exception_handler(exc, context)
request = context.get("request")
if response is None: if response is None:
logger.exception(
"api_unhandled_error method=%s path=%s",
getattr(request, "method", "-"),
getattr(request, "path", "-"),
)
return response 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 = { response.data = {
"error": { "error": {
"code": getattr(exc, "default_code", "request_error"), "code": code,
"message": "请求未能完成", "message": message,
"details": response.data, "details": details,
"request_id": getattr(request, "request_id", None), "request_id": getattr(request, "request_id", None),
} }
} }
+14
View File
@@ -13,6 +13,18 @@ def test_app_js_幂等键兼容非安全上下文():
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2 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_答题提交按钮可见且长弹窗可滚动(): def test_app_css_答题提交按钮可见且长弹窗可滚动():
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") 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 "pointerdown" in toolbox
assert "window.HuluGames" in games assert "window.HuluGames" in games
assert "sudoku-board" 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 "touch-action: none" in styles
assert ".calculator-controls [hidden]" in styles assert ".calculator-controls [hidden]" in styles
assert ".sudoku-board" in styles assert ".sudoku-board" in styles
+22 -2
View File
@@ -1,5 +1,6 @@
import ast import ast
import secrets import secrets
import unicodedata
from collections import Counter from collections import Counter
from fractions import Fraction 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)], 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): def _grid_from_text(value):
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)] 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): 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: if not source or len(source) > 120:
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"}) raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
try: try:
@@ -90,7 +106,11 @@ def _validate_twenty_four_expression(source, numbers):
used = [] used = []
def evaluate(node): 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) used.append(node.value)
return Fraction(node.value) return Fraction(node.value)
if isinstance(node, ast.BinOp) and isinstance( if isinstance(node, ast.BinOp) and isinstance(
+56
View File
@@ -47,6 +47,55 @@ def test_twenty_four_服务端校验数字使用与幂等提交(game_user):
assert replay["score"] == result["score"] 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 @pytest.mark.django_db
def test_twenty_four_拒绝额外数字和非四则表达式(game_user): def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
attempt = MathGameAttempt.objects.create( attempt = MathGameAttempt.objects.create(
@@ -70,6 +119,13 @@ def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
{"expression": "pow(2, 3) * 3"}, {"expression": "pow(2, 3) * 3"},
"invalid-function", "invalid-function",
) )
with pytest.raises(ValidationError, match="只允许"):
submit_game(
game_user,
attempt.id,
{"expression": "6 / (True - 3 / 4)"},
"invalid-boolean",
)
@pytest.mark.django_db @pytest.mark.django_db
+110 -1
View File
@@ -1,8 +1,17 @@
from datetime import timedelta
import pytest import pytest
from django.test import Client from django.test import Client
from django.utils import timezone
from accounts.models import User from accounts.models import User
from contest.models import Contest, ContestQuestion, Question, QuestionVersion from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
@pytest.fixture @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() final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final_state["result"]["winner"] == "self" assert final_state["result"]["winner"] == "self"
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42" 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"] == "联机码已失效或已被使用"
+53 -4
View File
@@ -70,22 +70,71 @@ function createIdempotencyKey() {
].join("-"); ].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 = {}) { async function api(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const requestPath = `/api/v1/${path}`;
const headers = { Accept: "application/json", ...(options.headers || {}) }; const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body && typeof options.body !== "string") { if (options.body && typeof options.body !== "string") {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body); options.body = JSON.stringify(options.body);
} }
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken(); if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken();
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers }); 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; if (response.status === 204) return null;
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const details = payload.error?.details; const details = payload.error?.details;
const message = payload.error?.message || payload.detail || const detailMessages = collectApiErrorMessages(details);
(details ? Object.values(details).flat().join(" ") : "请求失败"); 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); const error = new Error(message);
error.status = response.status; 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; throw error;
} }
return payload; return payload;
+10 -1
View File
@@ -143,6 +143,13 @@
input.placeholder = "例如:6 / (1 - 3 / 4)"; input.placeholder = "例如:6 / (1 - 3 / 4)";
input.autocomplete = "off"; input.autocomplete = "off";
input.inputMode = "text"; 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"); const keypad = document.createElement("div");
keypad.className = "game-keypad"; keypad.className = "game-keypad";
["+", "-", "*", "/", "(", ")"].forEach((operator) => { ["+", "-", "*", "/", "(", ")"].forEach((operator) => {
@@ -159,10 +166,11 @@
submit.className = "primary-button"; submit.className = "primary-button";
submit.type = "submit"; submit.type = "submit";
submit.textContent = "验证并计分"; submit.textContent = "验证并计分";
form.append(input, keypad, submit); form.append(input, keypad, errorMessage, submit);
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
submit.disabled = true; submit.disabled = true;
errorMessage.textContent = "";
try { try {
const result = await api( const result = await api(
`contests/games/attempts/${attempt.attempt_id}/submit/`, `contests/games/attempts/${attempt.attempt_id}/submit/`,
@@ -175,6 +183,7 @@
renderTwentyFour(result); renderTwentyFour(result);
showToast("得到 24,成绩已记录"); showToast("得到 24,成绩已记录");
} catch (error) { } catch (error) {
errorMessage.textContent = error.message;
showToast(error.message); showToast(error.message);
submit.disabled = false; submit.disabled = false;
} }