迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
121 lines
4.8 KiB
TypeScript
121 lines
4.8 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { asc, eq } from 'drizzle-orm';
|
|
import { nanoid } from 'nanoid';
|
|
import { z } from 'zod';
|
|
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
|
import { db } from '../db/index.js';
|
|
import { groupMembers, policyGroups, rules, nodes } from '../db/schema.js';
|
|
import { groupError } from '../services/group-validation.js';
|
|
|
|
function validate(input: z.output<typeof groupInputSchema>, id?: string): string | null {
|
|
return groupError(input, loadGroups(), db.select({ id: nodes.id, name: nodes.name }).from(nodes).all(), id);
|
|
}
|
|
|
|
function loadGroups(): GroupDto[] {
|
|
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
|
const members = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all();
|
|
return groups.map((g) => ({
|
|
...g,
|
|
members: members
|
|
.filter((m) => m.groupId === g.id)
|
|
.map((m) => ({ id: m.id, kind: m.memberKind, nodeId: m.nodeId, refName: m.refName, sortOrder: m.sortOrder })),
|
|
}));
|
|
}
|
|
|
|
function replaceMembers(groupId: string, members: z.output<typeof groupInputSchema>['members']) {
|
|
db.delete(groupMembers).where(eq(groupMembers.groupId, groupId)).run();
|
|
members.forEach((m, i) => {
|
|
db.insert(groupMembers)
|
|
.values({
|
|
groupId,
|
|
memberKind: m.kind,
|
|
nodeId: m.kind === 'node' ? (m.nodeId ?? null) : null,
|
|
refName: m.kind === 'node' ? null : (m.refName ?? null),
|
|
sortOrder: i,
|
|
})
|
|
.run();
|
|
});
|
|
}
|
|
|
|
export const groupsRoute = new Hono()
|
|
.get('/', (c) => c.json(loadGroups()))
|
|
.post('/', zValidator('json', groupInputSchema), (c) => {
|
|
const input = c.req.valid('json');
|
|
const error = validate(input);
|
|
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
|
const id = nanoid(10);
|
|
try {
|
|
db.transaction(() => {
|
|
db.insert(policyGroups)
|
|
.values({
|
|
id,
|
|
name: input.name,
|
|
type: input.type,
|
|
testUrl: input.testUrl ?? null,
|
|
interval: input.interval ?? null,
|
|
tolerance: input.tolerance ?? null,
|
|
filterRegex: input.filterRegex || null,
|
|
includeAllNodes: input.includeAllNodes,
|
|
sortOrder: input.sortOrder,
|
|
source: 'custom',
|
|
})
|
|
.run();
|
|
replaceMembers(id, input.members);
|
|
});
|
|
} catch (e: any) {
|
|
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
|
throw e;
|
|
}
|
|
return c.json({ id }, 201);
|
|
})
|
|
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
|
const { ids } = c.req.valid('json');
|
|
ids.forEach((id, i) => db.update(policyGroups).set({ sortOrder: i }).where(eq(policyGroups.id, id)).run());
|
|
return c.json({ ok: true });
|
|
})
|
|
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
|
const res = db
|
|
.update(policyGroups)
|
|
.set({ enabled: c.req.valid('json').enabled })
|
|
.where(eq(policyGroups.id, c.req.param('id')))
|
|
.run();
|
|
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
|
return c.json({ ok: true });
|
|
})
|
|
.put('/:id', zValidator('json', groupInputSchema), (c) => {
|
|
const id = c.req.param('id');
|
|
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
|
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
|
const input = c.req.valid('json');
|
|
const error = validate(input, id);
|
|
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
|
const oldName = existing.name;
|
|
db.transaction(() => {
|
|
db.update(policyGroups)
|
|
.set({
|
|
name: input.name,
|
|
type: input.type,
|
|
testUrl: input.testUrl ?? null,
|
|
interval: input.interval ?? null,
|
|
tolerance: input.tolerance ?? null,
|
|
filterRegex: input.filterRegex || null,
|
|
includeAllNodes: input.includeAllNodes,
|
|
})
|
|
.where(eq(policyGroups.id, id))
|
|
.run();
|
|
replaceMembers(id, input.members.map((m) => m.kind === 'group' && m.refName === oldName ? { ...m, refName: input.name } : m));
|
|
// 组名变更时同步引用它的成员与规则策略
|
|
if (oldName !== input.name) {
|
|
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
|
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
|
}
|
|
});
|
|
return c.json({ ok: true });
|
|
})
|
|
.delete('/:id', (c) => {
|
|
const res = db.delete(policyGroups).where(eq(policyGroups.id, c.req.param('id'))).run();
|
|
if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404);
|
|
return c.json({ ok: true });
|
|
});
|