fix:mcp-sibling-create-draft-confirmation

This commit is contained in:
陈煜 committed 2026-10-04 22:55:12 +08:00
1 parent b2c22ca050
commit ce8609116b
5 files changed
+115 -1

No files matched your search

+2
View File
@@ -131,3 +131,5 @@ git config remote.pushDefault github
| [Gold API](https://gold-api.com/docs) | [黄金 XAU](https://api.gold-api.com/price/XAU)、[白银 XAG](https://api.gold-api.com/price/XAG) | 美元/金衡盎司参考价;以 31.1034768 克/金衡盎司换算为每克价格。已配置贵金属每日尝试更新,可在网站手动刷新。 | 无需密钥;请求固定 XAU/XAG 品种,不发送个人数据和持仓。 | 12 秒超时;格式、时间或汇率异常时保留之前的报价与估值;已有历史导入报价保留。 |
上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。
MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。
+16
View File
@@ -337,6 +337,22 @@ export class AgentOperations {
if ((await this.state(r.userId)) !== row.snapshot)
throw new ConflictException('账目已变化,请取消并重新创建操作');
const result = await this.execute(t, grant, p);
if (row.tool === 'position_create') {
// Advance only sibling additions reviewed against the same snapshot.
// The user lock and transaction keep edits and other grants stale.
await this.db.agentOperation.updateMany({
where: {
userId: r.userId,
grantId: row.grantId,
tool: 'position_create',
status: 'pending',
snapshot: row.snapshot,
id: { not: row.id },
expiresAt: { gt: new Date() },
},
data: { snapshot: await this.state(r.userId) },
});
}
return this.view(
await this.db.agentOperation.update({
where: { id },
+73
View File
@@ -198,6 +198,79 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
const applied = await confirm(d, draft);
assert.ok(applied.result.id);
assert.equal((await call(d, 'positions_list')).total, 1);
// Only independent creates from the same connection and snapshot may advance.
const batchState = (await call(d, 'state_get')).state;
const batchArgs = [0, 1, 2].map((i) => ({
...position,
name: 'batch account ' + i,
amount: '8.86420975',
expectedState: batchState,
idempotencyKey: randomUUID(),
}));
const batch = await Promise.all(batchArgs.map((args) => call(d, 'position_create', args)));
const edit = await call(d, 'balance_record', {
id: applied.result.id,
data: { amount: '2', date: day },
expectedState: batchState,
idempotencyKey: randomUUID(),
});
const otherToken = await web(d.cookie, '/agent/tokens', 'POST', {
name: 'separate draft connection',
days: 1,
scopes: ['read', 'draft'],
password: d.password,
});
assert.equal(otherToken.status, 201);
const otherClient = new Client({ name: 'other draft connection', version: '1.31.0' });
clients.push(otherClient);
await otherClient.connect(
new StreamableHTTPClientTransport(new URL(resource), {
requestInit: { headers: { Authorization: 'Bearer ' + otherToken.data.token } },
}),
);
const otherDraft = await call({ client: otherClient }, 'position_create', {
...position,
name: 'other grant create',
expectedState: batchState,
idempotencyKey: randomUUID(),
});
const firstBatch = await confirm(d, batch[0]);
assert.equal(
(await call(d, 'position_create', batchArgs[0])).operationId,
firstBatch.operationId,
);
await Promise.all(batch.slice(1).map((operation) => confirm(d, operation)));
assert.equal((await call(d, 'positions_list', { q: 'batch account' })).total, 3);
assert.equal(
(await call(d, 'position_get', { id: firstBatch.result.id })).amount,
'8.86420975',
);
for (const operation of [edit, otherDraft]) {
assert.equal(
(
await web(d.cookie, '/agent/operations/' + operation.operationId, 'POST', {
approve: true,
})
).status,
409,
);
}
const webStale = await write(d, 'position_create', { ...position, name: 'web stale' });
assert.equal(
(
await web(d.cookie, '/positions', 'POST', {
...position,
name: 'manual web account',
})
).status,
201,
);
assert.equal(
(await web(d.cookie, '/agent/operations/' + webStale.operationId, 'POST', { approve: true }))
.status,
409,
);
const expired = await write(d, 'position_create', { ...position, name: 'expired' });
await db.agentOperation.update({
where: { id: expired.operationId },
+22
View File
@@ -0,0 +1,22 @@
# MCP 多账号草稿确认修复(2026-10-04)
## 问题与原因
通过 MCP 在同一个 state_get 状态下创建多个 position_create 草稿时,草稿都保存同一账目快照。确认第一个新增后账目变化,剩余新增草稿会报“账目已变化,请取消并重新创建操作”。
## 修复范围
确认 position_create 成功后,在用户锁和同一数据库事务内,将同一用户、同一连接、相同旧快照下尚未过期的待确认 position_create 草稿快照推进到最新状态。每份草稿仍需用户逐项审阅确认,不会自动执行。
仅独立新增草稿适用此规则。余额记录、修改、转账等其他草稿,以及不同连接的新增草稿,不会随之推进。网页新增或修改、设置变化仍触发原有冲突检查。原始幂等键和请求摘要不变,重试已完成操作不会重复创建账户。不需要数据库迁移。
修复前已因部分确认而落后于当前账目的草稿不自动恢复。应重新查询当前账号,只重新生成尚未新增的账号草稿,避免重复创建已经成功的账号。
## 验证
- 修复前通过官方 SDK 和真实 MySQL 复现后续草稿确认返回 409。
- 修复后三份同快照新增草稿全部确认成功,后两份并发确认也通过。
- 幂等重试返回原操作;金额十进制字符串保持精确。
- 不同连接的新增草稿、同快照余额草稿仍返回 409;网页新增也使旧草稿失效。
- MCP/OAuth 集成测试 6 项通过,单元测试 52 项通过,后端编译通过。
- 测试仅使用隔离用户,结束后清理。未确认或修改实际用户的草稿、账号或余额。
+2 -1
View File
@@ -92,4 +92,5 @@
- ~~快速记账的转账按钮 和快速记账显示在附近,不在显示在右边~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~还款可以优惠功能(负数表示手续费)~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md
- ~~净资产轨迹,可以设置隐藏某条折线,鼠标放线线可以查看当天数据~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md
- ~~净资产轨迹,可以设置隐藏某条折线,鼠标放线线可以查看当天数据~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md
- ~~修复 MCP 同批新增多个账号时后续草稿提示账目已变化~~ — 已完成并验证:2026-10-04 22:53(UTC+8),见 docs/fix-mcp-create-drafts-2026-10-04.md