Fix/api error details #14

Merged
Jacky merged 4 commits from fix/api-error-details into main 2026-08-09 03:37:16 +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
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),
}
}
+14
View File
@@ -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
+22 -2
View File
@@ -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(
+56
View File
@@ -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
+110 -1
View File
@@ -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"] == "联机码已失效或已被使用"
+53 -4
View File
@@ -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;
+10 -1
View File
@@ -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;
}