fix(settings): 数据源「套用」接管该源声明的全部能力

原实现手写 5 个偏好字段 (daily/adj_factor/realtime/minute/financial),
漏掉 depth5 与 full_minute —— 点「套用」后这两个能力仍停在旧数据源。

改为由后端能力矩阵驱动: 遍历矩阵中每个可路由能力 (id 为数据集名、
field 为偏好字段), 该源声明即接管, 未声明回落默认源; 矩阵未加载时
fail-closed 抛错。能力清单不再在前端重复维护。

验证: 新增 7 个用例 (含"必须覆盖全部字段"防回归); pnpm build 通过。
This commit is contained in:
shy3130
2026-10-20 13:59:18 +08:00
parent a3c2613486
commit 2c6f03dad7
3 changed files with 134 additions and 21 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { CapabilityRoute, ProviderField } from '@/lib/api'
import { DEFAULT_ROUTING, buildProviderRouting } from '@/lib/provider-routing'
/** 与后端能力注册表同形的路由能力 (id = 数据集名, field = 偏好字段)。 */
const CAPS: Pick<CapabilityRoute, 'id' | 'field'>[] = [
{ id: 'daily', field: 'daily_data_provider' },
{ id: 'adj_factor', field: 'adj_factor_provider' },
{ id: 'realtime', field: 'realtime_data_provider' },
{ id: 'minute', field: 'minute_data_provider' },
{ id: 'depth5', field: 'depth5_data_provider' },
{ id: 'financial', field: 'financial_data_provider' },
{ id: 'full_minute', field: 'full_minute_data_provider' },
]
const ALL_FIELDS: ProviderField[] = [
'daily_data_provider',
'adj_factor_provider',
'realtime_data_provider',
'minute_data_provider',
'depth5_data_provider',
'financial_data_provider',
'full_minute_data_provider',
]
const DEMO = 'demo_source'
describe('buildProviderRouting', () => {
it('接管声明过的全部能力, 包括 depth5 与 full_minute', () => {
const routing = buildProviderRouting(
DEMO,
['realtime', 'depth5', 'daily', 'minute', 'full_minute'],
CAPS,
)
expect(routing.daily_data_provider).toBe(DEMO)
expect(routing.realtime_data_provider).toBe(DEMO)
expect(routing.minute_data_provider).toBe(DEMO)
expect(routing.depth5_data_provider).toBe(DEMO)
expect(routing.full_minute_data_provider).toBe(DEMO)
})
it('未声明的能力回落默认源, 不残留上一个数据源', () => {
const routing = buildProviderRouting(
DEMO,
['realtime', 'depth5', 'daily', 'minute', 'full_minute'],
CAPS,
)
expect(routing.adj_factor_provider).toBe('tickflow')
expect(routing.financial_data_provider).toBe('tickflow')
})
it('每个可路由字段都被覆盖 (防止再漏能力)', () => {
const routing = buildProviderRouting(DEMO, [], CAPS)
for (const field of ALL_FIELDS) {
expect(routing[field], `missing field ${field}`).toBeDefined()
}
})
it('切换源时旧源的能力全部被覆盖', () => {
// 声明 6 项 (无 financial) 的源
const routing = buildProviderRouting(
DEMO,
['realtime', 'depth5', 'daily', 'adj_factor', 'minute', 'full_minute'],
CAPS,
)
expect(routing.adj_factor_provider).toBe(DEMO)
expect(routing.financial_data_provider).toBe('tickflow')
expect(Object.keys(routing).sort()).toEqual([...ALL_FIELDS].sort())
})
it('能力矩阵未加载时 fail-closed 抛错', () => {
expect(() => buildProviderRouting(DEMO, ['daily'], [])).toThrow(/能力矩阵未加载/)
})
it('不可路由能力 (field=null) 被跳过', () => {
const routing = buildProviderRouting(DEMO, ['daily'], [
{ id: 'daily', field: 'daily_data_provider' },
{ id: 'tickflow_only', field: null },
])
expect(routing.daily_data_provider).toBe(DEMO)
expect(Object.keys(routing).sort()).toEqual([...ALL_FIELDS].sort())
})
it('默认路由覆盖全部字段且均为 tickflow', () => {
expect(Object.keys(DEFAULT_ROUTING).sort()).toEqual([...ALL_FIELDS].sort())
expect(new Set(Object.values(DEFAULT_ROUTING))).toEqual(new Set(['tickflow']))
})
})
+39
View File
@@ -0,0 +1,39 @@
import type { CapabilityRoute, ProviderField } from '@/lib/api'
/** 全部可路由偏好字段的默认生效源。 */
export const DEFAULT_ROUTING: Record<ProviderField, string> = {
daily_data_provider: 'tickflow',
adj_factor_provider: 'tickflow',
minute_data_provider: 'tickflow',
full_minute_data_provider: 'tickflow',
depth5_data_provider: 'tickflow',
realtime_data_provider: 'tickflow',
financial_data_provider: 'tickflow',
}
/**
* 计算「一键套用」的目标路由: 该源声明了哪个数据集, 就接管对应能力;
* 未声明的能力回落默认源, 避免残留上一个数据源的配置。
*
* 能力清单取自后端能力矩阵 (dataset id 与能力 field 的单一权威), 不在前端
* 手写字段列表 —— 手写清单会漏掉新增能力 (depth5 / full_minute 就这样被漏过,
* 点「套用」后这两个能力仍停在旧数据源)。
*
* 矩阵未加载时 fail-closed 抛错, 而不是把全部路由重置成默认源。
*/
export function buildProviderRouting(
provider: string,
datasets: readonly string[],
capabilities: readonly Pick<CapabilityRoute, 'id' | 'field'>[],
): Record<ProviderField, string> {
if (!capabilities.length) {
throw new Error('能力矩阵未加载, 无法套用数据源, 请稍后重试')
}
const supported = new Set(datasets)
const routing: Record<ProviderField, string> = { ...DEFAULT_ROUTING }
for (const cap of capabilities) {
if (!cap.field) continue // 不可路由能力 (仅 TickFlow 提供) 没有偏好字段
routing[cap.field] = supported.has(cap.id) ? provider : DEFAULT_ROUTING[cap.field]
}
return routing
}
+7 -21
View File
@@ -41,6 +41,7 @@ import { QK } from '@/lib/queryKeys'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { AnchorWrap } from '@/lib/useCardFlash'
import { CAP_LABELS, TIER_RANK, tierRank, tierStyle, TierTag } from '@/lib/capability-labels'
import { DEFAULT_ROUTING, buildProviderRouting } from '@/lib/provider-routing'
import { toast } from '@/components/Toast'
import { DataSourceEditor } from './DataSourceEditor'
import { TickFlowKeySection, TierHelpPopover, useInvalidateTierRelated } from './Keys'
@@ -146,16 +147,6 @@ function patchMatrix(
return { ...matrix, capabilities: caps }
}
const DEFAULT_ROUTING: Record<ProviderField, string> = {
daily_data_provider: 'tickflow',
adj_factor_provider: 'tickflow',
minute_data_provider: 'tickflow',
full_minute_data_provider: 'tickflow',
depth5_data_provider: 'tickflow',
realtime_data_provider: 'tickflow',
financial_data_provider: 'tickflow',
}
/** 单个能力卡: 当前生效提供方 + 候选切换标签。
* candidates 只含当前可提供该能力的源; 未就绪源 (pending) 置灰提示;
* 生效源无法提供该能力 (usable=False, 无论档位不足还是源未就绪) 时显示琥珀警示。 */
@@ -685,18 +676,13 @@ export function SettingsDataSourcesPanel({ highlight }: { highlight?: string } =
if (name === 'tickflow') {
return api.updateDataProviders(DEFAULT_ROUTING)
}
const supported = new Set(
allItems.find(s => s.name === name)?.datasets ?? []
return api.updateDataProviders(
buildProviderRouting(
name,
allItems.find(s => s.name === name)?.datasets ?? [],
matrix.data?.capabilities ?? [],
),
)
const pick = (dataset: string) =>
supported.has(dataset) ? name : DEFAULT_ROUTING[`${dataset}_data_provider` as ProviderField] ?? 'tickflow'
return api.updateDataProviders({
daily_data_provider: pick('daily'),
adj_factor_provider: pick('adj_factor'),
realtime_data_provider: pick('realtime'),
minute_data_provider: pick('minute'),
financial_data_provider: pick('financial'),
})
},
onSuccess: (_d, name) => {
invalidateSources()