feat:cash-assets-batch-draft-review-and-login-memory
This commit is contained in:
1 parent
ce8609116b
commit
cfbba73791
16 files changed
+872
-46
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user