fix(admin): save published models as a new draft and allow deleting them
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:
@@ -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 版本不变。
|
||||
|
||||
@@ -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 后台。这一步要等代码推上去并部署之后,用已登录的管理账号点保存和删除。
|
||||
@@ -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)。
|
||||
|
||||
## 状态板
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user