From 0eef680fbaa79fb4bfd3b96ac36cf6eded293075 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 18:50:13 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B5=EF=BC=9A=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E7=9A=84=E5=A2=9E=E5=88=A0=E6=94=B9=E5=92=8C=E7=AB=8B?= =?UTF-8?q?=E5=88=BB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写配置的纯逻辑在 shared/providers.ts。删除前 subscriptionReferences 同时查 直接 use 和经订阅副本(共用缓存文件 path)的间接引用,有被引用的就拦下并列出 策略组。订阅不允许改名:名字被 use: 和副本引用,同步所有引用不如删了重建。 页面还没挂进 App.vue 的导航,由集成者统一加。 --- shared/providers.test.ts | 161 ++++++++++++++ shared/providers.ts | 146 +++++++++++++ web/api.ts | 21 +- web/components/ProviderList.vue | 111 ++++++++++ web/views/ConfigPage.vue | 355 +++++++++++++++++++++++++++++++ web/views/SubscriptionDetail.vue | 250 ++++++++++++++++++++++ web/views/SubscriptionForm.vue | 241 +++++++++++++++++++++ 7 files changed, 1281 insertions(+), 4 deletions(-) create mode 100644 shared/providers.test.ts create mode 100644 shared/providers.ts create mode 100644 web/components/ProviderList.vue create mode 100644 web/views/ConfigPage.vue create mode 100644 web/views/SubscriptionDetail.vue create mode 100644 web/views/SubscriptionForm.vue diff --git a/shared/providers.test.ts b/shared/providers.test.ts new file mode 100644 index 0000000..ea36151 --- /dev/null +++ b/shared/providers.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'bun:test' +import { + DEFAULT_INTERVAL, + addSubscription, + listSubscriptions, + providerNames, + removeSubscription, + subscriptionReferences, + updateSubscription, +} from './providers.ts' +import { applySetup, chainProviderName } from './inbound-groups.ts' +import type { OutboundSetup } from './inbound-groups.ts' +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +const URL = 'http://127.0.0.1:3001/token/download/V2House' + +/** 一份带一个订阅的配置,形状仿照目标机器上那份。 */ +function base(): ConfigObject { + return { + 'proxy-providers': { + subs: { + type: 'http', + url: URL, + path: './providers/subs.yaml', + interval: 86400, + 'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 }, + }, + }, + 'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }], + } +} + +const chainedSetup: OutboundSetup = { + proxy: { sources: ['subs'], filter: null }, + chain: { sources: ['subs'], filter: null }, +} + +describe('读出订阅列表', () => { + it('读出地址、间隔和健康检查', () => { + expect(listSubscriptions(base())).toEqual([ + { + name: 'subs', + url: URL, + interval: 86400, + healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 }, + }, + ]) + }) + + it('健康检查没写或没开都算未开启', () => { + const data = base() + const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject + delete subs['health-check'] + expect(listSubscriptions(data)[0]?.healthCheck).toBeNull() + subs['health-check'] = { enable: false, url: 'https://example.com', interval: 60 } + expect(listSubscriptions(data)[0]?.healthCheck).toBeNull() + }) + + it('不含 Patchbay 生成的订阅副本', () => { + const data = applySetup( + { ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] }, + 'test1', + chainedSetup, + ) + expect(listSubscriptions(data).map((s) => s.name)).toEqual(['subs']) + // 副本还在,只是不列出来 + expect(providerNames(data)).toContain(chainProviderName('test1', 'subs')) + }) + + it('没有 proxy-providers 段时是空列表', () => { + expect(listSubscriptions({ mode: 'rule' })).toEqual([]) + }) +}) + +describe('新增订阅', () => { + it('自动生成缓存路径和默认间隔', () => { + const next = addSubscription({}, 'subs2', URL) + expect(next['proxy-providers']).toEqual({ + subs2: { + type: 'http', + url: URL, + path: './providers/subs2.yaml', + interval: DEFAULT_INTERVAL, + }, + }) + }) + + it('不动已有的订阅', () => { + const data = base() + const next = addSubscription(data, 'subs2', URL) + expect((next['proxy-providers'] as ConfigObject).subs).toEqual( + (data['proxy-providers'] as ConfigObject).subs, + ) + }) +}) + +describe('编辑订阅', () => { + it('只改地址和间隔,其余字段原样保留', () => { + const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600) + const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject + expect(entry.url).toBe('http://example.com/new') + expect(entry.interval).toBe(3600) + expect(entry.path).toBe('./providers/subs.yaml') + expect(entry['health-check']).toEqual({ + enable: true, + url: 'https://www.gstatic.com/generate_204', + interval: 300, + }) + }) + + it('订阅不存在时不做改动', () => { + const data = base() + expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data) + }) +}) + +describe('删除订阅', () => { + it('从映射里去掉', () => { + const data = addSubscription(base(), 'subs2', URL) + const next = removeSubscription(data, 'subs2') + expect(providerNames(next)).toEqual(['subs']) + }) + + it('删到最后一个时整个键去掉,不留空映射', () => { + const next = removeSubscription(base(), 'subs') + expect('proxy-providers' in next).toBe(false) + }) + + it('订阅不存在时不做改动', () => { + const data = base() + expect(removeSubscription(data, 'ghost')).toBe(data) + }) +}) + +describe('删除前的引用检查', () => { + it('找出 use 它的策略组', () => { + expect(subscriptionReferences(base(), 'subs')).toEqual(['PROXY']) + }) + + it('没人在用时是空列表', () => { + const data = base() + ;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] } + expect(subscriptionReferences(data, 'subs')).toEqual([]) + }) + + it('链式代理的专属组 use 的是副本,也算引用原订阅', () => { + const data = applySetup( + { ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] }, + 'test1', + chainedSetup, + ) + const refs = subscriptionReferences(data, 'subs') + // 入口组直接 use 原订阅,三个专属组 use 副本,两边都要认出来 + expect(refs).toContain('patchbay:test1 链式') + expect(refs).toContain('patchbay:test1') + }) + + it('订阅不存在时是空列表', () => { + expect(subscriptionReferences(base(), 'ghost')).toEqual([]) + }) +}) diff --git a/shared/providers.ts b/shared/providers.ts new file mode 100644 index 0000000..e09e115 --- /dev/null +++ b/shared/providers.ts @@ -0,0 +1,146 @@ +/** + * 订阅的读写。 + * + * 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。 + * 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。 + * 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责, + * 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。 + * + * 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的 + * 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。 + */ +import type { ConfigObject } from '../vendor/config-editor/index.ts' +import { isGenerated, providerCachePath } from './inbound-groups.ts' + +/** 新增订阅时的默认更新间隔,秒。一天一次。 */ +export const DEFAULT_INTERVAL = 86400 + +/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */ +export interface Subscription { + name: string + url: string + /** 更新间隔,秒。null 表示配置里没写 */ + interval: number | null + /** 健康检查。没写或 enable 不是 true 都算未开启 */ + healthCheck: { url: string | null; interval: number | null } | null +} + +function isPlainObject(value: unknown): value is ConfigObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function providersOf(data: ConfigObject): ConfigObject { + return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {} +} + +function groupsOf(data: ConfigObject): ConfigObject[] { + return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject) +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */ +export function listSubscriptions(data: ConfigObject): Subscription[] { + const result: Subscription[] = [] + for (const [name, entry] of Object.entries(providersOf(data))) { + if (isGenerated(name) || !isPlainObject(entry)) continue + const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null + result.push({ + name, + url: typeof entry.url === 'string' ? entry.url : '', + interval: typeof entry.interval === 'number' ? entry.interval : null, + healthCheck: + hc !== null && hc.enable === true + ? { + url: typeof hc.url === 'string' ? hc.url : null, + interval: typeof hc.interval === 'number' ? hc.interval : null, + } + : null, + }) + } + return result +} + +/** + * `proxy-providers` 里的所有名字,含生成的副本。 + * 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。 + */ +export function providerNames(data: ConfigObject): string[] { + return Object.keys(providersOf(data)) +} + +/** + * 新增一个订阅。 + * + * `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个, + * 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源 + * (见 inbound-groups.ts 的 applySetup)。 + */ +export function addSubscription(data: ConfigObject, name: string, url: string): ConfigObject { + const next = { ...data } + next['proxy-providers'] = { + ...providersOf(data), + [name]: { type: 'http', url, path: providerCachePath(name), interval: DEFAULT_INTERVAL }, + } + return next +} + +/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */ +export function updateSubscription( + data: ConfigObject, + name: string, + url: string, + interval: number, +): ConfigObject { + const entry = providersOf(data)[name] + if (!isPlainObject(entry)) return data + const next = { ...data } + next['proxy-providers'] = { + ...providersOf(data), + [name]: { ...entry, url, interval }, + } + return next +} + +/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */ +export function removeSubscription(data: ConfigObject, name: string): ConfigObject { + const providers = { ...providersOf(data) } + if (!(name in providers)) return data + delete providers[name] + const next = { ...data } + if (Object.keys(providers).length === 0) delete next['proxy-providers'] + else next['proxy-providers'] = providers + return next +} + +/** + * 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了, + * 而空组会让指向它的入站无路可走。 + * + * 引用有两种,第二种容易漏: + * 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的 + * 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅 + * 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts) + */ +export function subscriptionReferences(data: ConfigObject, name: string): string[] { + const target = providersOf(data)[name] + if (!isPlainObject(target)) return [] + + const copies = new Set() + if (typeof target.path === 'string') { + for (const [copyName, entry] of Object.entries(providersOf(data))) { + if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) { + copies.add(copyName) + } + } + } + + return groupsOf(data) + .filter((group) => { + const use = stringList(group.use) + return use.includes(name) || use.some((u) => copies.has(u)) + }) + .map((group) => String(group.name)) +} diff --git a/web/api.ts b/web/api.ts index 7714233..059ec8d 100644 --- a/web/api.ts +++ b/web/api.ts @@ -99,6 +99,13 @@ export function latestDelay(info: ProxyInfo | undefined): number | null { return last === undefined || last === 0 ? null : last } +/** 一个订阅的运行时状态。updatedAt 是上次拉取成功的时刻,没拉过是 Go 的零值时间。 */ +export interface ProviderInfo { + vehicleType: string + updatedAt?: string + proxies: ProxyInfo[] +} + export interface FieldSpec { key: string type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]' @@ -158,6 +165,15 @@ export const api = { return request<{ delay: number }>('GET', path) }, + /** + * 让内核立刻重新拉取某个订阅。 + * + * 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回 + * 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。 + */ + refreshProvider: (name: string) => + request('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`), + /** * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 * @@ -165,8 +181,5 @@ export const api = { * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 */ providers: () => - request<{ providers: Record }>( - 'GET', - '/api/kernel/providers/proxies', - ), + request<{ providers: Record }>('GET', '/api/kernel/providers/proxies'), } diff --git a/web/components/ProviderList.vue b/web/components/ProviderList.vue new file mode 100644 index 0000000..6215c91 --- /dev/null +++ b/web/components/ProviderList.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/views/ConfigPage.vue b/web/views/ConfigPage.vue new file mode 100644 index 0000000..825cf49 --- /dev/null +++ b/web/views/ConfigPage.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/web/views/SubscriptionDetail.vue b/web/views/SubscriptionDetail.vue new file mode 100644 index 0000000..d0ffc28 --- /dev/null +++ b/web/views/SubscriptionDetail.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/web/views/SubscriptionForm.vue b/web/views/SubscriptionForm.vue new file mode 100644 index 0000000..530f5e9 --- /dev/null +++ b/web/views/SubscriptionForm.vue @@ -0,0 +1,241 @@ + + + + +