feat(会员): AGC 模型目录新增权限档与按档过滤能力

- 新增 agc_model_access.rs:AgcModelAccess(basic/full,缺省 basic 失败开放)、越权错误码 MODEL_NOT_AVAILABLE_FOR_PLAN、AgcModelResolveError,以及会员档位到模型权限档的映射
- AgcModel 末位追加 #[serde(default)] access,存量目录 JSON 缺字段按 basic 解析
- AgcModelCatalog 新增 available_models_for / default_model_id_for / resolve_requested_for:显式越权不静默回退,默认模型优先登记项、不可用时回退目录顺序首个可用项
- 覆盖缺省解析、档位过滤、越权与未知模型区分、无可用模型等纯函数测试
This commit is contained in:
2026-10-03 16:13:30 +08:00
parent 56708988ed
commit 325466d9b4
3 changed files with 299 additions and 0 deletions
@@ -0,0 +1,290 @@
//! AGC 模型权限档与「按会员档位过滤模型目录」的判定(纯函数,无 IO)。
//!
//! 模型目录是全局的,但每个模型带一个权限档;会员档位(`profile_membership_plan.model_access`)
//! 决定账号能看到、能调用哪些模型。这里只做判定与解析,SpacetimeDB 读写留在 `spacetime-module`,
//! HTTP 错误映射留在 `api-server`。
//!
//! 失败开放:模型权限档缺省 [`AgcModelAccess::Basic`],因此存量目录 JSON 缺字段、后台未人工标注
//! `Full` 时都不会拦截任何请求;只有明确标了 `Full` 的模型才会对 `Basic` 档账号报越权。
//!
//! 本文件把「档位可见集合」「档位默认模型」「请求侧解析」收在一处,调用方只接触
//! [`AgcModelCatalog::resolve_requested_for`] 这类语义方法,不自己拼过滤条件。
use serde::{Deserialize, Serialize};
use crate::{
AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess,
};
/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。
pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN";
/// 单个模型要求的权限档。
///
/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理,
/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgcModelAccess {
#[default]
Basic,
Full,
}
impl AgcModelAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Basic => "basic",
Self::Full => "full",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"basic" => Some(Self::Basic),
"full" => Some(Self::Full),
_ => None,
}
}
/// 当前账号档位是否覆盖模型要求的档位:`Full` 覆盖全部,`Basic` 只覆盖基础。
pub const fn allows(self, required: Self) -> bool {
matches!(
(self, required),
(Self::Full, _) | (Self::Basic, Self::Basic)
)
}
}
impl From<RuntimeProfileMembershipModelAccess> for AgcModelAccess {
fn from(value: RuntimeProfileMembershipModelAccess) -> Self {
match value {
RuntimeProfileMembershipModelAccess::Basic => Self::Basic,
RuntimeProfileMembershipModelAccess::Full => Self::Full,
}
}
}
/// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgcModelResolveError {
/// 标识不在目录、模型已停用,或该档位没有任何可用模型。
Unavailable,
/// 模型存在且启用,但当前会员档位不够(越权)。
NotAvailableForPlan,
}
impl AgcModelResolveError {
pub const fn code(self) -> &'static str {
match self {
Self::Unavailable => "MODEL_UNAVAILABLE",
Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN,
}
}
pub const fn message(self) -> &'static str {
match self {
Self::Unavailable => "所选模型不可用,请刷新模型列表",
Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型",
}
}
}
impl AgcModel {
/// 该模型对给定账号档位是否可见 / 可调用(既要启用,也要档位足够)。
pub fn is_available_for(&self, access: AgcModelAccess) -> bool {
self.enabled && access.allows(self.access)
}
}
impl AgcModelCatalog {
/// 该档位可见的模型,保持目录顺序。
pub fn available_models_for(&self, access: AgcModelAccess) -> impl Iterator<Item = &AgcModel> {
self.models
.iter()
.filter(move |model| model.is_available_for(access))
}
/// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时
/// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。
pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> {
let configured = self
.models
.iter()
.find(|model| model.id == self.default_model_id)
.filter(|model| model.is_available_for(access));
if configured.is_some() {
return Some(self.default_model_id.as_str());
}
self.available_models_for(access)
.map(|model| model.id.as_str())
.next()
}
/// 请求侧解析(带档位)。
///
/// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。
/// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`],
/// **不静默回退**成其它模型。
pub fn resolve_requested_for(
&self,
requested: Option<&str>,
access: AgcModelAccess,
) -> Result<&str, AgcModelResolveError> {
let requested = requested
.map(str::trim)
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
match requested {
Some(id) => match self.models.iter().find(|model| model.id == id) {
Some(model) if !model.enabled => Err(AgcModelResolveError::Unavailable),
Some(model) if access.allows(model.access) => Ok(model.model_id.as_str()),
Some(_) => Err(AgcModelResolveError::NotAvailableForPlan),
None => Err(AgcModelResolveError::Unavailable),
},
None => {
let id = self
.default_model_id_for(access)
.ok_or(AgcModelResolveError::Unavailable)?;
self.models
.iter()
.find(|model| model.id == id && model.enabled)
.map(|model| model.model_id.as_str())
.ok_or(AgcModelResolveError::Unavailable)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog_from_ids(ids: &[&str], default_index: usize) -> AgcModelCatalog {
let mut catalog =
AgcModelCatalog::from_upstream_models(ids.iter().map(|id| id.to_string()), 0)
.expect("目录应从上游模型名构建");
let default_id = catalog.models[default_index].id.clone();
catalog.default_model_id = default_id;
catalog
}
fn mark_full(catalog: &mut AgcModelCatalog, id: &str) {
let model = catalog
.models
.iter_mut()
.find(|model| model.id == id)
.expect("测试模型应存在");
model.access = AgcModelAccess::Full;
}
#[test]
fn access_defaults_to_basic_and_parses_strictly() {
assert_eq!(AgcModelAccess::default(), AgcModelAccess::Basic);
assert_eq!(AgcModelAccess::parse("basic"), Some(AgcModelAccess::Basic));
assert_eq!(AgcModelAccess::parse("full"), Some(AgcModelAccess::Full));
assert_eq!(AgcModelAccess::parse("Full"), None);
assert_eq!(AgcModelAccess::parse(""), None);
}
#[test]
fn full_plan_covers_every_model_and_basic_plan_only_basic() {
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Basic));
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Full));
assert!(AgcModelAccess::Basic.allows(AgcModelAccess::Basic));
assert!(!AgcModelAccess::Basic.allows(AgcModelAccess::Full));
}
#[test]
fn legacy_catalog_json_defaults_missing_access_to_basic() {
// 存量目录 JSON 没有 access 字段:按 Basic 解析,失败开放。
let catalog: AgcModelCatalog = serde_json::from_str(
r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex"}]}"#,
)
.expect("存量目录应可读");
assert_eq!(catalog.models[0].access, AgcModelAccess::Basic);
}
#[test]
fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() {
let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1);
mark_full(&mut catalog, "model-b");
let visible = catalog
.available_models_for(AgcModelAccess::Basic)
.map(|model| model.id.as_str())
.collect::<Vec<_>>();
assert_eq!(visible, vec!["model-a", "model-c"]);
// 登记的默认项 model-b 是 Full,Basic 档回退到目录顺序里第一个可用项。
assert_eq!(
catalog.default_model_id_for(AgcModelAccess::Basic),
Some("model-a")
);
// Full 档保留登记的默认项。
assert_eq!(
catalog.default_model_id_for(AgcModelAccess::Full),
Some("model-b")
);
}
#[test]
fn resolve_requested_for_distinguishes_unknown_from_out_of_plan() {
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
mark_full(&mut catalog, "model-b");
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Basic),
Err(AgcModelResolveError::NotAvailableForPlan)
);
assert_eq!(
catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic),
Err(AgcModelResolveError::Unavailable)
);
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
Ok("model-b")
);
// 未选择时用该档默认模型。
assert_eq!(
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
Ok("model-a")
);
assert_eq!(
catalog.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Full),
Ok("model-a")
);
}
#[test]
fn disabled_model_is_unavailable_even_for_full_plan() {
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
catalog.models[1].enabled = false;
assert_eq!(
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
Err(AgcModelResolveError::Unavailable)
);
}
#[test]
fn plan_with_no_available_model_falls_back_to_unavailable() {
let mut catalog = catalog_from_ids(&["model-a"], 0);
mark_full(&mut catalog, "model-a");
assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None);
assert_eq!(
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
Err(AgcModelResolveError::Unavailable)
);
}
#[test]
fn resolve_error_codes_are_stable() {
assert_eq!(
AgcModelResolveError::NotAvailableForPlan.code(),
MODEL_NOT_AVAILABLE_FOR_PLAN
);
assert_eq!(
AgcModelResolveError::Unavailable.code(),
"MODEL_UNAVAILABLE"
);
}
}
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::AgcModelAccess;
/// 目录 revision 乐观锁冲突。
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
@@ -120,6 +122,9 @@ pub struct AgcModel {
/// 保存时必须与 `agent_mode` 组成合法组合。
#[serde(default)]
pub protocol: Option<AgcModelProtocol>,
/// 该模型要求的会员档位;缺省 `Basic`(失败开放),由人工在后台标 `Full`。
#[serde(default)]
pub access: AgcModelAccess,
}
impl AgcModel {
@@ -169,6 +174,7 @@ impl AgcModelCatalog {
enabled: true,
agent_mode: AgcAgentMode::default(),
protocol: None,
access: AgcModelAccess::default(),
});
}
let default_model_id = entries
@@ -316,6 +322,7 @@ mod tests {
enabled: true,
agent_mode: AgcAgentMode::default(),
protocol: None,
access: AgcModelAccess::default(),
}
}
@@ -2,6 +2,7 @@ pub mod agc_analytics;
pub mod civil_calendar;
pub mod membership;
mod agc_model_access;
mod agc_models;
mod payment;
pub use payment::*;
@@ -12,6 +13,7 @@ mod errors;
mod events;
mod llm_billing;
pub use agc_model_access::*;
pub use agc_models::*;
pub use application::*;
pub use commands::*;