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
This commit was merged in pull request #14.
This commit is contained in:
+52
-4
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == "联机码已失效或已被使用"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user