befly 3.72.0 → 3.73.0

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/checks/table.js CHANGED
@@ -1,126 +1,42 @@
1
- import * as z from "zod";
2
-
3
- import { FIELD_RULE_DEFAULT_MAX, FIELD_RULE_DEFAULT_MIN, FIELD_RULE_INPUT_TYPES } from "../configs/constConfig.js";
4
1
  import { Logger } from "../lib/logger.js";
5
- import { formatZodIssues } from "../utils/formatZodIssues.js";
6
- import { isNoTrimStringAllowEmpty, isNullable, isRegexInput } from "../utils/is.js";
2
+ import { validateFieldRule } from "../lib/validator.js";
3
+ import { formatValidationIssues } from "../utils/formatValidationIssues.js";
4
+ import { isPlainObject } from "../utils/is.js";
7
5
  import { snakeCase } from "../utils/util.js";
8
6
 
9
- z.config(z.locales.zhCN());
10
-
11
7
  const lowerCamelRegex = /^_?[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
12
8
  const lowerSnakeRegex = /^_?[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
13
- const noTrimString = z.string().refine(isNoTrimStringAllowEmpty, "不允许首尾空格");
14
- const inputSchema = z.enum(FIELD_RULE_INPUT_TYPES);
15
9
 
16
10
  function isValidFieldName(fieldName) {
17
- if (String(fieldName).includes("_")) {
18
- return lowerSnakeRegex.test(fieldName);
19
- }
20
-
21
- return lowerCamelRegex.test(fieldName);
22
- }
23
-
24
- function addIssue(context, path, message) {
25
- context.addIssue({
26
- path: path,
27
- message: message
28
- });
11
+ return fieldName.includes("_") ? lowerSnakeRegex.test(fieldName) : lowerCamelRegex.test(fieldName);
29
12
  }
30
13
 
31
- const fieldDefSchema = z
32
- .object({
33
- name: z.string().min(1),
34
- input: inputSchema,
35
- check: noTrimString.optional(),
36
- min: z.number().nullable().optional(),
37
- max: z.number().nullable().optional(),
38
- detail: z.string().optional()
39
- })
40
- .strict()
41
- .superRefine((value, context) => {
42
- const min = isNullable(value.min) ? FIELD_RULE_DEFAULT_MIN : value.min;
43
- const max = isNullable(value.max) ? FIELD_RULE_DEFAULT_MAX : value.max;
44
- if (min > max) {
45
- addIssue(context, ["min"], "min 不能大于 max");
46
- }
47
-
48
- const checkValue = value.check;
49
- const input = value.input;
50
-
51
- if ((input === "enum" || input === "enumNumber" || input === "enumInteger" || input === "regexp") && (!checkValue || checkValue.length === 0)) {
52
- addIssue(context, ["check"], `${input} 必须提供 check`);
53
- return;
54
- }
55
-
56
- if (input === "regexp" && checkValue && !isRegexInput(checkValue)) {
57
- addIssue(context, ["check"], "regexp.check 必须是正则字面量或 @ 别名");
58
- return;
59
- }
60
-
61
- if ((input === "enum" || input === "enumNumber" || input === "enumInteger") && checkValue) {
62
- const items = String(checkValue)
63
- .split("|")
64
- .map((item) => item.trim())
65
- .filter((item) => item.length > 0);
66
-
67
- if (items.length === 0) {
68
- addIssue(context, ["check"], `${input}.check 必须是非空枚举值列表`);
69
- return;
70
- }
71
-
72
- if (input === "enumNumber" || input === "enumInteger") {
73
- for (const item of items) {
74
- const num = Number(item);
75
- if (!Number.isFinite(num)) {
76
- addIssue(context, ["check"], `${input}.check 存在非法数字值: ${item}`);
77
- return;
78
- }
79
- if (input === "enumInteger" && !Number.isInteger(num)) {
80
- addIssue(context, ["check"], `${input}.check 存在非法整数值: ${item}`);
81
- return;
82
- }
83
- }
14
+ export async function checkTable(tables) {
15
+ const issues = [];
16
+ if (!isPlainObject(tables)) {
17
+ issues.push({ path: [], message: "必须是对象格式" });
18
+ } else {
19
+ for (const [tableName, fields] of Object.entries(tables)) {
20
+ if (!lowerCamelRegex.test(tableName)) issues.push({ path: [tableName], message: "表名必须为 lowerCamelCase" });
21
+ if (!isPlainObject(fields)) {
22
+ issues.push({ path: [tableName], message: "必须是对象格式" });
23
+ continue;
84
24
  }
85
- }
86
- });
87
25
 
88
- const tableContentSchema = z.record(z.string().refine(isValidFieldName, "字段名必须为 lowerCamelCase 或 snake_case"), fieldDefSchema).superRefine((value, context) => {
89
- const fieldNameMap = new Map();
26
+ const fieldNameMap = new Map();
27
+ for (const [fieldName, rule] of Object.entries(fields)) {
28
+ if (!isValidFieldName(fieldName)) issues.push({ path: [tableName, fieldName], message: "字段名必须为 lowerCamelCase 或 snake_case" });
29
+ for (const issue of validateFieldRule(rule)) issues.push({ path: [tableName, fieldName, ...issue.path], message: issue.message });
90
30
 
91
- for (const fieldName of Object.keys(value)) {
92
- const dbFieldName = snakeCase(fieldName);
93
- const previousFieldName = fieldNameMap.get(dbFieldName);
94
-
95
- if (previousFieldName) {
96
- addIssue(context, [fieldName], `字段名 ${fieldName} 与 ${previousFieldName} 指向同一数据库字段 ${dbFieldName}`);
97
- continue;
31
+ const dbFieldName = snakeCase(fieldName);
32
+ const previousFieldName = fieldNameMap.get(dbFieldName);
33
+ if (previousFieldName) issues.push({ path: [tableName, fieldName], message: `字段名 ${fieldName} 与 ${previousFieldName} 指向同一数据库字段 ${dbFieldName}` });
34
+ else fieldNameMap.set(dbFieldName, fieldName);
35
+ }
98
36
  }
99
-
100
- fieldNameMap.set(dbFieldName, fieldName);
101
- }
102
- });
103
- const tableRegistrySchema = z.record(z.string().regex(lowerCamelRegex), tableContentSchema);
104
-
105
- /**
106
- * 检查表定义文件
107
- * @throws 当检查失败时抛出异常
108
- */
109
- export async function checkTable(tables) {
110
- const result = tableRegistrySchema.safeParse(tables);
111
- if (result.success) {
112
- return false;
113
37
  }
114
38
 
115
- Logger.warn(
116
- "表结构校验失败",
117
- {
118
- errors: formatZodIssues(result.error.issues, {
119
- items: tables,
120
- itemLabel: "table"
121
- })
122
- },
123
- false
124
- );
39
+ if (issues.length === 0) return false;
40
+ Logger.warn("表结构校验失败", { errors: formatValidationIssues(issues, { item: tables, itemLabel: "table" }) }, false);
125
41
  return true;
126
42
  }
@@ -55,7 +55,6 @@
55
55
  },
56
56
 
57
57
  "cors": {
58
- "origin": "*",
59
58
  "methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
60
59
  "allowedHeaders": "Content-Type,Authorization",
61
60
  "exposedHeaders": "",
@@ -68,7 +67,6 @@
68
67
  "defaultLimit": 1000,
69
68
  "defaultWindow": 60,
70
69
  "key": "ip",
71
- "skipRoutes": [],
72
- "rules": []
70
+ "skipRoutes": []
73
71
  }
74
72
  }
@@ -5,72 +5,13 @@ import { ErrorResponse } from "../utils/response.js";
5
5
 
6
6
  const RATE_LIMIT_ENABLED_VALUES = new Set([1, true, "1", "true"]);
7
7
 
8
- function isRateLimitEnabled(value) {
9
- return RATE_LIMIT_ENABLED_VALUES.has(value);
10
- }
11
-
12
8
  function toPositiveInt(value, fallbackValue) {
13
- const num = Number(value);
14
-
15
- if (!Number.isFinite(num) || num <= 0) {
16
- return fallbackValue;
17
- }
18
-
19
- return Math.floor(num);
9
+ const number = Number(value);
10
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallbackValue;
20
11
  }
21
12
 
22
13
  function toSafeString(value, fallbackValue) {
23
- if (typeof value !== "string") {
24
- return fallbackValue;
25
- }
26
-
27
- const trimmed = value.trim();
28
-
29
- if (!trimmed) {
30
- return fallbackValue;
31
- }
32
-
33
- return trimmed;
34
- }
35
-
36
- function toRuleMethods(rule) {
37
- if (Array.isArray(rule?.methods)) {
38
- const values = [];
39
-
40
- for (const method of rule.methods) {
41
- const text = toSafeString(method, "");
42
- if (!text) {
43
- continue;
44
- }
45
-
46
- values.push(text.toUpperCase());
47
- }
48
-
49
- return values;
50
- }
51
-
52
- if (typeof rule?.method === "string") {
53
- const value = toSafeString(rule.method, "");
54
- if (value) {
55
- return [value.toUpperCase()];
56
- }
57
- }
58
-
59
- return [];
60
- }
61
-
62
- function normalizeRule(rawRule, defaultLimit, defaultWindow, defaultKeyType) {
63
- const rule = rawRule && typeof rawRule === "object" ? rawRule : {};
64
- const pathPattern = toSafeString(rule.path || rule.apiPath || rule.route, "");
65
- const methods = toRuleMethods(rule);
66
-
67
- return {
68
- pathPattern: pathPattern,
69
- methods: methods,
70
- limit: toPositiveInt(rule.limit, defaultLimit),
71
- window: toPositiveInt(rule.window, defaultWindow),
72
- keyType: toSafeString(rule.key, defaultKeyType)
73
- };
14
+ return typeof value === "string" && value.trim() ? value.trim() : fallbackValue;
74
15
  }
75
16
 
76
17
  function createMatcher(pattern) {
@@ -82,117 +23,31 @@ function createMatcher(pattern) {
82
23
  }
83
24
 
84
25
  function normalizeConfig(config) {
85
- const defaultLimit = toPositiveInt(config?.defaultLimit, 1000);
86
- const defaultWindow = toPositiveInt(config?.defaultWindow, 60);
87
- const defaultKeyType = toSafeString(config?.key, "ip");
88
-
89
26
  const skipMatchers = [];
90
- const skipRoutes = Array.isArray(config?.skipRoutes) ? config.skipRoutes : [];
91
-
92
- for (const route of skipRoutes) {
93
- const pattern = toSafeString(route, "");
94
- if (!pattern) {
95
- continue;
96
- }
97
-
98
- const matcher = createMatcher(pattern);
99
- if (!matcher) {
100
- continue;
101
- }
102
-
103
- skipMatchers.push(matcher);
104
- }
105
-
106
- const rules = [];
107
- const rawRules = Array.isArray(config?.rules) ? config.rules : [];
108
-
109
- for (const rawRule of rawRules) {
110
- const normalizedRule = normalizeRule(rawRule, defaultLimit, defaultWindow, defaultKeyType);
111
-
112
- if (!normalizedRule.pathPattern) {
113
- continue;
114
- }
115
-
116
- const matcher = createMatcher(normalizedRule.pathPattern);
117
- if (!matcher) {
118
- continue;
119
- }
120
-
121
- rules.push({
122
- matcher: matcher,
123
- methods: normalizedRule.methods,
124
- limit: normalizedRule.limit,
125
- window: normalizedRule.window,
126
- keyType: normalizedRule.keyType
127
- });
27
+ for (const route of Array.isArray(config?.skipRoutes) ? config.skipRoutes : []) {
28
+ const matcher = createMatcher(toSafeString(route, ""));
29
+ if (matcher) skipMatchers.push(matcher);
128
30
  }
129
31
 
130
32
  return {
131
- enabled: isRateLimitEnabled(config?.enable),
132
- defaultLimit: defaultLimit,
133
- defaultWindow: defaultWindow,
134
- defaultKeyType: defaultKeyType,
135
- skipMatchers: skipMatchers,
136
- rules: rules
33
+ enabled: RATE_LIMIT_ENABLED_VALUES.has(config?.enable),
34
+ limit: toPositiveInt(config?.defaultLimit, 1000),
35
+ window: toPositiveInt(config?.defaultWindow, 60),
36
+ keyType: toSafeString(config?.key, "ip"),
37
+ skipMatchers: skipMatchers
137
38
  };
138
39
  }
139
40
 
140
41
  function resolveScopeByKeyType(keyType, ctx) {
141
- if (keyType === "userId") {
142
- if (isValidPositiveInt(ctx.userId)) {
143
- return `user:${ctx.userId}`;
144
- }
145
-
146
- return `ip:${ctx.ip || "unknown"}`;
147
- }
148
-
149
- if (keyType === "roleCode") {
150
- return `role:${ctx.roleCode || "guest"}`;
151
- }
152
-
153
- if (keyType === "apiPath") {
154
- return `path:${ctx.apiPath || "unknown"}`;
155
- }
156
-
157
- if (keyType === "global") {
158
- return "global";
159
- }
160
-
42
+ if (keyType === "userId") return isValidPositiveInt(ctx.userId) ? `user:${ctx.userId}` : `ip:${ctx.ip || "unknown"}`;
43
+ if (keyType === "roleCode") return `role:${ctx.roleCode || "guest"}`;
44
+ if (keyType === "apiPath") return `path:${ctx.apiPath || "unknown"}`;
45
+ if (keyType === "global") return "global";
161
46
  return `ip:${ctx.ip || "unknown"}`;
162
47
  }
163
48
 
164
- function resolveRateRule(normalized, ctx) {
165
- for (const rule of normalized.rules) {
166
- if (!rule.matcher(ctx.apiPath)) {
167
- continue;
168
- }
169
-
170
- if (rule.methods.length > 0 && !rule.methods.includes(ctx.method)) {
171
- continue;
172
- }
173
-
174
- return {
175
- limit: rule.limit,
176
- window: rule.window,
177
- keyType: rule.keyType
178
- };
179
- }
180
-
181
- return {
182
- limit: normalized.defaultLimit,
183
- window: normalized.defaultWindow,
184
- keyType: normalized.defaultKeyType
185
- };
186
- }
187
-
188
49
  function shouldSkipRateLimit(normalized, ctx) {
189
- for (const matcher of normalized.skipMatchers) {
190
- if (matcher(ctx.apiPath)) {
191
- return true;
192
- }
193
- }
194
-
195
- return false;
50
+ return normalized.skipMatchers.some((matcher) => matcher(ctx.apiPath));
196
51
  }
197
52
 
198
53
  function buildRateLimitRedisKey(ctx, keyType, scope) {
@@ -202,42 +57,26 @@ function buildRateLimitRedisKey(ctx, keyType, scope) {
202
57
  export default {
203
58
  order: 1.5,
204
59
  handler: async (befly, ctx) => {
205
- const normalized = normalizeConfig(befly?.config?.rateLimit);
206
-
207
- if (!normalized.enabled) {
208
- return;
209
- }
210
-
211
- if (!befly?.redis || typeof befly.redis.incrWithExpire !== "function") {
212
- return;
213
- }
214
-
215
- if (shouldSkipRateLimit(normalized, ctx)) {
216
- return;
217
- }
218
-
219
- const rateRule = resolveRateRule(normalized, ctx);
220
- const scope = resolveScopeByKeyType(rateRule.keyType, ctx);
221
- const redisKey = buildRateLimitRedisKey(ctx, rateRule.keyType, scope);
222
- const current = await befly.redis.incrWithExpire(redisKey, rateRule.window);
60
+ const rateLimit = normalizeConfig(befly?.config?.rateLimit);
61
+ if (!rateLimit.enabled || !befly?.redis || typeof befly.redis.incrWithExpire !== "function" || shouldSkipRateLimit(rateLimit, ctx)) return;
223
62
 
224
- if (current <= rateRule.limit) {
225
- return;
226
- }
63
+ const scope = resolveScopeByKeyType(rateLimit.keyType, ctx);
64
+ const current = await befly.redis.incrWithExpire(buildRateLimitRedisKey(ctx, rateLimit.keyType, scope), rateLimit.window);
65
+ if (current <= rateLimit.limit) return;
227
66
 
228
67
  ctx.corsHeaders = ctx.corsHeaders || {};
229
- ctx.corsHeaders["Retry-After"] = String(rateRule.window);
68
+ ctx.corsHeaders["Retry-After"] = String(rateLimit.window);
230
69
  ctx.response = ErrorResponse(
231
70
  ctx,
232
71
  "请求过于频繁,请稍后再试",
233
72
  1,
234
73
  null,
235
74
  {
236
- limit: rateRule.limit,
237
- window: rateRule.window,
75
+ limit: rateLimit.limit,
76
+ window: rateLimit.window,
238
77
  current: current,
239
78
  apiPath: ctx.apiPath,
240
- key: rateRule.keyType
79
+ key: rateLimit.keyType
241
80
  },
242
81
  "rateLimit"
243
82
  );
@@ -1,5 +1,5 @@
1
1
  // 相对导入
2
- import { Validator } from "../lib/validator.js";
2
+ import { validateFields } from "../lib/validator.js";
3
3
  import { isPlainObject, isString } from "../utils/is.js";
4
4
  import { ErrorResponse } from "../utils/response.js";
5
5
  import { snakeCase } from "../utils/util.js";
@@ -37,7 +37,7 @@ export default {
37
37
  ctx.body = nextBody;
38
38
 
39
39
  // 验证参数
40
- const result = Validator.validate(ctx.body, ctx.apiFields, ctx.apiRequired);
40
+ const result = validateFields(ctx.body, ctx.apiFields, ctx.apiRequired);
41
41
 
42
42
  if (result.code !== 0) {
43
43
  ctx.response = ErrorResponse(ctx, result.firstError || "参数验证失败", 1, null, result.fieldErrors, "validator");