feat: add authenticated remote MCP access and complete business workflows
This commit is contained in:
1 parent
f40f4da781
commit
027a8c1b6a
35 files changed
+4430
-183
No files matched your search
+67
-26
@@ -22,7 +22,13 @@ import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
||||
import { Prisma } from '@prisma/client';
|
||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
||||
export type UserRequest = Request & {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
revealed: boolean;
|
||||
agent?: boolean;
|
||||
agentGrantId?: string;
|
||||
};
|
||||
const Public = () => SetMetadata('public', true);
|
||||
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
export function allowedOrigin(
|
||||
@@ -44,7 +50,9 @@ export class AuthService {
|
||||
private attempts = new Map<string, { count: number; until: number }>();
|
||||
constructor(private db: Database) {}
|
||||
limit(req: Request) {
|
||||
const key = req.ip || 'local',
|
||||
const key =
|
||||
(req.ip || 'local') +
|
||||
('userId' in req && typeof req.userId === 'string' ? ':' + req.userId : ''),
|
||||
now = Date.now();
|
||||
let v = this.attempts.get(key);
|
||||
if (!v || v.until < now) {
|
||||
@@ -124,20 +132,17 @@ export class AuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@Controller('api')
|
||||
export class AuthController {
|
||||
|
||||
@Injectable()
|
||||
export class AuthBusinessService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
@Public() @Get('health') health() {
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
@Public() @Post('auth/register') async register(
|
||||
@Body() body: unknown,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
async register(body: unknown, req: Request, res: Response) {
|
||||
this.auth.limit(req);
|
||||
const v = credentials.parse(body),
|
||||
user = await this.db.user.create({
|
||||
@@ -146,11 +151,7 @@ export class AuthController {
|
||||
await this.auth.issue(user.id, res);
|
||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||
}
|
||||
@Public() @Post('auth/login') async login(
|
||||
@Body() body: unknown,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
async login(body: unknown, req: Request, res: Response) {
|
||||
this.auth.limit(req);
|
||||
const v = credentials.parse(body),
|
||||
user = await this.db.user.findUnique({ where: { username: v.username } });
|
||||
@@ -162,7 +163,7 @@ export class AuthController {
|
||||
await this.auth.issue(user.id, res);
|
||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||
}
|
||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||
async me(req: UserRequest) {
|
||||
const user = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: req.userId },
|
||||
select: {
|
||||
@@ -191,11 +192,7 @@ export class AuthController {
|
||||
lastActivity: session.lastActivity,
|
||||
};
|
||||
}
|
||||
@Patch('auth/credentials') async changeCredentials(
|
||||
@Req() r: UserRequest,
|
||||
@Body() body: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
async changeCredentials(r: UserRequest, body: unknown, res: Response) {
|
||||
this.auth.limit(r);
|
||||
const v = credentialChange.parse(body);
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
@@ -221,14 +218,14 @@ export class AuthController {
|
||||
this.auth.cookie(token, expiresAt, res);
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||
async activity(r: UserRequest) {
|
||||
await this.db.session.update({
|
||||
where: { id: r.sessionId },
|
||||
data: { lastActivity: new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
async reveal(r: UserRequest, b: unknown) {
|
||||
this.auth.limit(r);
|
||||
const revealUntil = await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
@@ -243,15 +240,59 @@ export class AuthController {
|
||||
});
|
||||
return { revealUntil };
|
||||
}
|
||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||
async lock(r: UserRequest) {
|
||||
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } });
|
||||
return { ok: true };
|
||||
}
|
||||
async logout(req: Request, res: Response) {
|
||||
await this.auth.logout(req, res);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class AuthController {
|
||||
constructor(private service: AuthBusinessService) {}
|
||||
@Public() @Get('health') health() {
|
||||
return this.service.health();
|
||||
}
|
||||
@Public() @Post('auth/register') async register(
|
||||
@Body() body: unknown,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.service.register(body, req, res);
|
||||
}
|
||||
@Public() @Post('auth/login') async login(
|
||||
@Body() body: unknown,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.service.login(body, req, res);
|
||||
}
|
||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||
return this.service.me(req);
|
||||
}
|
||||
@Patch('auth/credentials') async changeCredentials(
|
||||
@Req() r: UserRequest,
|
||||
@Body() body: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.service.changeCredentials(r, body, res);
|
||||
}
|
||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||
return this.service.activity(r);
|
||||
}
|
||||
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
return this.service.reveal(r, b);
|
||||
}
|
||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||
return this.service.lock(r);
|
||||
}
|
||||
@Post('auth/logout') async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
await this.auth.logout(req, res);
|
||||
return { ok: true };
|
||||
return this.service.logout(req, res);
|
||||
}
|
||||
}
|
||||
+64
-19
@@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { metalConfig, metalPriceInput } from './metals';
|
||||
import { scheduleInput } from './schedules';
|
||||
import { movementDeltas } from './movement';
|
||||
@@ -298,8 +299,9 @@ export function validateBackup(raw: unknown) {
|
||||
if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整');
|
||||
return b;
|
||||
}
|
||||
@Controller('api/backup')
|
||||
export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
|
||||
@Injectable()
|
||||
export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||
private uploads = new Map<
|
||||
string,
|
||||
{ sessionId: string; userId: string; path: string; expires: number }
|
||||
@@ -336,6 +338,20 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|
||||
}
|
||||
constructor(private db: Database) {}
|
||||
async snapshot(userId: string) {
|
||||
return this.fingerprint(await this.data(userId));
|
||||
}
|
||||
async inspectUpload(r: UserRequest, token: string) {
|
||||
const v = this.uploads.get(token);
|
||||
if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now())
|
||||
throw new BadRequestException('导入预览已失效,请重新上传备份');
|
||||
const data = await this.uploadedData(v.path);
|
||||
return { data, preview: await this.preview(r, data) };
|
||||
}
|
||||
async restoreUpload(r: UserRequest, token: string) {
|
||||
const prepared = await this.inspectUpload(r, token);
|
||||
return this.restore(r, { confirmed: true, backup: prepared.data });
|
||||
}
|
||||
private async data(
|
||||
userId: string,
|
||||
client: Database | Prisma.TransactionClient = this.db,
|
||||
@@ -472,10 +488,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
})),
|
||||
});
|
||||
}
|
||||
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
||||
async download(r: UserRequest, res: Response, expectedFingerprint?: string) {
|
||||
const b = await this.db.$transaction(
|
||||
async (tx) => {
|
||||
const b = await this.data(r.userId, tx);
|
||||
if (expectedFingerprint && this.fingerprint(b) !== expectedFingerprint)
|
||||
throw new ConflictException('账目已变化,请重新确认备份导出');
|
||||
await tx.session.update({
|
||||
where: { id: r.sessionId },
|
||||
data: {
|
||||
@@ -498,17 +516,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
archive.pipe(res);
|
||||
await archive.finalize().catch(() => res.destroy());
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: tmpdir(),
|
||||
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
||||
}),
|
||||
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
|
||||
}),
|
||||
)
|
||||
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
|
||||
|
||||
async upload(r: UserRequest, file?: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('请选择 ZIP 备份文件');
|
||||
try {
|
||||
const b = validateBackup(await this.uploadedData(file.path));
|
||||
@@ -534,7 +543,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.prune();
|
||||
}
|
||||
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
async importFile(r: UserRequest, raw: unknown) {
|
||||
const { token } = z
|
||||
.object({ confirmed: z.literal(true), token: z.string().uuid() })
|
||||
.strict()
|
||||
@@ -566,11 +575,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||
}
|
||||
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
||||
async clearStatus(r: UserRequest) {
|
||||
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
||||
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
|
||||
}
|
||||
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
async clear(r: UserRequest, raw: unknown) {
|
||||
z.object({ confirmation: z.literal('确定清空') })
|
||||
.strict()
|
||||
.parse(raw);
|
||||
@@ -596,7 +605,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||
);
|
||||
}
|
||||
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
async preview(r: UserRequest, raw: unknown) {
|
||||
const b = validateBackup(raw),
|
||||
existing = await this.data(r.userId);
|
||||
for (const i of b.icons || []) await validateStoredIcon(i.image, i.hash);
|
||||
@@ -631,7 +640,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
throw new ConflictException('已有同日汇率与备份冲突,未修改数据');
|
||||
}
|
||||
}
|
||||
@Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
async restore(r: UserRequest, raw: unknown) {
|
||||
const { backup } = z
|
||||
.object({ confirmed: z.literal(true), backup: backupSchema })
|
||||
.strict()
|
||||
@@ -819,3 +828,39 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/backup')
|
||||
export class BackupController {
|
||||
constructor(private service: BackupBusinessService) {}
|
||||
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
||||
return this.service.download(r, res);
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: tmpdir(),
|
||||
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
||||
}),
|
||||
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
|
||||
}),
|
||||
)
|
||||
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
|
||||
return this.service.upload(r, file);
|
||||
}
|
||||
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
return this.service.importFile(r, raw);
|
||||
}
|
||||
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
||||
return this.service.clearStatus(r);
|
||||
}
|
||||
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
return this.service.clear(r, raw);
|
||||
}
|
||||
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
return this.service.preview(r, raw);
|
||||
}
|
||||
@Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
return this.service.restore(r, raw);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import Decimal from 'decimal.js';
|
||||
@@ -24,8 +25,9 @@ export function calendarMonth(value: unknown) {
|
||||
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
|
||||
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
|
||||
}
|
||||
@Controller('api/calendar')
|
||||
export class CalendarController {
|
||||
|
||||
@Injectable()
|
||||
export class CalendarBusinessService {
|
||||
constructor(private db: Database) {}
|
||||
private async replay(
|
||||
tx: Prisma.TransactionClient,
|
||||
@@ -113,7 +115,7 @@ export class CalendarController {
|
||||
}),
|
||||
};
|
||||
}
|
||||
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
||||
async month(r: UserRequest, input?: string) {
|
||||
const q = calendarMonth(input);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
@@ -144,7 +146,7 @@ export class CalendarController {
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
||||
async day(r: UserRequest, input?: string) {
|
||||
const day = date.parse(input || today());
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
@@ -160,3 +162,14 @@ export class CalendarController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/calendar')
|
||||
export class CalendarController {
|
||||
constructor(private service: CalendarBusinessService) {}
|
||||
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
||||
return this.service.month(r, input);
|
||||
}
|
||||
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
||||
return this.service.day(r, input);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
const transactions = new AsyncLocalStorage<Prisma.TransactionClient>();
|
||||
@Injectable()
|
||||
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
constructor() {
|
||||
super();
|
||||
return new Proxy(this, {
|
||||
get(target, key, receiver) {
|
||||
const tx = transactions.getStore();
|
||||
if (tx && key === '$transaction')
|
||||
return (work: any) => (typeof work === 'function' ? work(tx) : Promise.all(work));
|
||||
if (tx && key in tx && key !== '$disconnect' && key !== '$connect') {
|
||||
const value = (tx as any)[key];
|
||||
return typeof value === 'function' ? value.bind(tx) : value;
|
||||
}
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
async atomic<T>(work: () => Promise<T>, timeout = 30000): Promise<T> {
|
||||
return this.serial((tx) => transactions.run(tx, work), timeout);
|
||||
}
|
||||
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>, timeout = 30000): Promise<T> {
|
||||
const current = transactions.getStore();
|
||||
if (current) return work(current);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await this.$transaction(work, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
timeout: 30000,
|
||||
timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
||||
|
||||
+36
-20
@@ -76,10 +76,11 @@ export class IconsService {
|
||||
throw new BadRequestException('图标不存在或无权使用');
|
||||
}
|
||||
}
|
||||
@Controller('api/icons')
|
||||
export class IconsController {
|
||||
|
||||
@Injectable()
|
||||
export class IconsBusinessService {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
||||
async list(r: UserRequest, q = '', page = '1') {
|
||||
const query = z.string().trim().max(100).parse(q);
|
||||
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
||||
const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } };
|
||||
@@ -95,11 +96,7 @@ export class IconsController {
|
||||
]);
|
||||
return { items, total, page: index };
|
||||
}
|
||||
@Get(':id/image') async image(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
async image(r: UserRequest, id: string, res: Response) {
|
||||
const icon = await this.db.icon.findFirst({
|
||||
where: { id, OR: [{ shared: true }, { ownerId: r.userId }] },
|
||||
});
|
||||
@@ -110,18 +107,8 @@ export class IconsController {
|
||||
// Preserve essential white artwork rather than applying the cutout twice.
|
||||
res.send(Buffer.from(icon.data));
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@Req() r: UserRequest,
|
||||
@Body() raw: unknown,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
|
||||
async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) {
|
||||
const v = z
|
||||
.object({
|
||||
name: iconName,
|
||||
@@ -145,3 +132,32 @@ export class IconsController {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/icons')
|
||||
export class IconsController {
|
||||
constructor(private service: IconsBusinessService) {}
|
||||
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
||||
return this.service.list(r, q, page);
|
||||
}
|
||||
@Get(':id/image') async image(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
return this.service.image(r, id, res);
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@Req() r: UserRequest,
|
||||
@Body() raw: unknown,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
return this.service.upload(r, raw, file);
|
||||
}
|
||||
}
|
||||
+31
-9
@@ -6,18 +6,24 @@ import { NestFactory, APP_GUARD } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { json } from 'express';
|
||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
||||
import { CalendarController } from './calendar';
|
||||
import { SchedulesController } from './schedules';
|
||||
import { TransfersController } from './transfers';
|
||||
import { PortfolioController } from './portfolio';
|
||||
import { BackupController } from './backup';
|
||||
import { IconsController, IconsService } from './icons';
|
||||
import { MetalsService, MetalsController } from './metals';
|
||||
import { AuthController, AuthBusinessService, AuthGuard, AuthService } from './auth';
|
||||
import { CalendarController, CalendarBusinessService } from './calendar';
|
||||
import { SchedulesController, SchedulesBusinessService } from './schedules';
|
||||
import { TransfersController, TransfersBusinessService } from './transfers';
|
||||
import { PortfolioController, PortfolioBusinessService } from './portfolio';
|
||||
import { BackupController, BackupBusinessService } from './backup';
|
||||
import { IconsController, IconsBusinessService, IconsService } from './icons';
|
||||
import { MetalsService, MetalsController, MetalsBusinessService } from './metals';
|
||||
import { Database } from './database';
|
||||
import { RatesService, SettingsController } from './rates';
|
||||
import { RatesService, SettingsController, SettingsBusinessService } from './rates';
|
||||
import { ZodError } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AgentOAuth } from './mcp/oauth';
|
||||
import { AgentCatalogue } from './mcp/catalogue';
|
||||
import { AgentOperations } from './mcp/operations';
|
||||
import { AgentFiles } from './mcp/files';
|
||||
import { AgentTransport } from './mcp/transport';
|
||||
import { AgentManagementController } from './mcp/management';
|
||||
@Catch()
|
||||
class SafeErrors implements ExceptionFilter {
|
||||
catch(error: unknown, host: ArgumentsHost) {
|
||||
@@ -53,6 +59,20 @@ class SafeErrors implements ExceptionFilter {
|
||||
RatesService,
|
||||
MetalsService,
|
||||
IconsService,
|
||||
AuthBusinessService,
|
||||
CalendarBusinessService,
|
||||
SchedulesBusinessService,
|
||||
TransfersBusinessService,
|
||||
PortfolioBusinessService,
|
||||
BackupBusinessService,
|
||||
IconsBusinessService,
|
||||
MetalsBusinessService,
|
||||
SettingsBusinessService,
|
||||
AgentOAuth,
|
||||
AgentCatalogue,
|
||||
AgentOperations,
|
||||
AgentFiles,
|
||||
AgentTransport,
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
],
|
||||
controllers: [
|
||||
@@ -65,6 +85,7 @@ class SafeErrors implements ExceptionFilter {
|
||||
MetalsController,
|
||||
SettingsController,
|
||||
BackupController,
|
||||
AgentManagementController,
|
||||
],
|
||||
})
|
||||
class AppModule {}
|
||||
@@ -83,6 +104,7 @@ async function bootstrap() {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
app.get(AgentTransport).install(app.getHttpAdapter().getInstance());
|
||||
app.useGlobalFilters(new SafeErrors());
|
||||
setupOpenApi(app);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { UserRequest } from '../auth';
|
||||
import { PortfolioBusinessService } from '../portfolio';
|
||||
import { TransfersBusinessService } from '../transfers';
|
||||
import { SchedulesBusinessService, scheduleInput } from '../schedules';
|
||||
import { CalendarBusinessService } from '../calendar';
|
||||
import { SettingsBusinessService } from '../rates';
|
||||
import { MetalsBusinessService, metalConfig, metalPriceInput } from '../metals';
|
||||
import { IconsBusinessService } from '../icons';
|
||||
import { BackupBusinessService } from '../backup';
|
||||
import {
|
||||
positionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
transferInput,
|
||||
settingsInput,
|
||||
date,
|
||||
currencies,
|
||||
} from '../validation';
|
||||
|
||||
const id = z.string().uuid().describe('稳定对象 UUID;名称重名时先搜索,再由用户选择 ID');
|
||||
export const empty = z.object({}).strict();
|
||||
const pageFields = {
|
||||
limit: z.number().int().min(1).max(100).default(50).describe('单页上限 100'),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(1000000)
|
||||
.default(0)
|
||||
.describe('列表偏移;数据变化后从第一页重读'),
|
||||
};
|
||||
const historyFields = {
|
||||
limit: pageFields.limit,
|
||||
cursor: z.string().max(512).optional().describe('上一页 nextCursor'),
|
||||
from: date.optional(),
|
||||
to: date.optional(),
|
||||
};
|
||||
const json = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
export function page<T>(items: T[], p: { limit: number; offset: number }) {
|
||||
return {
|
||||
items: items.slice(p.offset, p.offset + p.limit),
|
||||
total: items.length,
|
||||
nextOffset: p.offset + p.limit < items.length ? p.offset + p.limit : null,
|
||||
};
|
||||
}
|
||||
export type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
schema: z.ZodObject<any>;
|
||||
scope: 'read' | 'write' | 'sensitive';
|
||||
destructive?: boolean;
|
||||
web?: 'credentials' | 'reveal' | 'clear';
|
||||
run?: (r: UserRequest, p: any) => Promise<unknown>;
|
||||
};
|
||||
@Injectable()
|
||||
export class AgentCatalogue {
|
||||
readonly tools: ToolDefinition[];
|
||||
constructor(
|
||||
portfolio: PortfolioBusinessService,
|
||||
transfers: TransfersBusinessService,
|
||||
schedules: SchedulesBusinessService,
|
||||
calendar: CalendarBusinessService,
|
||||
settings: SettingsBusinessService,
|
||||
metals: MetalsBusinessService,
|
||||
icons: IconsBusinessService,
|
||||
backup: BackupBusinessService,
|
||||
) {
|
||||
const read = (
|
||||
name: string,
|
||||
description: string,
|
||||
schema: z.ZodObject<any>,
|
||||
run: ToolDefinition['run'],
|
||||
): ToolDefinition => ({ name, description, schema, scope: 'read', run });
|
||||
const write = (
|
||||
name: string,
|
||||
description: string,
|
||||
schema: z.ZodObject<any>,
|
||||
run: ToolDefinition['run'],
|
||||
destructive = false,
|
||||
): ToolDefinition => ({ name, description, schema, scope: 'write', run, destructive });
|
||||
this.tools = [
|
||||
read(
|
||||
'positions_list',
|
||||
'查询账户、独立资产、借入借出债务。原币余额字符串;负债账户正数为欠款、负数为溢缴。支持名称搜索,重名不自动选择。',
|
||||
z
|
||||
.object({
|
||||
...pageFields,
|
||||
kind: z.enum(['account', 'asset', 'debt']).optional(),
|
||||
q: z.string().max(100).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
side: z.enum(['asset', 'liability']).optional(),
|
||||
currency: z.enum(currencies).optional(),
|
||||
groupName: z.string().max(60).optional(),
|
||||
order: z.enum(['name', 'id']).default('id'),
|
||||
})
|
||||
.strict(),
|
||||
async (r, p) => {
|
||||
const rows = (await portfolio.list(r, p.kind)).filter(
|
||||
(v) =>
|
||||
(!p.q || v.name.includes(p.q)) &&
|
||||
(p.archived === undefined || p.archived === v.archived) &&
|
||||
(!p.side || p.side === v.side) &&
|
||||
(!p.currency || p.currency === v.currency) &&
|
||||
(p.groupName === undefined || p.groupName === v.groupName),
|
||||
);
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
(p.order === 'name' ? a.name.localeCompare(b.name) : 0) || a.id.localeCompare(b.id),
|
||||
);
|
||||
return page(rows, p);
|
||||
},
|
||||
),
|
||||
read(
|
||||
'position_get',
|
||||
'查询项目详情、配置和当前原币余额。隐藏项目需网页解锁。',
|
||||
z.object({ id }).strict(),
|
||||
(r, p) => portfolio.detail(r, p.id),
|
||||
),
|
||||
write(
|
||||
'position_create',
|
||||
'新增账户、独立资产或债务及初始绝对余额。金额为十进制字符串,业务 date 为 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm(UTC+8)。不新增日常消费功能。',
|
||||
positionInput,
|
||||
(r, p) => portfolio.create(r, p),
|
||||
),
|
||||
write(
|
||||
'position_update',
|
||||
'修改名称、分类、分组、图标、备注、归档、隐藏、统计开关;币种和资产负债属性固定。须提供完整元数据。',
|
||||
z.object({ id, data: positionMeta }).strict(),
|
||||
(r, p) => portfolio.edit(r, p.id, p.data),
|
||||
),
|
||||
write(
|
||||
'balance_record',
|
||||
'新增余额/估值/负债调整/单边还款历史:amount 是变更后的绝对余额,不是增量。资产估值使用 valuation;配对转账和还款使用 movement_create。',
|
||||
z.object({ id, data: revisionInput }).strict(),
|
||||
(r, p) => portfolio.revise(r, p.id, p.data),
|
||||
),
|
||||
read(
|
||||
'history_list',
|
||||
'分页查询全局或单项目历史,业务时间降序;返回真实前序余额。',
|
||||
z.object({ ...historyFields, positionId: id.optional() }).strict(),
|
||||
(r, p) => portfolio.history(r, p),
|
||||
),
|
||||
write(
|
||||
'history_update',
|
||||
'更正单条非配对历史,自动重放后续金额;配对记录使用 movement_update。',
|
||||
z.object({ id, revisionId: id, data: revisionInput }).strict(),
|
||||
(r, p) => portfolio.correct(r, p.id, p.revisionId, p.data),
|
||||
),
|
||||
write(
|
||||
'history_delete',
|
||||
'删除历史并重算;配对历史会撤销完整双边操作。',
|
||||
z.object({ id, revisionId: id }).strict(),
|
||||
(r, p) => portfolio.deleteRevision(r, p.id, p.revisionId),
|
||||
true,
|
||||
),
|
||||
write(
|
||||
'debt_links_set',
|
||||
'替换债务与自己账户/资产的关联,不参与金额求和。',
|
||||
z.object({ id, targetIds: z.array(id).max(20) }).strict(),
|
||||
(r, p) => portfolio.link(r, p.id, { targetIds: p.targetIds }),
|
||||
),
|
||||
read(
|
||||
'overview_get',
|
||||
'净资产、资产负债总额、结构及变化归因;遵循逐项目和独立资产纳入设置。明细分页。',
|
||||
z.object(pageFields).strict(),
|
||||
async (r, p) => {
|
||||
const v = await portfolio.overview(r);
|
||||
return { ...v, ...page(v.items, p) };
|
||||
},
|
||||
),
|
||||
read(
|
||||
'trend_get',
|
||||
'净资产趋势,缺失汇率不绘制完整总额。业务日 YYYY-MM-DD;支持日/周/月粒度。',
|
||||
z
|
||||
.object({
|
||||
from: date.optional(),
|
||||
to: date.optional(),
|
||||
grain: z.enum(['day', 'week', 'month']).optional(),
|
||||
})
|
||||
.strict(),
|
||||
(r, p) => portfolio.trend(r, p),
|
||||
),
|
||||
read(
|
||||
'movements_list',
|
||||
'查询转账、借入、借出、收款、还款配对记录,分页和业务日期筛选。',
|
||||
z.object(historyFields).strict(),
|
||||
(r, p) => transfers.list(r, p),
|
||||
),
|
||||
read(
|
||||
'movement_by_revision',
|
||||
'根据历史 UUID 找到对应双边记录。',
|
||||
z.object({ revisionId: id }).strict(),
|
||||
(r, p) => transfers.byRevision(r, p.revisionId),
|
||||
),
|
||||
write(
|
||||
'movement_create',
|
||||
'执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。',
|
||||
transferInput.safeExtend({ requestId: z.never().optional() }),
|
||||
(r, p) => transfers.create(r, p),
|
||||
),
|
||||
write(
|
||||
'movement_update',
|
||||
'修改完整双边资金往来并重放后续历史,保持余额一致。',
|
||||
z
|
||||
.object({ id, data: transferInput.safeExtend({ requestId: z.never().optional() }) })
|
||||
.strict(),
|
||||
(r, p) => transfers.edit(r, p.id, p.data),
|
||||
),
|
||||
write(
|
||||
'movement_delete',
|
||||
'撤销完整双边转账/借贷/还款并重算后续余额。',
|
||||
z.object({ id }).strict(),
|
||||
(r, p) => transfers.remove(r, p.id),
|
||||
true,
|
||||
),
|
||||
read(
|
||||
'calendar_month',
|
||||
'月日历现金流统计;YYYY-MM,时区 UTC+8。',
|
||||
z
|
||||
.object({
|
||||
month: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
(r, p) => calendar.month(r, p.month),
|
||||
),
|
||||
read(
|
||||
'calendar_day',
|
||||
'查询某业务日的收入支出变化明细,分页。',
|
||||
z.object({ ...pageFields, date: date.optional() }).strict(),
|
||||
async (r, p) => {
|
||||
const v = await calendar.day(r, p.date);
|
||||
return { ...v, ...page(v.items, p) };
|
||||
},
|
||||
),
|
||||
read(
|
||||
'schedules_list',
|
||||
'计划列表及启用、完成状态,按到期时间排序。执行记录也可在 history_list 查询。',
|
||||
z.object(pageFields).strict(),
|
||||
async (r, p) => page(await schedules.list(r), p),
|
||||
),
|
||||
write(
|
||||
'schedule_create',
|
||||
'新增现有支出或转账计划。nextAt 为 YYYY-MM-DDTHH:mm(UTC+8),intervalDays=0 表示一次。',
|
||||
scheduleInput,
|
||||
(r, p) => schedules.create(r, p),
|
||||
),
|
||||
write(
|
||||
'schedule_update',
|
||||
'修改未完成计划的完整配置。',
|
||||
z.object({ id, data: scheduleInput }).strict(),
|
||||
(r, p) => schedules.edit(r, p.id, p.data),
|
||||
),
|
||||
write(
|
||||
'schedule_toggle',
|
||||
'启用或停用计划;已完成一次性计划不可重启。',
|
||||
z.object({ id, enabled: z.boolean() }).strict(),
|
||||
(r, p) => schedules.toggle(r, p.id, { enabled: p.enabled }),
|
||||
),
|
||||
write(
|
||||
'schedule_delete',
|
||||
'删除计划;保留已完成的金额历史。',
|
||||
z.object({ id }).strict(),
|
||||
(r, p) => schedules.remove(r, p.id),
|
||||
true,
|
||||
),
|
||||
write(
|
||||
'schedules_run',
|
||||
'以事务执行最多 20 个已到期计划,任一失败回滚本批;可按 hasMore 分批继续。',
|
||||
empty,
|
||||
(r) => schedules.run(r, true),
|
||||
),
|
||||
read(
|
||||
'settings_get',
|
||||
'个人资料、本位币、显示菜单、备注、隐私、总览卡片、账户分组顺序及汇率状态;不返回密码或令牌。',
|
||||
empty,
|
||||
(r) => settings.settings(r, 'true'),
|
||||
),
|
||||
write(
|
||||
'settings_update',
|
||||
'修改个人设置、本位币、分组排序、登录时长及纳入统计配置。隐私设置变更需网页确认。',
|
||||
settingsInput,
|
||||
(r, p) => settings.update(r, p, undefined as any),
|
||||
),
|
||||
write('rates_refresh', '重试公共日汇率更新,失败保留原币和历史汇率。', empty, (r) =>
|
||||
settings.refresh(r),
|
||||
),
|
||||
read('metals_prices', '最近 100 条金银每克报价和更新状态。', empty, (r) => metals.list(r)),
|
||||
write('metals_refresh', '刷新贵金属报价并沿用现有自动估值规则。', empty, (r) =>
|
||||
metals.refresh(r),
|
||||
),
|
||||
write(
|
||||
'metal_price_set',
|
||||
'设置指定日期、币种、品种每克价格;price 十进制字符串,可能触发自动估值历史。',
|
||||
metalPriceInput,
|
||||
(r, p) => metals.manual(r, p),
|
||||
),
|
||||
write(
|
||||
'metal_configure',
|
||||
'设置金银重量、纯度和自动估值,复用现有估值规则。',
|
||||
z.object({ id, data: metalConfig }).strict(),
|
||||
(r, p) => metals.configure(r, p.id, p.data),
|
||||
),
|
||||
write(
|
||||
'metal_value',
|
||||
'按已有报价为指定贵金属资产生成估值历史。',
|
||||
z.object({ id }).strict(),
|
||||
(r, p) => metals.value(r, p.id),
|
||||
),
|
||||
read(
|
||||
'icons_list',
|
||||
'搜索私有或共享图标,固定单页 60 个;使用返回 ID 配置项目图标。',
|
||||
z
|
||||
.object({
|
||||
q: z.string().max(100).default(''),
|
||||
page: z.number().int().min(1).max(100000).default(1),
|
||||
})
|
||||
.strict(),
|
||||
(r, p) => icons.list(r, p.q, String(p.page)),
|
||||
),
|
||||
{
|
||||
name: 'backup_import',
|
||||
description:
|
||||
'提交已上传备份的追加恢复。先 file_upload_request → 上传 → import_preview;强制网页展示影响并确认,事务失败不保留部分账目。',
|
||||
schema: z.object({ token: id }).strict(),
|
||||
scope: 'sensitive',
|
||||
destructive: true,
|
||||
run: (r, p) => backup.restoreUpload(r, p.token),
|
||||
},
|
||||
read(
|
||||
'import_preview',
|
||||
'预检已上传备份并显示追加影响、冲突和条数。',
|
||||
z.object({ token: id }).strict(),
|
||||
async (r, p) => (await backup.inspectUpload(r, p.token)).preview,
|
||||
),
|
||||
{
|
||||
name: 'credentials_change_request',
|
||||
description:
|
||||
'发起账号或密码修改,返回网页入口。当前密码及新密码仅在网页输入,不传给 Agent。完成后 operation_get 查询结果。',
|
||||
schema: empty,
|
||||
scope: 'sensitive',
|
||||
web: 'credentials',
|
||||
},
|
||||
{
|
||||
name: 'hidden_unlock_request',
|
||||
description:
|
||||
'发起隐藏项目解锁。网页用户验证密码后本连接解锁 5 分钟;operation_get 查询结果。',
|
||||
schema: empty,
|
||||
scope: 'sensitive',
|
||||
web: 'reveal',
|
||||
},
|
||||
{
|
||||
name: 'data_clear_request',
|
||||
description:
|
||||
'发起清空本账号财务数据。网页须先下载当前备份、验证密码并输入“确定清空”,展示数量;账号保留。',
|
||||
schema: empty,
|
||||
scope: 'sensitive',
|
||||
web: 'clear',
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
get(name: string) {
|
||||
return this.tools.find((t) => t.name === name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Request, Response, Express } from 'express';
|
||||
import multer, { diskStorage, memoryStorage } from 'multer';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import { AgentOAuth, urls } from './oauth';
|
||||
import { UserRequest } from '../auth';
|
||||
import { BackupBusinessService } from '../backup';
|
||||
import { IconsBusinessService } from '../icons';
|
||||
import { MAX_UPLOAD_BYTES } from '../zip';
|
||||
import { Database } from '../database';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
|
||||
type Ticket = {
|
||||
userId: string;
|
||||
grantId: string;
|
||||
expires: number;
|
||||
kind: 'backup' | 'icon' | 'download' | 'image';
|
||||
snapshot?: string;
|
||||
iconId?: string;
|
||||
buffer?: Buffer;
|
||||
name?: string;
|
||||
preview?: unknown;
|
||||
token?: string;
|
||||
};
|
||||
@Injectable()
|
||||
export class AgentFiles implements OnModuleDestroy {
|
||||
private tickets = new Map<string, Ticket>();
|
||||
private timer = setInterval(() => {
|
||||
for (const [id, t] of this.tickets) if (t.expires < Date.now()) this.tickets.delete(id);
|
||||
}, 60000).unref();
|
||||
constructor(
|
||||
private oauth: AgentOAuth,
|
||||
private backup: BackupBusinessService,
|
||||
private icons: IconsBusinessService,
|
||||
private db: Database,
|
||||
) {}
|
||||
onModuleDestroy() {
|
||||
clearInterval(this.timer);
|
||||
this.tickets.clear();
|
||||
}
|
||||
async issue(r: UserRequest, grantId: string, kind: Ticket['kind'], iconId?: string) {
|
||||
if (
|
||||
this.tickets.size >= 1000 ||
|
||||
[...this.tickets.values()].filter((t) => t.userId === r.userId).length >= 20
|
||||
)
|
||||
throw new BadRequestException('文件请求过多,请等待过期');
|
||||
const id = randomUUID();
|
||||
this.tickets.set(id, {
|
||||
userId: r.userId,
|
||||
grantId,
|
||||
kind,
|
||||
iconId,
|
||||
expires: Date.now() + 600000,
|
||||
...(kind === 'download' ? { snapshot: await this.backup.snapshot(r.userId) } : {}),
|
||||
});
|
||||
return {
|
||||
fileId: id,
|
||||
url: new URL('/api/agent/files/' + id, urls().issuer).toString(),
|
||||
method: kind === 'download' || kind === 'image' ? 'GET' : 'POST',
|
||||
headers: { Authorization: 'Bearer <your access token>' },
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
maxBytes: kind === 'backup' ? MAX_UPLOAD_BYTES : 2 * 1024 * 1024,
|
||||
format:
|
||||
kind === 'backup'
|
||||
? 'multipart/form-data; field file; WorthPath ZIP/JSON'
|
||||
: 'multipart/form-data; field file; image',
|
||||
};
|
||||
}
|
||||
private ticket(r: UserRequest, grantId: string, id: string) {
|
||||
const t = this.tickets.get(id);
|
||||
if (!t || t.userId !== r.userId || t.grantId !== grantId || t.expires < Date.now())
|
||||
throw new ForbiddenException('文件入口已失效或不属于此连接');
|
||||
return t;
|
||||
}
|
||||
async publishIcon(r: UserRequest, grantId: string, id: string, name: string, shared: boolean) {
|
||||
const t = this.ticket(r, grantId, id);
|
||||
if (t.kind !== 'icon' || !t.buffer) throw new BadRequestException('请先上传图标');
|
||||
return this.icons.upload(
|
||||
r,
|
||||
{ name, shared: String(shared), ...(shared ? { confirmed: 'true' } : {}) },
|
||||
{ buffer: t.buffer } as Express.Multer.File,
|
||||
);
|
||||
}
|
||||
async inspect(r: UserRequest, grantId: string, id: string) {
|
||||
const t = this.ticket(r, grantId, id);
|
||||
return {
|
||||
fileId: id,
|
||||
uploaded: !!t.buffer || !!t.token,
|
||||
token: t.token,
|
||||
preview: t.preview,
|
||||
expiresAt: new Date(t.expires).toISOString(),
|
||||
};
|
||||
}
|
||||
async context(req: Request) {
|
||||
const match = /^Bearer ([A-Za-z0-9_-]+)$/.exec(req.headers.authorization || '');
|
||||
if (!match) throw new UnauthorizedException('Bearer token required');
|
||||
const auth = await this.oauth.verifyAccessToken(match[1]),
|
||||
grant = await this.oauth.grant(String(auth.extra.grantId));
|
||||
return {
|
||||
grant,
|
||||
r: {
|
||||
...req,
|
||||
userId: grant.userId,
|
||||
sessionId: grant.sessionId,
|
||||
revealed: false,
|
||||
agent: true,
|
||||
} as UserRequest,
|
||||
};
|
||||
}
|
||||
install(app: Express) {
|
||||
const disk = multer({
|
||||
storage: diskStorage({
|
||||
destination: tmpdir(),
|
||||
filename: (_r, _f, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
||||
}),
|
||||
limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, fields: 0 },
|
||||
}).single('file');
|
||||
const memory = multer({
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
|
||||
}).single('file');
|
||||
app.all('/api/agent/files/:id', async (req, res) => {
|
||||
try {
|
||||
const { grant, r } = await this.context(req);
|
||||
const t = this.ticket(r, grant.id, String(req.params.id));
|
||||
if (!['GET', 'POST'].includes(req.method)) {
|
||||
res.status(405).end();
|
||||
return;
|
||||
}
|
||||
if ((req.method === 'POST') !== (t.kind === 'backup' || t.kind === 'icon')) {
|
||||
res.status(405).end();
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
if (t.kind === 'download') await this.backup.download(r, res, t.snapshot);
|
||||
else await this.icons.image(r, t.iconId!, res);
|
||||
return;
|
||||
}
|
||||
const selected = grant.scopes as string[];
|
||||
if (!selected.includes('draft') && !selected.includes('write'))
|
||||
throw new ForbiddenException('上传需要 draft 或 write 权限');
|
||||
if (
|
||||
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
|
||||
'readonly'
|
||||
)
|
||||
throw new ForbiddenException('当前策略为只读');
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
(t.kind === 'backup' ? disk : memory)(req, res, (e) => (e ? reject(e) : resolve())),
|
||||
);
|
||||
if (!req.file) throw new BadRequestException('请选择文件');
|
||||
try {
|
||||
if (t.kind === 'backup') {
|
||||
const v = await this.backup.upload(r, req.file);
|
||||
t.token = v.token;
|
||||
t.preview = v;
|
||||
} else t.buffer = req.file.buffer;
|
||||
res.json(await this.inspect(r, grant.id, String(req.params.id)));
|
||||
} catch (e) {
|
||||
if (req.file.path) await unlink(req.file.path).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!res.headersSent)
|
||||
res
|
||||
.status(
|
||||
e instanceof HttpException
|
||||
? e.getStatus()
|
||||
: e instanceof InvalidTokenError
|
||||
? 401
|
||||
: 400,
|
||||
)
|
||||
.json({ message: e instanceof HttpException ? e.message : '文件操作失败或认证已失效' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Req,
|
||||
Param,
|
||||
Body,
|
||||
Res,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { compare } from 'bcryptjs';
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Database } from '../database';
|
||||
import { AuthService, UserRequest } from '../auth';
|
||||
import { AgentOAuth, urls, scopeInput } from './oauth';
|
||||
import { AgentOperations } from './operations';
|
||||
@Controller('api/agent')
|
||||
export class AgentManagementController {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private oauth: AgentOAuth,
|
||||
private operations: AgentOperations,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
const grants = await this.db.agentGrant.findMany({
|
||||
where: { userId: r.userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
scopes: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
clientId: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const operations = await this.db.agentOperation.findMany({
|
||||
where: { userId: r.userId },
|
||||
select: {
|
||||
id: true,
|
||||
tool: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const calls = await this.db.agentCall.findMany({
|
||||
where: { userId: r.userId },
|
||||
select: { id: true, tool: true, status: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return {
|
||||
mcpUrl: urls().resource.toString(),
|
||||
mode:
|
||||
(await this.db.agentPolicy.findUnique({ where: { userId: r.userId } }))?.mode || 'draft',
|
||||
grants,
|
||||
operations,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
@Put('policy') async policy(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
const { mode, password } = z
|
||||
.object({ mode: z.enum(['readonly', 'draft', 'direct']), password: z.string().max(72) })
|
||||
.strict()
|
||||
.parse(raw);
|
||||
this.auth.limit(r);
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
await this.db.agentPolicy.upsert({
|
||||
where: { userId: r.userId },
|
||||
create: { userId: r.userId, mode },
|
||||
update: { mode },
|
||||
});
|
||||
return { mode };
|
||||
}
|
||||
@Post('tokens') async token(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
const p = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
scopes: scopeInput,
|
||||
days: z.number().int().min(1).max(90),
|
||||
password: z.string().max(72),
|
||||
})
|
||||
.strict()
|
||||
.parse(raw);
|
||||
this.auth.limit(r);
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(p.password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
return this.db.atomic(async () => {
|
||||
const v = await this.oauth.issue(r.userId, p.name, p.scopes, p.days);
|
||||
return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt };
|
||||
});
|
||||
}
|
||||
@Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
z.string().uuid().parse(id);
|
||||
await this.db.agentGrant.updateMany({
|
||||
where: { id, userId: r.userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Get('authorizations/:id') pending(@Param('id') id: string) {
|
||||
return this.oauth.pending(z.string().uuid().parse(id));
|
||||
}
|
||||
@Post('authorizations/:id') async consent(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() raw: unknown,
|
||||
) {
|
||||
const { approve } = z.object({ approve: z.boolean() }).strict().parse(raw);
|
||||
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve);
|
||||
}
|
||||
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.operations.preview(r.userId, z.string().uuid().parse(id));
|
||||
}
|
||||
@Post('operations/:id') async confirm(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() raw: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
this.auth.limit(r);
|
||||
z.string().uuid().parse(id);
|
||||
try {
|
||||
return await this.operations.confirm(r, id, raw, res);
|
||||
} catch (e) {
|
||||
const row = await this.db.agentOperation.findFirst({
|
||||
where: { id, userId: r.userId, status: 'pending' },
|
||||
});
|
||||
if (row) {
|
||||
await this.db.agentOperation.updateMany({
|
||||
where: { id, userId: r.userId, status: 'pending' },
|
||||
data: {
|
||||
result: {
|
||||
status: 'submission_failed',
|
||||
message:
|
||||
e instanceof HttpException ? e.message : '提交失败,账目已回滚;可重试或取消',
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.db.agentCall.create({
|
||||
data: { userId: r.userId, grantId: row.grantId, tool: row.tool, status: 'error' },
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Database } from '../database';
|
||||
import {
|
||||
OAuthServerProvider,
|
||||
AuthorizationParams,
|
||||
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import {
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens,
|
||||
OAuthTokenRevocationRequest,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import {
|
||||
InvalidClientMetadataError,
|
||||
InvalidGrantError,
|
||||
InvalidScopeError,
|
||||
InvalidTokenError,
|
||||
InvalidTargetError,
|
||||
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
|
||||
export const scopes = ['read', 'draft', 'write', 'sensitive'] as const;
|
||||
export const scopeInput = z
|
||||
.array(z.enum(scopes))
|
||||
.min(1)
|
||||
.max(4)
|
||||
.refine((v) => v.includes('read') && new Set(v).size === v.length);
|
||||
export const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
const secret = () => randomBytes(32).toString('base64url');
|
||||
export function urls() {
|
||||
const resource = new URL(process.env.MCP_PUBLIC_URL || 'http://localhost:3100/mcp');
|
||||
if (
|
||||
resource.pathname !== '/mcp' ||
|
||||
resource.search ||
|
||||
resource.hash ||
|
||||
resource.username ||
|
||||
resource.password
|
||||
)
|
||||
throw Error('MCP_PUBLIC_URL must be the canonical /mcp URL');
|
||||
if (
|
||||
resource.protocol !== 'https:' &&
|
||||
!(
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
['localhost', '127.0.0.1', '[::1]'].includes(resource.hostname)
|
||||
)
|
||||
)
|
||||
throw Error('MCP requires HTTPS except local development');
|
||||
const web = new URL(process.env.MCP_WEB_URL || 'http://localhost:5173');
|
||||
if (
|
||||
web.protocol !== 'https:' &&
|
||||
!(process.env.NODE_ENV !== 'production' && ['localhost', '127.0.0.1'].includes(web.hostname))
|
||||
)
|
||||
throw Error('MCP web confirmation requires HTTPS');
|
||||
return { resource, issuer: new URL(resource.origin), web };
|
||||
}
|
||||
export function webLink(key: string, id: string) {
|
||||
const u = new URL(urls().web);
|
||||
u.searchParams.set(key, id);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentOAuth implements OAuthServerProvider {
|
||||
constructor(private db: Database) {}
|
||||
get clientsStore() {
|
||||
return {
|
||||
getClient: async (id: string) => {
|
||||
const row = await this.db.agentClient.findUnique({ where: { id } });
|
||||
return row?.metadata as OAuthClientInformationFull | undefined;
|
||||
},
|
||||
registerClient: async (
|
||||
input: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||
) => {
|
||||
if (input.token_endpoint_auth_method !== 'none')
|
||||
throw new InvalidClientMetadataError('Only public PKCE clients are supported');
|
||||
if (!input.redirect_uris.length || input.redirect_uris.length > 10)
|
||||
throw new InvalidClientMetadataError('Invalid redirect URIs');
|
||||
for (const value of input.redirect_uris) {
|
||||
const u = new URL(value);
|
||||
if (
|
||||
u.hash ||
|
||||
u.username ||
|
||||
u.password ||
|
||||
!(
|
||||
u.protocol === 'https:' ||
|
||||
(u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname))
|
||||
)
|
||||
)
|
||||
throw new InvalidClientMetadataError('HTTPS or loopback redirect required');
|
||||
}
|
||||
if ((await this.db.agentClient.count()) >= 10000)
|
||||
throw new InvalidClientMetadataError('Client registration limit reached');
|
||||
const client = {
|
||||
...input,
|
||||
client_name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.parse(input.client_name || 'MCP client'),
|
||||
client_id: randomUUID(),
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await this.db.agentClient.create({
|
||||
data: { id: client.client_id, metadata: JSON.parse(JSON.stringify(client)) },
|
||||
});
|
||||
return client;
|
||||
},
|
||||
};
|
||||
}
|
||||
private resource(resource?: URL) {
|
||||
if (resource?.toString() !== urls().resource.toString())
|
||||
throw new InvalidTargetError('WorthPath resource is required');
|
||||
}
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response) {
|
||||
this.resource(params.resource);
|
||||
const selected = params.scopes?.length ? params.scopes : ['read'];
|
||||
if (!scopeInput.safeParse(selected).success) throw new InvalidScopeError('Unsupported scope');
|
||||
const row = await this.db.agentAuthorization.create({
|
||||
data: {
|
||||
clientId: client.client_id,
|
||||
parameters: JSON.parse(
|
||||
JSON.stringify({ ...params, resource: params.resource!.toString(), scopes: selected }),
|
||||
),
|
||||
expiresAt: new Date(Date.now() + 600000),
|
||||
},
|
||||
});
|
||||
res.redirect(webLink('agent_authorization', row.id));
|
||||
}
|
||||
async pending(id: string) {
|
||||
const row = await this.db.agentAuthorization.findUnique({ where: { id } });
|
||||
if (!row || row.status !== 'pending' || row.expiresAt <= new Date())
|
||||
throw new BadRequestException('授权请求已失效');
|
||||
const client = await this.clientsStore.getClient(row.clientId);
|
||||
const p = row.parameters as any;
|
||||
return {
|
||||
id,
|
||||
name: client?.client_name,
|
||||
redirectUri: p.redirectUri,
|
||||
scopes: p.scopes,
|
||||
resource: p.resource,
|
||||
};
|
||||
}
|
||||
async consent(userId: string, id: string, approved: boolean) {
|
||||
return this.db.atomic(async () => {
|
||||
await this.pending(id);
|
||||
const row = await this.db.agentAuthorization.findUniqueOrThrow({ where: { id } });
|
||||
const code = secret();
|
||||
const changed = await this.db.agentAuthorization.updateMany({
|
||||
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
|
||||
data: {
|
||||
userId,
|
||||
status: approved ? 'approved' : 'denied',
|
||||
codeDigest: approved ? digest(code) : null,
|
||||
},
|
||||
});
|
||||
if (!changed.count) throw new BadRequestException('授权请求已处理');
|
||||
const p = row.parameters as any,
|
||||
callback = new URL(p.redirectUri);
|
||||
callback.searchParams.set(approved ? 'code' : 'error', approved ? code : 'access_denied');
|
||||
if (p.state) callback.searchParams.set('state', p.state);
|
||||
return { redirect: callback.toString() };
|
||||
});
|
||||
}
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, code: string) {
|
||||
const row = await this.db.agentAuthorization.findUnique({
|
||||
where: { codeDigest: digest(code) },
|
||||
});
|
||||
if (
|
||||
!row ||
|
||||
row.clientId !== client.client_id ||
|
||||
row.status !== 'approved' ||
|
||||
row.expiresAt <= new Date()
|
||||
)
|
||||
throw new InvalidGrantError('Invalid authorization code');
|
||||
return (row.parameters as any).codeChallenge as string;
|
||||
}
|
||||
async issue(userId: string, name: string, selected: string[], days: number, clientId?: string) {
|
||||
const access = secret(),
|
||||
refresh = clientId ? secret() : undefined,
|
||||
sessionId = digest(secret());
|
||||
const expiresAt = new Date(Date.now() + days * 86400000),
|
||||
refreshExpiresAt = clientId ? new Date(Date.now() + 30 * 86400000) : null;
|
||||
await this.db.session.create({
|
||||
data: { id: sessionId, userId, expiresAt: refreshExpiresAt || expiresAt },
|
||||
});
|
||||
const grant = await this.db.agentGrant.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
clientId,
|
||||
scopes: selected,
|
||||
resource: urls().resource.toString(),
|
||||
accessDigest: digest(access),
|
||||
refreshDigest: refresh ? digest(refresh) : null,
|
||||
expiresAt,
|
||||
refreshExpiresAt,
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
grant,
|
||||
tokens: {
|
||||
access_token: access,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.floor(days * 86400),
|
||||
scope: selected.join(' '),
|
||||
...(refresh ? { refresh_token: refresh } : {}),
|
||||
} as OAuthTokens,
|
||||
};
|
||||
}
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
code: string,
|
||||
verifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL,
|
||||
) {
|
||||
this.resource(resource);
|
||||
return this.db.atomic(async () => {
|
||||
await this.challengeForAuthorizationCode(client, code);
|
||||
const row = await this.db.agentAuthorization.findUniqueOrThrow({
|
||||
where: { codeDigest: digest(code) },
|
||||
}),
|
||||
p = row.parameters as any;
|
||||
// SDK tokenHandler validates S256 PKCE before invoking this provider, and passes
|
||||
// undefined for verifier after successful local validation (skipLocalPkceValidation=false).
|
||||
if (
|
||||
redirectUri !== p.redirectUri ||
|
||||
(verifier && createHash('sha256').update(verifier).digest('base64url') !== p.codeChallenge)
|
||||
)
|
||||
throw new InvalidGrantError('PKCE or redirect mismatch');
|
||||
const changed = await this.db.agentAuthorization.updateMany({
|
||||
where: { id: row.id, status: 'approved' },
|
||||
data: { status: 'used', codeDigest: null },
|
||||
});
|
||||
if (!changed.count) throw new InvalidGrantError('Code already used');
|
||||
return (
|
||||
await this.issue(
|
||||
row.userId!,
|
||||
client.client_name || 'MCP client',
|
||||
p.scopes,
|
||||
1 / 24,
|
||||
client.client_id,
|
||||
)
|
||||
).tokens;
|
||||
});
|
||||
}
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
token: string,
|
||||
selected?: string[],
|
||||
resource?: URL,
|
||||
) {
|
||||
this.resource(resource);
|
||||
return this.db.atomic(async () => {
|
||||
const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } });
|
||||
if (
|
||||
!row ||
|
||||
row.clientId !== client.client_id ||
|
||||
row.revokedAt ||
|
||||
!row.refreshExpiresAt ||
|
||||
row.refreshExpiresAt <= new Date()
|
||||
)
|
||||
throw new InvalidGrantError('Invalid refresh token');
|
||||
const current = row.scopes as string[];
|
||||
if (
|
||||
selected &&
|
||||
(!scopeInput.safeParse(selected).success || selected.some((s) => !current.includes(s)))
|
||||
)
|
||||
throw new InvalidScopeError('Scope escalation rejected');
|
||||
const access = secret(),
|
||||
refresh = secret();
|
||||
const changed = await this.db.agentGrant.updateMany({
|
||||
where: { id: row.id, refreshDigest: digest(token), revokedAt: null },
|
||||
data: {
|
||||
accessDigest: digest(access),
|
||||
refreshDigest: digest(refresh),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
scopes: selected || current,
|
||||
},
|
||||
});
|
||||
if (!changed.count) throw new InvalidGrantError('Refresh token already used');
|
||||
return {
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: (selected || current).join(' '),
|
||||
};
|
||||
});
|
||||
}
|
||||
async verifyAccessToken(token: string) {
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new InvalidTokenError('Invalid token');
|
||||
const row = await this.db.agentGrant.findUnique({ where: { accessDigest: digest(token) } });
|
||||
if (
|
||||
!row ||
|
||||
row.revokedAt ||
|
||||
row.expiresAt <= new Date() ||
|
||||
row.resource !== urls().resource.toString()
|
||||
)
|
||||
throw new InvalidTokenError('Expired, revoked or invalid resource token');
|
||||
return {
|
||||
token,
|
||||
clientId: row.clientId || row.id,
|
||||
scopes: row.scopes as string[],
|
||||
expiresAt: Math.floor(+row.expiresAt / 1000),
|
||||
resource: new URL(row.resource),
|
||||
extra: { grantId: row.id, userId: row.userId },
|
||||
};
|
||||
}
|
||||
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) {
|
||||
await this.db.agentGrant.updateMany({
|
||||
where: {
|
||||
clientId: client.client_id,
|
||||
OR: [{ accessDigest: digest(request.token) }, { refreshDigest: digest(request.token) }],
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
async grant(id: string, userId?: string) {
|
||||
const row = await this.db.agentGrant.findFirst({
|
||||
where: { id, ...(userId ? { userId } : {}), revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
if (!row) throw new ForbiddenException('Agent 连接已过期或撤销');
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, AgentGrant } from '@prisma/client';
|
||||
import { compare } from 'bcryptjs';
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Database } from '../database';
|
||||
import { AuthBusinessService, UserRequest } from '../auth';
|
||||
import { BackupBusinessService } from '../backup';
|
||||
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 auth: AuthBusinessService,
|
||||
private backup: BackupBusinessService,
|
||||
private files: AgentFiles,
|
||||
) {
|
||||
this.tools = [
|
||||
...catalogue.tools,
|
||||
{
|
||||
name: 'backup_export',
|
||||
description:
|
||||
'创建短期受 Bearer 保护的完整 ZIP 备份下载入口,包含隐藏项目;网页验证密码并确认后 operation_get 获取入口,URL 本身不是凭证。',
|
||||
schema: empty,
|
||||
scope: 'sensitive',
|
||||
run: async (r) => this.files.issue(r, r.agentGrantId!, 'download'),
|
||||
},
|
||||
{
|
||||
name: 'icon_publish',
|
||||
description:
|
||||
'保存已上传图标,shared=true 发布到共享库,须中文名称及网页确认。先 file_upload_request(kind=icon)。',
|
||||
schema: z
|
||||
.object({
|
||||
fileId: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
shared: z.boolean().default(false),
|
||||
})
|
||||
.strict(),
|
||||
scope: 'write',
|
||||
run: (r, p) => this.files.publishIcon(r, r.agentGrantId!, p.fileId, p.name, p.shared),
|
||||
},
|
||||
{
|
||||
name: 'hidden_lock',
|
||||
description: '立即锁定本连接的隐藏项目授权。',
|
||||
schema: empty,
|
||||
scope: 'write',
|
||||
run: async (r) => this.auth.lock(r),
|
||||
},
|
||||
];
|
||||
}
|
||||
get(name: string) {
|
||||
const t = this.tools.find((t) => t.name === name);
|
||||
if (!t) throw new BadRequestException('未知工具');
|
||||
return t;
|
||||
}
|
||||
async context(grant: AgentGrant) {
|
||||
const s = await this.db.session.findUnique({ where: { id: grant.sessionId } });
|
||||
if (!s || s.expiresAt <= new Date()) throw new ForbiddenException('连接会话失效,请重新授权');
|
||||
return {
|
||||
userId: grant.userId,
|
||||
sessionId: grant.sessionId,
|
||||
revealed: !!s.revealUntil && +s.revealUntil > Date.now(),
|
||||
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 sensitive(t: ToolDefinition, p: any) {
|
||||
return (
|
||||
t.scope === 'sensitive' ||
|
||||
(t.name === 'settings_update' && p.requireHiddenPassword !== undefined) ||
|
||||
(t.name === 'icon_publish' && p.shared)
|
||||
);
|
||||
}
|
||||
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
|
||||
const selected = grant.scopes as string[],
|
||||
mode =
|
||||
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ||
|
||||
'draft';
|
||||
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
|
||||
if (t.scope === 'read') return { mode, sensitive: false };
|
||||
const sensitive = this.sensitive(t, p);
|
||||
if (sensitive && !selected.includes('sensitive'))
|
||||
throw new ForbiddenException('此操作需要 sensitive 权限');
|
||||
if (!sensitive && !selected.includes('write') && !selected.includes('draft'))
|
||||
throw new ForbiddenException('缺少 draft 或 write 权限');
|
||||
if (mode === 'readonly' && !['backup_export', 'hidden_unlock_request'].includes(t.name))
|
||||
throw new ForbiddenException('当前用户写入策略为只读');
|
||||
return { mode, sensitive };
|
||||
}
|
||||
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),
|
||||
permission = 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.sensitive ||
|
||||
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), 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);
|
||||
let impact: unknown = { parameters: row.parameters, message: t.description };
|
||||
if (t.name === 'backup_import')
|
||||
impact = (
|
||||
await this.backup.inspectUpload(await this.context(grant), (row.parameters as any).token)
|
||||
).preview;
|
||||
if (t.web === 'clear')
|
||||
impact = {
|
||||
positions: await this.db.position.count({ where: { userId } }),
|
||||
history: await this.db.revision.count({ where: { position: { userId } } }),
|
||||
schedules: await this.db.schedule.count({ where: { userId } }),
|
||||
message: '清空账户、资产、债务、历史、私有图标和汇率;保留账号和个人设置。先下载当前备份。',
|
||||
};
|
||||
return {
|
||||
...this.view(row),
|
||||
impact,
|
||||
web: t.web,
|
||||
sensitive: this.sensitive(t, row.parameters),
|
||||
description: t.description,
|
||||
};
|
||||
}
|
||||
async confirm(r: UserRequest, id: string, raw: unknown, res: Response) {
|
||||
const input = z
|
||||
.object({
|
||||
approve: z.boolean(),
|
||||
password: z.string().max(72).optional(),
|
||||
username: z.string().max(64).optional(),
|
||||
newPassword: z.string().max(72).optional(),
|
||||
confirmation: z.string().max(20).optional(),
|
||||
})
|
||||
.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;
|
||||
const access = await this.permission(grant, t, p);
|
||||
if (access.sensitive) {
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!input.password || !(await compare(input.password, u.passwordHash)))
|
||||
throw new ForbiddenException('请验证当前密码');
|
||||
}
|
||||
if ((await this.state(r.userId)) !== row.snapshot)
|
||||
throw new ConflictException('账目已变化,请取消并重新创建操作');
|
||||
let result: unknown;
|
||||
if (t.web === 'credentials') {
|
||||
result = await this.auth.changeCredentials(
|
||||
r,
|
||||
{
|
||||
currentPassword: input.password,
|
||||
username: input.username,
|
||||
newPassword: input.newPassword,
|
||||
},
|
||||
res,
|
||||
);
|
||||
await this.db.agentGrant.updateMany({
|
||||
where: { userId: r.userId, id: { not: grant.id } },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.db.session.create({
|
||||
data: { id: grant.sessionId, userId: r.userId, expiresAt: new Date(Date.now() + 300000) },
|
||||
});
|
||||
await this.db.agentGrant.update({
|
||||
where: { id: grant.id },
|
||||
data: { scopes: ['read'], expiresAt: new Date(Date.now() + 300000), refreshDigest: null },
|
||||
});
|
||||
} else if (t.web === 'reveal')
|
||||
result = await this.auth.reveal(
|
||||
Object.assign(Object.create(r), { sessionId: grant.sessionId }),
|
||||
{ password: input.password },
|
||||
);
|
||||
else if (t.web === 'clear')
|
||||
result = await this.backup.clear(r, { confirmation: input.confirmation });
|
||||
else result = await this.execute(t, grant, p);
|
||||
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: 'backup' | '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 权限');
|
||||
if (
|
||||
(await this.db.agentPolicy.findUnique({ where: { userId: grant.userId } }))?.mode ===
|
||||
'readonly'
|
||||
)
|
||||
throw new ForbiddenException('当前策略为只读');
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Express } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
mcpAuthRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
} from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
||||
import { AgentOAuth, urls, scopes } from './oauth';
|
||||
import { AgentOperations, writing, plain } from './operations';
|
||||
import { AgentFiles } from './files';
|
||||
import { Database } from '../database';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export function toolResult(value: unknown) {
|
||||
const data = plain(value);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
summary: data?.status ? `操作状态:${data.status}` : '查询或操作已完成',
|
||||
data,
|
||||
}),
|
||||
},
|
||||
],
|
||||
structuredContent: { data },
|
||||
};
|
||||
}
|
||||
@Injectable()
|
||||
export class AgentTransport {
|
||||
constructor(
|
||||
private oauth: AgentOAuth,
|
||||
private operations: AgentOperations,
|
||||
private files: AgentFiles,
|
||||
private db: Database,
|
||||
) {}
|
||||
install(app: Express) {
|
||||
const { issuer, resource } = urls();
|
||||
app.use(
|
||||
mcpAuthRouter({
|
||||
provider: this.oauth,
|
||||
issuerUrl: issuer,
|
||||
resourceServerUrl: resource,
|
||||
scopesSupported: [...scopes],
|
||||
resourceName: 'WorthPath',
|
||||
}),
|
||||
);
|
||||
this.files.install(app);
|
||||
app.all(
|
||||
'/mcp',
|
||||
(req, res, next) => {
|
||||
const allowed = (process.env.MCP_ALLOWED_ORIGINS || urls().web.origin)
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
if (req.headers.origin && !allowed.includes(req.headers.origin)) {
|
||||
res.status(403).json({ error: 'Untrusted origin' });
|
||||
return;
|
||||
}
|
||||
const validHosts = [resource.host];
|
||||
if (process.env.NODE_ENV !== 'production')
|
||||
validHosts.push('127.0.0.1:' + resource.port, 'localhost:' + resource.port);
|
||||
if (!validHosts.includes(req.headers.host || '')) {
|
||||
res.status(403).json({ error: 'Untrusted host' });
|
||||
return;
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Authorization, Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id',
|
||||
);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
|
||||
next();
|
||||
},
|
||||
requireBearerAuth({
|
||||
verifier: this.oauth,
|
||||
requiredScopes: ['read'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
|
||||
}),
|
||||
async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', 'POST');
|
||||
res.status(405).end();
|
||||
return;
|
||||
}
|
||||
const grantId = String(req.auth?.extra?.grantId);
|
||||
const invoke = async (name: string, work: () => Promise<unknown>) => {
|
||||
let status = 'success';
|
||||
try {
|
||||
return toolResult(await work());
|
||||
} catch (e) {
|
||||
status = 'error';
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text:
|
||||
e instanceof HttpException || e instanceof ZodError
|
||||
? e.message
|
||||
: '操作失败,未提交账目变更;请检查参数或稍后重试',
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
const grant = await this.oauth.grant(grantId).catch(() => null);
|
||||
if (grant)
|
||||
await this.db.agentCall
|
||||
.create({ data: { userId: grant.userId, grantId, tool: name, status } })
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
const server = new McpServer(
|
||||
{ name: 'WorthPath', version: '1.0.0' },
|
||||
{
|
||||
instructions:
|
||||
'金额为十进制字符串;业务日期 UTC+8,时间戳 ISO UTC。先 state_get 再写入,所有写入要求幂等键。pending 返回网页入口,用户确认后用 operation_get 查询。只使用稳定 UUID;重名询问用户。',
|
||||
},
|
||||
);
|
||||
for (const tool of this.operations.tools) {
|
||||
server.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.scope === 'read' ? tool.schema : tool.schema.safeExtend(writing),
|
||||
annotations: {
|
||||
readOnlyHint: tool.scope === 'read',
|
||||
destructiveHint: !!tool.destructive,
|
||||
idempotentHint: tool.scope !== 'read',
|
||||
openWorldHint: ['rates_refresh', 'metals_refresh'].includes(tool.name),
|
||||
},
|
||||
},
|
||||
async (p: any) => {
|
||||
return invoke(tool.name, () => this.operations.call(grantId, tool.name, p));
|
||||
},
|
||||
);
|
||||
}
|
||||
const register = (
|
||||
name: string,
|
||||
description: string,
|
||||
schema: z.ZodObject<any>,
|
||||
run: (p: any) => Promise<unknown>,
|
||||
read = true,
|
||||
) =>
|
||||
server.registerTool(
|
||||
name,
|
||||
{
|
||||
description,
|
||||
inputSchema: schema,
|
||||
annotations: {
|
||||
readOnlyHint: read,
|
||||
destructiveHint: false,
|
||||
idempotentHint: read,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async (p: any) => invoke(name, () => run(p)),
|
||||
);
|
||||
register(
|
||||
'state_get',
|
||||
'获取当前用户账目并发版本 state,写入时作为 expectedState。',
|
||||
z.object({}).strict(),
|
||||
async () => ({
|
||||
state: await this.db.$transaction(async () =>
|
||||
this.operations.state((await this.oauth.grant(grantId)).userId),
|
||||
),
|
||||
}),
|
||||
);
|
||||
register(
|
||||
'operation_get',
|
||||
'查询此连接发起的操作状态、过期时间和最终结果。',
|
||||
z.object({ operationId: z.string().uuid() }).strict(),
|
||||
(p) => this.operations.status(grantId, p.operationId),
|
||||
);
|
||||
register(
|
||||
'file_upload_request',
|
||||
'创建受 Bearer 保护的短期文件上传入口;multipart/form-data 的 file 字段。',
|
||||
z.object({ kind: z.enum(['backup', 'icon']) }).strict(),
|
||||
(p) => this.operations.uploadRequest(grantId, p.kind),
|
||||
false,
|
||||
);
|
||||
register(
|
||||
'file_status',
|
||||
'查看此连接文件上传状态、预检结果和备份导入 token。',
|
||||
z.object({ fileId: z.string().uuid() }).strict(),
|
||||
(p) => this.operations.fileStatus(grantId, p.fileId),
|
||||
);
|
||||
register(
|
||||
'icon_image',
|
||||
'取得受 Bearer 保护的短期 PNG 图标读取入口。',
|
||||
z.object({ id: z.string().uuid() }).strict(),
|
||||
(p) => this.operations.iconImage(grantId, p.id),
|
||||
);
|
||||
register(
|
||||
'connection_info',
|
||||
'查询本连接权限、到期时间、资源和用户写入策略;不返回任何令牌。',
|
||||
z.object({}).strict(),
|
||||
async () => {
|
||||
const g = await this.oauth.grant(grantId);
|
||||
return {
|
||||
name: g.name,
|
||||
scopes: g.scopes,
|
||||
expiresAt: g.expiresAt,
|
||||
resource: g.resource,
|
||||
writePolicy:
|
||||
(await this.db.agentPolicy.findUnique({ where: { userId: g.userId } }))?.mode ||
|
||||
'draft',
|
||||
};
|
||||
},
|
||||
);
|
||||
register(
|
||||
'connection_revoke',
|
||||
'立即撤销本 Agent 连接并退出授权;不影响网页登录。撤销后无法继续查询,请先保存操作结果。',
|
||||
z.object({}).strict(),
|
||||
async () => {
|
||||
const g = await this.oauth.grant(grantId);
|
||||
await this.db.agentGrant.update({
|
||||
where: { id: g.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { revoked: true };
|
||||
},
|
||||
false,
|
||||
);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.once('close', () => {
|
||||
void transport.close();
|
||||
void server.close();
|
||||
});
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch {
|
||||
if (!res.headersSent) res.status(500).json({ error: 'MCP request failed' });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-12
@@ -258,14 +258,15 @@ export class MetalsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Controller('api/metals')
|
||||
export class MetalsController {
|
||||
|
||||
@Injectable()
|
||||
export class MetalsBusinessService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private metals: MetalsService,
|
||||
) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
void this.metals.daily(r.userId);
|
||||
async list(r: UserRequest) {
|
||||
if (!r.agent) void this.metals.daily(r.userId);
|
||||
return {
|
||||
status: this.metals.status(r.userId),
|
||||
prices: await this.db.metalPrice.findMany({
|
||||
@@ -275,10 +276,10 @@ export class MetalsController {
|
||||
}),
|
||||
};
|
||||
}
|
||||
@Post('refresh') refresh(@Req() r: UserRequest) {
|
||||
refresh(r: UserRequest) {
|
||||
return this.metals.refresh(r.userId);
|
||||
}
|
||||
@Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
async manual(r: UserRequest, body: unknown) {
|
||||
const v = metalPriceInput.parse(body);
|
||||
const key = {
|
||||
userId: r.userId,
|
||||
@@ -309,11 +310,7 @@ export class MetalsController {
|
||||
return { message: '贵金属价格已保存' };
|
||||
});
|
||||
}
|
||||
@Put(':id') async configure(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
async configure(r: UserRequest, id: string, body: unknown) {
|
||||
const v = metalConfig.parse(body);
|
||||
const result = await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
@@ -348,7 +345,31 @@ export class MetalsController {
|
||||
this.metals.invalidate(r.userId);
|
||||
return result;
|
||||
}
|
||||
@Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
value(r: UserRequest, id: string) {
|
||||
return this.db.serial((tx) => this.metals.apply(tx, r.userId, id, r.revealed, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/metals')
|
||||
export class MetalsController {
|
||||
constructor(private service: MetalsBusinessService) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
return this.service.list(r);
|
||||
}
|
||||
@Post('refresh') refresh(@Req() r: UserRequest) {
|
||||
return this.service.refresh(r);
|
||||
}
|
||||
@Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
return this.service.manual(r, body);
|
||||
}
|
||||
@Put(':id') async configure(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return this.service.configure(r, id, body);
|
||||
}
|
||||
@Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.service.value(r, id);
|
||||
}
|
||||
}
|
||||
+92
-42
@@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -32,8 +33,9 @@ import { MetalsService } from './metals';
|
||||
import { RatesService } from './rates';
|
||||
import { captureReplay } from './replay';
|
||||
import { changeMovement } from './transfers';
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioBusinessService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
@@ -45,7 +47,7 @@ export class PortfolioController {
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
return p;
|
||||
}
|
||||
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
|
||||
async list(r: UserRequest, inputKind?: string) {
|
||||
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
||||
@@ -71,18 +73,14 @@ export class PortfolioController {
|
||||
}));
|
||||
});
|
||||
}
|
||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
async detail(r: UserRequest, id: string) {
|
||||
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
|
||||
}
|
||||
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
async history(r: UserRequest, query: unknown) {
|
||||
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
|
||||
}
|
||||
@Get('positions/:id/history') async positionHistory(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Query() query: Record<string, string>,
|
||||
) {
|
||||
async positionHistory(r: UserRequest, id: string, query: Record<string, string>) {
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
@@ -91,7 +89,7 @@ export class PortfolioController {
|
||||
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
|
||||
});
|
||||
}
|
||||
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
async trend(r: UserRequest, query: unknown) {
|
||||
const q = trendInput(query);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
@@ -106,7 +104,7 @@ export class PortfolioController {
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
async create(r: UserRequest, b: unknown) {
|
||||
const v = positionInput.parse(b),
|
||||
{ amount, date, ...meta } = v;
|
||||
await this.icons.requireVisible(r.userId, meta.iconId);
|
||||
@@ -128,11 +126,7 @@ export class PortfolioController {
|
||||
this.fx.invalidate(r.userId);
|
||||
return created;
|
||||
}
|
||||
@Patch('positions/:id') async edit(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
async edit(r: UserRequest, id: string, b: unknown) {
|
||||
const v = positionMeta.parse(b),
|
||||
p = await this.own(r.userId, id, r.revealed);
|
||||
if (
|
||||
@@ -153,11 +147,7 @@ export class PortfolioController {
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('positions/:id/revisions') async revise(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
async revise(r: UserRequest, id: string, b: unknown) {
|
||||
const v = revisionInput.parse(b);
|
||||
if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口');
|
||||
return this.db.serial(async (tx) => {
|
||||
@@ -197,12 +187,7 @@ export class PortfolioController {
|
||||
});
|
||||
});
|
||||
}
|
||||
@Put('positions/:id/revisions/:revisionId') async correct(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('revisionId') revisionId: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
async correct(r: UserRequest, id: string, revisionId: string, b: unknown) {
|
||||
const v = revisionInput.parse(b);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
@@ -234,11 +219,7 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
async deleteRevision(r: UserRequest, id: string, revisionId: string) {
|
||||
return this.db.serial(async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
@@ -264,11 +245,7 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
@Put('positions/:id/links') async link(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
async link(r: UserRequest, id: string, b: unknown) {
|
||||
const { targetIds } = z
|
||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||
.strict()
|
||||
@@ -281,7 +258,12 @@ export class PortfolioController {
|
||||
});
|
||||
if (!source) throw new NotFoundException('债务不存在');
|
||||
const count = await tx.position.count({
|
||||
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
|
||||
where: {
|
||||
id: { in: targetIds },
|
||||
userId: r.userId,
|
||||
kind: { in: ['account', 'asset'] },
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
|
||||
await tx.positionLink.deleteMany({ where: { sourceId: id } });
|
||||
@@ -291,9 +273,11 @@ export class PortfolioController {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||
void this.fx.daily(r.userId);
|
||||
void this.metals.daily(r.userId);
|
||||
async overview(r: UserRequest) {
|
||||
if (!r.agent) {
|
||||
void this.fx.daily(r.userId);
|
||||
void this.metals.daily(r.userId);
|
||||
}
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
@@ -323,3 +307,69 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
constructor(private service: PortfolioBusinessService) {}
|
||||
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
|
||||
return this.service.list(r, inputKind);
|
||||
}
|
||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.service.detail(r, id);
|
||||
}
|
||||
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
return this.service.history(r, query);
|
||||
}
|
||||
@Get('positions/:id/history') async positionHistory(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Query() query: Record<string, string>,
|
||||
) {
|
||||
return this.service.positionHistory(r, id, query);
|
||||
}
|
||||
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
return this.service.trend(r, query);
|
||||
}
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
return this.service.create(r, b);
|
||||
}
|
||||
@Patch('positions/:id') async edit(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.edit(r, id, b);
|
||||
}
|
||||
@Post('positions/:id/revisions') async revise(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.revise(r, id, b);
|
||||
}
|
||||
@Put('positions/:id/revisions/:revisionId') async correct(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('revisionId') revisionId: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.correct(r, id, revisionId, b);
|
||||
}
|
||||
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
return this.service.deleteRevision(r, id, revisionId);
|
||||
}
|
||||
@Put('positions/:id/links') async link(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.link(r, id, b);
|
||||
}
|
||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||
return this.service.overview(r);
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -169,14 +169,15 @@ export class RatesService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Controller('api')
|
||||
export class SettingsController {
|
||||
|
||||
@Injectable()
|
||||
export class SettingsBusinessService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||
async settings(r: UserRequest, includeRates?: string) {
|
||||
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
@@ -217,11 +218,7 @@ export class SettingsController {
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(
|
||||
@Req() r: UserRequest,
|
||||
@Body() b: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
async update(r: UserRequest, b: unknown, res: Response) {
|
||||
const data = settingsInput.parse(b);
|
||||
const expiresAt =
|
||||
data.sessionHours === undefined
|
||||
@@ -236,11 +233,29 @@ export class SettingsController {
|
||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
|
||||
});
|
||||
if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
||||
if (expiresAt && !r.agent) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
||||
this.fx.invalidate(r.userId);
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
|
||||
async refresh(r: UserRequest) {
|
||||
return this.fx.refresh(r.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class SettingsController {
|
||||
constructor(private service: SettingsBusinessService) {}
|
||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||
return this.service.settings(r, includeRates);
|
||||
}
|
||||
@Patch('settings') async update(
|
||||
@Req() r: UserRequest,
|
||||
@Body() b: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.service.update(r, b, res);
|
||||
}
|
||||
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
|
||||
return this.service.refresh(r);
|
||||
}
|
||||
}
|
||||
+40
-12
@@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -56,8 +57,9 @@ export function occurrenceId(id: string, when: Date) {
|
||||
export function nextOccurrence(when: Date, intervalDays: number) {
|
||||
return new Date(+when + intervalDays * 86400000);
|
||||
}
|
||||
@Controller('api/schedules')
|
||||
export class SchedulesController {
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesBusinessService {
|
||||
constructor(private db: Database) {}
|
||||
private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) {
|
||||
return (
|
||||
@@ -71,7 +73,7 @@ export class SchedulesController {
|
||||
})
|
||||
).map((p) => p.id);
|
||||
}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
async list(r: UserRequest) {
|
||||
const ids = await this.visible(r);
|
||||
const rows = await this.db.schedule.findMany({
|
||||
where: {
|
||||
@@ -83,7 +85,7 @@ export class SchedulesController {
|
||||
});
|
||||
return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) }));
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
async create(r: UserRequest, body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
await this.validateAccounts(tx, r, v);
|
||||
@@ -123,7 +125,7 @@ export class SchedulesController {
|
||||
)
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
async edit(r: UserRequest, id: string, body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
@@ -148,11 +150,7 @@ export class SchedulesController {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Patch(':id') async toggle(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
async toggle(r: UserRequest, id: string, body: unknown) {
|
||||
const v = z.object({ enabled: z.boolean() }).strict().parse(body);
|
||||
const ids = await this.visible(r);
|
||||
const row = await this.db.schedule.findFirst({
|
||||
@@ -176,7 +174,7 @@ export class SchedulesController {
|
||||
if (!result.count) throw new NotFoundException('计划不存在');
|
||||
return { ok: true };
|
||||
}
|
||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
async remove(r: UserRequest, id: string) {
|
||||
const ids = await this.visible(r);
|
||||
const result = await this.db.schedule.deleteMany({
|
||||
where: {
|
||||
@@ -189,7 +187,7 @@ export class SchedulesController {
|
||||
if (!result.count) throw new NotFoundException('计划不存在');
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('run') async run(@Req() r: UserRequest) {
|
||||
async run(r: UserRequest, atomicBatch = false) {
|
||||
const ids = await this.visible(r),
|
||||
now = new Date();
|
||||
const due = await this.db.schedule.findMany({
|
||||
@@ -281,6 +279,9 @@ export class SchedulesController {
|
||||
});
|
||||
if (applied) executed++;
|
||||
} catch (e) {
|
||||
// MCP wraps the complete call plus its idempotency result in one transaction.
|
||||
// A failed nested plan must abort that batch rather than leave partial work.
|
||||
if (atomicBatch) throw e;
|
||||
errors.push({
|
||||
id: candidate.id,
|
||||
message:
|
||||
@@ -297,3 +298,30 @@ export class SchedulesController {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesBusinessService) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
return this.service.list(r);
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
return this.service.create(r, body);
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
return this.service.edit(r, id, body);
|
||||
}
|
||||
@Patch(':id') async toggle(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return this.service.toggle(r, id, body);
|
||||
}
|
||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.service.remove(r, id);
|
||||
}
|
||||
@Post('run') async run(@Req() r: UserRequest) {
|
||||
return this.service.run(r);
|
||||
}
|
||||
}
|
||||
+32
-10
@@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -21,10 +22,11 @@ import { businessTime } from './calculation';
|
||||
import { movementDeltas } from './movement';
|
||||
import { captureReplay } from './replay';
|
||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
|
||||
@Injectable()
|
||||
export class TransfersBusinessService {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
async list(r: UserRequest, query: unknown) {
|
||||
const q = pageInput(query);
|
||||
const rows = await this.db.$transaction(async (tx) => {
|
||||
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
|
||||
@@ -48,10 +50,7 @@ export class TransfersController {
|
||||
revealed: r.revealed,
|
||||
};
|
||||
}
|
||||
@Get('revision/:revisionId') async byRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
async byRevision(r: UserRequest, revisionId: string) {
|
||||
const row = await this.db.transfer.findFirst({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
@@ -67,11 +66,11 @@ export class TransfersController {
|
||||
const { userId, importedFromId, effectiveDate, ...v } = row;
|
||||
return { ...v, date: businessTime(effectiveDate) };
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
async edit(r: UserRequest, id: string, body: unknown) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.change(r, id, v);
|
||||
}
|
||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
async remove(r: UserRequest, id: string) {
|
||||
return this.change(r, id);
|
||||
}
|
||||
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
|
||||
@@ -79,7 +78,7 @@ export class TransfersController {
|
||||
return changeMovement(tx, r, id, v);
|
||||
});
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
async create(r: UserRequest, body: unknown) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||
}
|
||||
@@ -244,3 +243,26 @@ export async function executeMovement(
|
||||
});
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
constructor(private service: TransfersBusinessService) {}
|
||||
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
return this.service.list(r, query);
|
||||
}
|
||||
@Get('revision/:revisionId') async byRevision(
|
||||
@Req() r: UserRequest,
|
||||
@Param('revisionId') revisionId: string,
|
||||
) {
|
||||
return this.service.byRevision(r, revisionId);
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
return this.service.edit(r, id, body);
|
||||
}
|
||||
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.service.remove(r, id);
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
return this.service.create(r, body);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user