// Profile、资料库、Agent Key、审计:后台管理的配置类数据 import crypto from 'node:crypto' import { and, desc, eq, isNull, ne } from 'drizzle-orm' import { z } from 'zod' import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared' import type { DB } from '../db' import { apiKeys, auditLogs, knowledgeRoots, mcpServers, profiles, profileVersions } from '../db/schema' import { AppError, badRequest, notFound } from '../lib/errors' import { newId, nowIso } from '../lib/ids' import { checkKnowledgePath } from './access-policy' import type { SettingsService } from './settings' type ProfileRow = typeof profiles.$inferSelect const pathRule = z.union([z.object({ knowledgeRootId: z.string() }), z.object({ path: z.string().min(1) })]) const fileAccessSchema: z.ZodType = z.object({ mode: z.enum(['restricted', 'broad']), read: z.array(pathRule), write: z.array(pathRule), deny: z.array(z.string().min(1)), }) export const profileInputSchema: z.ZodType = z.object({ name: z.string().trim().min(1).max(60), description: z.string().max(500), provider: z.enum(['claude', 'codex']), model: z.string().min(1), systemPrompt: z.string().max(20000), permissionMode: z.enum(['ask', 'auto', 'readonly']), allowedTools: z.array(z.string()), disallowedTools: z.array(z.string()), skills: z.union([z.literal('all'), z.array(z.string())]), fileAccess: fileAccessSchema, knowledgeRootIds: z.array(z.string()), mcpServerIds: z.array(z.string()).default([]), isDefault: z.boolean(), }) const toProfileDto = (r: ProfileRow): ProfileDto => ({ id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt, permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills, fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, mcpServerIds: r.mcpServerIds, isDefault: r.isDefault, builtin: r.builtin, builtinKey: r.builtinKey ?? null, version: r.version, updatedAt: r.updatedAt, }) const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'mcpServerIds'] as const satisfies readonly (keyof ProfileSnapshot)[] function snapshotOf(p: ProfileSnapshot): ProfileSnapshot { // 早期版本的快照没有 mcpServerIds,按空列表比较 return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot } function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] { return SNAPSHOT_KEYS.filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k])) } /** 内置的标准 Profile:每个引擎一个"通用助手",不可删除,引擎不可修改 */ const BUILTIN_NAMES: Record = { claude: '通用助手(Claude)', codex: '通用助手(Codex)' } const FALLBACK_MODELS: Record = { claude: 'sonnet', codex: 'gpt-6.1-sol' } export class ProfileService { constructor(private db: DB, private settings: SettingsService) { this.ensureBuiltins() } /** 补齐每个引擎的内置 Profile;旧版本的"通用助手"改名为 Claude 版本 */ private ensureBuiltins() { const hasAny = !!this.db.select({ id: profiles.id }).from(profiles).limit(1).get() for (const provider of ['claude', 'codex'] as const) { const existing = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get() if (existing) { if (existing.name === '通用助手') this.db.update(profiles).set({ name: BUILTIN_NAMES[provider] }).where(eq(profiles.id, existing.id)).run() continue } const s = this.settings.get() this.create({ name: BUILTIN_NAMES[provider], description: `数据分析、资料查找与文档生成(${provider === 'claude' ? 'Claude' : 'Codex'} 标准版)`, provider, // 设置中的默认模型可能为空(例如保存过空值),回落到内置默认 model: s.providers[provider].defaultModel || FALLBACK_MODELS[provider], systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all', fileAccess: { mode: s.security.defaultFileAccessMode, read: [], write: [], deny: [] }, knowledgeRootIds: [], // 全新安装时 Claude 版本为默认 Profile isDefault: provider === 'claude' && !hasAny, }, true) } } list(): ProfileDto[] { return this.db.select().from(profiles).orderBy(desc(profiles.isDefault), profiles.name).all().map(toProfileDto) } get(id: string): ProfileDto { const row = this.db.select().from(profiles).where(eq(profiles.id, id)).get() if (!row) throw notFound('Profile') return toProfileDto(row) } getDefault(): ProfileDto { const row = this.db.select().from(profiles).where(eq(profiles.isDefault, true)).get() ?? this.db.select().from(profiles).limit(1).get() if (!row) throw notFound('默认 Profile') return toProfileDto(row) } /** Profile 只能引用已登记的 MCP 服务 */ private checkMcp(ids: string[] | undefined) { if (!ids?.length) return const known = new Set(this.db.select({ id: mcpServers.id }).from(mcpServers).all().map(r => r.id)) const missing = ids.filter(id => !known.has(id)) if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`) } create(input: ProfileInput, builtin = false, builtinKey: 'lead' | null = null): ProfileDto { const data = parse(profileInputSchema, input) this.checkMcp(data.mcpServerIds) const id = newId('prf') this.db.transaction(tx => { if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run() tx.insert(profiles).values({ id, ...data, builtin, builtinKey }).run() tx.insert(profileVersions).values({ profileId: id, version: 1, snapshot: snapshotOf(data) }).run() }) return this.get(id) } /** 保存修改:内容有变化时版本号加一并保存快照;只改"是否默认"不产生新版本 */ update(id: string, input: ProfileInput): ProfileDto { const prev = this.get(id) const data = parse(profileInputSchema, input) this.checkMcp(data.mcpServerIds) if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认') if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改') const snapshot = snapshotOf(data) const changed = changedFields(snapshotOf(prev), snapshot).length > 0 const version = changed ? prev.version + 1 : prev.version this.db.transaction(tx => { if (data.isDefault) tx.update(profiles).set({ isDefault: false }).where(ne(profiles.id, id)).run() tx.update(profiles).set({ ...data, version, updatedAt: nowIso() }).where(eq(profiles.id, id)).run() if (changed) tx.insert(profileVersions).values({ profileId: id, version, snapshot }).run() }) return this.get(id) } /** 历史版本(新的在前),附带与上一个版本相比改动的字段 */ versions(id: string): ProfileVersionDto[] { this.get(id) const rows = this.db.select().from(profileVersions).where(eq(profileVersions.profileId, id)).orderBy(profileVersions.version).all() return rows.map((r, i) => ({ version: r.version, createdAt: r.createdAt, snapshot: r.snapshot, changed: i === 0 ? [] : changedFields(rows[i - 1]!.snapshot, r.snapshot), })).reverse() } /** 恢复到某个历史版本:以该版本的内容保存为一个新版本(历史只追加) */ restore(id: string, version: number): ProfileDto { const current = this.get(id) const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get() if (!row) throw notFound(`Profile 的版本 ${version}`) return this.update(id, { ...row.snapshot, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault }) } getBuiltin(provider: ProviderId = 'claude'): ProfileDto { const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get() return row ? toProfileDto(row) : this.getDefault() } /** 内置的组长 Profile(没有时返回 undefined) */ findBuiltinLead(provider: ProviderId): ProfileDto | undefined { const row = this.db.select().from(profiles).where(and(eq(profiles.builtinKey, 'lead'), eq(profiles.provider, provider))).get() return row ? toProfileDto(row) : undefined } delete(id: string) { const p = this.get(id) if (p.builtin) throw badRequest('内置的 Profile 不能删除') if (p.isDefault) throw badRequest('不能删除默认 Profile') this.db.transaction(tx => { tx.delete(profiles).where(eq(profiles.id, id)).run() tx.delete(profileVersions).where(eq(profileVersions.profileId, id)).run() }) } } const knowledgeInputSchema = z.object({ name: z.string().trim().min(1).max(60), path: z.string().trim().min(1), description: z.string().max(1000).default(''), access: z.enum(['read', 'write']).default('read'), enabled: z.boolean().default(true), }) export type KnowledgeInput = z.input export class KnowledgeService { constructor(private db: DB, private settings: SettingsService) {} list(): KnowledgeRootDto[] { return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all() .map(r => ({ id: r.id, name: r.name, path: r.path, description: r.description, access: r.access, enabled: r.enabled, check: checkKnowledgePath(r.path, this.settings.get()) })) } get(id: string): KnowledgeRootDto { const k = this.list().find(r => r.id === id) if (!k) throw notFound('资料库') return k } create(input: KnowledgeInput): KnowledgeRootDto { const data = this.validate(input) const id = newId('kb') this.db.insert(knowledgeRoots).values({ id, ...data }).run() return this.get(id) } update(id: string, input: KnowledgeInput): KnowledgeRootDto { this.get(id) const data = this.validate(input) this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run() return this.get(id) } delete(id: string) { this.get(id) this.db.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run() } private validate(input: KnowledgeInput) { const data = parse(knowledgeInputSchema, input) const check = checkKnowledgePath(data.path, this.settings.get()) if (check.denied || !check.exists || !check.isDirectory || !check.readable) throw badRequest(check.message ?? '路径不可用', check) return data } } export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex') export class ApiKeyService { private lastTouched = new Map() constructor(private db: DB, private profiles: ProfileService) {} list(): ApiKeyDto[] { return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all() .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedProfileIds: r.allowedProfileIds, defaultProfileId: r.defaultProfileId, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, })) } /** 返回明文 Key,只在创建时出现一次 */ create(name: string, access: ApiKeyProfiles): { key: string; apiKey: ApiKeyDto } { if (!name.trim()) throw badRequest('名称不能为空') const checked = this.checkAccess(access) const key = `ask_${crypto.randomBytes(24).toString('base64url')}` const id = newId('key') this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10), ...checked }).run() return { key, apiKey: this.list().find(k => k.id === id)! } } setProfiles(id: string, access: ApiKeyProfiles) { const checked = this.checkAccess(access) const r = this.db.update(apiKeys).set(checked).where(eq(apiKeys.id, id)).run() if (r.changes === 0) throw notFound('Agent Key') } /** 平台可用的 Profile 与默认 Profile */ access(id: string): ApiKeyProfiles { const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get() if (!row) throw notFound('Agent Key') const allowed = row.allowedProfileIds return { allowedProfileIds: allowed, defaultProfileId: row.defaultProfileId ?? allowed[0] ?? '' } } /** 平台要使用某个 Profile(省略时用默认),无权使用时抛 403 */ resolveProfile(id: string, requested?: string | null): ProfileDto { const acc = this.access(id) const target = requested || acc.defaultProfileId if (!target || !acc.allowedProfileIds.includes(target)) { throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 Agent Key 配置') } return this.profiles.get(target) } /** 引用了某个 Profile 的 Key 名称(删除 Profile 前检查) */ referencing(profileId: string): string[] { return this.list().filter(k => k.allowedProfileIds.includes(profileId)).map(k => k.name) } private checkAccess(access: ApiKeyProfiles) { const data = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), access) const allowed = [...new Set(data.allowedProfileIds)] if (!allowed.length) throw badRequest('至少选择一个 Profile') if (!data.defaultProfileId) throw badRequest('请选择默认 Profile') for (const pid of allowed) this.profiles.get(pid) if (!allowed.includes(data.defaultProfileId)) throw badRequest('默认 Profile 必须在可用的 Profile 中') return { allowedProfileIds: allowed, defaultProfileId: data.defaultProfileId } } setEnabled(id: string, enabled: boolean) { const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run() if (r.changes === 0) throw notFound('Agent Key') } delete(id: string) { const r = this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run() if (r.changes === 0) throw notFound('Agent Key') } verify(key: string): string | null { const row = this.db.select().from(apiKeys).where(and(eq(apiKeys.keyHash, hashKey(key)), eq(apiKeys.enabled, true))).get() if (!row) return null this.touch(row.id) return row.id } /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */ isActive(id: string): boolean { const row = this.db.select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.enabled, true))).get() if (row) this.touch(id) return !!row } /** 最近使用时间每分钟最多写一次 */ private touch(id: string) { const now = Date.now() if ((this.lastTouched.get(id) ?? 0) < now - 60_000) { this.lastTouched.set(id, now) this.db.update(apiKeys).set({ lastUsedAt: nowIso() }).where(eq(apiKeys.id, id)).run() } } } export type AuditKind = typeof auditLogs.$inferInsert['kind'] export class AuditService { constructor(private db: DB) {} log(entry: { actor: string; sessionId?: string | null; kind: AuditKind; summary: string; detail?: unknown }) { this.db.insert(auditLogs).values({ ...entry, sessionId: entry.sessionId ?? null, detail: entry.detail ?? null }).run() } list(opts: { kind?: AuditKind; sessionId?: string; limit?: number }) { const where = and(opts.kind ? eq(auditLogs.kind, opts.kind) : undefined, opts.sessionId ? eq(auditLogs.sessionId, opts.sessionId) : undefined) return this.db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.id)).limit(Math.min(opts.limit ?? 200, 1000)).all() } } export function parse(schema: z.ZodType, input: unknown): T { const r = schema.safeParse(input) if (!r.success) throw badRequest('参数校验失败', z.treeifyError(r.error)) return r.data }