diff --git a/CHANGELOG.md b/CHANGELOG.md index 34726279..2ccd1f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 66a9cfe3..dc3e1e37 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`,未提交。 diff --git a/docs/tasks/PROGRESS-model-admin-draft-delete-20261020.md b/docs/tasks/PROGRESS-model-admin-draft-delete-20261020.md new file mode 100644 index 00000000..704ad5d7 --- /dev/null +++ b/docs/tasks/PROGRESS-model-admin-draft-delete-20261020.md @@ -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 后台。这一步要等代码推上去并部署之后,用已登录的管理账号点保存和删除。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index bc283416..0237ef3f 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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)。 ## 状态板 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 08406b6f..f1eb2ad2 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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 ``, 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; diff --git a/frontend/src/app/api/admin/models/route.ts b/frontend/src/app/api/admin/models/route.ts index 20f857ed..3c253ad6 100644 --- a/frontend/src/app/api/admin/models/route.ts +++ b/frontend/src/app/api/admin/models/route.ts @@ -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; diff --git a/frontend/src/components/admin/model-management.tsx b/frontend/src/components/admin/model-management.tsx index 1cc71cc1..91f9b2c9 100644 --- a/frontend/src/components/admin/model-management.tsx +++ b/frontend/src/components/admin/model-management.tsx @@ -96,7 +96,7 @@ const modelStatusLabels: Record = { }; export default function ModelManagement() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const { data: identity } = useGetIdentity(); const [providerForm] = Form.useForm(); const [modelForm] = Form.useForm(); @@ -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, success: string, id: string) { setActingId(id); try { @@ -311,7 +346,8 @@ export default function ModelManagement() { title: "操作", fixed: "right", render: (_, item) => - {canWrite && } + {canWrite && } + {canWrite && } {canTest && } {canPublish && item.status !== "published" && } {canRollback && item.status !== "draft" && } @@ -371,8 +407,9 @@ export default function ModelManagement() { - modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden> + modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden> form={modelForm} layout="vertical" onFinish={saveModel}> + {editingModel && editingModel.status !== "draft" ? 保存后会生成新版本草稿,当前已发布版本保持不变。发布新草稿后才会替换线上模型。 : null} diff --git a/frontend/src/lib/admin/admin-error-response.ts b/frontend/src/lib/admin/admin-error-response.ts index f7f52b6a..36ac744e 100644 --- a/frontend/src/lib/admin/admin-error-response.ts +++ b/frontend/src/lib/admin/admin-error-response.ts @@ -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 }); diff --git a/frontend/src/lib/admin/model-mutation-handler.ts b/frontend/src/lib/admin/model-mutation-handler.ts index f728b14f..b37ba2d7 100644 --- a/frontend/src/lib/admin/model-mutation-handler.ts +++ b/frontend/src/lib/admin/model-mutation-handler.ts @@ -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;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[]>;assertAllowedUrl(value:string):Promise;probeAllowed(value:string,headers:Readonly>):Promise;invalidateCatalog():void;environment?:Readonly>}; 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}}); } diff --git a/frontend/supabase/migrations/20261020150000_admin_model_version_draft_and_delete.sql b/frontend/supabase/migrations/20261020150000_admin_model_version_draft_and_delete.sql new file mode 100644 index 00000000..a3dbeca6 --- /dev/null +++ b/frontend/supabase/migrations/20261020150000_admin_model_version_draft_and_delete.sql @@ -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; diff --git a/frontend/tests/admin-auth.test.ts b/frontend/tests/admin-auth.test.ts index d4d8a202..a5df8fe4 100644 --- a/frontend/tests/admin-auth.test.ts +++ b/frontend/tests/admin-auth.test.ts @@ -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), diff --git a/frontend/tests/admin-model-management-ui-contract.test.ts b/frontend/tests/admin-model-management-ui-contract.test.ts index cd26ce17..50c37e89 100644 --- a/frontend/tests/admin-model-management-ui-contract.test.ts +++ b/frontend/tests/admin-model-management-ui-contract.test.ts @@ -33,9 +33,17 @@ test("model discovery is the only model picker and generates system fields", () assert.match(component, /name="providerModel" label="模型"[\s\S]* { @@ -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/); diff --git a/frontend/tests/database-billing-admin.test.ts b/frontend/tests/database-billing-admin.test.ts index 1f050cf2..35b00033 100644 --- a/frontend/tests/database-billing-admin.test.ts +++ b/frontend/tests/database-billing-admin.test.ts @@ -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(); }