feat(会员): 后台 AGC 模型支持标注权限档

- AdminAgcModel 新增 access 字段(缺省 basic,失败开放),GET 回读 PUT 写入
- api-server 保存时校验权限档只允许 basic/full
- 后台 AGC 模型页新增「权限档」列,可按模型选择基础/高性能模型
- fake API 与页面用例覆盖权限档选择与保存
This commit is contained in:
2026-10-03 16:29:03 +08:00
parent 103280025c
commit 2630b0f690
6 changed files with 75 additions and 2 deletions
+3
View File
@@ -1174,6 +1174,7 @@ export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc';
export type AdminAgcModelAccess = 'basic' | 'full';
export type AdminAgcModelProtocol =
| 'openai_responses'
| 'openai_chat'
@@ -1187,6 +1188,8 @@ export interface AdminAgcModel {
agentMode: AdminAgcAgentMode;
/** 上游线协议,与执行器正交。 */
protocol?: AdminAgcModelProtocol;
/** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */
access?: AdminAgcModelAccess;
}
export interface AdminAgcModelCatalog {
@@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () =>
modelId: '',
enabled: true,
agentMode: 'cc',
access: 'basic',
});
});
});
test('权限档可按模型单独选择并随目录保存', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
access: 'basic' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('gpt-6-astra');
const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement;
expect(access.value).toBe('basic');
fireEvent.change(access, { target: { value: 'full' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models[0]).toMatchObject({ access: 'full' });
});
});
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
const catalog = {
revision: 3,
@@ -118,6 +118,7 @@ export function AdminAgcModelsPage({
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'protocol', label: '协议' },
{ key: 'access', label: '权限档' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -159,6 +160,7 @@ export function AdminAgcModelsPage({
enabled: true,
agentMode: 'codex',
protocol: 'openai_responses',
access: 'basic',
},
],
},
@@ -246,6 +248,21 @@ export function AdminAgcModelsPage({
<option value="anthropic">Anthropic Messages</option>
</select>
</td>
<td>
<select
aria-label={`模型 ${index + 1} 权限档`}
value={model.access ?? 'basic'}
disabled={busy}
onChange={(e) =>
update(model.id, {
access: e.target.value as AdminAgcModel['access'],
})
}
>
<option value="basic">基础模型</option>
<option value="full">高性能模型</option>
</select>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
+3
View File
@@ -1075,18 +1075,21 @@ function route(pathname, method, url, requestBody = '') {
alias: '标准模型',
modelId: 'gpt-5.6-sol',
enabled: true,
access: 'full',
},
{
id: 'model_terra',
alias: '快速模型',
modelId: 'gpt-5.6-terra',
enabled: true,
access: 'basic',
},
{
id: 'model_legacy',
alias: '旧版模型',
modelId: 'gpt-5.4',
enabled: false,
access: 'basic',
},
],
};
@@ -8,8 +8,8 @@ use axum::{
http::StatusCode,
};
use module_runtime::{
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelCatalog,
AgcModelProtocol,
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelAccess,
AgcModelCatalog, AgcModelProtocol,
};
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
use spacetime_client::SpacetimeClientError;
@@ -244,6 +244,10 @@ pub async fn admin_save_agc_models(
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
})?;
let access = AgcModelAccess::parse(&m.access).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("权限档只允许 basic 或 full")
})?;
Ok(module_runtime::AgcModel {
id: m.id,
alias: m.alias,
@@ -251,6 +255,7 @@ pub async fn admin_save_agc_models(
enabled: m.enabled,
agent_mode,
protocol,
access,
})
})
.collect::<Result<Vec<_>, AppError>>()?;
@@ -297,6 +302,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
enabled: m.enabled,
agent_mode: m.agent_mode.as_str().to_string(),
protocol: Some(protocol),
access: m.access.as_str().to_string(),
}
})
.collect(),
@@ -1704,6 +1704,11 @@ fn default_codex_agent_mode() -> String {
"codex".to_string()
}
/// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。
fn default_basic_agc_model_access() -> String {
"basic".to_string()
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminAgcModel {
@@ -1718,6 +1723,9 @@ pub struct AdminAgcModel {
/// 服务端回读时始终给出解析后的具体值。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
/// 该模型要求的会员档位:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。
#[serde(default = "default_basic_agc_model_access")]
pub access: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]