收敛外部错误体、注入提示词与锁诊断的不设界行为

收敛外部错误体、注入提示词与锁诊断的不设界行为(PR #316 review)

- resource_editor.rs:2476 [bug · medium] 已被修复:`error_details` 改为 `error.details` 优先、顶层 `details` 兜底,与 docstring 声明的取值顺序一致;新增「两种形状同时出现」用例钉住优先级。
- resource_editor.rs:2468 [performance · low] 已被修复:4xx 错误体改走 `bytes_stream` 有界读取(64KiB 上限,超限即放弃取原因),不再用 `response.text()` 无上限缓冲外部正文。
- resource_editor.rs:2514-2525 [maintainability · low] 已被修复:provider / assetKind / mediaType / code 四个追加字段各自按 80 字符截断(`MAX_EDITOR_ERROR_DETAIL_CHARS`),最终文案长度不再随外部正文增长;新增超长字段整串比对用例。
- direct_codex_references.rs:175-182 [performance · medium] 已被修复:`resourceIds` 超过 32 条直接按模块失败关闭口径拒绝,并在同一 id 的重复项上做去重,注入提示词的「关联素材 ID」行长度与 manifest 扫描次数都有界;新增去重 + 超限用例。
- direct_runtime.rs:1795-1797 [maintainability · low] 已被修复:`direct_project_history_contention_failure` 收窄为只认追加锁超时标记(项目写锁争用已在更早的专用分支判掉,是死代码),恢复提示文案相应去掉「或项目锁」;新增用例钉住两条判据互不重叠、各自命中正确提示。
- conversation.rs:984-989 [maintainability · medium] 已被修复:`DIRECT_PROJECT_HISTORY_RECORD_TYPE` 提升为 `pub(crate)`,读取侧 `is_direct_project_history_row` 引用同一常量,`project.jsonl` 的信封契约改为编译期共享。
- agent_db.rs:3570-3574 [other · medium] 已被修复:`lock_with_attempts` 的进程内锁改为有界 `try_lock` 轮询(窗口与调用方一致,2000/200 次 × 5ms),同进程读路径不再被写者的 10s 跨进程等待拖住;顺序仍是「进程内锁 → 跨进程锁」,无 ABBA;新增用例验证短窗口 <5s 失败且释放后立即可重取。
- agent_db.rs:3668-3669 [maintainability · low] 已被修复:新增与锁同级的旁路诊断文件(普通共享写入),诊断优先读它、读不到再退回锁文件,Windows 上 zero-share 独占锁文件时也能报出持锁方;新增「取锁写出旁路文件」「锁文件不可读时退到旁路」两条用例。仅存 pid/用途/时间戳,不参与判活、回收或抢占。
- agent_db.rs:3827-3828 [maintainability · low] 已被修复:仅当修复错误真的符合提权口径(`windows_acl_error_may_need_elevation`)时才追加「自动提权修复未完成」,否则原样透传底层错误,不再重复报同一个错误并谎称尝试过提权。
- main.rs:29-31 [maintainability · low] 判定为不适用/不改:实测删掉那四个 import 会产生 18 处 E0425(`direct_tool_bridge` / `canvas_generation` / `assets` / `manifest` / `resource_editor` / `resource_dependency_graph` / `recovery_tests` 等经 `use super::*` 从 crate 根复用它们),而保留它们并不产生 `unused_imports` 警告(cargo check --all-targets 无一条指向这几行);已按原样恢复 main.rs,工作树中该文件无改动。
This commit is contained in:
2026-09-12 20:20:39 +08:00
parent daef9f6cc9
commit 64b0d2f766
7 changed files with 358 additions and 32 deletions
@@ -172,13 +172,26 @@ fn render_runtime_region_reference_line(
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
// 每个 id 都要扫一遍 manifestO(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!(
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
));
}
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
if !related_resource_ids.contains(&resource_id) {
related_resource_ids.push(resource_id);
}
}
let mut parts = vec![format!("名称:{label}")];
@@ -325,4 +338,45 @@ mod tests {
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
#[test]
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
let project = fixture_project();
// 同一个 id 重复出现只应产生一条关联。
let duplicated: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&duplicated),
)
.expect("render")
.expect("section");
assert!(
section.contains("关联素材 IDasset-hero\n")
|| section.trim_end().ends_with("关联素材 IDasset-hero"),
"{section}"
);
assert!(
!section.contains("asset-hero,"),
"重复 id 不得在注入提示词里重复出现:{section}"
);
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
.map(|_| "\"asset-hero\"".to_string())
.collect::<Vec<_>>()
.join(",");
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
))
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&oversized),
)
.expect_err("oversized resource id list must fail closed");
assert!(error.contains("最多关联"), "{error}");
}
}
@@ -13,7 +13,9 @@ use std::path::{Path, PathBuf};
const DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED: usize = 0;
const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1;
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享,
/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。
pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
/// 格式切换到 `response_item`#282)之前,DirectProject 主对话通过通用对话写入器
/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。
const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
@@ -1793,7 +1793,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
}
if direct_project_history_contention_failure(error) {
return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
}
match stage {
DirectCodexFailureStage::ArtPreparation => {
@@ -1880,15 +1880,18 @@ fn direct_project_history_shape_failure(error: &str) -> bool {
.any(|marker| error.contains(marker))
}
/// 跨进程锁 / 项目锁争用
/// 追加写的**跨进程追加锁**超时
///
/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身
/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此
/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。
/// 判据只认两个常量,不各自复制中文。
///
/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`
/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在
/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句
/// 与真实原因不符的描述有机会出现。
fn direct_project_history_contention_failure(error: &str) -> bool {
error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER)
|| error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
}
fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
@@ -4625,6 +4628,42 @@ mod tests {
assert!(direct_codex_failure_is_retryable(io_error));
}
/// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示,
/// 追加锁超时才落到"历史未能落盘"。两条判据不得重叠。
#[test]
fn direct_project_history_contention_hint_is_append_lock_only() {
let append_lock_timeout = format!(
"获取DirectProject 历史追加写{}",
crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER
);
assert!(direct_project_history_contention_failure(
&append_lock_timeout
));
assert_eq!(
direct_codex_failure_recovery_hint(
DirectCodexFailureStage::CodeGeneration,
&append_lock_timeout
),
"另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"
);
let write_lock_contention = format!(
"{}C:/project",
crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX
);
assert!(
!direct_project_history_contention_failure(&write_lock_contention),
"项目写锁争用不得再落进历史争用判据"
);
assert_eq!(
direct_codex_failure_recovery_hint(
DirectCodexFailureStage::CodeGeneration,
&write_lock_contention
),
"当前项目仍有写入正在结束,请稍后再次发送该需求"
);
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -3572,10 +3572,7 @@ impl ProjectAppendLock {
error_label: &str,
max_attempts: usize,
) -> Result<ProjectAppendGuard<'_>, String> {
let process_guard = self
.process_lock
.lock()
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?;
let process_guard = self.try_lock_process(error_label, max_attempts)?;
let os_lock =
acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?;
Ok(ProjectAppendGuard {
@@ -3583,6 +3580,39 @@ impl ProjectAppendLock {
_os_lock: os_lock,
})
}
/// 进程内锁也按**有界**等待取,窗口与调用方自己的窗口同长。
///
/// 这里曾经是无上限的 `lock()`:写者拿着进程内锁把 10s 的跨进程等待窗口走完,同一个进程里的
/// `lock_short` 读者就一直卡在 `process_lock.lock()` 上 —— "短窗口让读路径保持可响应"只在
/// 跨进程成立,同进程内并不成立。
///
/// 顺序不变:进程内锁**永远先于**跨进程锁取,且从不在持跨进程锁时回头取进程内锁,
/// 因此不存在 ABBA。拿不到时复用 `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`,调用方既有的
/// "争用可重试"判据照样成立。
fn try_lock_process(
&self,
error_label: &str,
max_attempts: usize,
) -> Result<std::sync::MutexGuard<'_, ()>, String> {
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
match self.process_lock.try_lock() {
Ok(guard) => return Ok(guard),
Err(std::sync::TryLockError::Poisoned(_)) => {
return Err(format!("获取{error_label}进程内锁失败:锁已损坏"));
}
Err(std::sync::TryLockError::WouldBlock) => {
if attempt + 1 < max_attempts {
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
}
}
}
}
Err(format!(
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:同一个客户端进程内还有一次追加写未结束(进程内锁等待已达上限)"
))
}
}
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
@@ -3647,7 +3677,7 @@ fn acquire_project_append_os_lock(
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? {
refresh_project_append_lock_diagnostic(&mut file, error_label);
refresh_project_append_lock_diagnostic(&mut file, path, error_label);
return Ok(file);
}
if attempt + 1 < max_attempts {
@@ -3666,10 +3696,21 @@ fn acquire_project_append_os_lock(
/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是
/// 一句"检查运行时配置",那既不是现象也不是动作。
fn project_append_lock_holder_diagnostic(path: &Path) -> String {
// Windows 上持锁方把锁文件本身按 zero-share 打开(`try_open_project_append_os_lock` 里的
// `share_mode(0)`),别的进程连"读"都拿不到它,所以先读与锁同目录、按普通共享方式写出的
// 旁路诊断文件。旁路读不到再退回锁文件本身:Unix 一直是这么读的,而且老版本客户端持锁时
// 也只有锁文件里有线索。两个来源都读不到才算"身份不可读"。
if let Ok(content) = fs::read_to_string(project_append_lock_holder_record_path(path)) {
return describe_project_append_lock_holder(&content);
}
let Ok(content) = fs::read_to_string(path) else {
return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string();
};
let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) else {
describe_project_append_lock_holder(&content)
}
fn describe_project_append_lock_holder(content: &str) -> String {
let Ok(record) = serde_json::from_str::<serde_json::Value>(content) else {
return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string();
};
let pid = record.get("pid").and_then(serde_json::Value::as_u64);
@@ -3687,23 +3728,32 @@ fn project_append_lock_holder_diagnostic(path: &Path) -> String {
}
}
/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写
/// 旁路诊断文件:与锁同级、普通共享写入,专门给"锁文件本身读不到"的平台用
///
/// 它是**只读诊断**,不参与判活 / 回收 / 抢占:拿它永远拿不到锁,锁所有权始终是那个
/// zero-share 句柄。写入失败也不影响取锁结果(见 [`refresh_project_append_lock_diagnostic`])。
fn project_append_lock_holder_record_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("append.lock");
path.with_file_name(format!("{file_name}.holder.json"))
}
/// 把"谁在持这把锁"写进锁文件与旁路诊断文件,供事后排障;同一进程重复取同一把锁时不重复写。
///
/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与
/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md
/// 2026-09-09「项目写锁残留回收与启动诊断」)。
fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
fn refresh_project_append_lock_diagnostic(file: &mut File, path: &Path, error_label: &str) {
let pid = std::process::id();
let mut current = String::new();
if file.seek(SeekFrom::Start(0)).is_ok()
let lock_file_already_records_this_process = file.seek(SeekFrom::Start(0)).is_ok()
&& std::io::Read::read_to_string(file, &mut current).is_ok()
&& serde_json::from_str::<serde_json::Value>(&current)
.ok()
.and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64))
== Some(u64::from(pid))
{
return;
}
== Some(u64::from(pid));
let Ok(serialized) = serde_json::to_string(&serde_json::json!({
"acquiredAt": unix_timestamp(),
"label": error_label,
@@ -3714,11 +3764,19 @@ fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
})) else {
return;
};
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
return;
if !lock_file_already_records_this_process {
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
return;
}
let _ = file.write_all(serialized.as_bytes());
let _ = file.flush();
}
let _ = file.write_all(serialized.as_bytes());
let _ = file.flush();
// 旁路文件每次取锁都刷新(不跟着上面的"本进程已写过"短路):上一次可能就是写它失败的那次。
// 失败静默:它只是排障线索,不能影响取锁结果。
let _ = fs::write(
project_append_lock_holder_record_path(path),
serialized.as_bytes(),
);
}
/// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。
@@ -3825,7 +3883,17 @@ fn release_project_append_os_lock_then_repair_acl(
) -> Result<(), String> {
drop(held_lock);
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
.map_err(|repair_error| format!("{strict_error};自动提权修复未完成:{repair_error}"))
.map_err(|repair_error| {
// 只有"可能靠提权修好"的错误才配得上这句后缀。非提权类失败(锁文件在打开与严格校验
// 之间被删掉、ACL 报错不在提权口径内)时,自动修复没有做任何事就原样返回了同一个
// 错误,再贴一句"自动提权修复未完成"等于把同一条错误报两遍,还把现场指向一个
// 根本没发生过的动作。
if crate::config::windows_acl_error_may_need_elevation(&repair_error) {
format!("{strict_error};自动提权修复未完成:{repair_error}")
} else {
strict_error.to_string()
}
})
}
#[cfg(windows)]
@@ -3197,6 +3197,91 @@ fn append_lock_holder_diagnostic_reports_the_recorded_pid() {
fs::remove_dir_all(&root).ok();
}
/// 取锁必须同时写出与锁同级的旁路诊断文件。
///
/// Windows 上持锁方以 zero-share 打开锁文件本身,别的进程连读都读不到,"谁在持锁"就只能靠
/// 这个普通共享的旁路文件回答——那正是这套 UAC / 锁争用改动要落地的平台。
#[test]
fn append_lock_acquisition_writes_the_holder_record_sidecar() {
let (root, target) = append_lock_test_target("append-lock-holder-sidecar");
let lock = project_append_lock_for(&target).expect("resolve lock");
drop(
lock.lock("DirectProject 历史追加写")
.expect("acquire append lock"),
);
let lock_path = project_append_os_lock_path(&target).expect("lock path");
let sidecar = project_append_lock_holder_record_path(&lock_path);
let raw = fs::read_to_string(&sidecar).expect("read holder record sidecar");
let record: serde_json::Value =
serde_json::from_str(&raw).expect("parse holder record sidecar");
assert_eq!(
record["pid"],
serde_json::json!(u64::from(std::process::id()))
);
assert_eq!(
record["label"],
serde_json::json!("DirectProject 历史追加写")
);
assert!(record["acquiredAt"].is_number(), "{raw}");
fs::remove_dir_all(&root).ok();
}
/// 锁文件读不到时,诊断必须退回旁路文件,而不是直接判"身份不可读"。
#[test]
fn append_lock_holder_diagnostic_falls_back_to_the_sidecar() {
let (root, target) = append_lock_test_target("append-lock-holder-sidecar-fallback");
let lock_path = project_append_os_lock_path(&target).expect("lock path");
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
fs::write(
project_append_lock_holder_record_path(&lock_path),
"{\"acquiredAt\":1,\"label\":\"旁路用途\",\"pid\":4242,\"processStartedAt\":7}",
)
.expect("seed holder record sidecar");
// 锁文件此刻不存在:旧实现只会报"身份不可读"。
assert!(!lock_path.exists());
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
assert!(diagnostic.contains("旁路用途"), "{diagnostic}");
fs::remove_dir_all(&root).ok();
}
/// 同进程内的读路径不能被写者的进程内锁拖住:短窗口必须有界地失败,而不是等写者走完
/// 完整的跨进程等待窗口。
#[test]
fn append_lock_short_wait_is_bounded_while_a_same_process_writer_holds_the_lock() {
let (root, target) = append_lock_test_target("append-lock-process-lock-bound");
let lock = project_append_lock_for(&target).expect("resolve lock");
let writer = lock
.lock("DirectProject 历史追加写")
.expect("acquire append lock");
let started = std::time::Instant::now();
let error = match lock.lock_short("DirectProject 历史追加读") {
Ok(_guard) => panic!("进程内锁被占满时短窗口必须失败而不是无限等待"),
Err(error) => error,
};
let elapsed = started.elapsed();
assert!(
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
"{error}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"短窗口必须留在 1s 级,实际等待 {elapsed:?}"
);
drop(writer);
drop(
lock.lock_short("DirectProject 历史追加读")
.expect("reacquire after writer releases"),
);
fs::remove_dir_all(&root).ok();
}
/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**——
/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。
#[test]
@@ -981,9 +981,14 @@ fn read_persisted_local_conversation_records_unlocked(
/// 只认 DirectProject 那一种明确枚举的行信封:`type=response_item` 且带 `payload`。
/// 其余任何形状都不算“对方的行”,仍由上方按坏行失败关闭。
///
/// 类型串引用写入侧的 `DIRECT_PROJECT_HISTORY_RECORD_TYPE`,不复制字面量:两边读写的
/// 是同一份 `project.jsonl`,一旦只在写入侧改名,这里就会把每一行 DirectProject 记录
/// 都判成坏行并以 `解析对话记录失败` 失败关闭,共享文件的读取整条断掉。
fn is_direct_project_history_row(line: &str) -> bool {
serde_json::from_str::<serde_json::Value>(line).is_ok_and(|parsed| {
parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item")
parsed.get("type").and_then(serde_json::Value::as_str)
== Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE)
&& parsed.get("payload").is_some()
})
}
@@ -1,4 +1,5 @@
use super::*;
use futures::StreamExt;
use reqwest::multipart::{Form, Part};
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -2460,20 +2461,39 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool {
/// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。
///
/// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、
/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都
/// `{details:{message}}` 或顶层 `{message}`。**`error.details.message` 优先**:平台把所有 4xx 都
/// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在
/// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message`
/// 会让用户永远看到「请求参数不合法」。
async fn editor_api_rejection_reason(response: reqwest::Response) -> Option<String> {
let body = response.text().await.ok()?;
// 错误体来自外部编辑器端点,不能无上限地缓冲:有界的读法把"上游返回一个超大 4xx 正文"
// 变成"读不到可读原因",而不是让 Tauri 后端按对方给的长度分配。
const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024;
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.ok()?;
if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES {
return None;
}
body.extend_from_slice(&chunk);
}
let body = String::from_utf8(body).ok()?;
editor_api_rejection_reason_from_body(body.as_str())
}
/// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把
/// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。
const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80;
fn editor_api_rejection_reason_from_body(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
let error = value.get("error");
let details = value.get("details");
let error_details = details.or_else(|| error.and_then(|error| error.get("details")));
// 先 `error.details`、再顶层 `details`:与下面注释声明的优先级一致。
// 反过来写会让同时带两种形状的响应体永远命中笼统的顶层 `details.message`
// 真正可操作的原因(`error.details.message`)永远看不到。
let error_details = error.and_then(|error| error.get("details")).or(details);
// 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本
// → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。
let candidate = error_details
@@ -2512,16 +2532,20 @@ fn editor_api_rejection_reason_from_body(body: &str) -> Option<String> {
.filter(|code| !code.is_empty() && *code != "BAD_REQUEST");
let mut reason: String = candidate.chars().take(200).collect();
if let Some(provider) = detail_text("provider") {
reason.push_str(format!("provider {provider}").as_str());
reason.push_str("provider ");
reason.extend(provider.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(asset_kind) = detail_text("assetKind") {
reason.push_str(format!("|素材类型 {asset_kind}").as_str());
reason.push_str("|素材类型 ");
reason.extend(asset_kind.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(media_type) = detail_text("mediaType") {
reason.push_str(format!("|媒体类型 {media_type}").as_str());
reason.push_str("|媒体类型 ");
reason.extend(media_type.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(code) = code {
reason.push_str(format!("|错误码 {code}").as_str());
reason.push_str("|错误码 ");
reason.extend(code.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
Some(reason)
}
@@ -5612,6 +5636,55 @@ mod tests {
assert_eq!(reason, "".repeat(200));
}
/// 两种形状同时出现时 `error.details.message` 必须赢;每个透出字段都要单独有界。
///
/// 平台自身目前只发 `error.details`,所以这条是"代码与文档契约一致"的护栏:
/// 一旦取值顺序被写反,同时带顶层 `details` 的响应体就只剩笼统兜底文案。
#[test]
fn editor_api_rejection_reason_prefers_nested_error_details_and_caps_every_field() {
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": { "details": { "message": "嵌套里的具体原因" } },
"details": { "message": "顶层的通用文案" },
"message": "更外层文案",
})
.to_string()
.as_str(),
),
Some("嵌套里的具体原因".to_string()),
"error.details.message 必须优先于顶层 details.message"
);
// 单个超长字段不能把"有界正文"的初衷抵消掉:provider / assetKind / mediaType / code
// 都按 80 字符截断,最终文案长度因此是常数级而不是随外部正文增长。
let long = "".repeat(500);
let reason = editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": long.clone(),
"details": {
"message": "具体原因",
"provider": long.clone(),
"assetKind": long.clone(),
"mediaType": long.clone(),
},
}
})
.to_string()
.as_str(),
)
.expect("超长字段不应让原因缺失");
let truncated = "".repeat(80);
assert_eq!(
reason,
format!(
"具体原因|provider {truncated}|素材类型 {truncated}|媒体类型 {truncated}|错误码 {truncated}"
),
"每个透出字段都必须按 80 字符截断"
);
}
async fn with_test_external_editor_credentials<T>(
api_base_url: &str,
api_key: &str,