{o.error}
+ +删除记录并重算余额,请仔细核对。
+ )} + {o.impact.current && ( +diff --git a/README.md b/README.md
index 4f83d9d..965e7cc 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
## 连接 Agent / MCP
-设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。
+设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权使用独立页面,草稿在弹窗中审阅并可全选批量同意;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。
密码修改、清空数据、备份恢复、共享图标发布、汇率及贵金属报价修改在网站完成,MCP 不提供这些敏感操作。远程接入必须配置客户端可访问的 MCP_PUBLIC_URL 与 MCP_WEB_URL;localhost 仅代表客户端本机。详见 [连接与授权更新](docs/update-agent-accounts-2026-10-04.md)。
@@ -133,3 +133,5 @@ git config remote.pushDefault github
上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。
MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。
+
+独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
diff --git a/apps/api/package.json b/apps/api/package.json
index 98f29fb..343883f 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -5,7 +5,7 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
- "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts",
+ "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts
index 71458d7..26b1579 100644
--- a/apps/api/src/mcp/management.ts
+++ b/apps/api/src/mcp/management.ts
@@ -5,6 +5,7 @@ import {
Delete,
Req,
Param,
+ Query,
Body,
Res,
ForbiddenException,
@@ -117,6 +118,18 @@ export class AgentManagementController {
.parse(raw);
return this.oauth.consent(r.userId, z.string().uuid().parse(id), approve, scopes, days);
}
+
+ @Get('drafts') drafts(@Req() r: UserRequest, @Query('cursor') cursor?: string) {
+ return this.operations.drafts(r.userId, cursor ? z.string().uuid().parse(cursor) : undefined);
+ }
+ @Post('operations/confirm-batch') async confirmBatch(
+ @Req() r: UserRequest,
+ @Body() raw: unknown,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ this.auth.limit(r);
+ return this.operations.confirmBatch(r, raw, res);
+ }
@Get('operations/:id') preview(@Req() r: UserRequest, @Param('id') id: string) {
return this.operations.preview(r.userId, z.string().uuid().parse(id));
}
diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts
index 2044886..52b424b 100644
--- a/apps/api/src/mcp/operations.ts
+++ b/apps/api/src/mcp/operations.ts
@@ -361,6 +361,86 @@ export class AgentOperations {
);
}, 300000);
}
+
+ async drafts(userId: string, cursor?: string) {
+ const rows = await this.db.agentOperation.findMany({
+ where: { userId, status: 'pending', expiresAt: { gt: new Date() } },
+ orderBy: { id: 'asc' },
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
+ take: 51,
+ });
+ const page = rows.slice(0, 50);
+ const items = await Promise.all(
+ page.map(async (row) => {
+ try {
+ return await this.preview(userId, row.id);
+ } catch {
+ return {
+ operationId: row.id,
+ tool: row.tool,
+ status: 'unavailable',
+ expiresAt: row.expiresAt,
+ error: '连接已失效,请取消或重新授权后刷新',
+ };
+ }
+ }),
+ );
+ return { items, nextCursor: rows.length > 50 ? page[49].id : null };
+ }
+ async confirmBatch(r: UserRequest, raw: unknown, res: Response) {
+ const input = z
+ .object({
+ operationIds: z
+ .array(z.string().uuid())
+ .min(1)
+ .max(100)
+ .refine((ids) => new Set(ids).size === ids.length, '草稿编号不能重复'),
+ approve: z.boolean(),
+ })
+ .strict()
+ .parse(raw);
+ return this.db.atomic(async () => {
+ await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
+ const rows = await this.db.agentOperation.findMany({
+ where: { userId: r.userId, id: { in: input.operationIds } },
+ });
+ if (rows.length !== input.operationIds.length) throw new NotFoundException('操作不存在');
+ // A lost success response may be retried without executing anything twice.
+ if (input.approve && rows.every((row) => row.status === 'completed'))
+ return {
+ operations: input.operationIds.map((id) => this.view(rows.find((row) => row.id === id)!)),
+ };
+ if (!input.approve) {
+ if (rows.some((row) => row.status !== 'pending' || row.expiresAt <= new Date()))
+ throw new ConflictException('部分草稿已完成或失效,请刷新后重新选择');
+ await this.db.agentOperation.updateMany({
+ where: { userId: r.userId, id: { in: input.operationIds } },
+ data: { status: 'cancelled', completedAt: new Date() },
+ });
+ return { operations: rows.map((row) => this.view({ ...row, status: 'cancelled' })) };
+ }
+ const snapshot = await this.state(r.userId);
+ for (const row of rows) {
+ if (row.status !== 'pending' || row.expiresAt <= new Date())
+ throw new ConflictException('部分草稿已完成或失效,请刷新后重新选择');
+ if (row.snapshot !== snapshot)
+ throw new ConflictException('账目已变化,请取消并重新创建操作');
+ const grant = await this.oauth.grant(row.grantId, r.userId);
+ await this.permission(grant, this.get(row.tool), row.parameters);
+ }
+ const operations = [];
+ for (const id of input.operationIds) {
+ // All selected drafts were reviewed against the same baseline above.
+ // Only this atomic, manually confirmed batch may advance their snapshots.
+ await this.db.agentOperation.update({
+ where: { id },
+ data: { snapshot: await this.state(r.userId) },
+ });
+ operations.push(await this.confirm(r, id, { approve: true }, res));
+ }
+ return { operations };
+ }, 300000);
+ }
async uploadRequest(grantId: string, kind: 'icon') {
const grant = await this.oauth.grant(grantId);
const selected = grant.scopes as string[];
diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts
index 5b83c96..8d7c4f1 100644
--- a/apps/api/test/mcp.test.ts
+++ b/apps/api/test/mcp.test.ts
@@ -271,6 +271,126 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
409,
);
+ const reviewedState = (await call(d, 'state_get')).state;
+ const reviewedCreates = await Promise.all(
+ [0, 1, 2].map((i) =>
+ call(d, 'position_create', {
+ ...position,
+ name: 'atomic batch ' + i,
+ expectedState: reviewedState,
+ idempotencyKey: randomUUID(),
+ }),
+ ),
+ );
+ const reviewedBalances = await Promise.all(
+ ['11.86420975', '12.86420975'].map((amount) =>
+ call(d, 'balance_record', {
+ id: applied.result.id,
+ data: { amount, date: day },
+ expectedState: reviewedState,
+ idempotencyKey: randomUUID(),
+ }),
+ ),
+ );
+ const batchBody = {
+ approve: true,
+ operationIds: [...reviewedCreates, ...reviewedBalances].map((o) => o.operationId),
+ };
+ const confirmedBatch = await web(
+ d.cookie,
+ '/agent/operations/confirm-batch',
+ 'POST',
+ batchBody,
+ );
+ assert.equal(confirmedBatch.status, 201, JSON.stringify(confirmedBatch.data));
+ assert.equal(confirmedBatch.data.operations.length, 5);
+ assert.ok(confirmedBatch.data.operations.every((o: any) => o.status === 'completed'));
+ assert.equal((await call(d, 'position_get', { id: applied.result.id })).amount, '12.86420975');
+ assert.equal(
+ (await web(d.cookie, '/agent/operations/confirm-batch', 'POST', batchBody)).status,
+ 201,
+ );
+ assert.equal((await call(d, 'positions_list', { q: 'atomic batch' })).total, 3);
+ assert.equal(
+ (await web(b.cookie, '/agent/operations/confirm-batch', 'POST', batchBody)).status,
+ 404,
+ );
+ assert.equal(
+ (
+ await web(d.cookie, '/agent/operations/confirm-batch', 'POST', {
+ approve: true,
+ operationIds: [batchBody.operationIds[0], batchBody.operationIds[0]],
+ })
+ ).status,
+ 400,
+ );
+ const rollbackState = (await call(d, 'state_get')).state;
+ const rollbackCreate = await call(d, 'position_create', {
+ ...position,
+ name: 'must roll back',
+ expectedState: rollbackState,
+ idempotencyKey: randomUUID(),
+ });
+ const invalidMovement = await call(d, 'movement_create', {
+ sourceId: applied.result.id,
+ targetId: randomUUID(),
+ amount: '1',
+ received: '1',
+ date: day,
+ expectedState: rollbackState,
+ idempotencyKey: randomUUID(),
+ });
+ const rejectedBatch = await web(d.cookie, '/agent/operations/confirm-batch', 'POST', {
+ approve: true,
+ operationIds: [rollbackCreate.operationId, invalidMovement.operationId],
+ });
+ assert.equal(rejectedBatch.status, 400);
+ assert.equal((await call(d, 'positions_list', { q: 'must roll back' })).total, 0);
+ assert.equal(
+ (await call(d, 'operation_get', { operationId: rollbackCreate.operationId })).status,
+ 'pending',
+ );
+ assert.equal((await call(d, 'state_get')).state, rollbackState);
+ assert.equal(
+ (
+ await web(d.cookie, '/agent/operations/confirm-batch', 'POST', {
+ approve: true,
+ operationIds: [webStale.operationId],
+ })
+ ).status,
+ 409,
+ );
+ // All live drafts are paginated; the management panel's recent-100 cap is not used.
+ const pagesState = (await call(d, 'state_get')).state;
+ const pageDrafts = [];
+ for (let i = 0; i < 53; i++)
+ pageDrafts.push(
+ await call(d, 'position_create', {
+ ...position,
+ name: 'page draft ' + i,
+ expectedState: pagesState,
+ idempotencyKey: randomUUID(),
+ }),
+ );
+ const pageIds: string[] = [];
+ let cursor: string | null = null;
+ do {
+ const page = await web(d.cookie, '/agent/drafts' + (cursor ? '?cursor=' + cursor : ''));
+ assert.equal(page.status, 200);
+ assert.ok(page.data.items.length <= 50);
+ pageIds.push(...page.data.items.map((o: any) => o.operationId));
+ cursor = page.data.nextCursor;
+ } while (cursor);
+ assert.equal(new Set(pageIds).size, pageIds.length);
+ assert.ok(pageDrafts.every((o) => pageIds.includes(o.operationId)));
+ const cancelledBatch = await web(d.cookie, '/agent/operations/confirm-batch', 'POST', {
+ approve: false,
+ operationIds: [rollbackCreate.operationId, invalidMovement.operationId],
+ });
+ assert.equal(cancelledBatch.status, 201);
+ assert.ok(cancelledBatch.data.operations.every((o: any) => o.status === 'cancelled'));
+ assert.equal((await call(d, 'positions_list', { q: 'must roll back' })).total, 0);
+
const expired = await write(d, 'position_create', { ...position, name: 'expired' });
await db.agentOperation.update({
where: { id: expired.operationId },
diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx
index fd3b284..13180e6 100644
--- a/apps/web/src/AgentConnections.tsx
+++ b/apps/web/src/AgentConnections.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { api } from './api';
+import { AgentDrafts } from './AgentDrafts';
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
import { showToast, useToast } from './Toast';
@@ -116,7 +117,8 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
}
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
-export function AgentConnections() {
+export function AgentConnections({ changed }: { changed?: () => void }) {
+ const [review, setReview] = useState 没有等待确认的修改。 有效至 {new Date(o.expiresAt).toLocaleTimeString()}待确认草稿
+
{!pending.length &&
正在读取全部草稿…
+ ) : detail ? ( +核对每项修改后选择。一次最多同意 100 项,任一失败整批回滚。
+没有等待确认的修改。
} +{o.error}
+ +删除记录并重算余额,请仔细核对。
+ )} + {o.impact.current && ( ++ {error} + +
+ )} +