From c6db23744815e3db8fe3193947b5749d5f9440cf Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 3 Sep 2026 16:44:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=A7=84?= =?UTF-8?q?=E5=88=99=E7=BD=AE=E9=A1=B6=E7=94=9F=E6=95=88=EF=BC=8C=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=89=8D=E6=A0=A1=E9=AA=8C=E7=AD=96=E7=95=A5=E5=90=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则首条匹配即生效,自定义规则此前插在模板规则之后、FINAL 之前,被 download.conf 等宽泛规则集抢先命中(brew.sh 配了代理仍直连)。现在新建 规则插到 seq 0,重新播种时也把自定义规则整体排在模板规则之前。 同时把 BUILTIN_POLICIES 提到 shared 共用,规则新建/编辑时校验策略必须是 内置策略或已有策略组,避免引用不存在的策略被 IR 静默剔除;前端表单默认 策略由不存在的「Proxy」改为 DIRECT。 Co-Authored-By: Claude Opus 4.6 --- server/src/db/seed.ts | 18 ++++++------------ server/src/routes/rules.ts | 31 +++++++++++++++---------------- server/src/services/ir.ts | 6 +++--- shared/src/schemas.ts | 3 +++ web/src/pages/RulesPage.vue | 4 ++-- 5 files changed, 29 insertions(+), 33 deletions(-) diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts index 97f0382..7c92af7 100644 --- a/server/src/db/seed.ts +++ b/server/src/db/seed.ts @@ -59,6 +59,12 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) { if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run(); let seq = 0; + // 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前 + for (const c of custom.sort((a, b) => a.seq - b.seq)) { + db.insert(rules) + .values({ ...c, seq: seq++ }) + .run(); + } for (const r of parsed.rules) { let value = r.value; if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) { @@ -95,18 +101,6 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) { }) .run(); } - // 自定义规则重新插回:保持原相对顺序,排在 FINAL 之前 - if (custom.length > 0) { - const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get(); - let base = finalRow ? finalRow.seq : seq; - // 给自定义行腾出 seq 空间:FINAL 及之后行整体后移 - for (const c of custom.sort((a, b) => a.seq - b.seq)) { - db.insert(rules) - .values({ ...c, seq: base++ }) - .run(); - } - if (finalRow) db.update(rules).set({ seq: base }).where(eq(rules.id, finalRow.id)).run(); - } } export function seedIfEmpty(db: Db) { diff --git a/server/src/routes/rules.ts b/server/src/routes/rules.ts index b32b8fe..8aa52c0 100644 --- a/server/src/routes/rules.ts +++ b/server/src/routes/rules.ts @@ -1,11 +1,11 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; -import { asc, eq } from 'drizzle-orm'; +import { asc, eq, sql } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { z } from 'zod'; -import { ruleInputSchema, type RuleDto } from '@proxy-station/shared'; +import { BUILTIN_POLICIES, ruleInputSchema, type RuleDto } from '@proxy-station/shared'; import { db } from '../db/index.js'; -import { rules, rulesets } from '../db/schema.js'; +import { policyGroups, rules, rulesets } from '../db/schema.js'; import { seedRulesFromTemplate } from '../db/seed.js'; function loadRules(): RuleDto[] { @@ -22,29 +22,26 @@ function loadRules(): RuleDto[] { }); } -function nextSeq(): number { - const rows = db.select({ seq: rules.seq }).from(rules).all(); - return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0; +/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */ +function policyError(policy: string): string | null { + if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null; + const exists = db.select({ id: policyGroups.id }).from(policyGroups).where(eq(policyGroups.name, policy)).get(); + return exists ? null : `策略「${policy}」不存在:请选择内置策略或已有策略组`; } export const rulesRoute = new Hono() .get('/', (c) => c.json(loadRules())) .post('/', zValidator('json', ruleInputSchema), (c) => { const input = c.req.valid('json'); - // 新自定义规则插到 FINAL 之前 - const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get(); - let seq: number; - if (finalRow) { - seq = finalRow.seq; - db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run(); - } else { - seq = nextSeq(); - } + const err = policyError(input.policy); + if (err) return c.json({ error: err }, 400); + // 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前 + db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run(); const id = nanoid(10); db.insert(rules) .values({ id, - seq, + seq: 0, type: input.type, value: input.value ?? null, policy: input.policy, @@ -69,6 +66,8 @@ export const rulesRoute = new Hono() const existing = db.select().from(rules).where(eq(rules.id, id)).get(); if (!existing) return c.json({ error: '规则不存在' }, 404); const input = c.req.valid('json'); + const err = policyError(input.policy); + if (err) return c.json({ error: err }, 400); db.update(rules) .set({ type: input.type, diff --git a/server/src/services/ir.ts b/server/src/services/ir.ts index 0eefb50..6b2f423 100644 --- a/server/src/services/ir.ts +++ b/server/src/services/ir.ts @@ -1,7 +1,7 @@ import { asc, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js'; -import type { NodeDto } from '@proxy-station/shared'; +import { BUILTIN_POLICIES, type NodeDto } from '@proxy-station/shared'; import { rowToDto } from '../routes/nodes.js'; export interface GroupIR { @@ -71,9 +71,9 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR { // 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效 const groupRows = allGroupRows.filter((g) => g.enabled); const liveNames = new Set(groupRows.map((g) => g.name)); - const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']); + const builtins = new Set(BUILTIN_POLICIES); /** 组已被删除或停用时,引用它的成员/规则应当跳过 */ - const isDeadRef = (name: string) => !liveNames.has(name) && !BUILTIN_POLICIES.has(name); + const isDeadRef = (name: string) => !liveNames.has(name) && !builtins.has(name); const groups: GroupIR[] = groupRows.map((g) => ({ name: g.name, type: g.type, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 8d6148f..e22447d 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -85,6 +85,9 @@ export const RULE_TYPES = [ 'FINAL', ] as const; +/** 无需策略组即可引用的内置策略 */ +export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF'] as const; + export const ruleInputSchema = z.object({ type: z.enum(RULE_TYPES), value: z.string().optional().nullable(), diff --git a/web/src/pages/RulesPage.vue b/web/src/pages/RulesPage.vue index ff2e8c0..e56cf63 100644 --- a/web/src/pages/RulesPage.vue +++ b/web/src/pages/RulesPage.vue @@ -39,7 +39,7 @@ function openPreview(rule: RuleDto) { previewRule.value = rule; showPreview.value = true; } -const form = ref({ type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true }); +const form = ref({ type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true }); const policyOptions = computed(() => [ ...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })), @@ -53,7 +53,7 @@ const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-S function openCreate() { editing.value = null; - form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true }; + form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true }; showForm.value = true; }