Files
WorthPath/apps/api/src/mcp/operations.ts
T

380 lines
14 KiB
TypeScript

import {
Injectable,
BadRequestException,
ForbiddenException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Prisma, AgentGrant } from '@prisma/client';
import { Response } from 'express';
import { z } from 'zod';
import { Database } from '../database';
import { UserRequest } from '../auth';
import { AgentOAuth, digest, webLink } from './oauth';
import { AgentCatalogue, ToolDefinition, empty } from './catalogue';
import { AgentFiles } from './files';
export const writing = {
idempotencyKey: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9_.:-]+$/)
.describe('同用户唯一幂等键;重试使用相同键和全部参数,改动参数必须换键'),
expectedState: z
.string()
.regex(/^[a-f0-9]{64}$/)
.describe('先 state_get 获取 state,避免覆盖并发修改;状态变化后重新读取并使用新幂等键'),
};
export const plain = (v: unknown) => JSON.parse(JSON.stringify(v));
function stable(v: any): string {
return JSON.stringify(v, (_k, x) =>
x && typeof x === 'object' && !Array.isArray(x)
? Object.fromEntries(
Object.keys(x)
.sort()
.map((k) => [k, x[k]]),
)
: x,
);
}
@Injectable()
export class AgentOperations {
readonly tools: ToolDefinition[];
constructor(
private db: Database,
private oauth: AgentOAuth,
catalogue: AgentCatalogue,
private files: AgentFiles,
) {
this.tools = [
...catalogue.tools,
{
name: 'icon_publish',
description: '保存已上传私有图标,不允许发布共享图标。先 file_upload_request(kind=icon)。',
schema: z
.object({
fileId: z.string().uuid(),
name: z.string().min(1).max(100),
shared: z.literal(false).default(false),
})
.strict(),
scope: 'write',
run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared),
},
];
}
get(name: string) {
const t = this.tools.find((t) => t.name === name);
if (!t) throw new BadRequestException('未知工具');
return t;
}
async context(grant: AgentGrant, writing = false) {
let s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
// A permanent PAT is the credential. Its internal business session has a bounded
// lifetime and may be recreated after the normal expired-session cleanup.
if (!grant.expiresAt && !grant.clientId && (!s || s.expiresAt <= new Date())) {
await this.oauth.grant(grant.id, grant.userId);
s = await this.db.session.upsert({
where: { id: grant.sessionId },
create: {
id: grant.sessionId,
userId: grant.userId,
expiresAt: new Date(Date.now() + 86400000),
},
update: {
expiresAt: new Date(Date.now() + 86400000),
revealUntil: null,
backupDigest: null,
backupExpiresAt: null,
},
});
}
if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权');
return {
userId: grant.userId,
sessionId: grant.sessionId,
revealed: (grant.scopes as string[]).includes(writing ? 'hidden_write' : 'hidden_read'),
agent: true,
agentGrantId: grant.id,
cookies: {},
} as UserRequest;
}
async state(userId: string) {
const data = await Promise.all([
this.db.user.findUniqueOrThrow({
where: { id: userId },
select: {
username: true,
baseCurrency: true,
hiddenMenus: true,
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
includeIndependentAssets: true,
},
}),
this.db.position.findMany({
where: { userId },
orderBy: { id: 'asc' },
include: { revisions: { orderBy: { id: 'asc' } }, outgoing: { orderBy: { id: 'asc' } } },
}),
this.db.transfer.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
this.db.schedule.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
this.db.exchangeRate.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
this.db.metalPrice.findMany({ where: { userId }, orderBy: { id: 'asc' } }),
this.db.icon.findMany({
where: { ownerId: userId },
select: { id: true, name: true, hash: true, shared: true },
orderBy: { id: 'asc' },
}),
]);
return digest(stable(plain(data)));
}
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
const selected = grant.scopes as string[];
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
const mode = selected.includes('write')
? 'direct'
: selected.includes('draft')
? 'draft'
: 'readonly';
if (t.scope === 'read') return { mode };
if (mode === 'readonly') throw new ForbiddenException('本连接只有只读权限');
if (!selected.includes('hidden_write') && (p.hidden === true || p.data?.hidden === true))
throw new ForbiddenException('缺少隐藏账户修改权限');
return { mode };
}
async call(grantId: string, name: string, input: any) {
const t = this.get(name);
const parsed = (t.scope === 'read' ? t.schema : t.schema.safeExtend(writing)).parse(input);
const { idempotencyKey, expectedState, ...p } = parsed as any;
const grant = await this.oauth.grant(grantId);
await this.permission(grant, t, p);
if (t.scope === 'read') return t.run!(await this.context(grant), p);
const hash = digest(stable({ tool: name, parameters: p, expectedState }));
return this.db.atomic(async () => {
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${grant.userId} FOR UPDATE`);
const fresh = await this.oauth.grant(grantId),
access = await this.permission(fresh, t, p);
const existing = await this.db.agentOperation.findUnique({
where: { userId_key: { userId: grant.userId, key: idempotencyKey } },
});
if (existing) {
if (existing.hash !== hash || existing.grantId !== grant.id)
throw new ConflictException('幂等键已用于不同参数或连接');
return this.view(existing);
}
const snapshot = await this.state(grant.userId);
if (snapshot !== expectedState)
throw new ConflictException('账目已变化,请重新读取 state_get 和数据后使用新幂等键');
const row = await this.db.agentOperation.create({
data: {
userId: grant.userId,
grantId,
key: idempotencyKey,
hash,
tool: name,
parameters: plain(p),
snapshot,
expiresAt: new Date(Date.now() + 600000),
},
});
if (access.mode === 'draft' || !(fresh.scopes as string[]).includes('write'))
return this.view(row);
const result = await this.execute(t, fresh, p);
return this.view(
await this.db.agentOperation.update({
where: { id: row.id },
data: { status: 'completed', result: plain(result), completedAt: new Date() },
}),
);
});
}
private async execute(t: ToolDefinition, grant: AgentGrant, p: any) {
return t.run!(await this.context(grant, true), p);
}
private view(row: any) {
return {
operationId: row.id,
tool: row.tool,
status: row.status === 'pending' && row.expiresAt < new Date() ? 'expired' : row.status,
expiresAt: row.expiresAt,
result: row.result,
confirmationUrl: row.status === 'pending' ? webLink('agent_operation', row.id) : undefined,
};
}
async status(grantId: string, id: string) {
const grant = await this.oauth.grant(grantId),
row = await this.db.agentOperation.findFirst({
where: { id, userId: grant.userId, grantId },
});
if (!row) throw new NotFoundException('操作不存在');
return this.view(row);
}
async preview(userId: string, id: string) {
const row = await this.db.agentOperation.findFirst({ where: { id, userId } });
if (!row) throw new NotFoundException('操作不存在');
const t = this.get(row.tool),
grant = await this.oauth.grant(row.grantId, userId);
const selected = grant.scopes as string[];
const ids = new Set<string>();
const collect = (value: unknown) => {
if (typeof value === 'string' && /^[a-f0-9-]{36}$/i.test(value)) ids.add(value);
else if (Array.isArray(value)) value.forEach(collect);
else if (value && typeof value === 'object') Object.values(value).forEach(collect);
};
collect(row.parameters);
const visible = { userId, ...(selected.includes('hidden_read') ? {} : { hidden: false }) };
const positions = await this.db.position.findMany({
where: { ...visible, id: { in: [...ids] } },
select: { id: true, name: true, currency: true, side: true, kind: true },
});
const schedules = await this.db.schedule.findMany({
where: { userId, id: { in: [...ids] } },
select: { id: true, name: true, sourceId: true, targetId: true },
});
const visibleScheduleIds = new Set(
(
await this.db.position.findMany({
where: {
...visible,
id: { in: schedules.flatMap((s) => [s.sourceId, ...(s.targetId ? [s.targetId] : [])]) },
},
select: { id: true },
})
).map((p) => p.id),
);
const references = Object.fromEntries([
...positions.map((p) => [p.id, p]),
...schedules
.filter(
(s) =>
visibleScheduleIds.has(s.sourceId) &&
(!s.targetId || visibleScheduleIds.has(s.targetId)),
)
.map((s) => [s.id, { name: s.name, kind: 'schedule' }]),
]);
const p = row.parameters as Record<string, any>;
let current: Record<string, unknown> | undefined;
if (['movement_update', 'movement_delete'].includes(row.tool)) {
const movement = await this.db.transfer.findFirst({
where: { id: p.id, userId, source: visible, target: visible },
include: { source: true, target: true },
});
if (movement) {
references[movement.sourceId] = {
name: movement.source.name,
currency: movement.source.currency,
};
references[movement.targetId] = {
name: movement.target.name,
currency: movement.target.currency,
};
current = {
operation: movement.operation,
sourceId: movement.sourceId,
targetId: movement.targetId,
amount: movement.amount.toString(),
received: movement.received.toString(),
fee: movement.fee.toString(),
date: new Date(+movement.effectiveDate + 8 * 3600000).toISOString().slice(0, 16),
notes: movement.notes,
};
}
} else if (['history_update', 'history_delete', 'balance_record'].includes(row.tool)) {
const revision = await this.db.revision.findFirst({
where: {
positionId: p.id,
position: visible,
...(p.revisionId ? { id: p.revisionId } : {}),
},
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
});
if (revision)
current = {
id: p.id,
amount: revision.amount.toString(),
date: new Date(+revision.effectiveDate + 8 * 3600000).toISOString().slice(0, 16),
notes: revision.notes,
};
}
const impact = { parameters: row.parameters, references, current };
return {
...this.view(row),
impact,
connectionName: grant.name,
destructive: !!t.destructive,
description: t.description,
};
}
async confirm(r: UserRequest, id: string, raw: unknown, res: Response) {
const input = z
.object({
approve: z.boolean(),
})
.strict()
.parse(raw);
return this.db.atomic(async () => {
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
const row = await this.db.agentOperation.findFirst({ where: { id, userId: r.userId } });
if (!row) throw new NotFoundException('操作不存在');
if (row.status !== 'pending' || row.expiresAt <= new Date())
throw new ConflictException('操作已完成或失效');
if (!input.approve)
return this.view(
await this.db.agentOperation.update({
where: { id },
data: { status: 'cancelled', completedAt: new Date() },
}),
);
const grant = await this.oauth.grant(row.grantId, r.userId),
t = this.get(row.tool),
p = row.parameters as any;
await this.permission(grant, t, p);
if ((await this.state(r.userId)) !== row.snapshot)
throw new ConflictException('账目已变化,请取消并重新创建操作');
const result = await this.execute(t, grant, p);
if (row.tool === 'position_create') {
// Advance only sibling additions reviewed against the same snapshot.
// The user lock and transaction keep edits and other grants stale.
await this.db.agentOperation.updateMany({
where: {
userId: r.userId,
grantId: row.grantId,
tool: 'position_create',
status: 'pending',
snapshot: row.snapshot,
id: { not: row.id },
expiresAt: { gt: new Date() },
},
data: { snapshot: await this.state(r.userId) },
});
}
return this.view(
await this.db.agentOperation.update({
where: { id },
data: { status: 'completed', result: plain(result), completedAt: new Date() },
}),
);
}, 300000);
}
async uploadRequest(grantId: string, kind: 'icon') {
const grant = await this.oauth.grant(grantId);
const selected = grant.scopes as string[];
if (!selected.includes('draft') && !selected.includes('write'))
throw new ForbiddenException('上传需要 draft 或 write 权限');
return this.files.issue(await this.context(grant), grantId, kind);
}
async fileStatus(grantId: string, id: string) {
const g = await this.oauth.grant(grantId);
return this.files.inspect(await this.context(g), grantId, id);
}
async iconImage(grantId: string, id: string) {
const g = await this.oauth.grant(grantId);
return this.files.issue(await this.context(g), grantId, 'image', id);
}
}