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
+3
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class ApiError extends HttpException {
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
public constructor(
status: HttpStatus,
code: string,
+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(',')}}`;
}
+6
View File
@@ -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);