fix(admin): save published models as a new draft and allow deleting them
Independent Staging Quality Gate / validate (push) Successful in 14m0s
Independent Staging Quality Gate / publish (push) Successful in 4m5s

Editing a published model tried to update a non-draft row, so the save collapsed into a generic constraint error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-20 15:02:42 +08:00
co-authored by Cursor
parent dac6b7fae9
commit 0a5d93ab75
13 changed files with 322 additions and 12 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-20 — 后台可以修改并删除已发布模型
- 编辑已发布或已下线的模型会另存成下一版草稿,当前线上版本保持不变。更换供应商模型时,显示名跟着新模型走。
- 模型版本列表可以删除草稿、已发布和已下线版本。还有其他已发布模型时,不能删掉当前默认模型。已经绑定被删版本的会话不能继续提问。
- Skill 版本不变。新增迁移 `20261020150000_admin_model_version_draft_and_delete.sql`。
## 2026-10-20 — 完整合盘报告定价 19 点
- `synastry.full` 按标准、高级、内部三个模型档发布,每份 19 点。某一档已经有生效价格时,迁移不再覆盖。Skill 版本不变。
+16
View File
@@ -36936,3 +36936,19 @@
- 复发自:无。
- 修复版本:分支 `codex/synastry-page-dynamic-20261020`。
## BUG-1398 | 后台保存已发布模型报「提交内容不符合业务约束」,也不能删除
- 状态:resolved(本地修复,待提交)
- 首次发现:2026-10-20
- 最近更新:2026-10-20
- 影响面:后台模型配置 `POST /api/admin/models` 的 `saveDraft` / `delete`,`admin_save_model_draft`、`admin_delete_model_version`,模型管理页。
- 用户现象:编辑已有模型并保存草稿时,接口返回「提交内容不符合业务约束」。列表里也不能删除已发布模型。
- 触发条件:对状态不是草稿的版本调用 `action=saveDraft` 并带上该版本 id。管理页每一行都写成「编辑草稿」,已发布行走同一条保存。
- 根因:`admin_save_model_draft` 只更新 `status='draft'` 的行。已发布或已下线的版本更新不到,抛出 `model_draft_not_found`(PostgreSQL `22023`)。`adminErrorResponse` 把 `22023` / `23514` / `23505` 收成同一句「提交内容不符合业务约束」。删除入口不存在;`model_publish_events` 对版本 id 是 `ON DELETE RESTRICT`,直接删版本行也会被挡住。
- 修复:保存已发布或已下线版本时,更新同一模型已有草稿,没有则插入下一版草稿,不改当前线上行。新增 `admin_delete_model_version`:先删掉引用该版本的发布事件,再删除版本;最后一个版本连同模型配置一起删除。若它是当前默认模型且还有其他已发布模型,拒绝删除并提示先改默认。其他模型把回退指向已无已发布版本的模型时,回退清空。更换供应商模型时,显示名改用新模型名。上述异常映射成可读错误,不再落进笼统业务约束。
- 验证:见 `docs/tasks/PROGRESS-model-admin-draft-delete-20261020.md`。数据库回归覆盖:用已发布版本 id 保存得到新草稿且原行不变,再次保存更新同一草稿;删除草稿;还有其他已发布模型时删除默认模型失败;删除已发布非默认模型后发布事件消失、回退被清空;删掉同模型剩余草稿后配置消失;只剩这一个已发布模型时可以删除。
- 防复发:后台模型写路径必须区分草稿与已发布。已发布模型的保存不得要求调用方先手建草稿,也不得改正在线上的那一行。`model_draft_not_found`、`model_version_not_found`、`model_default_required` 不得再折叠成「提交内容不符合业务约束」。删除已发布版本要先处理 `model_publish_events` 的限制外键。还有其他已发布模型时,不得删掉唯一的默认模型。
- 相关记录:BUG-275(商品保存已发布 id 被折叠成同一句错误)。
- 复发自:无。同型问题曾出现在商品目录,模型保存未一起改。
- 修复版本:分支 `codex/model-admin-draft-delete-20261020`,未提交。
@@ -0,0 +1,19 @@
# 后台已发布模型另存草稿并删除
- 基线:`origin/staging` `dac6b7fae`
- 分支:`codex/model-admin-draft-delete-20261020`
- 记录:BUG-1398
## 做了什么
编辑已发布或已下线模型时,保存写入下一版草稿,不改当前线上行。同一模型已有草稿时,再次保存更新那份草稿。
模型版本可以删除。草稿、已发布、已下线都从列表消失。还有其他已发布模型时,不能删除当前默认模型。已经绑定被删版本的会话之后不能继续提问。更换下拉里的供应商模型时,显示名跟着新模型走。
## 测试
- `tsc --noEmit`:0 错。
- 改动文件 `eslint`:0 error。
- `admin-model-management-ui-contract`、`admin-auth`、`high-risk-billing-routes-contract`:27 pass,0 fail,0 cancelled。
- `database-billing-admin`:1 pass。其中删除默认模型命中 `model_default_required`,无权限删除命中 `admin_permission_denied`。
- 未在浏览器里打开 staging 后台。这一步要等代码推上去并部署之后,用已登录的管理账号点保存和删除。
+1
View File
@@ -13,6 +13,7 @@
- [三份修复单整合与独立验收](PROGRESS-three-fixes-integration-20260925.md) — 2026-09-25,人物档案优先,报告与西洋盘并行;Node 22 / Linux 全量对照与断言三栏为交付硬门槛。
- [双 agent 自动循环试点](TASK-agent-loop-pilot-20261013.md) — 2026-10-13,Claude(本开发机)规划验收 + Grok Build(产品负责人本地电脑)执行,Gitea issue 标签交接;待产品负责人做 T0/T1。
- [后台已发布模型可另存草稿并删除](PROGRESS-model-admin-draft-delete-20261020.md) — 2026-10-20,直接执行(BUG-1398)。
## 状态板
+1
View File
@@ -852,6 +852,7 @@ admin page — so nothing here inherits the tokens above.
sanctioned exception to the no-raw-color rule.
- **Content width:** 1200px centred, set by antd layout, not by `.admin-scroll`.
- **Loading:** antd `<Spin>`, outside the waiting vocabulary in section 9.
- **Model versions (2026-10-20):** editing a published or retired row saves a new draft and leaves the live row unchanged. Delete removes that version from the list. Both use the existing antd `Modal` and link buttons; no new class.
- **Aggregate pages (「校正统计」, 2026-09-26):** built only from antd `Card`,
`Statistic`, `Table`, `Progress` and `Segmented` — no chart library and no new
class. Distributions are a table whose last column is a small `Progress` bar;
@@ -57,6 +57,7 @@ const actionSchema = z.discriminatedUnion("action", [
draftSchema,
z.object({ action: z.literal("test"), versionId: z.string().uuid() }).strict(),
z.object({ action: z.literal("publish"), versionId: z.string().uuid() }).strict(),
z.object({ action: z.literal("delete"), versionId: z.string().uuid() }).strict(),
z.object({ action: z.literal("rollback"), configId: z.string().uuid(), targetVersion: z.number().int().positive() }).strict(),
]).superRefine((value, context) => {
if (value.action === "saveDraft" && value.isDefault && !value.enabled) {
@@ -68,6 +69,7 @@ const modelMutationAuditReasons = {
saveProvider: "保存模型供应商",
saveDraft: "保存模型草稿",
publish: "发布模型版本",
delete: "删除模型版本",
rollback: "回滚模型版本",
} as const;
@@ -96,7 +96,7 @@ const modelStatusLabels: Record<string, string> = {
};
export default function ModelManagement() {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [providerForm] = Form.useForm<ProviderForm>();
const [modelForm] = Form.useForm<ModelForm>();
@@ -225,8 +225,12 @@ export default function ModelManagement() {
async function saveModel(values: ModelForm) {
setSaving(true);
try {
const discovered = discoveredModels.find((item) => item.id === values.providerModel);
const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, values.providerModel);
const providerModel = values.providerModel.trim();
const discovered = discoveredModels.find((item) => item.id === providerModel);
const label = (!editingModel || editingModel.providerModel !== providerModel
? (discovered?.label?.trim() || providerModel)
: editingModel.label).slice(0, 60);
const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, providerModel);
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({
@@ -234,8 +238,8 @@ export default function ModelManagement() {
modelId,
versionId: editingModel?.id ?? null,
providerId: values.providerId,
label: editingModel?.label ?? (discovered?.label?.trim() || values.providerModel).slice(0, 60),
providerModel: values.providerModel.trim(),
label,
providerModel,
creditCost: values.creditCost,
enabled: values.enabled,
description: editingModel?.description ?? "",
@@ -248,7 +252,9 @@ export default function ModelManagement() {
settings: editingModel?.settings ?? {},
}),
});
message.success("模型草稿已保存");
message.success(editingModel && editingModel.status !== "draft"
? "已保存为新版本草稿,当前已发布版本未改动"
: "模型草稿已保存");
setModelOpen(false);
await load();
} catch (error) {
@@ -258,6 +264,35 @@ export default function ModelManagement() {
}
}
async function removeModel(item: ModelVersion) {
setActingId(item.id);
try {
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({ action: "delete", versionId: item.id }),
});
message.success(item.status === "draft" ? "草稿已删除" : "模型已删除");
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : "删除失败");
} finally {
setActingId(null);
}
}
function confirmRemove(item: ModelVersion) {
modal.confirm({
title: item.status === "draft" ? "删除这份草稿?" : "删除这个模型版本?",
content: item.status === "published"
? "它会从模型列表和用户可选模型中消失。已经绑定这个版本的会话将无法继续提问。"
: "它会从模型列表中消失。",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => removeModel(item),
});
}
async function act(body: Record<string, unknown>, success: string, id: string) {
setActingId(id);
try {
@@ -311,7 +346,8 @@ export default function ModelManagement() {
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openModel(item)}>编辑草稿</Button>}
{canWrite && <Button type="link" onClick={() => openModel(item)}>{item.status === "draft" ? "编辑草稿" : "修改"}</Button>}
{canWrite && <Button type="link" danger loading={actingId === item.id} onClick={() => confirmRemove(item)}>删除</Button>}
{canTest && <Button type="link" loading={actingId === item.id} onClick={() => void testVersion(item)}>测试此版本</Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => void runVersionAction("publish", item)}>发布</Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => void runVersionAction("rollback", item)}>回滚到此版</Button>}
@@ -371,8 +407,9 @@ export default function ModelManagement() {
</Form>
</Modal>
<Modal title={editingModel ? `编辑 ${editingModel.modelId}` : "新增模型草稿"} open={modelOpen} width={720} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden>
<Modal title={editingModel ? `${editingModel.status === "draft" ? "编辑" : "修改"} ${editingModel.modelId}` : "新增模型草稿"} open={modelOpen} width={720} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden>
<Form<ModelForm> form={modelForm} layout="vertical" onFinish={saveModel}>
{editingModel && editingModel.status !== "draft" ? <Text type="secondary">保存后会生成新版本草稿,当前已发布版本保持不变。发布新草稿后才会替换线上模型。</Text> : null}
<Form.Item label="供应商" required>
<Space.Compact block>
<Form.Item name="providerId" noStyle rules={[{ required: true, message: "请选择供应商" }]}>
@@ -40,6 +40,15 @@ export function adminErrorResponse(error: unknown) {
if (message.includes("product_draft_not_found")) {
return NextResponse.json({ error: "只能编辑草稿。已发布商品请下架,或保存为新版本草稿后再发布" }, { status: 400 });
}
if (message.includes("model_version_not_found")) {
return NextResponse.json({ error: "模型版本不存在" }, { status: 404 });
}
if (message.includes("model_draft_not_found")) {
return NextResponse.json({ error: "只能修改草稿。已发布模型保存后会生成新版本,不会直接改当前版本" }, { status: 400 });
}
if (message.includes("model_default_required")) {
return NextResponse.json({ error: "请先把另一个已发布模型设为默认,再删除这个模型" }, { status: 409 });
}
if (isPostgresError(error)) {
if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 });
if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 });
@@ -2,7 +2,7 @@ import { decryptModelProviderApiKey, encryptModelProviderApiKey, modelConnection
type SaveProviderAction={action:"saveProvider";id?:string|null;name:string;providerType:ModelProviderType;baseUrl?:string|null;apiKey?:string;enabled:boolean;reason:string};
type SaveDraftAction={action:"saveDraft";modelId:string;versionId?:string|null;providerId:string;label:string;description:string;providerModel:string;modelTier:"standard"|"premium"|"internal";creditCost:number;contextWindow?:number|null;inputCostMicrousdPerMillion:number;outputCostMicrousdPerMillion:number;enabled:boolean;isDefault:boolean;fallbackModelId?:string|null;settings:Record<string,unknown>;reason:string};
export type AdminModelMutation=SaveProviderAction|SaveDraftAction|{action:"test";versionId:string}|{action:"publish";versionId:string;reason:string}|{action:"rollback";configId:string;targetVersion:number;reason:string};
export type AdminModelMutation=SaveProviderAction|SaveDraftAction|{action:"test";versionId:string}|{action:"publish";versionId:string;reason:string}|{action:"delete";versionId:string;reason:string}|{action:"rollback";configId:string;targetVersion:number;reason:string};
type ProviderRow={id:string;code:string;provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean;version_id:string;version_enabled:boolean};
type Dependencies={queryRows(sql:string,values?:readonly unknown[]):Promise<readonly Record<string,unknown>[]>;assertAllowedUrl(value:string):Promise<unknown>;probeAllowed(value:string,headers:Readonly<Record<string,string>>):Promise<number>;invalidateCatalog():void;environment?:Readonly<Record<string,string|undefined>>};
const json=(body:unknown,status=200)=>Response.json(body,{status});
@@ -14,5 +14,6 @@ export async function handleAdminModelMutation(a:AdminModelMutation,c:Readonly<{
if(a.action==="saveDraft"){const rows=await d.queryRows("select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",[c.actorUserId,a.modelId,a.versionId??null,a.providerId,a.label,a.description,a.providerModel,a.modelTier,a.creditCost,a.contextWindow??null,a.inputCostMicrousdPerMillion,a.outputCostMicrousdPerMillion,a.enabled,a.isDefault,a.fallbackModelId??null,JSON.stringify(a.settings),a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d,true);if(!r.recorded)return json({error:"数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);}
if(a.action==="publish"){const rows=await d.queryRows("select public.admin_publish_model($1,$2,$3,$4) id",[c.actorUserId,a.versionId,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
if(a.action==="delete"){const rows=await d.queryRows("select public.admin_delete_model_version($1,$2,$3,$4) id",[c.actorUserId,a.versionId,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
const p=await versionProvider(d,"v.config_id=$1 and v.version=$2 and v.status='retired'",[a.configId,a.targetVersion]);if(!p)return json({error:"回滚版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"回滚版本或供应商未启用,或数据库密钥不可用"},409);if(!r.success)return json({error:"回滚版本运行时连接测试失败",data:{status:r.status,requestId:c.requestId}},409);const rows=await d.queryRows("select public.admin_rollback_model($1,$2,$3,$4,$5) id",[c.actorUserId,a.configId,a.targetVersion,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});
}
@@ -0,0 +1,145 @@
begin;
-- 保存已发布或已下线版本时,不改那一行,改为更新同一模型已有草稿,或插入下一版草稿。
-- 删除版本时先清掉发布事件:model_publish_events 对版本 id 是 ON DELETE RESTRICT。
create or replace function public.admin_save_model_draft(
p_actor_user_id uuid,p_model_id text,p_version_id uuid,p_provider_id uuid,p_label text,p_description text,
p_provider_model text,p_model_tier text,p_credit_cost integer,p_context_window integer,
p_input_cost bigint,p_output_cost bigint,p_enabled boolean,p_is_default boolean,p_fallback_model_id text,
p_settings jsonb,p_reason text,p_request_id text
)
returns uuid language plpgsql security definer set search_path=''
as $$
declare
v_config_id uuid;
v_version integer;
v_id uuid;
v_existing public.model_config_versions%rowtype;
begin
if not public.admin_has_permission(p_actor_user_id,'models.write') then
raise exception 'admin_permission_denied' using errcode='42501';
end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
if p_is_default and not p_enabled then
raise exception 'default_model_disabled' using errcode='23514';
end if;
if public.model_settings_contain_secrets(coalesce(p_settings,'{}'::jsonb)) then
raise exception 'model_settings_secret_forbidden' using errcode='23514';
end if;
insert into public.model_configs(model_id) values(p_model_id)
on conflict(model_id) do update set model_id=excluded.model_id
returning id into v_config_id;
if p_version_id is null then
select coalesce(max(version),0)+1 into v_version from public.model_config_versions where config_id=v_config_id;
insert into public.model_config_versions(config_id,version,provider_id,label,description,provider_model,model_tier,
credit_cost,context_window,input_cost_microusd_per_million,output_cost_microusd_per_million,enabled,is_default,
fallback_model_id,settings,created_by)
values(v_config_id,v_version,p_provider_id,p_label,p_description,p_provider_model,p_model_tier,p_credit_cost,p_context_window,
p_input_cost,p_output_cost,p_enabled,p_is_default,nullif(btrim(p_fallback_model_id),''),coalesce(p_settings,'{}'::jsonb),p_actor_user_id)
returning id into v_id;
else
select * into v_existing from public.model_config_versions
where id=p_version_id and config_id=v_config_id
for update;
if not found then raise exception 'model_version_not_found' using errcode='22023'; end if;
if v_existing.status='draft' then
update public.model_config_versions set provider_id=p_provider_id,label=p_label,description=p_description,
provider_model=p_provider_model,model_tier=p_model_tier,credit_cost=p_credit_cost,context_window=p_context_window,
input_cost_microusd_per_million=p_input_cost,output_cost_microusd_per_million=p_output_cost,enabled=p_enabled,
is_default=p_is_default,fallback_model_id=nullif(btrim(p_fallback_model_id),''),settings=coalesce(p_settings,'{}'::jsonb)
where id=v_existing.id and status='draft'
returning id into v_id;
else
select id into v_id from public.model_config_versions
where config_id=v_config_id and status='draft'
for update;
if v_id is not null then
update public.model_config_versions set provider_id=p_provider_id,label=p_label,description=p_description,
provider_model=p_provider_model,model_tier=p_model_tier,credit_cost=p_credit_cost,context_window=p_context_window,
input_cost_microusd_per_million=p_input_cost,output_cost_microusd_per_million=p_output_cost,enabled=p_enabled,
is_default=p_is_default,fallback_model_id=nullif(btrim(p_fallback_model_id),''),settings=coalesce(p_settings,'{}'::jsonb)
where id=v_id and status='draft'
returning id into v_id;
else
select coalesce(max(version),0)+1 into v_version from public.model_config_versions where config_id=v_config_id;
insert into public.model_config_versions(config_id,version,provider_id,label,description,provider_model,model_tier,
credit_cost,context_window,input_cost_microusd_per_million,output_cost_microusd_per_million,enabled,is_default,
fallback_model_id,settings,created_by)
values(v_config_id,v_version,p_provider_id,p_label,p_description,p_provider_model,p_model_tier,p_credit_cost,p_context_window,
p_input_cost,p_output_cost,p_enabled,p_is_default,nullif(btrim(p_fallback_model_id),''),coalesce(p_settings,'{}'::jsonb),p_actor_user_id)
returning id into v_id;
end if;
end if;
end if;
insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,after_value,
request_id,permission_used,reason)
select p_actor_user_id,lower(btrim(u.email)),'admin','models.draft.save','model_config_version',v_id,
jsonb_build_object('modelId',p_model_id,'providerId',p_provider_id,'enabled',p_enabled,'isDefault',p_is_default),
p_request_id,'models.write',btrim(p_reason) from identity.users u where u.id=p_actor_user_id on conflict do nothing;
return v_id;
end $$;
create or replace function public.admin_delete_model_version(
p_actor_user_id uuid, p_version_id uuid, p_reason text, p_request_id text
)
returns uuid language plpgsql security definer set search_path=''
as $$
declare
v_version public.model_config_versions%rowtype;
v_model_id text;
begin
if not public.admin_has_permission(p_actor_user_id,'models.write') then
raise exception 'admin_permission_denied' using errcode='42501';
end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
select * into v_version from public.model_config_versions where id=p_version_id for update;
if not found then raise exception 'model_version_not_found' using errcode='22023'; end if;
select model_id into v_model_id from public.model_configs where id=v_version.config_id for update;
if v_model_id is null then raise exception 'model_version_not_found' using errcode='22023'; end if;
if v_version.status='published' and v_version.enabled and v_version.is_default and exists(
select 1 from public.model_config_versions other
join public.model_providers provider on provider.id=other.provider_id
where other.config_id<>v_version.config_id
and other.status='published' and other.enabled and provider.enabled
) then
raise exception 'model_default_required' using errcode='23514';
end if;
delete from public.model_publish_events
where from_version_id=p_version_id or to_version_id=p_version_id;
delete from public.model_config_versions where id=p_version_id;
update public.model_config_versions
set fallback_model_id=null
where fallback_model_id=v_model_id
and not exists(
select 1 from public.model_configs config
join public.model_config_versions live on live.config_id=config.id
where config.model_id=v_model_id and live.status='published' and live.enabled
);
if not exists(select 1 from public.model_config_versions where config_id=v_version.config_id) then
delete from public.model_publish_events where config_id=v_version.config_id;
delete from public.model_configs where id=v_version.config_id;
end if;
insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,
before_value,after_value,request_id,permission_used,reason)
select p_actor_user_id,lower(btrim(u.email)),'admin','models.version.delete','model_config_version',p_version_id,
jsonb_build_object('modelId',v_model_id,'status',v_version.status,'enabled',v_version.enabled),
jsonb_build_object('modelId',v_model_id,'status','deleted'),
p_request_id,'models.write',btrim(p_reason)
from identity.users u where u.id=p_actor_user_id on conflict do nothing;
return p_version_id;
end $$;
revoke all on function public.admin_delete_model_version(uuid,uuid,text,text) from public, anon, authenticated;
grant execute on function public.admin_delete_model_version(uuid,uuid,text,text) to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname='admin_runtime') then
grant execute on function public.admin_delete_model_version(uuid,uuid,text,text) to admin_runtime;
end if;
end $$;
commit;
+12
View File
@@ -200,6 +200,18 @@ test("adminErrorResponse maps product catalog failures without exposing details"
}
});
test("adminErrorResponse maps model draft and delete failures without the generic constraint message", async () => {
for (const [error, status, message] of [
[{ code: "22023", message: "model_version_not_found" }, 404, "模型版本不存在"],
[{ code: "22023", message: "model_draft_not_found" }, 400, "只能修改草稿。已发布模型保存后会生成新版本,不会直接改当前版本"],
[{ code: "23514", message: "model_default_required" }, 409, "请先把另一个已发布模型设为默认,再删除这个模型"],
] as const) {
const response = adminErrorResponse(error);
assert.equal(response.status, status);
assert.deepEqual(await response.json(), { error: message });
}
});
test("adminErrorResponse preserves the sanitized authorization 503", async () => {
const response = adminErrorResponse(
new AdminAuthorizationError("后台服务暂时不可用", 503),
@@ -33,9 +33,17 @@ test("model discovery is the only model picker and generates system fields", ()
assert.match(component, /name="providerModel" label="模型"[\s\S]*<Select[\s\S]*showSearch[\s\S]*optionFilterProp="label"/);
assert.doesNotMatch(component, /供应商模型名(可手工输入)|name="modelId"|name="label" label="显示名称"/);
assert.match(component, /import \{ createDiscoveredModelId \} from "\.\/model-id"/);
assert.match(component, /const modelId = editingModel\?\.modelId \?\? await createDiscoveredModelId\(values\.providerId, values\.providerModel\)/);
// 原值: const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, values.providerModel)
// 新值: const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, providerModel)
// 原因: 供应商模型名先 trim,显示名在模型变更时改用新模型名。
assert.match(component, /const modelId = editingModel\?\.modelId \?\? await createDiscoveredModelId\(values\.providerId, providerModel\)/);
assert.doesNotMatch(component, /providerModel\.toLowerCase\(\)\.replace/);
assert.match(component, /label: editingModel\?\.label \?\? \(discovered\?\.label\?\.trim\(\) \|\| values\.providerModel\)\.slice\(0, 60\)/);
// 原值: label: editingModel?.label ?? (discovered?.label?.trim() || values.providerModel).slice(0, 60)
// 新值: 未改供应商模型时保留原显示名;改了则用发现列表里的名称或新模型名
// 原因: 编辑时更换 deepseek 模型,显示名必须跟着变,不能一直留着旧名。
assert.match(component, /const label = \(!editingModel \|\| editingModel\.providerModel !== providerModel/);
assert.match(component, /\? \(discovered\?\.label\?\.trim\(\) \|\| providerModel\)/);
assert.match(component, /: editingModel\.label\)\.slice\(0, 60\)/);
});
test("model form exposes only essential switches and credit cost", () => {
@@ -76,16 +84,38 @@ test("model mutations omit client reasons while the server keeps fixed audit rea
assert.match(component, /onClick=\{\(\) => void runVersionAction\("rollback", item\)\}>回滚到此版<\/Button>/);
assert.doesNotMatch(route, /reason:\s*z\.string/);
for (const reason of ["保存模型供应商", "保存模型草稿", "发布模型版本", "回滚模型版本"]) {
for (const reason of ["保存模型供应商", "保存模型草稿", "发布模型版本", "删除模型版本", "回滚模型版本"]) {
assert.match(route, new RegExp(reason));
}
assert.match(route, /action: z\.literal\("delete"\), versionId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /reason:\s*modelMutationAuditReasons\[body\.data\.action\]/);
assert.match(mutationHandler, /admin_save_model_provider[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_save_model_draft[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_publish_model[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_delete_model_version[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_rollback_model[\s\S]*a\.reason/);
});
test("published model edits fork a draft and every version can be deleted", () => {
const migration = readFileSync(
new URL("../supabase/migrations/20261020150000_admin_model_version_draft_and_delete.sql", import.meta.url),
"utf8",
);
assert.match(component, /item\.status === "draft" \? "编辑草稿" : "修改"/);
assert.match(component, /onClick=\{\(\) => confirmRemove\(item\)\}>删除<\/Button>/);
assert.match(component, /保存后会生成新版本草稿,当前已发布版本保持不变/);
assert.match(component, /action: "delete", versionId: item\.id/);
assert.match(component, /已经绑定这个版本的会话将无法继续提问/);
assert.match(migration, /v_existing\.status='draft'/);
assert.match(migration, /admin_delete_model_version/);
assert.match(migration, /model_default_required/);
assert.match(migration, /delete from public\.model_publish_events/);
assert.match(
readFileSync(new URL("../src/lib/admin/admin-error-response.ts", import.meta.url), "utf8"),
/请先把另一个已发布模型设为默认,再删除这个模型/,
);
});
test("provider rows expose a direct add-model entry with the provider preselected", () => {
assert.match(component, /function openModel\(model\?: ModelVersion, providerId\?: string\)/);
assert.match(component, /providerId: providerId \?\? providers\[0\]\?\.id/);
@@ -513,6 +513,37 @@ test("billing, subscriptions, usage authorization, RBAC, and model publication r
);
assert.equal(sql(`select count(*) from public.model_config_versions where status='published' and enabled and is_default`), "1");
assert.equal(sql(`select count(*) from public.model_publish_events where action='rollback'`), "1");
const forked = sql(`select public.admin_save_model_draft('${ids.model}','standard-model','${standardV3}','${provider}','Forked label','', 'standard-v3-fork','standard',1,64000,12,22,true,false,'premium-model','{}','保存已发布模型为草稿','standard-fork-1')`);
assert.notEqual(forked, standardV3);
assert.equal(sql(`select status||':'||label from public.model_config_versions where id='${standardV3}'`), "published:Standard 3");
assert.equal(sql(`select status||':'||label from public.model_config_versions where id='${forked}'`), "draft:Forked label");
const forkedAgain = sql(`select public.admin_save_model_draft('${ids.model}','standard-model','${standardV3}','${provider}','Forked again','', 'standard-v3-fork-2','standard',1,64000,13,23,true,false,'premium-model','{}','再次保存已发布模型','standard-fork-2')`);
assert.equal(forkedAgain, forked);
assert.equal(sql(`select count(*) from public.model_config_versions where config_id=(select id from public.model_configs where model_id='standard-model') and status='draft'`), "1");
sql(`select public.admin_delete_model_version('${ids.model}','${forked}','删除草稿','model-delete-draft')`);
assert.equal(sql(`select count(*) from public.model_config_versions where id='${forked}'`), "0");
assert.equal(sql(`select status from public.model_config_versions where id='${standardV3}'`), "published");
expectSqlError(
`select public.admin_delete_model_version('${ids.support}','${standardV3}','无权删除','model-delete-denied')`,
/admin_permission_denied/,
);
expectSqlError(
`select public.admin_delete_model_version('${ids.model}','${standardV3}','默认模型仍被使用','model-delete-default')`,
/model_default_required/,
);
sql(`select public.admin_delete_model_version('${ids.model}','${premiumDraft}','删除已发布非默认模型','model-delete-published')`);
assert.equal(sql(`select count(*) from public.model_config_versions where id='${premiumDraft}'`), "0");
assert.equal(sql(`select status from public.model_config_versions where id='${premiumV2}'`), "draft");
assert.equal(sql(`select count(*) from public.model_publish_events where to_version_id='${premiumDraft}' or from_version_id='${premiumDraft}'`), "0");
assert.equal(sql(`select fallback_model_id is null from public.model_config_versions where id='${standardV3}'`), "t");
assert.equal(sql(`select after_value ? 'modelId' from audit.admin_audit_logs where action='models.version.delete' and request_id='model-delete-published'`), "t");
assert.equal(sql(`select after_value ? 'code' or after_value ? 'token' or after_value ? 'secret' or after_value ? 'key' from audit.admin_audit_logs where action='models.version.delete' and request_id='model-delete-published'`), "f");
sql(`select public.admin_delete_model_version('${ids.model}','${premiumV2}','删除剩余草稿','model-delete-premium-draft')`);
assert.equal(sql(`select count(*) from public.model_configs where model_id='premium-model'`), "0");
assert.equal(sql("select has_function_privilege('admin_runtime','public.admin_delete_model_version(uuid,uuid,text,text)','execute')"), "t");
sql(`select public.admin_delete_model_version('${ids.model}','${standardV3}','删除唯一已发布默认模型','model-delete-last-default')`);
assert.equal(sql(`select count(*) from public.model_config_versions where id='${standardV3}'`), "0");
} finally {
fixture.stop();
}