From d6942c570289aaa8284578b4978a3a49d07971a4 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Wed, 12 Aug 2026 17:30:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E9=BB=98=E8=AE=A4=E5=BC=80=E5=90=AF=EF=BC=8C=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=94=AF=E6=8C=81=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核没有默认的健康检查地址,url 为空时检查根本不会运行 (adapter/provider/healthcheck.go:196-199),所以默认值必须写全; 开关只翻 enable,手写的 url/interval 留着下次开启时还用。 改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存 链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型 的数据本体和副本认亲都靠它。 --- shared/providers.test.ts | 103 ++++++++++++++++++++++++++++++- shared/providers.ts | 82 +++++++++++++++++++++++- web/components/ProviderList.vue | 19 +----- web/views/ConfigPage.vue | 32 ++++++++-- web/views/SubscriptionDetail.vue | 32 +++++----- web/views/SubscriptionForm.vue | 18 +++--- 6 files changed, 237 insertions(+), 49 deletions(-) diff --git a/shared/providers.test.ts b/shared/providers.test.ts index d46768b..300498d 100644 --- a/shared/providers.test.ts +++ b/shared/providers.test.ts @@ -1,17 +1,27 @@ import { describe, expect, it } from 'bun:test' import { + DEFAULT_HEALTH_CHECK, DEFAULT_INTERVAL, addFileSubscription, addSubscription, listSubscriptions, providerNames, removeSubscription, + renameSubscription, + setSubscriptionHealthCheck, subscriptionPathConflict, subscriptionReferences, updateSubscription, } from './providers.ts' -import { applySetup, chainProviderName, providerCachePath } from './inbound-groups.ts' +import { + applySetup, + chainProviderName, + providerCachePath, + pruneGenerated, + syncGenerated, +} from './inbound-groups.ts' import type { OutboundSetup } from './inbound-groups.ts' +import { collectInbounds } from './inbounds.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' const URL = 'http://127.0.0.1:3001/token/download/V2House' @@ -77,7 +87,7 @@ describe('读出订阅列表', () => { }) describe('新增订阅', () => { - it('自动生成缓存路径和默认间隔', () => { + it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => { const next = addSubscription({}, 'subs2', URL) expect(next['proxy-providers']).toEqual({ subs2: { @@ -85,6 +95,7 @@ describe('新增订阅', () => { url: URL, path: './providers/subs2.yaml', interval: DEFAULT_INTERVAL, + 'health-check': DEFAULT_HEALTH_CHECK, }, }) }) @@ -106,11 +117,97 @@ describe('新增订阅', () => { it('从文件导入的订阅是 file 类型,没有地址和间隔', () => { const next = addFileSubscription({}, 'subs2') expect(next['proxy-providers']).toEqual({ - subs2: { type: 'file', path: './providers/subs2.yaml' }, + subs2: { + type: 'file', + path: './providers/subs2.yaml', + 'health-check': DEFAULT_HEALTH_CHECK, + }, }) }) }) +describe('健康检查开关', () => { + it('开启时缺的字段用默认值补齐', () => { + const data = base() + const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject + delete subs['health-check'] + const next = setSubscriptionHealthCheck(data, 'subs', true) + const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject + expect(entry['health-check']).toEqual(DEFAULT_HEALTH_CHECK) + }) + + it('关闭只翻 enable,手写的 url 和 interval 留着', () => { + const data = base() + const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject + subs['health-check'] = { enable: true, url: 'https://example.com/ping', interval: 60 } + const next = setSubscriptionHealthCheck(data, 'subs', false) + const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject + expect(entry['health-check']).toEqual({ + enable: false, + url: 'https://example.com/ping', + interval: 60, + }) + // 再开启时它们原样回来 + const back = setSubscriptionHealthCheck(next, 'subs', true) + expect(listSubscriptions(back)[0]?.healthCheck).toEqual({ + url: 'https://example.com/ping', + interval: 60, + }) + }) + + it('本来就没写又要关闭时不做改动', () => { + const data = base() + const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject + delete subs['health-check'] + expect(setSubscriptionHealthCheck(data, 'subs', false)).toBe(data) + }) + + it('订阅不存在时不做改动', () => { + const data = base() + expect(setSubscriptionHealthCheck(data, 'ghost', true)).toBe(data) + }) +}) + +describe('订阅改名', () => { + it('改映射键并同步策略组的 use 引用,path 不动', () => { + const next = renameSubscription(base(), 'subs', 'subs-new') + const providers = next['proxy-providers'] as ConfigObject + expect(Object.keys(providers)).toEqual(['subs-new']) + expect((providers['subs-new'] as ConfigObject).path).toBe('./providers/subs.yaml') + const group = (next['proxy-groups'] as ConfigObject[])[0]! + expect(group.use).toEqual(['subs-new']) + }) + + it('保持条目在映射里的位置', () => { + const data = addSubscription(addSubscription(base(), 'a', URL), 'b', URL) + const next = renameSubscription(data, 'a', 'z') + expect(Object.keys(next['proxy-providers'] as ConfigObject)).toEqual(['subs', 'z', 'b']) + }) + + it('改名后保存链路按新名字重建链式副本', () => { + const data = applySetup( + { ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] }, + 'test1', + chainedSetup, + ) + const renamed = renameSubscription(data, 'subs', 'subs2') + // 页面 commit() 每次落盘前都跑这两步 + const synced = pruneGenerated(syncGenerated(renamed, collectInbounds(renamed))) + expect(providerNames(synced)).toContain(chainProviderName('test1', 'subs2')) + expect(providerNames(synced)).not.toContain(chainProviderName('test1', 'subs')) + // 引用检查也要认得出新名字,删除保护不能因为改名失效 + expect(subscriptionReferences(synced, 'subs2')).not.toEqual([]) + }) + + it('原名不存在、新名已占用或没改时都不做改动', () => { + const data = base() + expect(renameSubscription(data, 'ghost', 'x')).toBe(data) + const withX = addSubscription(data, 'x', URL) + expect(renameSubscription(withX, 'subs', 'x')).toBe(withX) + expect(renameSubscription(data, 'subs', 'subs')).toBe(data) + }) +}) + describe('编辑订阅', () => { it('只改地址和间隔,其余字段原样保留', () => { const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600) diff --git a/shared/providers.ts b/shared/providers.ts index e851848..1e57236 100644 --- a/shared/providers.ts +++ b/shared/providers.ts @@ -6,8 +6,8 @@ * 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责, * 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。 * - * 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的 - * 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。 + * 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和 + * 同步引用要在同一次变换里完成,散开做漏一处就是断引用。 */ import type { ConfigObject } from '../vendor/config-editor/index.ts' import { isGenerated, providerCachePath } from './inbound-groups.ts' @@ -15,6 +15,19 @@ import { isGenerated, providerCachePath } from './inbound-groups.ts' /** 新增订阅时的默认更新间隔,秒。一天一次。 */ export const DEFAULT_INTERVAL = 86400 +/** + * 新增订阅或开启健康检查时写入的默认值。 + * + * `url` 必须写:内核没有默认检查地址,为空时 interval 被强制归零、检查不会运行 + * (内核源码 adapter/provider/healthcheck.go:196-199)。`interval` 与内核在 + * enable 时的默认一致(adapter/provider/parser.go:66-68),写死一个更稳。 + */ +export const DEFAULT_HEALTH_CHECK = { + enable: true, + url: 'https://www.gstatic.com/generate_204', + interval: 300, +} + /** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */ export interface Subscription { name: string @@ -104,6 +117,7 @@ export function addSubscription( url, path: providerCachePath(name), ...(interval !== null ? { interval } : {}), + 'health-check': { ...DEFAULT_HEALTH_CHECK }, }, } return next @@ -137,7 +151,11 @@ export function addFileSubscription(data: ConfigObject, name: string): ConfigObj const next = { ...data } next['proxy-providers'] = { ...providersOf(data), - [name]: { type: 'file', path: providerCachePath(name) }, + [name]: { + type: 'file', + path: providerCachePath(name), + 'health-check': { ...DEFAULT_HEALTH_CHECK }, + }, } return next } @@ -162,6 +180,64 @@ export function updateSubscription( return next } +/** + * 开关一个订阅的健康检查。 + * + * 关闭写 `enable: false` 而不是删掉整个键:用户手写的 url/interval 要留着, + * 下次开启时还用它们。开启时缺的字段用默认值补齐——url 不能缺,内核没有默认 + * 检查地址(见 DEFAULT_HEALTH_CHECK 的注)。 + */ +export function setSubscriptionHealthCheck( + data: ConfigObject, + name: string, + enable: boolean, +): ConfigObject { + const entry = providersOf(data)[name] + if (!isPlainObject(entry)) return data + const existing = isPlainObject(entry['health-check']) ? entry['health-check'] : null + // 本来就没写又要关闭,不必写一个 enable: false 的完整声明进去 + if (!enable && existing === null) return data + const next = { ...data } + next['proxy-providers'] = { + ...providersOf(data), + [name]: { ...entry, 'health-check': { ...DEFAULT_HEALTH_CHECK, ...existing, enable } }, + } + return next +} + +/** + * 改订阅的名字,并同步所有策略组 `use:` 里的引用。 + * + * 缓存路径 `path` 保持不变:file 类型的数据本体就在旧路径上,链式代理的副本靠 + * 「和原订阅同一个 path」认来源(见 inbound-groups.ts),路径一动这两件事都得另做迁移。 + * 生成的副本这里不碰:写配置的链路每次都跑 syncGenerated/pruneGenerated, + * 会按新名字重建副本、清掉旧的。 + */ +export function renameSubscription( + data: ConfigObject, + oldName: string, + newName: string, +): ConfigObject { + const providers = providersOf(data) + if (oldName === newName || !(oldName in providers) || newName in providers) return data + + const next = { ...data } + // 逐项重建以保住条目在映射里的位置,diff 才不会把整段重排 + next['proxy-providers'] = Object.fromEntries( + Object.entries(providers).map(([name, entry]) => [name === oldName ? newName : name, entry]), + ) + + if (Array.isArray(data['proxy-groups'])) { + next['proxy-groups'] = data['proxy-groups'].map((group) => { + if (!isPlainObject(group) || !Array.isArray(group.use)) return group + if (!group.use.includes(oldName)) return group + return { ...group, use: group.use.map((item) => (item === oldName ? newName : item)) } + }) + } + + return next +} + /** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */ export function removeSubscription(data: ConfigObject, name: string): ConfigObject { const providers = { ...providersOf(data) } diff --git a/web/components/ProviderList.vue b/web/components/ProviderList.vue index 6215c91..298aae6 100644 --- a/web/components/ProviderList.vue +++ b/web/components/ProviderList.vue @@ -1,6 +1,6 @@