fix: expose actionable API error details
This commit is contained in:
+52
-4
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"] == "联机码已失效或已被使用"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user