feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -25,6 +25,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
||||
}>();
|
||||
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||
if (exception instanceof ApiError) {
|
||||
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
|
||||
const body = exception.getResponse() as {
|
||||
error: Record<string, unknown>;
|
||||
};
|
||||
@@ -33,6 +34,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
||||
.json({ error: { ...body.error, request_id: requestId } });
|
||||
}
|
||||
if (exception instanceof HttpException) {
|
||||
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
|
||||
return response.status(exception.getStatus()).json({
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
@@ -50,6 +52,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
||||
path: request.originalUrl,
|
||||
},
|
||||
);
|
||||
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
|
||||
return response.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class ApiError extends HttpException {
|
||||
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
|
||||
public constructor(
|
||||
status: HttpStatus,
|
||||
code: string,
|
||||
|
||||
@@ -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(',')}}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
|
||||
|
||||
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
|
||||
Reference in New Issue
Block a user