fix(models): 系统渠道模型 - 上游 ID 重命名级联价格档并补管理端可见性

根因:价格档 provider_model_key 非空时优先于模型级上游键发往供应商,统一价格档曾把当时的顶层上游键固化为档位显式值,模型级重命名后被旧值静默遮蔽,任务请求继续发往旧上游模型(Agent 及其他入口共用该解析路径)。

- SaveAdminChannelModel 在模型级上游键变更时,把与旧值相同的档位键级联为新值;管理员显式配置的其他上游 SKU 不受影响
- 编辑无价格档旧模型不再预填顶层上游键;统一价格档检测到与模型级不一致的存量上游键时展示告警并支持一键清除恢复跟随模型默认;模型身份区提示档位独立上游键数量,比较口径统一剥 models/ 前缀
- 新增后端级联与请求解析回归测试、前端价格档表单单测
- 同步 backend-database.mdx 价格档语义与 pending-test.mdx 待验收项
This commit is contained in:
VicoHu
2026-09-16 10:38:44 +08:00
parent 9868f5e8b8
commit 25e78fe4f8
8 changed files with 222 additions and 5 deletions

View File

@@ -0,0 +1,107 @@
package app
import (
"testing"
"infinite-canvas/backend/internal/model"
)
// 模型级上游键重命名后,任务请求必须使用新键;价格档中与旧值相同的上游键属于
// “跟随模型默认”的隐式固化,保存时必须级联跟随(见 SaveAdminChannelModel)。
func upstreamRenameFixture(t *testing.T) (*Service, *model.User, *model.ModelChannel) {
t.Helper()
svc, db := newChannelModelTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "model-gateway", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
return svc, admin, &channel
}
func TestSaveAdminChannelModelCascadesUpstreamKeyToMatchingTiers(t *testing.T) {
svc, admin, channel := upstreamRenameFixture(t)
enabled := true
first, err := svc.SaveAdminChannelModel(admin, channel.ID, "", ChannelModelRequest{
ModelKey: "deepseek-v4-flash", ProviderModelKey: "deepseek-v4-flash", DisplayName: "deepseek-v4-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4-flash"),
PriceTiers: []ChannelModelPriceTierRequest{
{Selector: map[string]string{}, ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
{Selector: map[string]string{"operation": "text_generation"}, ProviderModelKey: "deepseek-v4-flash-preview", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("initial save failed: %v", err)
}
// 管理端表单会把已存档位键原样回传(统一价格档的上游键字段不可见),重命名顶层上游键。
renamed, err := svc.SaveAdminChannelModel(admin, channel.ID, first.ID, ChannelModelRequest{
ModelKey: "deepseek-v4.1-flash", ProviderModelKey: "deepseek-v4.1-flash", DisplayName: "deepseek-v4.1-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4.1-flash"),
PriceTiers: []ChannelModelPriceTierRequest{
{Selector: map[string]string{}, ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
{Selector: map[string]string{"operation": "text_generation"}, ProviderModelKey: "deepseek-v4-flash-preview", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled},
},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("rename save failed: %v", err)
}
if len(renamed.PriceTiers) != 2 {
t.Fatalf("price tiers = %#v, want 2", renamed.PriceTiers)
}
for _, tier := range renamed.PriceTiers {
if tier.ProviderModelKey == "deepseek-v4-flash" {
t.Fatalf("tier %s still carries the retired upstream key deepseek-v4-flash; it must follow the model rename", tier.ID)
}
}
// 管理员显式配置的其他上游 SKU 不能被级联覆盖。
keptDistinct := false
for _, tier := range renamed.PriceTiers {
if tier.ProviderModelKey == "deepseek-v4-flash-preview" {
keptDistinct = true
}
}
if !keptDistinct {
t.Fatalf("distinct tier upstream key must survive the rename: %#v", renamed.PriceTiers)
}
}
func TestRenamedUpstreamKeyReachesSystemChannelRequests(t *testing.T) {
svc, admin, channel := upstreamRenameFixture(t)
enabled := true
first, err := svc.SaveAdminChannelModel(admin, channel.ID, "", ChannelModelRequest{
ModelKey: "deepseek-v4-flash", ProviderModelKey: "deepseek-v4-flash", DisplayName: "deepseek-v4-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4-flash"),
PriceTiers: []ChannelModelPriceTierRequest{{ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
})
if err != nil {
t.Fatalf("initial save failed: %v", err)
}
if _, err := svc.SaveAdminChannelModel(admin, channel.ID, first.ID, ChannelModelRequest{
ModelKey: "deepseek-v4.1-flash", ProviderModelKey: "deepseek-v4.1-flash", DisplayName: "deepseek-v4.1-flash",
Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "deepseek-v4.1-flash"),
PriceTiers: []ChannelModelPriceTierRequest{{ProviderModelKey: "deepseek-v4-flash", BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
}); err != nil {
t.Fatalf("rename save failed: %v", err)
}
// 用户在画布 Agent 选中新模型发起任务;config 形状与 cloud_agent.go 组装一致。
resolved, err := svc.resolveSystemChannelModelSelection(map[string]any{
"config": map[string]any{"channelId": channel.ID, "model": "deepseek-v4.1-flash", "channelModelKey": "deepseek-v4.1-flash"},
}, "canvas_text", "")
if err != nil {
t.Fatalf("resolveSystemChannelModelSelection() error = %v", err)
}
config := resolved["config"].(map[string]any)
if got := config["providerModelKey"]; got != "deepseek-v4.1-flash" {
t.Fatalf("providerModelKey = %v, want deepseek-v4.1-flash (user-selected model must reach the upstream request)", got)
}
}

View File

@@ -326,13 +326,24 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return nil, err
}
item := &model.ChannelModel{ID: modelID, ChannelID: channelID, Enabled: true, PriceVersion: 1}
previousProviderModelKey := ""
if id != "" {
item, err = s.repo.ChannelModelByID(channelID, id)
if err != nil {
return nil, err
}
previousProviderModelKey = strings.TrimPrefix(strings.TrimSpace(item.ProviderModelKey), "models/")
item.PriceVersion++
}
// 模型级上游键重命名时,与旧值相同的档位键属于“跟随模型默认”的隐式固化,必须级联跟随;
// 否则任务请求会继续把旧上游键发给供应商。管理员显式配置的其他上游 SKU 不受影响。
if previousProviderModelKey != "" && providerModelKey != previousProviderModelKey {
for index := range tiers {
if strings.TrimPrefix(strings.TrimSpace(tiers[index].ProviderModelKey), "models/") == previousProviderModelKey {
tiers[index].ProviderModelKey = providerModelKey
}
}
}
item.ModelKey = modelKey
item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName)

View File

@@ -172,7 +172,7 @@ docker compose -f docker-compose.deploy.yml up -d backend web
| --- | --- | --- |
| `model_channels` | 保存系统渠道与用户渠道的连接配置,以及系统渠道前台别名和排序 | 系统渠道密钥不进入公开目录;公开名称优先使用 `public_alias`,为空时使用原名;按 `sort_order, created_at, id` 升序展示 |
| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、排序、能力、协议和版本状态 | 模型排序独立保存,不递增价格或能力版本;能力、协议、启用状态和价格仍由 service 强校验 |
| `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 |
| `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 |
| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 |
图片分档尺寸保存在现有模型能力 JSON 的 `image.size.presets` 中,不新增表、列或 SQL 迁移。每项结构为 `{ tier, ratio, size, width, height }`,例如 `{ "tier": "1k", "ratio": "16:9", "size": "1824x1024", "width": 1824, "height": 1024 }`。

View File

@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 价格档上游键级联与重命名告警
- 修复“管理端重命名渠道模型的产品标识 / 显示名称 / 上游模型 ID 后,任务请求仍把旧上游键发给供应商”的问题:价格档 `provider_model_key` 非空时优先于模型级上游键,此前统一价格档会把当时的顶层上游键固化为档位显式值,重命名后被旧值静默遮蔽。
- `SaveAdminChannelModel` 现在在模型级上游键变更时,把与旧值相同的档位键级联更新为新值;管理员显式配置的其他上游 SKU 不受影响。前端编辑无价格档的旧模型时不再预填顶层上游键,统一价格档检测到与模型级不一致的存量上游键时展示告警并支持一键清除恢复“跟随模型默认”,模型级字段提示存在独立档位上游键。
- 已通过后端 app 包级联与请求解析回归测试、前端价格档表单单测和生产构建;待生产验收:重命名 deepseek 类模型后,画布 Agent 与其他入口的实际请求正文使用新上游 ID(可对照 `api_call_logs.model`),管理端价格档告警在存量旧数据上正常出现并可清除。
- 已知未覆盖:前台逻辑模型(`price_policy=channel`)的路由快照同样会固化档位上游键(`applyRoutedProviderSelection`),本次级联只在渠道模型保存路径生效;逻辑模型路由如出现同类旧键残留,需重新保存对应渠道模型或后续单独处理。
## 欢迎页开放开关
- 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。

View File

@@ -10,7 +10,7 @@ import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type Mode
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
@@ -50,6 +50,8 @@ export function ChannelModelEditor({
const modelEnabled = Form.useWatch("enabled", form) !== false;
const priceTiers = Form.useWatch("priceTiers", form) || [];
const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default");
const tiersWithOwnUpstream = priceTiers.filter((tier) => tier.providerModelKey?.trim());
const modelUpstream = normalizeUpstreamModelKey(providerModelKey || modelKey);
const busy = saving || testing;
const requestClose = () => {
@@ -252,7 +254,7 @@ export function ChannelModelEditor({
placeholder="例如:seedance-2-5"
/>
</Form.Item>
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。">
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="后台显示名称" tooltip="仅用于后台识别,不改变调用 ID。">
@@ -262,6 +264,14 @@ export function ChannelModelEditor({
<ModelIconPicker />
</Form.Item>
</div>
{tiersWithOwnUpstream.length ? (
<Alert
type="info"
showIcon
title={`${tiersWithOwnUpstream.length} 个价格档配置了独立上游模型 ID`}
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
/>
) : null}
</section>
<section className="admin-model-editor-section">
<SectionHeading title="能力与协议" description="先选任务类型,再选择对应的调用协议;更换后请核对参数与价格。" />
@@ -376,6 +386,7 @@ export function ChannelModelEditor({
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}

View File

@@ -1,9 +1,10 @@
import { Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd";
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd";
import { Trash2 } from "lucide-react";
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
export function PriceTierFields({
index,
@@ -12,6 +13,7 @@ export function PriceTierFields({
capability,
protocol,
capabilityConfig,
modelUpstream,
onDirty,
onRemove,
}: {
@@ -21,6 +23,7 @@ export function PriceTierFields({
capability: EditableCapability | undefined;
protocol: ModelProtocol | undefined;
capabilityConfig?: ModelCapabilityConfig;
modelUpstream: string;
onDirty: () => void;
onRemove: () => void;
}) {
@@ -28,6 +31,10 @@ export function PriceTierFields({
const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default";
const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false;
const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false;
const tierUpstream = normalizeUpstreamModelKey(Form.useWatch(["priceTiers", index, "providerModelKey"], form));
// 统一价格档不展示上游键输入,但历史数据可能固化了独立键;它与模型级不一致时会
// 静默改变实际发往供应商的模型,必须显式提示并允许一键恢复“跟随模型默认”。
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
@@ -155,6 +162,25 @@ export function PriceTierFields({
</Form.Item>
</div>
)}
{staleTierUpstream ? (
<Alert
type="warning"
showIcon
title="该统一价格档仍指向独立上游模型 ID"
description={`命中此档的请求会优先使用「${staleTierUpstream}」,而不是模型默认上游「${modelUpstream}」。`}
action={
<Button
size="small"
onClick={() => {
onDirty();
form.setFieldValue(["priceTiers", index, "providerModelKey"], "");
}}
>
清除并跟随模型
</Button>
}
/>
) : null}
</div>
</div>
);

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
// Node 原生 TypeScript 测试运行器要求保留扩展名,项目编译器不允许该写法。
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm } from "./channel-model-price-tier-form.ts";
test("编辑无价格档的旧模型时不再把顶层上游键固化进统一价格档", () => {
const legacy: ChannelModel = {
id: "model-1",
modelKey: "deepseek-v4.1-flash",
providerModelKey: "deepseek-v4.1-flash",
billingMode: "fixed_request",
unitPriceMicrocredits: 10,
priceConfigured: true,
enabled: true,
} as unknown as ChannelModel;
const tier = legacyPriceTierToForm(legacy);
assert.equal(tier.providerModelKey, "");
assert.equal(tier.matchMode, "default");
});
test("已存档位键在回显时保持原值,清除与否交给管理端告警处理", () => {
const tier = priceTierToForm({
selector: {},
resolution: "*",
videoSeconds: 0,
providerModelKey: "sku-preview",
billingMode: "fixed_request",
unitPriceMicrocredits: 10,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
priceConfigured: true,
enabled: true,
} as unknown as ChannelModelPriceTier);
assert.equal(tier.providerModelKey, "sku-preview");
assert.equal(tier.matchMode, "default");
});
test("新建默认价格档不携带上游键", () => {
assert.equal(defaultPriceTier().providerModelKey, "");
assert.equal(defaultPriceTier("advanced").providerModelKey, "");
});

View File

@@ -3,6 +3,11 @@ import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet"
export type PriceTierMatchMode = "default" | "advanced";
// 与后端保存口径一致:上游键比较前剥掉一次 models/ 前缀,避免极旧存量行误报差异。
export function normalizeUpstreamModelKey(value: unknown): string {
return String(value || "").trim().replace(/^models\//, "");
}
export type PriceTierFormValues = {
matchMode: PriceTierMatchMode;
operation: string;
@@ -66,7 +71,8 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
export function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
return {
...defaultPriceTier(),
providerModelKey: item.providerModelKey || "",
// 不预填顶层上游键:统一价格档保持“跟随模型默认上游 ID”,
// 否则首次保存会把当时的上游键固化进档位,之后模型级重命名会被旧值遮蔽。
billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000,
inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,