fix(会员): 模型权限读链路失败改报独立 503 错误码

- 新增 MODEL_ACCESS_UNAVAILABLE(503),与 403 档位不足 / 422 模型不可用区分,不再把付费账号静默降级成 Basic
- resolve_owner_agc_model_access 返回 Result 并由 load_owner_llm_catalog 透传;缺会员行 / 缺目录行仍由 module 内失败关闭并返回成功
- 入口注释列出三类错误码语义;补错误码映射单测
This commit is contained in:
2026-10-04 10:30:30 +08:00
parent 6ed5d8fe88
commit 964948bed2
2 changed files with 40 additions and 10 deletions
+10 -1
View File
@@ -126,6 +126,15 @@ mod model_catalog_tests {
agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable);
assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn access_lookup_failure_maps_to_service_unavailable_with_dedicated_code() {
// 读链路失败必须是独立的 503 + 专用错误码,不能与 403 档位不足 / 422 模型不可用混用,
// 也不能把付费账号静默降级成 Basic。
let error = model_access::model_access_unavailable_error();
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code(), model_access::MODEL_ACCESS_UNAVAILABLE);
}
}
#[cfg(test)]
@@ -357,7 +366,7 @@ async fn load_owner_llm_catalog(
);
Ok(OwnerAgcModelCatalog {
catalog: catalog?,
access,
access: access?,
})
}
@@ -3,26 +3,47 @@
//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成
//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。
//!
//! 失败关闭:会员行缺失、目录行缺失、或 SpacetimeDB 读失败时都按 `Normal`(`Basic`)处理,
//! 只放宽到基础模型,不放开高性能模型。
//! LLM 路由对外错误码(三条互不重叠,客户端据此区分处理):
//! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。
//! - `MODEL_UNAVAILABLE`(422,列表无可用模型时 503):模型不在目录、已停用,或该档在目录里没有任何可用模型。
//! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。
//! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。
//! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。
use crate::state::AppState;
use axum::http::StatusCode;
use module_runtime::{AgcModelAccess, AgcModelCatalog};
use crate::{http_error::AppError, state::AppState};
/// 读取账号权限档失败(基础设施)时的专用错误码,与档位不足 / 模型不可用区分。
pub(crate) const MODEL_ACCESS_UNAVAILABLE: &str = "MODEL_ACCESS_UNAVAILABLE";
/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。
pub(crate) struct OwnerAgcModelCatalog {
pub(crate) catalog: AgcModelCatalog,
pub(crate) access: AgcModelAccess,
}
/// 读链路失败时的统一错误:503 + [`MODEL_ACCESS_UNAVAILABLE`]。
///
/// 独立成函数是为了让「基础设施失败」这件事可被单测直接断言,而不是散落的构造。
pub(crate) fn model_access_unavailable_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_code(MODEL_ACCESS_UNAVAILABLE)
.with_message("会员权限档暂不可用,请稍后重试")
}
/// 解析账号的 AGC 模型权限档。
///
/// 走专用只读 procedure `get_profile_agc_model_access`:它只读会员账期投影与档位目录,
/// 不触发换期 / 免费点刷新,适合 LLM 代理热路径。
///
/// 成功返回档位;读链路失败返回 503 + [`MODEL_ACCESS_UNAVAILABLE`],由路由层透传,不再静默降级成 `Basic`。
/// 缺会员行 / 缺档位目录行仍由 module 内失败关闭到 `Basic` 并返回成功。
pub(crate) async fn resolve_owner_agc_model_access(
state: &AppState,
owner_user_id: &str,
) -> AgcModelAccess {
) -> Result<AgcModelAccess, AppError> {
#[cfg(test)]
if super::test_provisioned_router_credentials()
.lock()
@@ -30,21 +51,21 @@ pub(crate) async fn resolve_owner_agc_model_access(
.contains_key(owner_user_id)
{
// 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。
return AgcModelAccess::Full;
return Ok(AgcModelAccess::Full);
}
match state
.spacetime_client()
.get_profile_agc_model_access(owner_user_id.to_string())
.await
{
Ok(record) => AgcModelAccess::from(record.model_access),
Ok(record) => Ok(AgcModelAccess::from(record.model_access)),
Err(error) => {
tracing::warn!(
tracing::error!(
user_id = %owner_user_id,
error = %error,
"读取会员档位失败,AGC 模型权限按 Basic 失败关闭"
"读取会员档位失败,AGC 模型权限按 503 上报"
);
AgcModelAccess::Basic
Err(model_access_unavailable_error())
}
}
}