use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Map, Value};
fn template(key: &str) -> Option<&'static str> {
Some(match key {
"err.auth.bad_login_request" => "非法登录请求: {0}",
"err.auth.locked" => "尝试过于频繁,请 {0} 秒后再试",
"err.not_implemented" => "{0} 尚未实现({1});本端点按计划推迟到后续阶段交付",
"err.bad_body" => "非法请求体: {0}",
"err.artifact.multipart_read_failed" => "multipart 读取失败: {0}",
"err.artifact.field_read_failed" => "字段 {0} 读取失败",
"err.artifact.duplicate" => "制品 {0} {1} 已存在",
"err.artifact.not_found" => "制品 #{0} 不存在",
"err.artifact.in_use" => "制品被节点引用: {0}",
"err.node.not_found" => "节点 #{0} 不存在",
"err.node.bad_register_body" => "非法注册请求: {0}",
"err.node.bad_heartbeat_body" => "非法心跳请求: {0}",
"err.node.probe_failed" => "节点探活失败:{0}/healthz 未返回 200",
"err.node.in_use" => "节点仍被管道指派: {0}",
"err.node.deploy_name_taken" => "节点名称已存在:{0}",
"err.node.action_unknown" => {
"未知动作 {0}(支持 start/stop/restart/upgrade/rollback/uninstall)"
}
"err.node.remote_cleanup_failed" => {
"远端清理失败(SSH 链不可达或命令失败):{0};已按「跳过并警告」分支仅清理控制面元数据"
}
"err.pipeline.not_found" => "管道 #{0} 不存在",
"err.pipeline.table_objects_missing" => "表对象未登记(先在数据源页登记): {0}",
"err.pipeline.statement_table_missing" => {
"语句 {0} 的{1}表对象未登记: {2}(先在数据源页登记库表)"
}
"err.pipeline.node_no_api_url" => "节点 {0} 未上报 api_url(等待首次心跳)",
"err.pipeline.engine_assembly_failed" => "引擎装配失败: {0}",
"err.pipeline.undeployed" => "管道 #{0} 未部署(无指派节点)",
"err.pipeline.undeployed_plain" => "pipeline #{0} 未部署(无指派节点)",
"err.pipeline.assigned_node_missing" => "指派节点 #{0} 不存在",
"err.datasource.not_found" => "数据源 #{0} 不存在",
"err.datasource.unknown_type" => "未知数据源类型 {0}",
"err.datasource.unknown_type_registry" => {
"未知数据源类型 {0}(注册表:kafka/doris/starrocks/file/mysql/postgresql)"
}
"err.datasource.missing_required_fields" => "connJson 缺少必填字段: {0}",
"err.datasource.in_use" => "数据源仍被引用(管道 {0} 个,表对象 {1} 个)",
"err.datasource.jdbc_url_unparsable" => "jdbcUrl 无法解析(需 host:port): {0}",
"err.datasource.jdbc_mysql_unparsable" => {
"jdbcUrl 无法解析(需 jdbc:mysql://host:port/db): {0}"
}
"err.datasource.jdbc_pg_unparsable" => {
"jdbcUrl 无法解析(需 jdbc:postgresql://host:port/db): {0}"
}
"err.datasource.kafka_format_unsupported" => {
"kafka 样本推断不支持格式 {0}(ogg/json/jsonl;csv/parquet 请手工登记列)"
}
"probe.jdbc.timeout" => "{0}: 超时 (>3s)",
"probe.jdbc.reachable" => "jdbc server 可达({0})",
"probe.jdbc.unreachable" => "jdbc server 不可达: {0}",
"err.table_object.not_found" => "表对象 #{0} 不存在",
"err.table_object.in_use" => "表对象被已发布管道引用: {0}",
_ => return None,
})
}
pub fn t(key: &str, args: &[&str]) -> String {
let mut out = template(key).unwrap_or(key).to_string();
for (i, a) in args.iter().enumerate() {
out = out.replace(&format!("{{{i}}}"), a);
}
out
}
fn envelope(code: &str, key: &str, args: &[&str], refs: Option<Map<String, Value>>) -> Value {
let mut error = serde_json::json!({ "code": code, "message": t(key, args) });
error["message_key"] = Value::String(key.to_string());
if let Some(refs) = refs {
error["refs"] = Value::Object(refs);
}
serde_json::json!({ "error": error })
}
pub fn respond(status: StatusCode, code: &str, key: &str, args: &[&str]) -> Response {
(status, Json(envelope(code, key, args, None))).into_response()
}
pub fn respond_with_refs(
status: StatusCode,
code: &str,
key: &str,
args: &[&str],
refs: Map<String, Value>,
) -> Response {
(status, Json(envelope(code, key, args, Some(refs)))).into_response()
}
pub fn bad_request(key: &str, args: &[&str]) -> Response {
respond(
StatusCode::BAD_REQUEST,
console_auth::error::code::INVALID_ARGUMENT,
key,
args,
)
}
pub fn not_found(key: &str, args: &[&str]) -> Response {
respond(
StatusCode::NOT_FOUND,
console_auth::error::code::NOT_FOUND,
key,
args,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interpolation_is_byte_identical_to_legacy_direct_writes() {
assert_eq!(
t("err.auth.locked", &["30"]),
"尝试过于频繁,请 30 秒后再试"
);
assert_eq!(
t("err.artifact.duplicate", &["loader", "1.2.0"]),
"制品 loader 1.2.0 已存在"
);
assert_eq!(
t("err.datasource.jdbc_url_unparsable", &["jdbc:x://bad"]),
"jdbcUrl 无法解析(需 host:port): jdbc:x://bad"
);
assert!(t("err.not_implemented", &["SSH 部署链八步", "§4.4"]).contains("八步"));
}
#[test]
fn unknown_key_falls_back_to_key() {
assert_eq!(t("err.no_such_key", &[]), "err.no_such_key");
}
#[test]
fn templates_use_only_positional_placeholders() {
let keys: Vec<&str> = KEY_LIST.to_vec();
assert!(!keys.is_empty());
assert_eq!(keys.len(), 39, "字典规模漂移:同步模块文档「规模口径」");
for key in keys {
let tpl = template(key).expect("listed key resolves");
let mut rest = tpl;
while let Some(open) = rest.find('{') {
let close = rest[open..].find('}').expect("balanced braces") + open;
let inner = &rest[open + 1..close];
assert!(
inner.chars().all(|c| c.is_ascii_digit()) && !inner.is_empty(),
"{key}: non-positional placeholder {{{inner}}}"
);
rest = &rest[close + 1..];
}
let filled = t(key, &["x", "x", "x"]);
assert!(
!filled.contains('{') && !filled.contains('}'),
"{key}: {filled}"
);
}
}
#[test]
fn envelope_bytes_match_apierror_shape_plus_message_key() {
assert_eq!(
envelope("NOT_FOUND", "err.pipeline.not_found", &["42"], None).to_string(),
r#"{"error":{"code":"NOT_FOUND","message":"管道 #42 不存在","message_key":"err.pipeline.not_found"}}"#
);
let mut refs = Map::new();
refs.insert("pipelines".into(), serde_json::json!([7]));
assert_eq!(
envelope("IN_USE", "err.node.in_use", &["[7]"], Some(refs)).to_string(),
r#"{"error":{"code":"IN_USE","message":"节点仍被管道指派: [7]","message_key":"err.node.in_use","refs":{"pipelines":[7]}}}"#
);
}
#[test]
fn no_chinese_format_direct_writes_outside_dictionary() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let exempt: &[&str] = &["api/messages.rs", "compile.rs", "deploy.rs"];
let mut offenders: Vec<String> = Vec::new();
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
let entries = std::fs::read_dir(&dir).expect("read src dir");
for entry in entries {
let path = entry.expect("entry").path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let rel = path.strip_prefix(&src).expect("under src").to_path_buf();
let rel_str = rel.to_string_lossy().to_string();
if exempt.contains(&rel_str.as_str()) {
continue;
}
let text = std::fs::read_to_string(&path).expect("read source");
for off in format_call_spans(&text) {
let call = &text[off.clone()];
if contains_cjk(call) {
let lineno = text[..off.start].matches('\n').count() + 1;
offenders.push(format!("{rel_str}:{lineno},"));
}
}
}
}
assert!(
offenders.is_empty(),
"中文格式化直写漏网(须收敛进 api/messages.rs 字典):\n{}",
offenders.join("\n")
);
}
#[test]
fn api_error_chinese_literals_match_pinned_registry() {
let api_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/api");
let pinned: &[(&str, &[&str])] = &[
(
"artifacts.rs",
&[
"multipart 缺少 file 字段的文件名",
"制品必须是 .tar.gz 包",
"创建制品目录失败: {e}",
"暂存文件创建失败: {e}",
"制品超过 512MB 上限",
"写入暂存失败: {e}",
"multipart 缺少 file 字段",
"缺少 version 字段",
"name 仅允许 [A-Za-z0-9._-]",
"制品落盘失败: {e}",
],
),
("auth.rs", &["缺少用户名或密码", "用户名或密码错误"]),
(
"datasources.rs",
&[
"缺少数据源名称",
"connJson 必须是对象",
"缺少 generation(CAS 乐观锁必填)",
"generation 不匹配,数据源已被其他人修改",
"数据源名称已被占用",
"connJson 缺少 bootstrap",
"connJson 缺少 jdbcUrl",
"connJson 缺少 jdbcUrl",
"文件数据源不支持发现(路径下的文件请直接登记表对象)",
"缺少 dbName / tableName",
"缺少 dbName / tableName",
],
),
("nodes.rs", &["节点不存在"]),
(
"nodes_admin.rs",
&[
"hosts 不能为空(或改用 nodeId 重新部署既有节点)",
"hosts 与 nodeId 二选一",
"hosts[].host / hosts[].name 不能为空",
"upgrade 需要 artifactId",
"缺少 artifactId(制品未指定)",
"缺少 hosts 或 nodeId",
"缺少 name / host",
"节点名称或 node_key 已存在",
"节点未上报 api_url(等待首次心跳)",
"节点非 SSH 托管(manual/self 注册无部署链身份),仅 deployed 节点可重新部署",
"节点非 SSH 托管(manual/self 注册无部署链身份),进程动作不可用",
],
),
(
"pipelines.rs",
&[
"缺少管道名称",
"管道名称已存在",
"管道名称已存在",
"管道尚未发布(无 spec 快照),先执行发布",
"没有可指派的在线节点(需心跳 90s 内在线且已上报 api_url)",
"节点未上报 api_url",
"节点未上报 api_url",
],
),
];
let mut offenders: Vec<String> = Vec::new();
for (file, expected) in pinned {
let text = std::fs::read_to_string(api_dir.join(file)).expect("read api source");
let mut found: Vec<String> = api_error_call_spans(&text)
.iter()
.flat_map(|span| cjk_string_literals(&text[span.clone()]))
.collect();
found.sort();
let mut want: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
want.sort();
if found != want {
offenders.push(format!("{file}: found {found:#?}\n{file}: want {want:#?}"));
}
}
let registered: Vec<&str> = pinned.iter().map(|(f, _)| *f).collect();
for entry in std::fs::read_dir(&api_dir).expect("read api dir") {
let path = entry.expect("entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().to_string();
if registered.contains(&name.as_str()) {
continue;
}
let text = std::fs::read_to_string(&path).expect("read api source");
let hits: Vec<String> = api_error_call_spans(&text)
.iter()
.flat_map(|span| cjk_string_literals(&text[span.clone()]))
.collect();
if !hits.is_empty() {
offenders.push(format!("{name}: 未登记的 ApiError 中文字面量 {hits:#?}"));
}
}
assert!(
offenders.is_empty(),
"豁免②清单漂移(收敛站点进字典时同步更新本表):\n{}",
offenders.join("\n")
);
}
#[test]
fn format_call_spans_is_multiline_aware() {
let sample = concat!(
"let a = 1;\n",
"let s = format!(\n",
" \"中文 {0}\",\n",
" a\n",
");\n",
"let t = format!(\"english only {}\", a);\n",
);
let spans = format_call_spans(sample);
assert_eq!(spans.len(), 2, "两个 format! 调用都要找到");
let multiline = &sample[spans[0].clone()];
assert!(multiline.contains('\n'), "切片跨行");
assert!(multiline.contains("中文 {0}"), "切片覆盖次行中文字面量");
assert!(contains_cjk(multiline), "多行直写被判中");
let single = &sample[spans[1].clone()];
assert!(!contains_cjk(single), "英文单行保持阴性");
}
fn format_call_spans(text: &str) -> Vec<std::ops::Range<usize>> {
let mut spans = Vec::new();
let mut i = 0;
while let Some(rel) = text[i..].find("format!(") {
let start = i + rel;
match balanced_paren_end(text, start + "format!".len()) {
Some(end) => {
spans.push(start..end);
i = end;
}
None => break,
}
}
spans
}
fn api_error_call_spans(text: &str) -> Vec<std::ops::Range<usize>> {
let mut spans = Vec::new();
let mut i = 0;
while let Some(rel) = text[i..].find("ApiError::") {
let start = i + rel;
let mut j = start + "ApiError::".len();
let bytes = text.as_bytes();
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
if let Some(end) = balanced_paren_end(text, j) {
spans.push(start..end);
i = end;
continue;
}
}
i = start + "ApiError::".len();
}
spans
}
fn balanced_paren_end(text: &str, open: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut in_str = false;
let mut escaped = false;
let mut j = open;
while j < bytes.len() {
let c = bytes[j] as char;
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = !in_str;
} else if !in_str {
if c == '(' {
depth += 1;
} else if c == ')' {
depth -= 1;
if depth == 0 {
return Some(j + 1);
}
}
}
j += 1;
}
None
}
fn cjk_string_literals(call: &str) -> Vec<String> {
let bytes = call.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'"' {
i += 1;
continue;
}
let mut j = i + 1;
while j < bytes.len() {
if bytes[j] == b'\\' {
j += 2;
continue;
}
if bytes[j] == b'"' {
break;
}
j += 1;
}
let lit = &call[i + 1..j.min(call.len())];
if contains_cjk(lit) {
out.push(lit.to_string());
}
i = j + 1;
}
out
}
fn contains_cjk(s: &str) -> bool {
s.chars().any(|c| {
matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3000}'..='\u{303F}' | '\u{FF00}'..='\u{FFEF}')
})
}
const KEY_LIST: &[&str] = &[
"err.auth.bad_login_request",
"err.auth.locked",
"err.not_implemented",
"err.bad_body",
"err.artifact.multipart_read_failed",
"err.artifact.field_read_failed",
"err.artifact.duplicate",
"err.artifact.not_found",
"err.artifact.in_use",
"err.node.not_found",
"err.node.bad_register_body",
"err.node.bad_heartbeat_body",
"err.node.probe_failed",
"err.node.in_use",
"err.node.deploy_name_taken",
"err.node.action_unknown",
"err.node.remote_cleanup_failed",
"err.pipeline.not_found",
"err.pipeline.table_objects_missing",
"err.pipeline.statement_table_missing",
"err.pipeline.node_no_api_url",
"err.pipeline.engine_assembly_failed",
"err.pipeline.undeployed",
"err.pipeline.undeployed_plain",
"err.pipeline.assigned_node_missing",
"err.datasource.not_found",
"err.datasource.unknown_type",
"err.datasource.unknown_type_registry",
"err.datasource.missing_required_fields",
"err.datasource.in_use",
"err.datasource.jdbc_url_unparsable",
"err.datasource.jdbc_mysql_unparsable",
"err.datasource.jdbc_pg_unparsable",
"err.datasource.kafka_format_unsupported",
"probe.jdbc.timeout",
"probe.jdbc.reachable",
"probe.jdbc.unreachable",
"err.table_object.not_found",
"err.table_object.in_use",
];
}