330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
|
|
/**
|
|||
|
|
* 待确认动作(任务 A4.4):确认卡与一次性执行授权的持久层。
|
|||
|
|
*
|
|||
|
|
* 一张表同时承担「待确认动作」与「执行记录」两件事(A4 备注:不设单独的凭据表与审批事件表):
|
|||
|
|
* 参数摘要、状态、回执、幂等键、有效期都在一行里,审批与执行的状态迁移是同一行的状态迁移。
|
|||
|
|
*
|
|||
|
|
* 三条不变量:
|
|||
|
|
* 1. 确认卡绑定动作、参数摘要、目标版本与有效期——版本变了确认即失效
|
|||
|
|
* 2. 确认人必须是会话发起人本人(单用户场景不做委托与转派)
|
|||
|
|
* 3. 待确认状态落库,进程重启后仍在;同一写请求重试不重复生效
|
|||
|
|
*/
|
|||
|
|
import { randomUUID } from 'node:crypto';
|
|||
|
|
|
|||
|
|
import type { ExecutionReceipt } from '@aps/contracts';
|
|||
|
|
|
|||
|
|
import type { SealedSecret } from '../credentials/broker.js';
|
|||
|
|
import type { Database } from '../storage/db.js';
|
|||
|
|
|
|||
|
|
export type PendingStatus = 'pending-confirmation' | 'succeeded' | 'failed' | 'rejected' | 'expired';
|
|||
|
|
export type ApprovalDecision = 'approved' | 'rejected' | 'expired';
|
|||
|
|
|
|||
|
|
export interface PendingAction {
|
|||
|
|
executionId: string;
|
|||
|
|
approvalId: string;
|
|||
|
|
sessionId: string;
|
|||
|
|
projectId: string | null;
|
|||
|
|
ownerUserId: string;
|
|||
|
|
tenantId: string;
|
|||
|
|
capabilityId: string;
|
|||
|
|
providerId: string;
|
|||
|
|
paramsDigest: string;
|
|||
|
|
/** 脱敏后的关键参数:给确认卡看的,不是完整参数 */
|
|||
|
|
paramsPreview: Record<string, unknown>;
|
|||
|
|
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
|
|||
|
|
paramsSealed: SealedSecret;
|
|||
|
|
impactSummary: string;
|
|||
|
|
/** 目标数据版本;版本变了确认就失效 */
|
|||
|
|
revision: number;
|
|||
|
|
status: PendingStatus;
|
|||
|
|
decision?: ApprovalDecision;
|
|||
|
|
decidedBy?: string;
|
|||
|
|
decidedAt?: number;
|
|||
|
|
/** 执行幂等键:同一写请求重试不重复生效 */
|
|||
|
|
idempotencyKey?: string;
|
|||
|
|
/** 确认请求的幂等键 */
|
|||
|
|
decisionIdempotencyKey?: string;
|
|||
|
|
grantId?: string;
|
|||
|
|
receipt: ExecutionReceipt;
|
|||
|
|
expiresAt: number;
|
|||
|
|
createdAt: number;
|
|||
|
|
updatedAt: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface CreatePendingInput {
|
|||
|
|
sessionId: string;
|
|||
|
|
projectId: string | null;
|
|||
|
|
ownerUserId: string;
|
|||
|
|
tenantId: string;
|
|||
|
|
capabilityId: string;
|
|||
|
|
providerId: string;
|
|||
|
|
paramsDigest: string;
|
|||
|
|
paramsPreview: Record<string, unknown>;
|
|||
|
|
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
|
|||
|
|
paramsSealed: SealedSecret;
|
|||
|
|
impactSummary: string;
|
|||
|
|
revision: number;
|
|||
|
|
expiresAt: number;
|
|||
|
|
idempotencyKey?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type DecideKind = 'approved' | 'rejected' | 'expired' | 'replayed' | 'not-found' | 'revision-mismatch';
|
|||
|
|
|
|||
|
|
export interface DecideResult {
|
|||
|
|
kind: DecideKind;
|
|||
|
|
action: PendingAction;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface PendingRow {
|
|||
|
|
execution_id: string;
|
|||
|
|
approval_id: string;
|
|||
|
|
session_id: string;
|
|||
|
|
project_id: string | null;
|
|||
|
|
owner_user_id: string;
|
|||
|
|
tenant_id: string;
|
|||
|
|
capability_id: string;
|
|||
|
|
provider_id: string;
|
|||
|
|
params_digest: string;
|
|||
|
|
params_preview: string;
|
|||
|
|
params_iv: string | null;
|
|||
|
|
params_ciphertext: string | null;
|
|||
|
|
params_tag: string | null;
|
|||
|
|
impact_summary: string;
|
|||
|
|
revision: number;
|
|||
|
|
status: string;
|
|||
|
|
decision: string | null;
|
|||
|
|
decided_by: string | null;
|
|||
|
|
decided_at: number | null;
|
|||
|
|
idempotency_key: string | null;
|
|||
|
|
decision_idempotency_key: string | null;
|
|||
|
|
grant_id: string | null;
|
|||
|
|
receipt: string;
|
|||
|
|
expires_at: number;
|
|||
|
|
created_at: number;
|
|||
|
|
updated_at: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sealedFromRow(row: PendingRow): SealedSecret {
|
|||
|
|
return { iv: row.params_iv ?? '', ciphertext: row.params_ciphertext ?? '', tag: row.params_tag ?? '' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fromRow(row: PendingRow): PendingAction {
|
|||
|
|
return {
|
|||
|
|
executionId: row.execution_id,
|
|||
|
|
approvalId: row.approval_id,
|
|||
|
|
sessionId: row.session_id,
|
|||
|
|
projectId: row.project_id,
|
|||
|
|
ownerUserId: row.owner_user_id,
|
|||
|
|
tenantId: row.tenant_id,
|
|||
|
|
capabilityId: row.capability_id,
|
|||
|
|
providerId: row.provider_id,
|
|||
|
|
paramsDigest: row.params_digest,
|
|||
|
|
paramsPreview: JSON.parse(row.params_preview) as Record<string, unknown>,
|
|||
|
|
paramsSealed: sealedFromRow(row),
|
|||
|
|
impactSummary: row.impact_summary,
|
|||
|
|
revision: row.revision,
|
|||
|
|
status: row.status as PendingStatus,
|
|||
|
|
...(row.decision === null ? {} : { decision: row.decision as ApprovalDecision }),
|
|||
|
|
...(row.decided_by === null ? {} : { decidedBy: row.decided_by }),
|
|||
|
|
...(row.decided_at === null ? {} : { decidedAt: row.decided_at }),
|
|||
|
|
...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }),
|
|||
|
|
...(row.decision_idempotency_key === null ? {} : { decisionIdempotencyKey: row.decision_idempotency_key }),
|
|||
|
|
...(row.grant_id === null ? {} : { grantId: row.grant_id }),
|
|||
|
|
receipt: JSON.parse(row.receipt) as ExecutionReceipt,
|
|||
|
|
expiresAt: row.expires_at,
|
|||
|
|
createdAt: row.created_at,
|
|||
|
|
updatedAt: row.updated_at,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export class PendingActionStore {
|
|||
|
|
private readonly memory = new Map<string, PendingAction>();
|
|||
|
|
|
|||
|
|
constructor(
|
|||
|
|
private readonly db?: Database,
|
|||
|
|
private readonly now: () => number = Date.now,
|
|||
|
|
) {}
|
|||
|
|
|
|||
|
|
create(input: CreatePendingInput): PendingAction {
|
|||
|
|
const at = this.now();
|
|||
|
|
const action: PendingAction = {
|
|||
|
|
executionId: `exec-${randomUUID()}`,
|
|||
|
|
approvalId: `apr-${randomUUID()}`,
|
|||
|
|
sessionId: input.sessionId,
|
|||
|
|
projectId: input.projectId,
|
|||
|
|
ownerUserId: input.ownerUserId,
|
|||
|
|
tenantId: input.tenantId,
|
|||
|
|
capabilityId: input.capabilityId,
|
|||
|
|
providerId: input.providerId,
|
|||
|
|
paramsDigest: input.paramsDigest,
|
|||
|
|
paramsPreview: input.paramsPreview,
|
|||
|
|
paramsSealed: input.paramsSealed,
|
|||
|
|
impactSummary: input.impactSummary,
|
|||
|
|
revision: input.revision,
|
|||
|
|
status: 'pending-confirmation',
|
|||
|
|
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
|||
|
|
receipt: { executionId: '', status: 'pending-confirmation' },
|
|||
|
|
expiresAt: input.expiresAt,
|
|||
|
|
createdAt: at,
|
|||
|
|
updatedAt: at,
|
|||
|
|
};
|
|||
|
|
action.receipt = { executionId: action.executionId, status: 'pending-confirmation', approvalId: action.approvalId };
|
|||
|
|
this.save(action);
|
|||
|
|
return action;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get(approvalId: string): PendingAction | undefined {
|
|||
|
|
if (this.db) {
|
|||
|
|
const row = this.db.prepare('select * from pending_actions where approval_id = ?').get(approvalId) as PendingRow | undefined;
|
|||
|
|
return row ? fromRow(row) : undefined;
|
|||
|
|
}
|
|||
|
|
return this.memory.get(approvalId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
byExecution(executionId: string): PendingAction | undefined {
|
|||
|
|
if (this.db) {
|
|||
|
|
const row = this.db.prepare('select * from pending_actions where execution_id = ?').get(executionId) as PendingRow | undefined;
|
|||
|
|
return row ? fromRow(row) : undefined;
|
|||
|
|
}
|
|||
|
|
return [...this.memory.values()].find((action) => action.executionId === executionId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 同一写请求的重试:找到原待确认动作,不新建第二张确认卡 */
|
|||
|
|
findByIdempotencyKey(sessionId: string, idempotencyKey: string): PendingAction | undefined {
|
|||
|
|
if (this.db) {
|
|||
|
|
const row = this.db
|
|||
|
|
.prepare('select * from pending_actions where session_id = ? and idempotency_key = ? limit 1')
|
|||
|
|
.get(sessionId, idempotencyKey) as PendingRow | undefined;
|
|||
|
|
return row ? fromRow(row) : undefined;
|
|||
|
|
}
|
|||
|
|
return [...this.memory.values()].find((action) => action.sessionId === sessionId && action.idempotencyKey === idempotencyKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
listBySession(sessionId: string): PendingAction[] {
|
|||
|
|
if (this.db) {
|
|||
|
|
const rows = this.db
|
|||
|
|
.prepare('select * from pending_actions where session_id = ? order by created_at')
|
|||
|
|
.all(sessionId) as unknown as PendingRow[];
|
|||
|
|
return rows.map(fromRow);
|
|||
|
|
}
|
|||
|
|
return [...this.memory.values()].filter((action) => action.sessionId === sessionId).sort((a, b) => a.createdAt - b.createdAt);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 确认或拒绝。返回 kind 描述这次调用发生了什么;已决策过的再次调用返回 replayed(幂等) */
|
|||
|
|
decide(
|
|||
|
|
approvalId: string,
|
|||
|
|
input: { decision: 'approve' | 'reject'; revision: number; decidedBy: string; idempotencyKey: string },
|
|||
|
|
): DecideResult | undefined {
|
|||
|
|
const action = this.get(approvalId);
|
|||
|
|
if (!action) return undefined;
|
|||
|
|
|
|||
|
|
// 已经决策过:同一请求重放返回同一结果;不重复执行
|
|||
|
|
if (action.decision !== undefined && action.decision !== 'expired') return { kind: 'replayed', action };
|
|||
|
|
|
|||
|
|
const at = this.now();
|
|||
|
|
if (at > action.expiresAt) {
|
|||
|
|
const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at };
|
|||
|
|
this.save(expired);
|
|||
|
|
return { kind: 'expired', action: expired };
|
|||
|
|
}
|
|||
|
|
if (input.revision !== action.revision) {
|
|||
|
|
// 目标数据在等待确认期间变了:这次确认作废,必须重新发起(协议第 7 节)
|
|||
|
|
const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at };
|
|||
|
|
this.save(expired);
|
|||
|
|
return { kind: 'revision-mismatch', action: expired };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (input.decision === 'reject') {
|
|||
|
|
const rejected: PendingAction = {
|
|||
|
|
...action,
|
|||
|
|
status: 'rejected',
|
|||
|
|
decision: 'rejected',
|
|||
|
|
decidedBy: input.decidedBy,
|
|||
|
|
decidedAt: at,
|
|||
|
|
decisionIdempotencyKey: input.idempotencyKey,
|
|||
|
|
updatedAt: at,
|
|||
|
|
};
|
|||
|
|
this.save(rejected);
|
|||
|
|
return { kind: 'rejected', action: rejected };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const approved: PendingAction = {
|
|||
|
|
...action,
|
|||
|
|
decision: 'approved',
|
|||
|
|
decidedBy: input.decidedBy,
|
|||
|
|
decidedAt: at,
|
|||
|
|
decisionIdempotencyKey: input.idempotencyKey,
|
|||
|
|
updatedAt: at,
|
|||
|
|
};
|
|||
|
|
this.save(approved);
|
|||
|
|
return { kind: 'approved', action: approved };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 签发一次性授权后记账 */
|
|||
|
|
attachGrant(approvalId: string, grantId: string): PendingAction | undefined {
|
|||
|
|
const action = this.get(approvalId);
|
|||
|
|
if (!action) return undefined;
|
|||
|
|
const updated: PendingAction = { ...action, grantId, updatedAt: this.now() };
|
|||
|
|
this.save(updated);
|
|||
|
|
return updated;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 执行结束:回执与状态落到同一行 */
|
|||
|
|
complete(executionId: string, receipt: ExecutionReceipt): PendingAction | undefined {
|
|||
|
|
const action = this.byExecution(executionId);
|
|||
|
|
if (!action) return undefined;
|
|||
|
|
const status: PendingStatus = receipt.status === 'succeeded' ? 'succeeded' : receipt.status === 'failed' ? 'failed' : 'pending-confirmation';
|
|||
|
|
const updated: PendingAction = { ...action, status, receipt, updatedAt: this.now() };
|
|||
|
|
this.save(updated);
|
|||
|
|
return updated;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private save(action: PendingAction): void {
|
|||
|
|
if (this.db) {
|
|||
|
|
this.db
|
|||
|
|
.prepare(
|
|||
|
|
`insert into pending_actions (execution_id, approval_id, session_id, project_id, owner_user_id, tenant_id, capability_id, provider_id, params_digest, params_preview, params_iv, params_ciphertext, params_tag, impact_summary, revision, status, decision, decided_by, decided_at, idempotency_key, decision_idempotency_key, grant_id, receipt, expires_at, created_at, updated_at)
|
|||
|
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
on conflict(execution_id) do update set
|
|||
|
|
status = excluded.status,
|
|||
|
|
decision = excluded.decision,
|
|||
|
|
decided_by = excluded.decided_by,
|
|||
|
|
decided_at = excluded.decided_at,
|
|||
|
|
decision_idempotency_key = excluded.decision_idempotency_key,
|
|||
|
|
grant_id = excluded.grant_id,
|
|||
|
|
receipt = excluded.receipt,
|
|||
|
|
updated_at = excluded.updated_at`,
|
|||
|
|
)
|
|||
|
|
.run(
|
|||
|
|
action.executionId,
|
|||
|
|
action.approvalId,
|
|||
|
|
action.sessionId,
|
|||
|
|
action.projectId,
|
|||
|
|
action.ownerUserId,
|
|||
|
|
action.tenantId,
|
|||
|
|
action.capabilityId,
|
|||
|
|
action.providerId,
|
|||
|
|
action.paramsDigest,
|
|||
|
|
JSON.stringify(action.paramsPreview),
|
|||
|
|
action.paramsSealed.iv,
|
|||
|
|
action.paramsSealed.ciphertext,
|
|||
|
|
action.paramsSealed.tag,
|
|||
|
|
action.impactSummary,
|
|||
|
|
action.revision,
|
|||
|
|
action.status,
|
|||
|
|
action.decision ?? null,
|
|||
|
|
action.decidedBy ?? null,
|
|||
|
|
action.decidedAt ?? null,
|
|||
|
|
action.idempotencyKey ?? null,
|
|||
|
|
action.decisionIdempotencyKey ?? null,
|
|||
|
|
action.grantId ?? null,
|
|||
|
|
JSON.stringify(action.receipt),
|
|||
|
|
action.expiresAt,
|
|||
|
|
action.createdAt,
|
|||
|
|
action.updatedAt,
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
this.memory.set(action.approvalId, action);
|
|||
|
|
}
|
|||
|
|
}
|