fix(ai): allow disabling withdrawn models without blocking configuration saves
This commit is contained in:
parent
90b2e6e83c
commit
924e54dbb2
@ -9,7 +9,7 @@
|
||||
- Gemini 分析请求编号 `c6893b1f-c2fa-4248-8bc6-485f2b543b98`,文案 `cb014e22-1f31-499f-8c80-2594051d919e`,Nano `25905516-3c9c-42f0-8906-3a829a481e83`。图片结果通过同一已知任务查询取回,未因等待或解析问题重复提交生成。
|
||||
- 实际 HTTP 权限检查:匿名 401;普通用户、普通管理员、撤权前签发的旧会话 403;伪造 `compatibility` 和不匹配的生图用途 400;拒绝路径没有新增模型验证记录。
|
||||
- 内置浏览器实测桌面及 390px 手机布局、能力/状态筛选、用途禁选、测试费用确认与取消、未保存配置禁测、撤销和协议详情。没有人工兼容复选框;手机模型控制无横向溢出,顶部既有导航保持横向滚动。
|
||||
- 11 组相关 Jest 共 225 项通过(最后一处用途说明调整后单独复跑前端 27 项);迁移及发布门禁 32 项通过;TypeScript 和隔离源码的 Next.js 生产构建通过。
|
||||
- 11 组相关 Jest 共 225 项通过(最后一处用途说明调整后单独复跑前端 27 项);迁移及发布门禁 33 项通过;TypeScript 和隔离源码的 Next.js 生产构建通过。
|
||||
- 独立审查修复异步任务恢复期间再次撤权的竞争条件:保留已受理的上游任务,标为待核查,不允许重发生成。
|
||||
- 真实素材案例:朴美 `GBPM-AD0358`,沿用素材库同款两张参考图。项目 `8b89c9ae-4531-4558-b21f-426f404600d5` 完成分析、人工审阅大纲、首屏生成及单屏重生成;共 2 次文本和 2 次生图 POST,没有重复提交。原始大纲保留;审阅删除缺尺寸依据的“小巧”描述,并把未确认材质改为木纹外观。
|
||||
- 中文多行文案编辑、图片替换、保存恢复通过;PNG/JPG/长 PNG 均为 750×1200,已查看实际导出像素,无乱码、遮挡或裁断。PSD 2,338,309 字节,按屏分组,背景和产品场景独立,标题和正文是 2 个文本层,字体 `NotoSansSC-Regular`。这些是文件结构验证,不等同于 Photoshop 应用内验收。
|
||||
@ -22,3 +22,11 @@
|
||||
|
||||
本轮没有批量测试全部目录模型,也没有增加 TTS、音乐、视频或 3D 业务工作台。品牌规范确认、Photoshop 中改字与保存重开仍需单独验收,不能从接口测试、PSD 解析或部署成功推断。
|
||||
本次真实产品只验证一屏及其重生成,不据此宣称完整多屏的品牌风格一致性已验收。
|
||||
|
||||
## 生产验收与保存修正
|
||||
|
||||
`90b2e6e83cfac6eeadd59ebe74714e08490df2a3` 已完成数据库迁移、应用发布和内网、IPv6、域名健康验证。生产环境分别调用一次分析、文案和参考图生图,三个用途均通过,运行时默认模型为 Gemini 3 Flash / Nano Banana 2。已下架的 Gemini 2.5 不再作为默认项;75 个在目录中的模型按实际能力分类,其余模型未被批量测试。
|
||||
|
||||
生产浏览器验收发现全量保存会重新校验未变的历史用途,导致停用下架模型失败。配置保存现跳过未变模型;停用时允许保留或减少已有用途,不允许增加不匹配的用途,也不能重新启用未通过测试的模型。服务、权限和前端相关 90 项测试通过,包含该问题的三项回归验证。
|
||||
|
||||
迁移辅助校验曾错误统一两条历史 CRLF 文件的换行符;原迁移执行及回执均有效。修正按 Git 属性计算实际镜像字节后,36 条迁移、备份与恢复回执及新的只读审计全部一致,未重复执行数据库迁移。
|
||||
|
||||
@ -156,6 +156,36 @@ test('forged read-only capability metadata is rejected before writes', async ()
|
||||
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'], capabilities: { allowedStages: ['image'] } }] }] })).rejects.toThrow('格式无效')
|
||||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('unchanged withdrawn model settings do not block saving another model and are not rewritten', async () => {
|
||||
models[0].present = false
|
||||
models[0].capabilityMetadata = null
|
||||
await configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [
|
||||
{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, stages: [...models[0].stages] }] },
|
||||
{ id: secondId, revision: 1, models: [{ key: secondModelKey, enabled: false, stages: [...models[1].stages] }] },
|
||||
] })
|
||||
const updates = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('UPDATE ai_model_catalog'))
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].slice(1)).toEqual([secondModelKey, false, models[1].stages])
|
||||
})
|
||||
|
||||
test('withdrawn model can be disabled while retaining prior selections, but cannot be re-enabled', async () => {
|
||||
models[0].present = false
|
||||
models[0].capabilityMetadata = null
|
||||
const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: false, stages: [...models[0].stages] }] }] }
|
||||
await configureAiModels('admin', input)
|
||||
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining('UPDATE ai_model_catalog'), legacyModelKey, false, models[0].stages)
|
||||
models[0].enabled = false
|
||||
input.providers[0].models[0].enabled = true
|
||||
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
|
||||
})
|
||||
|
||||
test('disabling a model cannot add an unsupported purpose to its prior selections', async () => {
|
||||
models[0].stages = ['copy']
|
||||
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
|
||||
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: false, stages: ['copy', 'image'] }] }] })).rejects.toMatchObject({ code: 'model_capability_unsupported' })
|
||||
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
|
||||
})
|
||||
test('stale enabled rows are filtered from menus, defaults and old project execution after capability withdrawal', async () => {
|
||||
const binding = (await resolveAiModel('image', legacyModelKey)).binding
|
||||
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
|
||||
|
||||
@ -188,10 +188,14 @@ export async function configureAiModels(userId: string, body: unknown): Promise<
|
||||
for (const model of provider.models) {
|
||||
const existing = prior.find(value => value.key === model.key && value.providerId === provider.id)
|
||||
if (!existing) throw new AiModelError('模型不属于当前服务')
|
||||
if (new Set(model.stages).size !== model.stages.length) throw new AiModelError('所选用途重复', 400, 'model_verification_required')
|
||||
const stagesUnchanged = JSON.stringify([...existing.stages].sort()) === JSON.stringify([...model.stages].sort())
|
||||
if (existing.enabled === model.enabled && stagesUnchanged) continue
|
||||
const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata)
|
||||
if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途不适用于该模型的当前接口与能力', 400, 'model_capability_unsupported')
|
||||
if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || !existing.present || model.stages.some(stage => !modelVerified(existing, current, stage, verifications)))) throw new AiModelError('启用前须通过所选用途的实际测试,并确认模型仍在上游目录中', 400, 'model_verification_required')
|
||||
if (existing.enabled !== model.enabled || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) }
|
||||
const onlyRetainsPriorStagesWhileDisabled = !model.enabled && model.stages.every(stage => existing.stages.includes(stage))
|
||||
if (!onlyRetainsPriorStagesWhileDisabled && model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途不适用于该模型的当前接口与能力', 400, 'model_capability_unsupported')
|
||||
if (model.enabled && (!model.stages.length || !existing.present || model.stages.some(stage => !modelVerified(existing, current, stage, verifications)))) throw new AiModelError('启用前须通过所选用途的实际测试,并确认模型仍在上游目录中', 400, 'model_verification_required')
|
||||
changed = true; changes.push({ ...model, providerId: provider.id })
|
||||
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages)
|
||||
}
|
||||
if (changed) await db.$executeRawUnsafe(`UPDATE ai_model_providers SET revision=revision+1,updated_at=NOW() WHERE id=$1`, provider.id)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user