迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
140 lines
6.4 KiB
TypeScript
140 lines
6.4 KiB
TypeScript
import { Hono } from 'hono';
|
||
import { zValidator } from '@hono/zod-validator';
|
||
import { asc, eq, sql } from 'drizzle-orm';
|
||
import { nanoid } from 'nanoid';
|
||
import { z } from 'zod';
|
||
import { BUILTIN_POLICIES, ruleInputSchema, type RuleDto } from '@proxy-station/shared';
|
||
import { db } from '../db/index.js';
|
||
import { policyGroups, rules, rulesets } from '../db/schema.js';
|
||
import { seedRulesFromTemplate } from '../db/seed.js';
|
||
|
||
function loadRules(): RuleDto[] {
|
||
const rows = db.select().from(rules).orderBy(asc(rules.seq)).all();
|
||
const sets = new Map(db.select().from(rulesets).all().map((r) => [r.id, r]));
|
||
return rows.map((r) => {
|
||
const rs = r.value ? sets.get(r.value) : undefined;
|
||
return {
|
||
...r,
|
||
params: JSON.parse(r.params),
|
||
rulesetName: rs?.name ?? null,
|
||
rulesetUrl: rs?.surgeUrl ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
|
||
function policyError(policy: string): string | null {
|
||
if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null;
|
||
const group = db.select().from(policyGroups).where(eq(policyGroups.name, policy)).get();
|
||
return group?.enabled ? null : `策略「${policy}」不存在或已停用:请选择内置策略或启用的策略组`;
|
||
}
|
||
|
||
function inputError(input: z.output<typeof ruleInputSchema>, id?: string): string | null {
|
||
const err = policyError(input.policy);
|
||
if (err) return err;
|
||
if (input.type === 'FINAL' && db.select().from(rules).all().some((r) => r.type === 'FINAL' && r.id !== id)) return '只能有一条 FINAL 规则';
|
||
if (['RULE-SET', 'DOMAIN-SET'].includes(input.type) && !db.select().from(rulesets).where(eq(rulesets.id, input.value!)).get()) return '规则集不存在';
|
||
return null;
|
||
}
|
||
|
||
export const rulesRoute = new Hono()
|
||
.get('/', (c) => c.json(loadRules()))
|
||
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||
const input = c.req.valid('json');
|
||
const err = inputError(input);
|
||
if (err) return c.json({ error: err }, 400);
|
||
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
|
||
const id = nanoid(10);
|
||
db.transaction(() => {
|
||
if (input.type !== 'FINAL') db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
|
||
const seq = input.type === 'FINAL' ? Math.max(-1, ...db.select().from(rules).all().map((r) => r.seq)) + 1 : 0;
|
||
db.insert(rules)
|
||
.values({
|
||
id,
|
||
seq,
|
||
type: input.type,
|
||
value: input.value ?? null,
|
||
policy: input.policy,
|
||
params: JSON.stringify(input.params),
|
||
enabled: input.enabled,
|
||
source: 'custom',
|
||
})
|
||
.run();
|
||
});
|
||
return c.json({ id }, 201);
|
||
})
|
||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||
const { ids } = c.req.valid('json');
|
||
const rows = db.select().from(rules).all();
|
||
const known = new Set(rows.map((r) => r.id));
|
||
if (ids.length !== rows.length || new Set(ids).size !== ids.length || ids.some((id) => !known.has(id))) return c.json({ error: '排序必须包含全部规则且每条仅出现一次' }, 400);
|
||
const finals = rows.filter((r) => r.type === 'FINAL');
|
||
if (finals.length !== 1 || !finals[0].enabled || ids.at(-1) !== finals[0].id) return c.json({ error: '启用的唯一 FINAL 必须位于末尾' }, 400);
|
||
db.transaction(() => ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run()));
|
||
return c.json({ ok: true });
|
||
})
|
||
.post('/reset', (c) => {
|
||
seedRulesFromTemplate(db, { keepCustom: true });
|
||
return c.json({ ok: true, rules: loadRules().length });
|
||
})
|
||
.put('/:id', zValidator('json', ruleInputSchema), (c) => {
|
||
const id = c.req.param('id');
|
||
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');
|
||
if (existing.type === 'FINAL' && input.type !== 'FINAL') return c.json({ error: '不能将 FINAL 改成其他规则类型' }, 400);
|
||
if (existing.type !== 'FINAL' && input.type === 'FINAL') return c.json({ error: '请编辑已有 FINAL,不能改变普通规则的类型为 FINAL' }, 400);
|
||
const err = inputError(input, id);
|
||
if (err) return c.json({ error: err }, 400);
|
||
db.update(rules)
|
||
.set({
|
||
type: input.type,
|
||
value: input.value ?? null,
|
||
policy: input.policy,
|
||
params: JSON.stringify(input.params),
|
||
enabled: input.enabled,
|
||
})
|
||
.where(eq(rules.id, id))
|
||
.run();
|
||
return c.json({ ok: true });
|
||
})
|
||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
||
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
|
||
if (!row) return c.json({ error: '规则不存在' }, 404);
|
||
if (row.type === 'FINAL' && !c.req.valid('json').enabled) return c.json({ error: '不能停用 FINAL' }, 400);
|
||
const res = db
|
||
.update(rules)
|
||
.set({ enabled: c.req.valid('json').enabled })
|
||
.where(eq(rules.id, c.req.param('id')))
|
||
.run();
|
||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||
return c.json({ ok: true });
|
||
})
|
||
.delete('/:id', (c) => {
|
||
const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get();
|
||
if (!row) return c.json({ error: '规则不存在' }, 404);
|
||
if (row.type === 'FINAL') return c.json({ error: '不能删除 FINAL' }, 400);
|
||
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
|
||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
export const rulesetsRoute = new Hono()
|
||
.get('/', (c) => {
|
||
const rows = db.select().from(rulesets).all();
|
||
return c.json(rows);
|
||
})
|
||
.get('/:id/content', async (c) => {
|
||
const rs = db.select().from(rulesets).where(eq(rulesets.id, c.req.param('id'))).get();
|
||
if (!rs) return c.json({ error: '规则集不存在' }, 404);
|
||
const refresh = c.req.query('refresh') === '1';
|
||
try {
|
||
const { fetchRulesetContent } = await import('../services/rulesets/fetch.js');
|
||
const { content, fetchedAt, stale } = await fetchRulesetContent(rs.surgeUrl, { refresh });
|
||
const lineCount = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length;
|
||
return c.json({ content, fetchedAt, stale, lineCount });
|
||
} catch (e: any) {
|
||
return c.json({ error: `拉取失败:${e.message}` }, 502);
|
||
}
|
||
});
|