feat(api): 开发平台、门店相关代码

This commit is contained in:
R524809
2026-09-03 16:04:44 +08:00
parent 35fcf8776e
commit ada02e9135
40 changed files with 7014 additions and 5807 deletions
+133
View File
@@ -0,0 +1,133 @@
import { createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from './api-error.js';
type IdempotentResponse = { status: number; body: unknown };
type IdempotencyInput = {
workspaceId: string;
userId: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
key: string;
request: unknown;
};
type IdempotencyRow = {
request_fingerprint: string;
status: 'in_progress' | 'completed';
response_status: number | null;
response_body: unknown;
};
@Injectable()
export class IdempotencyService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
/**
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
*/
public async run(
input: IdempotencyInput,
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
): Promise<IdempotentResponse> {
const fingerprint = createHmac(
'sha256',
this.config.IDEMPOTENCY_KEY_SECRET,
)
.update(canonicalize(input.request))
.digest('base64url');
return this.database.workspaceTransaction(
input.workspaceId,
async (transaction) => {
const inserted = await transaction.query<IdempotencyRow>(
`INSERT INTO atlas.idempotency_requests
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
RETURNING request_fingerprint, status, response_status, response_body`,
[
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
fingerprint,
],
);
if (!inserted.rows[0]) {
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
const existing = await transaction.query<IdempotencyRow>(
`SELECT request_fingerprint, status, response_status, response_body
FROM atlas.idempotency_requests
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
AND request_path = $4 AND idempotency_key = $5
FOR UPDATE`,
[
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
],
);
const row = existing.rows[0];
if (!row || row.request_fingerprint !== fingerprint) {
throw new ApiError(
409,
'IDEMPOTENCY_KEY_REUSED',
'幂等键已用于不同请求',
);
}
if (row.status !== 'completed' || !row.response_status) {
throw new ApiError(
409,
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
'相同请求仍在处理中',
);
}
return {
status: row.response_status,
body: row.response_body,
};
}
const response = await work(transaction);
await transaction.query(
`UPDATE atlas.idempotency_requests
SET status = 'completed', response_status = $1, response_body = $2,
completed_at = now()
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
AND request_path = $6 AND idempotency_key = $7`,
[
response.status,
response.body,
input.workspaceId,
input.userId,
input.method,
input.path,
input.key,
],
);
return response;
},
);
}
}
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object')
return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
.join(',')}}`;
}