fix: expose actionable API error details

This commit is contained in:
2026-08-09 03:26:45 +08:00
parent 6dc9052219
commit b1ac6cef86
4 changed files with 227 additions and 9 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),
} }
} }
+12
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")
+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
@@ -43,22 +43,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;