fix: 自定义规则置顶生效,写入前校验策略名存在
规则首条匹配即生效,自定义规则此前插在模板规则之后、FINAL 之前,被 download.conf 等宽泛规则集抢先命中(brew.sh 配了代理仍直连)。现在新建 规则插到 seq 0,重新播种时也把自定义规则整体排在模板规则之前。 同时把 BUILTIN_POLICIES 提到 shared 共用,规则新建/编辑时校验策略必须是 内置策略或已有策略组,避免引用不存在的策略被 IR 静默剔除;前端表单默认 策略由不存在的「Proxy」改为 DIRECT。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,12 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
|||||||
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
|
||||||
|
|
||||||
let seq = 0;
|
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) {
|
for (const r of parsed.rules) {
|
||||||
let value = r.value;
|
let value = r.value;
|
||||||
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(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();
|
.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) {
|
export function seedIfEmpty(db: Db) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { asc, eq } from 'drizzle-orm';
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { z } from 'zod';
|
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 { 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';
|
import { seedRulesFromTemplate } from '../db/seed.js';
|
||||||
|
|
||||||
function loadRules(): RuleDto[] {
|
function loadRules(): RuleDto[] {
|
||||||
@@ -22,29 +22,26 @@ function loadRules(): RuleDto[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextSeq(): number {
|
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
|
||||||
const rows = db.select({ seq: rules.seq }).from(rules).all();
|
function policyError(policy: string): string | null {
|
||||||
return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0;
|
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()
|
export const rulesRoute = new Hono()
|
||||||
.get('/', (c) => c.json(loadRules()))
|
.get('/', (c) => c.json(loadRules()))
|
||||||
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
// 新自定义规则插到 FINAL 之前
|
const err = policyError(input.policy);
|
||||||
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
|
if (err) return c.json({ error: err }, 400);
|
||||||
let seq: number;
|
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
|
||||||
if (finalRow) {
|
db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
|
||||||
seq = finalRow.seq;
|
|
||||||
db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run();
|
|
||||||
} else {
|
|
||||||
seq = nextSeq();
|
|
||||||
}
|
|
||||||
const id = nanoid(10);
|
const id = nanoid(10);
|
||||||
db.insert(rules)
|
db.insert(rules)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
seq,
|
seq: 0,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
value: input.value ?? null,
|
value: input.value ?? null,
|
||||||
policy: input.policy,
|
policy: input.policy,
|
||||||
@@ -69,6 +66,8 @@ export const rulesRoute = new Hono()
|
|||||||
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
||||||
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
|
const err = policyError(input.policy);
|
||||||
|
if (err) return c.json({ error: err }, 400);
|
||||||
db.update(rules)
|
db.update(rules)
|
||||||
.set({
|
.set({
|
||||||
type: input.type,
|
type: input.type,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { asc, eq } from 'drizzle-orm';
|
import { asc, eq } from 'drizzle-orm';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.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';
|
import { rowToDto } from '../routes/nodes.js';
|
||||||
|
|
||||||
export interface GroupIR {
|
export interface GroupIR {
|
||||||
@@ -71,9 +71,9 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
|||||||
// 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效
|
// 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效
|
||||||
const groupRows = allGroupRows.filter((g) => g.enabled);
|
const groupRows = allGroupRows.filter((g) => g.enabled);
|
||||||
const liveNames = new Set(groupRows.map((g) => g.name));
|
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<string>(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) => ({
|
const groups: GroupIR[] = groupRows.map((g) => ({
|
||||||
name: g.name,
|
name: g.name,
|
||||||
type: g.type,
|
type: g.type,
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export const RULE_TYPES = [
|
|||||||
'FINAL',
|
'FINAL',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 无需策略组即可引用的内置策略 */
|
||||||
|
export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF'] as const;
|
||||||
|
|
||||||
export const ruleInputSchema = z.object({
|
export const ruleInputSchema = z.object({
|
||||||
type: z.enum(RULE_TYPES),
|
type: z.enum(RULE_TYPES),
|
||||||
value: z.string().optional().nullable(),
|
value: z.string().optional().nullable(),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function openPreview(rule: RuleDto) {
|
|||||||
previewRule.value = rule;
|
previewRule.value = rule;
|
||||||
showPreview.value = true;
|
showPreview.value = true;
|
||||||
}
|
}
|
||||||
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true });
|
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true });
|
||||||
|
|
||||||
const policyOptions = computed(() => [
|
const policyOptions = computed(() => [
|
||||||
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
|
...['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() {
|
function openCreate() {
|
||||||
editing.value = null;
|
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;
|
showForm.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user