继续收敛外围 HTTP 错误正文

统一画板、模板、图片分离和素材下载的状态码与安全正文

保留资源编辑、生成恢复、项目快照和错误报告的上游详情

补充精确脱敏测试与错误处理文档
This commit is contained in:
kdletters
2026-10-05 20:20:30 +08:00
parent 6f7ccc2832
commit 7bc724d8e2
12 changed files with 288 additions and 145 deletions
@@ -2907,7 +2907,12 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
access.validate_frozen_session()?;
let status = response.status();
if !status.is_success() {
return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16()));
let body = response.text().await.unwrap_or_default();
return Err(crate::assets::external_asset_http_failure(
"读取陶泥儿画布资源",
status,
&body,
));
}
let payload = response
.json::<serde_json::Value>()
@@ -933,8 +933,8 @@ pub(crate) fn classify_external_generation_initial_response(
))
}
_ => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}",
status.as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}",
format_external_http_error("平台图片生成", status, &payload.to_string())
)),
}
}
@@ -1361,9 +1361,11 @@ pub(crate) async fn submit_external_generation_request(
)
})?;
if response.status().is_server_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
response.status().as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知",
format_external_http_error("平台图片生成", status, &body)
));
}
Ok(response)
@@ -1394,8 +1396,8 @@ async fn resume_prepared_external_generation_at(
let body = response.text().await.unwrap_or_default();
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留",
status.as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留",
format_external_http_error("External Editor prepared 恢复提交", status, &body)
));
}
let submission_payload_result = response
@@ -2137,9 +2139,12 @@ async fn upload_manifest_asset_remote_reference_at(
.await
.map_err(|error| format!("上传当前账号参考图失败:{error}"))?;
if upload_response.status().as_u16() != ticket.success_action_status {
return Err(format!(
"上传当前账号参考图失败:HTTP {}",
upload_response.status().as_u16()
let status = upload_response.status();
let body = upload_response.text().await.unwrap_or_default();
return Err(crate::assets::external_asset_http_failure(
"上传当前账号参考图",
status,
&body,
));
}
access.validate_frozen_session()?;
@@ -11,6 +11,60 @@ const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str =
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str =
"private-external-editor-credential-persistence-failed";
/// Extract a bounded, redacted upstream error without treating a status code as the whole reason.
fn external_asset_http_detail(body: &str) -> Option<String> {
let parsed = serde_json::from_str::<serde_json::Value>(body).ok();
let error = parsed
.as_ref()
.and_then(|value| value.get("error"))
.or_else(|| parsed.as_ref());
let detail = ["message", "detail", "code", "additionalDetails"]
.into_iter()
.filter_map(|field| {
error
.and_then(|value| value.get(field))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
.collect::<Vec<_>>()
.join(";");
let detail = (!detail.is_empty()).then_some(detail).or_else(|| {
let safe = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
body.trim(),
600,
);
(!safe.trim().is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe)
})?;
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
600,
);
(!detail.trim().is_empty()).then_some(detail)
}
pub(crate) fn external_asset_http_failure(
action: &str,
status: reqwest::StatusCode,
body: &str,
) -> String {
let detail = external_asset_http_detail(body)
.map(|detail| format!(":{detail}"))
.unwrap_or_default();
match status {
reqwest::StatusCode::UNAUTHORIZED => {
format!("authentication-required: {action}失败(HTTP 401),请重新登录后重试{detail}")
}
reqwest::StatusCode::FORBIDDEN => {
format!("permission-denied: {action}被服务器拒绝(HTTP 403){detail}")
}
_ => format!("{action}失败:HTTP {}{detail}", status.as_u16()),
}
}
/// A task-scoped External Editor credential used only by the direct Codex
/// runtime. It never changes the GUI account session and deliberately keeps
/// the key private: callers may use it to make authenticated requests but may
@@ -406,15 +460,11 @@ async fn create_private_external_editor_api_credentials_from_platform_session(
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(match status {
reqwest::StatusCode::UNAUTHORIZED => {
"authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string()
}
reqwest::StatusCode::FORBIDDEN => {
"permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string()
}
_ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()),
});
return Err(external_asset_http_failure(
"创建本机陶泥儿开发者 Key",
status,
&body,
));
}
let payload = response
.json::<serde_json::Value>()
@@ -907,9 +957,10 @@ pub(crate) async fn sync_canvas_project_assets_at(
if !project_status.is_success() {
let body = project_response.text().await.unwrap_or_default();
crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body);
return Err(format!(
"读取画板项目失败:HTTP {}",
project_status.as_u16()
return Err(external_asset_http_failure(
"读取画板项目",
project_status,
&body,
));
}
let project_payload = project_response
@@ -1364,7 +1415,8 @@ where
return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string());
}
if !status.is_success() {
return Err(format!("下载画板资产失败:HTTP {}", status.as_u16()));
let body = response.text().await.unwrap_or_default();
return Err(external_asset_http_failure("下载画板资产", status, &body));
}
if response
.content_length()
@@ -1436,7 +1488,7 @@ pub(crate) async fn resolve_external_asset_signed_url(
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(format!("换签画板资产失败:HTTP {}", status.as_u16()));
return Err(external_asset_http_failure("换签画板资产", status, &body));
}
let payload = response
.json::<serde_json::Value>()
@@ -2257,6 +2309,19 @@ mod tests {
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::{Read, Write};
#[test]
fn external_asset_http_failure_keeps_status_and_redacted_upstream_detail() {
let detail = external_asset_http_failure(
"读取画板项目",
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"code":"TOKEN_EXPIRED","message":"access token expired;authorization=Bearer secret"}}"#,
);
assert!(detail.contains("HTTP 401"), "{detail}");
assert!(detail.contains("TOKEN_EXPIRED"), "{detail}");
assert!(detail.contains("access token expired"), "{detail}");
assert!(!detail.contains("Bearer secret"), "{detail}");
}
#[test]
fn design_artifact_registration_reports_only_real_manifest_changes() {
let temporary = tempfile::tempdir().expect("tempdir");
@@ -3706,9 +3706,12 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result<Ve
return Err("平台素材下载地址发生重定向,已拒绝继续请求".to_string());
}
if !response.status().is_success() {
return Err(format!(
"下载平台素材失败:HTTP {}",
response.status().as_u16()
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(crate::assets::external_asset_http_failure(
"下载平台素材",
status,
&body,
));
}
if response
@@ -69,40 +69,66 @@ fn stable_submission_id(events: &[Event]) -> String {
}
fn response_error(status: StatusCode, body: &str) -> String {
if status == StatusCode::UNAUTHORIZED {
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
}
if status == StatusCode::FORBIDDEN {
return "permission-denied: 当前账号没有提交错误报告的权限".to_string();
}
let detail = serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| {
let error = value.get("error").unwrap_or(&value);
error
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.or_else(|| {
error
.get("code")
.and_then(Value::as_str)
.map(str::to_string)
})
.or_else(|| serde_json::to_string(error).ok())
Some(
["message", "detail", "code", "additionalDetails"]
.into_iter()
.filter_map(|field| {
error
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
.collect::<Vec<_>>()
.join(";"),
)
})
.filter(|value| !value.is_empty())
.or_else(|| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
body.trim(),
600,
);
(!detail.trim().is_empty()).then_some(detail)
(!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\""))
.then_some(detail)
})
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
format!("错误报告提交失败:{detail}")
.map(|detail| {
crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
600,
)
})
.filter(|value| !value.trim().is_empty());
if status == StatusCode::UNAUTHORIZED {
return match detail {
Some(detail) => {
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
}
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
};
}
if status == StatusCode::FORBIDDEN {
return format!(
"permission-denied: 当前账号没有提交错误报告的权限{}",
detail
.map(|detail| format!(":{detail}"))
.unwrap_or_default()
);
}
format!(
"错误报告提交失败:HTTP {}{}",
status.as_u16(),
detail
.map(|detail| format!(":{detail}"))
.unwrap_or_default()
)
}
fn response_data(body: &str) -> Result<Value, String> {
@@ -234,4 +260,16 @@ mod tests {
assert_eq!(value["batchId"], "batch-1");
assert!(!value.to_string().contains("token"));
}
#[test]
fn response_error_keeps_status_and_redacted_server_detail() {
let detail = response_error(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":{"code":"RATE_LIMITED","message":"too many requests;token=secret"}}"#,
);
assert!(detail.contains("HTTP 429"), "{detail}");
assert!(detail.contains("RATE_LIMITED"), "{detail}");
assert!(detail.contains("too many requests"), "{detail}");
assert!(!detail.contains("token=secret"), "{detail}");
}
}
@@ -376,10 +376,13 @@ async fn upload_object(
.await
.map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?;
validate_session(snapshot)?;
if response.status().as_u16() != ticket.success_action_status {
return Err(format!(
"上传素材到对象存储失败(HTTP {}),请重试",
response.status().as_u16()
let status = response.status();
if status.as_u16() != ticket.success_action_status {
let body = response.text().await.unwrap_or_default();
return Err(crate::assets::external_asset_http_failure(
"上传素材到对象存储",
status,
&body,
));
}
Ok(())
@@ -1768,32 +1768,31 @@ async fn request_resource_edit_upload_ticket(
.await
.map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authentication_error());
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authentication_error);
return Err(append_editor_api_reason(base, response).await);
}
if response.status() == reqwest::StatusCode::FORBIDDEN {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authorization_error());
if status == reqwest::StatusCode::FORBIDDEN {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authorization_error);
return Err(append_editor_api_reason(base, response).await);
}
if !response.status().is_success() {
if response.status().is_server_error() {
return Err(format!(
if !status.is_success() {
let base = if status.is_server_error() {
format!(
"result-unknown: 创建源资源上传凭证返回 HTTP {}",
response.status().as_u16()
));
}
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(format!(
"创建源资源上传凭证失败:HTTP {}",
response.status().as_u16()
));
status.as_u16()
)
} else if let Some(error) = post_response_session_error.as_ref() {
error.clone()
} else {
format!("创建源资源上传凭证失败:HTTP {}", status.as_u16())
};
return Err(append_editor_api_reason(base, response).await);
}
let payload = response
.json::<serde_json::Value>()
@@ -1901,19 +1900,16 @@ async fn upload_resource_edit_source(
.map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?;
let post_response_session_error = access.validate_frozen_session().err();
if response.status().as_u16() != ticket.success_action_status {
let status = response.status();
if response.status().is_server_error() {
return Err(format!(
"result-unknown: 上传源资源返回 HTTP {}",
response.status().as_u16()
));
let base = format!("result-unknown: 上传源资源返回 HTTP {}", status.as_u16());
return Err(append_editor_api_reason(base, response).await);
}
if let Some(error) = post_response_session_error {
return Err(error);
return Err(append_editor_api_reason(error, response).await);
}
return Err(format!(
"上传源资源失败:HTTP {}",
response.status().as_u16()
));
let base = format!("上传源资源失败:HTTP {}", status.as_u16());
return Err(append_editor_api_reason(base, response).await);
}
Ok(ResourceEditRemoteStage {
value: (),
@@ -1955,32 +1951,31 @@ async fn confirm_resource_edit_source(
.await
.map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authentication_error());
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authentication_error);
return Err(append_editor_api_reason(base, response).await);
}
if response.status() == reqwest::StatusCode::FORBIDDEN {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authorization_error());
if status == reqwest::StatusCode::FORBIDDEN {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authorization_error);
return Err(append_editor_api_reason(base, response).await);
}
if !response.status().is_success() {
if response.status().is_server_error() {
return Err(format!(
if !status.is_success() {
let base = if status.is_server_error() {
format!(
"result-unknown: 确认源资源上传返回 HTTP {}",
response.status().as_u16()
));
}
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(format!(
"确认源资源上传失败:HTTP {}",
response.status().as_u16()
));
status.as_u16()
)
} else if let Some(error) = post_response_session_error.as_ref() {
error.clone()
} else {
format!("确认源资源上传失败:HTTP {}", status.as_u16())
};
return Err(append_editor_api_reason(base, response).await);
}
let payload = response
.json::<serde_json::Value>()
@@ -2067,32 +2062,31 @@ async fn register_resource_edit_source_image(
.await
.map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authentication_error());
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authentication_error);
return Err(append_editor_api_reason(base, response).await);
}
if response.status() == reqwest::StatusCode::FORBIDDEN {
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(editor_api_authorization_error());
if status == reqwest::StatusCode::FORBIDDEN {
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authorization_error);
return Err(append_editor_api_reason(base, response).await);
}
if !response.status().is_success() {
if response.status().is_server_error() {
return Err(format!(
if !status.is_success() {
let base = if status.is_server_error() {
format!(
"result-unknown: 登记源图片项目资源返回 HTTP {}",
response.status().as_u16()
));
}
if let Some(error) = post_response_session_error {
return Err(error);
}
return Err(format!(
"登记源图片项目资源失败:HTTP {}",
response.status().as_u16()
));
status.as_u16()
)
} else if let Some(error) = post_response_session_error.as_ref() {
error.clone()
} else {
format!("登记源图片项目资源失败:HTTP {}", status.as_u16())
};
return Err(append_editor_api_reason(base, response).await);
}
let payload = response
.json::<serde_json::Value>()
@@ -2667,6 +2661,13 @@ async fn editor_api_rejection_reason(response: reqwest::Response) -> Option<Stri
editor_api_rejection_reason_from_body(body.as_str())
}
async fn append_editor_api_reason(base: String, response: reqwest::Response) -> String {
match editor_api_rejection_reason(response).await {
Some(reason) => format!("{base}:{reason}"),
None => base,
}
}
/// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把
/// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。
const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80;
@@ -2788,16 +2789,16 @@ async fn submit_resource_edit_remote(
let mut post_response_session_error = access.validate_frozen_session().err();
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error.as_ref() {
return Err(error.clone());
}
return Err(editor_api_authentication_error());
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authentication_error);
return Err(append_editor_api_reason(base, response).await);
}
if status == reqwest::StatusCode::FORBIDDEN {
if let Some(error) = post_response_session_error.as_ref() {
return Err(error.clone());
}
return Err(editor_api_authorization_error());
let base = post_response_session_error
.clone()
.unwrap_or_else(editor_api_authorization_error);
return Err(append_editor_api_reason(base, response).await);
}
if status == reqwest::StatusCode::BAD_REQUEST {
ledger.terminal_failure_code = Some("remote-request-bad-request".to_string());
@@ -2934,10 +2935,13 @@ async fn wait_for_resource_edit_remote(
continue;
}
if !response.status().is_success() {
return Err(ResourceEditError::Other(format!(
let base = format!(
"result-unknown: 查询资源编辑任务返回 HTTP {}",
response.status().as_u16()
)));
);
return Err(ResourceEditError::Other(
append_editor_api_reason(base, response).await,
));
}
let payload = response
.json::<serde_json::Value>()
@@ -323,7 +323,13 @@ async fn project_snapshot_response_error(
let detail = response
.text()
.await
.map(|body| body.chars().take(200).collect::<String>())
.map(|body| {
crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
&body,
200,
)
})
.unwrap_or_default();
let detail = detail.trim().to_string();
if detail.is_empty() {
@@ -572,7 +572,13 @@ async fn fetch_limited_bytes(
.await
.map_err(|error| format!("请求模板库失败:{error}"))?;
if !response.status().is_success() {
return Err(format!("模板库返回 HTTP {}", response.status().as_u16()));
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(crate::assets::external_asset_http_failure(
"模板库请求",
status,
&body,
));
}
if response
.content_length()
@@ -118,11 +118,17 @@ async fn raw_extract_inner(
format!("图片分离请求失败:{error}")
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
app_log!(
"ui_separation.error stage=image_edit reason=http_status status={}",
response.status()
status
);
return Err(format!("图片分离请求失败(HTTP {})", response.status()));
return Err(crate::assets::external_asset_http_failure(
"图片分离请求",
status,
&body,
));
}
let payload = response.json::<RawEditResponse>().await.map_err(|error| {
app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}");
@@ -131,6 +131,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
- 外部 MCP loopback 的 401/429/桥上下文缺失必须返回带正文的 JSON 错误体(同时保留 HTTP 状态);不能让 Claude Code 侧只看到空 body 的状态码。
- 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。
- 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。
- 画板/模板/图片分离/参考图/External Editor/资源编辑/项目快照/错误报告等外围 HTTP 入口也遵循同一出口:读取有限响应正文,保留状态码、服务端 message/code 和操作上下文,凭据、URL、路径按统一规则脱敏;不能因为不是 Direct 回合主链就回到“HTTP N”兜底。
## 后果与边界
@@ -52,3 +52,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时,
- 本轮继续扫出三类未收敛口:外部 MCP HTTP 的 401/429/桥上下文缺失原先只有空状态码响应,现改为保留 HTTP 状态、稳定错误码和中文正文;认证 5xx 结构化载荷、账户/素材上传/发行发布的 401/403 现保留服务端 message/code(仍精确脱敏)。
- 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。
- 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。
- 继续扫描又发现画板项目/资产下载、模板库、图片分离、参考图上传、External Editor 生成恢复、资源编辑上传/轮询、项目快照和错误报告提交仍有“HTTP 只有编号”或未脱敏正文路径;已统一补上状态码 + 有界正文 + 精确脱敏。资源编辑拒绝原因继续复用既有 `details.message` 解析,避免丢掉平台具体原因。