@lark-apaas/coding-template-nestjs-react-fullstack 0.1.45 → 0.1.47-alpha.20260923111704
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/api/error.ts +84 -0
- package/template/client/src/api/index.ts +15 -12
- package/template/package-lock.json +4 -4
- package/template/package.json +1 -1
- package/template/server/common/filters/error-response.ts +92 -0
- package/template/server/common/filters/exception.filter.ts +6 -69
- package/template/server/common/interfaces/api_response.interface.ts +2 -21
- package/template/server/common/interfaces/exception.interface.ts +2 -2
- package/template/shared/api.interface.ts +16 -1
package/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AxiosResponse } from 'axios';
|
|
2
|
+
import type { ApiErrorResponse } from '@shared/api.interface';
|
|
3
|
+
|
|
4
|
+
export interface ApiErrorOptions {
|
|
5
|
+
/** 当前操作失败时的安全 UI 提示,不能使用异常、details、stack 或 cause 拼接。 */
|
|
6
|
+
fallbackMessage: string;
|
|
7
|
+
/** 仅在已核实端点的旧响应文案是用户可见合同后,由调用方显式启用。 */
|
|
8
|
+
allowLegacyUserMessage?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 失败始终作为异常传递;status 和原始响应 / rejected error 保存在 cause。 */
|
|
12
|
+
export class ApiRequestError extends Error {
|
|
13
|
+
public readonly cause: unknown;
|
|
14
|
+
|
|
15
|
+
constructor(message: string, public readonly status: number | undefined, cause: unknown) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ApiRequestError';
|
|
18
|
+
this.cause = cause;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isResponse(value: unknown): value is { status: number; data: unknown; headers?: unknown } {
|
|
27
|
+
return isRecord(value) && typeof value.status === 'number' && Number.isFinite(value.status);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizedMessage(value: unknown): string | undefined {
|
|
31
|
+
if (typeof value === 'string') return value.trim() || undefined;
|
|
32
|
+
if (!Array.isArray(value) || !value.every((item: unknown) => typeof item === 'string')) return undefined;
|
|
33
|
+
return value.map((item: string) => item.trim()).filter(Boolean).join(';') || undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function contentTypeOf(headers: unknown): string | undefined {
|
|
37
|
+
if (!isRecord(headers)) return undefined;
|
|
38
|
+
const get = headers.get;
|
|
39
|
+
if (typeof get === 'function') {
|
|
40
|
+
const value = get.call(headers, 'content-type');
|
|
41
|
+
if (typeof value === 'string') return value;
|
|
42
|
+
}
|
|
43
|
+
const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === 'content-type');
|
|
44
|
+
return typeof entry?.[1] === 'string' ? entry[1] : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isJsonResponse(response: { headers?: unknown }): boolean {
|
|
48
|
+
const contentType = contentTypeOf(response.headers);
|
|
49
|
+
if (!contentType) return false;
|
|
50
|
+
const mediaType = contentType.split(';', 1)[0].trim();
|
|
51
|
+
return /^application\/(?:json|[a-z0-9!#$%&'*+.^_`|~-]+\+json)$/i.test(mediaType);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function userMessage(response: { status: number; data: unknown; headers?: unknown }, options: ApiErrorOptions): string | undefined {
|
|
55
|
+
if (response.status < 400 || response.status >= 500 || !isJsonResponse(response)) return undefined;
|
|
56
|
+
const data = response.data;
|
|
57
|
+
if (!isRecord(data)) return undefined;
|
|
58
|
+
if (isRecord(data.error)) {
|
|
59
|
+
if (data.error.userFacing === true) {
|
|
60
|
+
const message: ApiErrorResponse['error']['message'] | undefined =
|
|
61
|
+
typeof data.error.message === 'string' ? data.error.message : undefined;
|
|
62
|
+
return normalizedMessage(message);
|
|
63
|
+
}
|
|
64
|
+
if (data.error.userFacing === false) return undefined;
|
|
65
|
+
}
|
|
66
|
+
return options.allowLegacyUserMessage ? normalizedMessage(data.message) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 兼容 axios reject 与当前 SDK 降级成 resolve 的非 2xx response;无网络响应则安全回退。 */
|
|
70
|
+
export function toApiRequestError(error: unknown, options: ApiErrorOptions): ApiRequestError {
|
|
71
|
+
if (error instanceof ApiRequestError) return error;
|
|
72
|
+
const response = isRecord(error) && isResponse(error.response) ? error.response : isResponse(error) ? error : undefined;
|
|
73
|
+
return new ApiRequestError(
|
|
74
|
+
(response && userMessage(response, options)) || options.fallbackMessage,
|
|
75
|
+
response?.status,
|
|
76
|
+
error,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 只返回真正成功的 data;resolved 的 403 等非 2xx 必须抛出。SDK 的 401 登录拦截器仍先行执行。 */
|
|
81
|
+
export function requireApiSuccess<T>(response: AxiosResponse<T>, options: ApiErrorOptions): T {
|
|
82
|
+
if (response.status >= 200 && response.status < 300) return response.data;
|
|
83
|
+
throw toApiRequestError(response, options);
|
|
84
|
+
}
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBackend';
|
|
1
|
+
export { ApiRequestError, requireApiSuccess, toApiRequestError } from './error';
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Add more API functions here. 先核对服务端路径 / method / 响应形状;不要把失败返回成成功数据。
|
|
4
|
+
// 使用示例(此处只演示调用方式,按真实 Controller 替换接口):
|
|
5
|
+
// import { logger } from '@lark-apaas/client-toolkit/logger';
|
|
6
|
+
// import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBackend';
|
|
7
|
+
// import { requireApiSuccess, toApiRequestError } from './error';
|
|
8
8
|
// export async function getUserData(userId: string) {
|
|
9
|
+
// const errorOptions = { fallbackMessage: '获取用户数据失败,请稍后重试' };
|
|
9
10
|
// try {
|
|
10
|
-
// const response = await axiosForBackend({
|
|
11
|
-
//
|
|
12
|
-
// method: 'GET'
|
|
13
|
-
// });
|
|
14
|
-
// return response.data;
|
|
11
|
+
// const response = await axiosForBackend({ url: `/api/users/${userId}`, method: 'GET' });
|
|
12
|
+
// return requireApiSuccess(response, errorOptions); // resolved 403 等非 2xx 也会抛错
|
|
15
13
|
// } catch (error) {
|
|
14
|
+
// const apiError = toApiRequestError(error, errorOptions); // rejected 4xx / 网络错误
|
|
16
15
|
// logger.error('获取用户数据失败', error);
|
|
17
|
-
// throw
|
|
16
|
+
// throw apiError; // 页面 catch 可展示 apiError.message,状态和原始错误在 status / cause
|
|
18
17
|
// }
|
|
19
18
|
// }
|
|
19
|
+
|
|
20
|
+
// 有已核实的旧端点将顶层 message:string|string[] 定义为用户文案时,才显式传
|
|
21
|
+
// { fallbackMessage: '操作失败,请稍后重试', allowLegacyUserMessage: true };默认只认 error.userFacing: true。
|
|
22
|
+
// 401 登录跳转由 axiosForBackend 自带拦截器负责,不在此处自行模拟登录。
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@hookform/resolvers": "^5.2.2",
|
|
35
35
|
"@lark-apaas/client-toolkit": "^1.2.72",
|
|
36
36
|
"@lark-apaas/coding-preset-vite-react": "^1.0.25",
|
|
37
|
-
"@lark-apaas/fullstack-presets": "^1.1.
|
|
37
|
+
"@lark-apaas/fullstack-presets": "^1.1.25",
|
|
38
38
|
"@nestjs/cli": "^10.4.9",
|
|
39
39
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
40
40
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
@@ -1735,9 +1735,9 @@
|
|
|
1735
1735
|
}
|
|
1736
1736
|
},
|
|
1737
1737
|
"node_modules/@lark-apaas/fullstack-presets": {
|
|
1738
|
-
"version": "1.1.
|
|
1739
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/fullstack-presets/-/fullstack-presets-1.1.
|
|
1740
|
-
"integrity": "sha512-
|
|
1738
|
+
"version": "1.1.25",
|
|
1739
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/fullstack-presets/-/fullstack-presets-1.1.25.tgz",
|
|
1740
|
+
"integrity": "sha512-NeS9AHBbk7FMlFjrB5QP4OWHWazO3vXLtoPxLiY25U0oUsl+TUZQI7p+Bud+bCDd/ZUUzvzLnf8jQbBt8np+Kg==",
|
|
1741
1741
|
"dev": true,
|
|
1742
1742
|
"license": "MIT",
|
|
1743
1743
|
"dependencies": {
|
package/template/package.json
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@hookform/resolvers": "^5.2.2",
|
|
55
55
|
"@lark-apaas/client-toolkit": "^1.2.72",
|
|
56
56
|
"@lark-apaas/coding-preset-vite-react": "^1.0.25",
|
|
57
|
-
"@lark-apaas/fullstack-presets": "^1.1.
|
|
57
|
+
"@lark-apaas/fullstack-presets": "^1.1.25",
|
|
58
58
|
"@nestjs/cli": "^10.4.9",
|
|
59
59
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
60
60
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { HttpException, HttpStatus } from '@nestjs/common';
|
|
2
|
+
import type { ApiErrorResponse } from '../interfaces/api_response.interface';
|
|
3
|
+
import { HTTP_STATUS_TO_RESPONSE_CODE_MAP, ResponseCode } from '../constants/api_response_code';
|
|
4
|
+
import { BusinessException } from '../interfaces/exception.interface';
|
|
5
|
+
|
|
6
|
+
/** 状态对应的固定安全文案,不读取任意异常的 message / response / cause。 */
|
|
7
|
+
function safeStatusMessage(status: number): string {
|
|
8
|
+
switch (status) {
|
|
9
|
+
case HttpStatus.BAD_REQUEST:
|
|
10
|
+
case HttpStatus.UNPROCESSABLE_ENTITY:
|
|
11
|
+
return '请求参数有误';
|
|
12
|
+
case HttpStatus.UNAUTHORIZED:
|
|
13
|
+
return '请先登录';
|
|
14
|
+
case HttpStatus.FORBIDDEN:
|
|
15
|
+
return '无操作权限';
|
|
16
|
+
case HttpStatus.NOT_FOUND:
|
|
17
|
+
return '资源不存在';
|
|
18
|
+
case HttpStatus.CONFLICT:
|
|
19
|
+
return '请求冲突';
|
|
20
|
+
case HttpStatus.TOO_MANY_REQUESTS:
|
|
21
|
+
return '请求过于频繁';
|
|
22
|
+
default:
|
|
23
|
+
return status >= 500 ? '服务暂不可用' : '请求失败';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createErrorResponse(exception: unknown): {
|
|
28
|
+
httpStatus: number;
|
|
29
|
+
body: ApiErrorResponse;
|
|
30
|
+
} {
|
|
31
|
+
const timestamp = Date.now();
|
|
32
|
+
if (exception instanceof BusinessException) {
|
|
33
|
+
const httpStatus = exception.httpStatus;
|
|
34
|
+
return {
|
|
35
|
+
httpStatus,
|
|
36
|
+
body: {
|
|
37
|
+
error: {
|
|
38
|
+
code: exception.code,
|
|
39
|
+
message: exception.message.trim() || safeStatusMessage(httpStatus),
|
|
40
|
+
userFacing: true,
|
|
41
|
+
details: exception.details,
|
|
42
|
+
fieldErrors: exception.fieldErrors,
|
|
43
|
+
timestamp,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (exception instanceof HttpException) {
|
|
49
|
+
const httpStatus = exception.getStatus();
|
|
50
|
+
return {
|
|
51
|
+
httpStatus,
|
|
52
|
+
body: {
|
|
53
|
+
error: {
|
|
54
|
+
code:
|
|
55
|
+
HTTP_STATUS_TO_RESPONSE_CODE_MAP[httpStatus] ??
|
|
56
|
+
(httpStatus >= 500 ? ResponseCode.INTERNAL_ERROR : ResponseCode.BAD_REQUEST),
|
|
57
|
+
message: safeStatusMessage(httpStatus),
|
|
58
|
+
userFacing: false,
|
|
59
|
+
timestamp,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (typeof exception === 'object' && exception !== null && 'code' in exception && exception.code === '22P02') {
|
|
65
|
+
// 非法 UUID 保留原来的 not-found 语义,但不是用户可见的业务文案。
|
|
66
|
+
return {
|
|
67
|
+
httpStatus: HttpStatus.NOT_FOUND,
|
|
68
|
+
body: {
|
|
69
|
+
error: {
|
|
70
|
+
code: ResponseCode.NOT_FOUND,
|
|
71
|
+
message: safeStatusMessage(HttpStatus.NOT_FOUND),
|
|
72
|
+
userFacing: false,
|
|
73
|
+
timestamp,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
httpStatus: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
80
|
+
body: {
|
|
81
|
+
error: {
|
|
82
|
+
code: ResponseCode.INTERNAL_ERROR,
|
|
83
|
+
message: safeStatusMessage(HttpStatus.INTERNAL_SERVER_ERROR),
|
|
84
|
+
userFacing: false,
|
|
85
|
+
...(process.env.NODE_ENV === 'development' && exception instanceof Error
|
|
86
|
+
? { stack: exception.stack, cause: typeof exception.cause === 'string' ? exception.cause : undefined }
|
|
87
|
+
: {}),
|
|
88
|
+
timestamp,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -1,78 +1,15 @@
|
|
|
1
|
-
import { ExceptionFilter, Catch, ArgumentsHost
|
|
1
|
+
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
|
|
2
2
|
import type { Response } from 'express';
|
|
3
|
-
import {
|
|
4
|
-
import { HTTP_STATUS_TO_RESPONSE_CODE_MAP, ResponseCode } from '../constants/api_response_code';
|
|
5
|
-
import { ApiErrorResponse } from '../interfaces/api_response.interface';
|
|
3
|
+
import { createErrorResponse } from './error-response';
|
|
6
4
|
|
|
7
5
|
// 全局异常过滤器,用于捕获所有未处理的异常
|
|
8
6
|
@Catch()
|
|
9
7
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
10
8
|
catch(exception: unknown, host: ArgumentsHost) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// 如果响应头已发送,则不处理
|
|
15
|
-
if (response.headersSent) {
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
9
|
+
const response = host.switchToHttp().getResponse<Response>();
|
|
10
|
+
if (response.headersSent) return;
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (exception instanceof BusinessException) {
|
|
23
|
-
// 业务异常
|
|
24
|
-
httpStatus = exception.httpStatus;
|
|
25
|
-
errorResponse = {
|
|
26
|
-
error: {
|
|
27
|
-
code: exception.code,
|
|
28
|
-
message: exception.message,
|
|
29
|
-
details: exception.details,
|
|
30
|
-
fieldErrors: exception.fieldErrors,
|
|
31
|
-
timestamp: Date.now(),
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
} else if (exception instanceof HttpException) {
|
|
35
|
-
// HTTP异常
|
|
36
|
-
httpStatus = exception.getStatus() as HttpStatus;
|
|
37
|
-
const exceptionResponse = exception.getResponse();
|
|
38
|
-
|
|
39
|
-
errorResponse = {
|
|
40
|
-
error: {
|
|
41
|
-
code: HTTP_STATUS_TO_RESPONSE_CODE_MAP[httpStatus],
|
|
42
|
-
message: typeof exceptionResponse === 'string' ? exceptionResponse : exception.message,
|
|
43
|
-
details: typeof exceptionResponse === 'object' ? JSON.stringify(exceptionResponse) : undefined,
|
|
44
|
-
timestamp: Date.now(),
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
} else if (
|
|
48
|
-
typeof exception === 'object' &&
|
|
49
|
-
exception !== null &&
|
|
50
|
-
(exception as { code?: unknown }).code === '22P02'
|
|
51
|
-
) {
|
|
52
|
-
// Postgres invalid_text_representation:路径/查询参数与列类型不匹配(最常见是非法 UUID)
|
|
53
|
-
// 与「合法 UUID 但记录不存在」走同一条 not-found 语义,避免 500 噪声
|
|
54
|
-
httpStatus = HttpStatus.NOT_FOUND;
|
|
55
|
-
errorResponse = {
|
|
56
|
-
error: {
|
|
57
|
-
code: ResponseCode.NOT_FOUND,
|
|
58
|
-
message: '资源不存在',
|
|
59
|
-
timestamp: Date.now(),
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
} else {
|
|
63
|
-
// 未知异常
|
|
64
|
-
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
65
|
-
errorResponse = {
|
|
66
|
-
error: {
|
|
67
|
-
code: ResponseCode.INTERNAL_ERROR,
|
|
68
|
-
message: '服务器内部错误',
|
|
69
|
-
stack: (exception as Error).stack,
|
|
70
|
-
cause: (exception as Error).cause as string,
|
|
71
|
-
timestamp: Date.now(),
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
response.status(httpStatus).json(errorResponse);
|
|
12
|
+
const { httpStatus, body } = createErrorResponse(exception);
|
|
13
|
+
response.status(httpStatus).json(body);
|
|
77
14
|
}
|
|
78
15
|
}
|
|
@@ -1,21 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
export
|
|
3
|
-
/** 错误详情 */
|
|
4
|
-
error: {
|
|
5
|
-
/** 错误代码 */
|
|
6
|
-
code: string;
|
|
7
|
-
/** 错误消息 */
|
|
8
|
-
message: string;
|
|
9
|
-
/** 错误详情 */
|
|
10
|
-
details?: string;
|
|
11
|
-
/** 字段验证错误 */
|
|
12
|
-
fieldErrors?: Record<string, string[]>;
|
|
13
|
-
/** 调用栈(仅开发环境) */
|
|
14
|
-
stack?: string;
|
|
15
|
-
/** 错误原因 */
|
|
16
|
-
cause?: string;
|
|
17
|
-
/** 错误发生时间 */
|
|
18
|
-
timestamp?: number;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
1
|
+
// 旧的服务端导入路径保持可用;实际合同由 shared 统一维护。
|
|
2
|
+
export type { ApiErrorResponse } from '../../../shared/api.interface';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 业务异常类
|
|
2
2
|
import { HttpStatus } from '@nestjs/common';
|
|
3
|
-
import {
|
|
3
|
+
import { ResponseCode } from '../constants/api_response_code';
|
|
4
4
|
|
|
5
5
|
export class BusinessException extends Error {
|
|
6
6
|
constructor(
|
|
@@ -14,6 +14,6 @@ export class BusinessException extends Error {
|
|
|
14
14
|
this.name = 'BusinessException';
|
|
15
15
|
}
|
|
16
16
|
getHttpStatus(): HttpStatus {
|
|
17
|
-
return
|
|
17
|
+
return this.httpStatus;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -1 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/** 全栈模板的错误响应合同;成功响应仍由业务接口各自定义。 */
|
|
2
|
+
export interface ApiErrorResponse {
|
|
3
|
+
error: {
|
|
4
|
+
code: string;
|
|
5
|
+
/** 仅 userFacing 为 true 时可作为 UI 文案;其他情况由调用方提供安全提示。 */
|
|
6
|
+
message: string;
|
|
7
|
+
/** 只由本模板 Filter 为受控 BusinessException 设为 true。 */
|
|
8
|
+
userFacing: boolean;
|
|
9
|
+
/** 以下字段仅用于诊断,绝不能拼接到 UI 文案。 */
|
|
10
|
+
details?: string;
|
|
11
|
+
fieldErrors?: Record<string, string[]>;
|
|
12
|
+
stack?: string;
|
|
13
|
+
cause?: string;
|
|
14
|
+
timestamp?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|