@usethink/cf-core 0.3.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/README.md +88 -0
- package/dist/features/anti-abuse/index.d.ts +62 -0
- package/dist/features/anti-abuse/index.d.ts.map +1 -0
- package/dist/features/anti-abuse/index.js +139 -0
- package/dist/features/anti-abuse/index.js.map +1 -0
- package/dist/features/email/index.d.ts +68 -0
- package/dist/features/email/index.d.ts.map +1 -0
- package/dist/features/email/index.js +120 -0
- package/dist/features/email/index.js.map +1 -0
- package/dist/features/payment/fetch-utils.d.ts +17 -0
- package/dist/features/payment/fetch-utils.d.ts.map +1 -0
- package/dist/features/payment/fetch-utils.js +56 -0
- package/dist/features/payment/fetch-utils.js.map +1 -0
- package/dist/features/payment/index.d.ts +20 -0
- package/dist/features/payment/index.d.ts.map +1 -0
- package/dist/features/payment/index.js +21 -0
- package/dist/features/payment/index.js.map +1 -0
- package/dist/features/payment/providers/alipay.d.ts +30 -0
- package/dist/features/payment/providers/alipay.d.ts.map +1 -0
- package/dist/features/payment/providers/alipay.js +149 -0
- package/dist/features/payment/providers/alipay.js.map +1 -0
- package/dist/features/payment/providers/stripe.d.ts +34 -0
- package/dist/features/payment/providers/stripe.d.ts.map +1 -0
- package/dist/features/payment/providers/stripe.js +168 -0
- package/dist/features/payment/providers/stripe.js.map +1 -0
- package/dist/features/payment/providers/trc20.d.ts +24 -0
- package/dist/features/payment/providers/trc20.d.ts.map +1 -0
- package/dist/features/payment/providers/trc20.js +96 -0
- package/dist/features/payment/providers/trc20.js.map +1 -0
- package/dist/features/payment/registry.d.ts +43 -0
- package/dist/features/payment/registry.d.ts.map +1 -0
- package/dist/features/payment/registry.js +65 -0
- package/dist/features/payment/registry.js.map +1 -0
- package/dist/features/payment/types.d.ts +72 -0
- package/dist/features/payment/types.d.ts.map +1 -0
- package/dist/features/payment/types.js +8 -0
- package/dist/features/payment/types.js.map +1 -0
- package/dist/features/thompson-router/index.d.ts +101 -0
- package/dist/features/thompson-router/index.d.ts.map +1 -0
- package/dist/features/thompson-router/index.js +186 -0
- package/dist/features/thompson-router/index.js.map +1 -0
- package/dist/features/webhook/index.d.ts +76 -0
- package/dist/features/webhook/index.d.ts.map +1 -0
- package/dist/features/webhook/index.js +127 -0
- package/dist/features/webhook/index.js.map +1 -0
- package/dist/src/audit.d.ts +45 -0
- package/dist/src/audit.d.ts.map +1 -0
- package/dist/src/audit.js +40 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/auth/jwt.d.ts +33 -0
- package/dist/src/auth/jwt.d.ts.map +1 -0
- package/dist/src/auth/jwt.js +87 -0
- package/dist/src/auth/jwt.js.map +1 -0
- package/dist/src/auth/password.d.ts +26 -0
- package/dist/src/auth/password.d.ts.map +1 -0
- package/dist/src/auth/password.js +52 -0
- package/dist/src/auth/password.js.map +1 -0
- package/dist/src/bootstrap.d.ts +74 -0
- package/dist/src/bootstrap.d.ts.map +1 -0
- package/dist/src/bootstrap.js +231 -0
- package/dist/src/bootstrap.js.map +1 -0
- package/dist/src/cache.d.ts +52 -0
- package/dist/src/cache.d.ts.map +1 -0
- package/dist/src/cache.js +76 -0
- package/dist/src/cache.js.map +1 -0
- package/dist/src/config.d.ts +83 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +96 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/crypto.d.ts +33 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +87 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/db/connection.d.ts +53 -0
- package/dist/src/db/connection.d.ts.map +1 -0
- package/dist/src/db/connection.js +104 -0
- package/dist/src/db/connection.js.map +1 -0
- package/dist/src/db/index.d.ts +6 -0
- package/dist/src/db/index.d.ts.map +1 -0
- package/dist/src/db/index.js +6 -0
- package/dist/src/db/index.js.map +1 -0
- package/dist/src/db/schema.d.ts +649 -0
- package/dist/src/db/schema.d.ts.map +1 -0
- package/dist/src/db/schema.js +76 -0
- package/dist/src/db/schema.js.map +1 -0
- package/dist/src/error.d.ts +47 -0
- package/dist/src/error.d.ts.map +1 -0
- package/dist/src/error.js +94 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/http.d.ts +83 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +116 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/idempotency.d.ts +78 -0
- package/dist/src/idempotency.d.ts.map +1 -0
- package/dist/src/idempotency.js +84 -0
- package/dist/src/idempotency.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/logger.d.ts +31 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +45 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/middleware/admin-auth.d.ts +38 -0
- package/dist/src/middleware/admin-auth.d.ts.map +1 -0
- package/dist/src/middleware/admin-auth.js +55 -0
- package/dist/src/middleware/admin-auth.js.map +1 -0
- package/dist/src/middleware/api-key-auth.d.ts +42 -0
- package/dist/src/middleware/api-key-auth.d.ts.map +1 -0
- package/dist/src/middleware/api-key-auth.js +104 -0
- package/dist/src/middleware/api-key-auth.js.map +1 -0
- package/dist/src/middleware/index.d.ts +3 -0
- package/dist/src/middleware/index.d.ts.map +1 -0
- package/dist/src/middleware/index.js +3 -0
- package/dist/src/middleware/index.js.map +1 -0
- package/dist/src/rate-limit.d.ts +54 -0
- package/dist/src/rate-limit.d.ts.map +1 -0
- package/dist/src/rate-limit.js +134 -0
- package/dist/src/rate-limit.js.map +1 -0
- package/dist/src/security.d.ts +78 -0
- package/dist/src/security.d.ts.map +1 -0
- package/dist/src/security.js +175 -0
- package/dist/src/security.js.map +1 -0
- package/dist/src/types.d.ts +64 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/features/anti-abuse/index.ts +180 -0
- package/features/anti-abuse/tests/index.test.ts +50 -0
- package/features/email/index.ts +172 -0
- package/features/email/tests/index.test.ts +44 -0
- package/features/payment/fetch-utils.ts +65 -0
- package/features/payment/index.ts +39 -0
- package/features/payment/providers/alipay.ts +171 -0
- package/features/payment/providers/stripe.ts +192 -0
- package/features/payment/providers/trc20.ts +115 -0
- package/features/payment/registry.ts +87 -0
- package/features/payment/tests/index.test.ts +506 -0
- package/features/payment/types.ts +93 -0
- package/features/telegram-miniapp/index.ts +109 -0
- package/features/telegram-miniapp/tests/index.test.ts +11 -0
- package/features/thompson-router/index.ts +243 -0
- package/features/thompson-router/tests/index.test.ts +93 -0
- package/features/webhook/index.ts +183 -0
- package/features/webhook/tests/index.test.ts +21 -0
- package/package.json +202 -0
- package/src/audit.ts +70 -0
- package/src/auth/jwt.ts +114 -0
- package/src/auth/password.ts +75 -0
- package/src/bootstrap.ts +322 -0
- package/src/cache.ts +78 -0
- package/src/config.ts +134 -0
- package/src/crypto.ts +106 -0
- package/src/db/connection.ts +127 -0
- package/src/db/index.ts +6 -0
- package/src/db/schema.ts +90 -0
- package/src/error.ts +125 -0
- package/src/http.ts +150 -0
- package/src/idempotency.ts +127 -0
- package/src/index.ts +85 -0
- package/src/logger.ts +63 -0
- package/src/middleware/admin-auth.ts +71 -0
- package/src/middleware/api-key-auth.ts +164 -0
- package/src/middleware/index.ts +2 -0
- package/src/rate-limit.ts +167 -0
- package/src/security.ts +219 -0
- package/src/types.ts +70 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* libSQL 数据库连接工厂
|
|
3
|
+
*
|
|
4
|
+
* 核心设计:Isolate 级连接复用
|
|
5
|
+
* Cloudflare Workers 的 isolate 在多个请求间复用。
|
|
6
|
+
* 缓存 client 实例避免每次请求重新 createClient(节省 ~1ms CPU)。
|
|
7
|
+
*
|
|
8
|
+
* 合并自:
|
|
9
|
+
* - eshop: src/db/database.ts + src/db/client.ts
|
|
10
|
+
* - xtools: src/db/database.ts + src/db/client.ts
|
|
11
|
+
* - vcode: src/db/index.ts(含自动迁移)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createClient, type Client } from "@libsql/client";
|
|
15
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
16
|
+
|
|
17
|
+
/** Drizzle ORM 实例类型(通用) */
|
|
18
|
+
export type DrizzleInstance<TSchema extends Record<string, unknown> = Record<string, never>> =
|
|
19
|
+
ReturnType<typeof drizzle<TSchema>>;
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// Isolate 级缓存(模块级别变量在 Workers isolate 生命周期内持久存在)
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
let _cachedUrl: string | undefined;
|
|
26
|
+
let _cachedClient: Client | undefined;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 创建或复用 libSQL Client(Isolate 级缓存)
|
|
30
|
+
*
|
|
31
|
+
* 同一 isolate + 同一 URL 下复用 client 实例。
|
|
32
|
+
* URL 变化时(极少见)重建实例。
|
|
33
|
+
*/
|
|
34
|
+
export function getOrCreateClient(url: string, authToken?: string): Client {
|
|
35
|
+
if (_cachedClient && _cachedUrl === url) {
|
|
36
|
+
return _cachedClient;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const client = createClient({ url, authToken });
|
|
40
|
+
_cachedUrl = url;
|
|
41
|
+
_cachedClient = client;
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 创建 Drizzle ORM 实例
|
|
47
|
+
*
|
|
48
|
+
* @param client - libSQL Client
|
|
49
|
+
* @param schema - Drizzle schema 对象(可选,传入后支持关系查询)
|
|
50
|
+
*/
|
|
51
|
+
export function createDrizzle<TSchema extends Record<string, unknown>>(
|
|
52
|
+
client: Client,
|
|
53
|
+
schema?: TSchema,
|
|
54
|
+
): DrizzleInstance<TSchema> {
|
|
55
|
+
return (schema ? drizzle(client, { schema }) : drizzle(client)) as DrizzleInstance<TSchema>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 一步到位:初始化数据库(client + drizzle),带 Isolate 级缓存 + 连接验证。
|
|
60
|
+
*
|
|
61
|
+
* 首次连接时执行 `SELECT 1` 验证连通性,失败时自动重试(最多 2 次,指数退避)。
|
|
62
|
+
* 后续请求复用 Isolate 级缓存的 client,不再探活(节省 CPU 时间)。
|
|
63
|
+
*
|
|
64
|
+
* @param url - Turso URL(libsql://xxx.turso.io)
|
|
65
|
+
* @param authToken - Turso 认证 Token(可选)
|
|
66
|
+
* @param schema - Drizzle schema(可选)
|
|
67
|
+
*/
|
|
68
|
+
export function initDatabase<TSchema extends Record<string, unknown>>(
|
|
69
|
+
url?: string,
|
|
70
|
+
authToken?: string,
|
|
71
|
+
schema?: TSchema,
|
|
72
|
+
): DrizzleInstance<TSchema> {
|
|
73
|
+
if (!url) throw new Error("TURSO_URL is required");
|
|
74
|
+
const client = getOrCreateClient(url, authToken);
|
|
75
|
+
return createDrizzle(client, schema);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 带连接验证的数据库初始化(用于 bootstrap 中间件)。
|
|
80
|
+
*
|
|
81
|
+
* 首次连接时执行 `SELECT 1` 验证连通性,失败时重试。
|
|
82
|
+
* 后续请求直接返回缓存的 drizzle 实例(零额外开销)。
|
|
83
|
+
*/
|
|
84
|
+
export async function initDatabaseWithHealthCheck<TSchema extends Record<string, unknown>>(
|
|
85
|
+
url?: string,
|
|
86
|
+
authToken?: string,
|
|
87
|
+
schema?: TSchema,
|
|
88
|
+
): Promise<DrizzleInstance<TSchema>> {
|
|
89
|
+
if (!url) throw new Error("TURSO_URL is required");
|
|
90
|
+
|
|
91
|
+
// 如果已有缓存的连接,直接返回(Isolate 复用场景)
|
|
92
|
+
if (_cachedClient && _cachedUrl === url) {
|
|
93
|
+
return createDrizzle(_cachedClient, schema);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 首次连接:创建 client 并验证连通性
|
|
97
|
+
const client = getOrCreateClient(url, authToken);
|
|
98
|
+
const db = createDrizzle(client, schema);
|
|
99
|
+
|
|
100
|
+
// 最多重试 2 次(500ms → 1s),避免 Turso 短暂不可达导致部署失败
|
|
101
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
await client.execute("SELECT 1");
|
|
104
|
+
return db;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (attempt >= 2) throw err;
|
|
107
|
+
const delay = 500 * Math.pow(2, attempt);
|
|
108
|
+
console.warn(`[db:health-check] 连接失败,${delay}ms 后重试 (${attempt + 1}/2)`);
|
|
109
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
110
|
+
// 重建 client(可能连接状态已损坏)
|
|
111
|
+
_cachedClient = undefined;
|
|
112
|
+
_cachedUrl = undefined;
|
|
113
|
+
const newClient = getOrCreateClient(url, authToken);
|
|
114
|
+
Object.assign(db, createDrizzle(newClient, schema));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return db;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 重置 Isolate 级缓存(仅用于测试)
|
|
123
|
+
*/
|
|
124
|
+
export function _resetCache(): void {
|
|
125
|
+
_cachedUrl = undefined;
|
|
126
|
+
_cachedClient = undefined;
|
|
127
|
+
}
|
package/src/db/index.ts
ADDED
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @usethink/cf-core — 公共数据库 Schema
|
|
3
|
+
*
|
|
4
|
+
* 所有项目共享的 Drizzle ORM 表定义。
|
|
5
|
+
* 各项目在此基础上添加自己的业务表。
|
|
6
|
+
*
|
|
7
|
+
* 公共表清单:
|
|
8
|
+
* 1. systemConfig — 系统配置(KV 存储,热生效)
|
|
9
|
+
* 2. adminAuditLogs — 管理员审计日志
|
|
10
|
+
* 3. rateLimitWindows — 限流计数窗口(DB 版限流)
|
|
11
|
+
* 4. idempotencyKeys — 幂等键(防重复提交)
|
|
12
|
+
* 5. apiKeys — API Key 认证(可选启用)
|
|
13
|
+
*
|
|
14
|
+
* 来源:eshop/xtools/vcode 三项目 schema.ts 中公共部分提取
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
18
|
+
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
// 1. 系统配置表(KV 存储,热生效)
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
export const systemConfig = sqliteTable("system_config", {
|
|
24
|
+
key: text("key").primaryKey(),
|
|
25
|
+
value: text("value").default("").notNull(),
|
|
26
|
+
updatedAt: text("updated_at").default("").notNull(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
30
|
+
// 2. 管理员审计日志表
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
export const adminAuditLogs = sqliteTable("admin_audit_logs", {
|
|
34
|
+
id: text("id").primaryKey(),
|
|
35
|
+
action: text("action").notNull(),
|
|
36
|
+
targetType: text("target_type").default("").notNull(),
|
|
37
|
+
targetId: text("target_id").default("").notNull(),
|
|
38
|
+
metadataJson: text("metadata_json").default("{}").notNull(),
|
|
39
|
+
ipHash: text("ip_hash").default("").notNull(),
|
|
40
|
+
createdAt: text("created_at").default("").notNull(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
// 3. 限流窗口表(DB 版限流 — 原子 upsert)
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
export const rateLimitWindows = sqliteTable(
|
|
48
|
+
"rate_limit_windows",
|
|
49
|
+
{
|
|
50
|
+
action: text("action").notNull(),
|
|
51
|
+
ipHash: text("ip_hash").notNull(),
|
|
52
|
+
windowStart: integer("window_start").notNull(),
|
|
53
|
+
requestCount: integer("request_count").default(0).notNull(),
|
|
54
|
+
},
|
|
55
|
+
(table) => ({
|
|
56
|
+
pk: primaryKey({ columns: [table.action, table.ipHash, table.windowStart] }),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
61
|
+
// 4. 幂等键表(防重复提交)
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
export const idempotencyKeys = sqliteTable("idempotency_keys", {
|
|
65
|
+
key: text("key").notNull(),
|
|
66
|
+
action: text("action").notNull(),
|
|
67
|
+
resourceId: text("resource_id").notNull(),
|
|
68
|
+
responseJson: text("response_json").notNull(),
|
|
69
|
+
createdAt: text("created_at").default("").notNull(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
// 5. API Key 表(可选启用)
|
|
74
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
export const apiKeys = sqliteTable("api_keys", {
|
|
77
|
+
id: text("id").primaryKey(),
|
|
78
|
+
name: text("name").notNull(),
|
|
79
|
+
keyHash: text("key_hash").notNull(),
|
|
80
|
+
userId: text("user_id").default("").notNull(),
|
|
81
|
+
tier: text("tier").default("free").notNull(),
|
|
82
|
+
enabled: integer("enabled").default(1).notNull(),
|
|
83
|
+
monthlyQuota: integer("monthly_quota").default(0).notNull(),
|
|
84
|
+
monthlyUsage: integer("monthly_usage").default(0).notNull(),
|
|
85
|
+
monthlyResetAt: text("monthly_reset_at"),
|
|
86
|
+
lastUsedAt: text("last_used_at"),
|
|
87
|
+
createdAt: text("created_at").default("").notNull(),
|
|
88
|
+
updatedAt: text("updated_at").default("").notNull(),
|
|
89
|
+
expiresAt: text("expires_at"),
|
|
90
|
+
});
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误分类与指数退避重试
|
|
3
|
+
*
|
|
4
|
+
* 区分可重试 / 不可重试 / 需延迟重试的错误类型,
|
|
5
|
+
* 配合指数退避(exponential backoff + jitter)减少无效重试。
|
|
6
|
+
*
|
|
7
|
+
* 来源:xtools src/lib/error-classifier.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export enum ErrorType {
|
|
11
|
+
/** 网络超时、5xx 服务器错误 → 可重试 */
|
|
12
|
+
TRANSIENT = "transient",
|
|
13
|
+
/** 4xx 认证失败、invalid_grant → 不可重试 */
|
|
14
|
+
PERMANENT = "permanent",
|
|
15
|
+
/** 429 Too Many Requests → 延迟重试 */
|
|
16
|
+
RATE_LIMIT = "rate_limit",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PERMANENT_KEYWORDS = [
|
|
20
|
+
"invalid_grant",
|
|
21
|
+
"invalid_client",
|
|
22
|
+
"unauthorized",
|
|
23
|
+
"interaction_required",
|
|
24
|
+
"access_denied",
|
|
25
|
+
"invalid_token",
|
|
26
|
+
"401",
|
|
27
|
+
"403",
|
|
28
|
+
"account_disabled",
|
|
29
|
+
"consent_required",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const RATE_LIMIT_KEYWORDS = [
|
|
33
|
+
"429",
|
|
34
|
+
"too many requests",
|
|
35
|
+
"rate limit",
|
|
36
|
+
"throttl",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 分类错误类型
|
|
41
|
+
*/
|
|
42
|
+
export function classifyError(error: unknown): ErrorType {
|
|
43
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
44
|
+
|
|
45
|
+
if (RATE_LIMIT_KEYWORDS.some((kw) => message.includes(kw))) {
|
|
46
|
+
return ErrorType.RATE_LIMIT;
|
|
47
|
+
}
|
|
48
|
+
if (PERMANENT_KEYWORDS.some((kw) => message.includes(kw))) {
|
|
49
|
+
return ErrorType.PERMANENT;
|
|
50
|
+
}
|
|
51
|
+
return ErrorType.TRANSIENT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RetryOptions {
|
|
55
|
+
/** 最大重试次数(默认 3) */
|
|
56
|
+
maxRetries?: number;
|
|
57
|
+
/** 基础延迟毫秒(默认 1000) */
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
/** 限流延迟倍数(默认 5) */
|
|
60
|
+
rateLimitMultiplier?: number;
|
|
61
|
+
/** 最大延迟毫秒(默认 30000) */
|
|
62
|
+
maxDelayMs?: number;
|
|
63
|
+
/** 重试回调(用于日志) */
|
|
64
|
+
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 带指数退避的重试执行器
|
|
69
|
+
*
|
|
70
|
+
* - TRANSIENT: 指数退避(1s → 2s → 4s)
|
|
71
|
+
* - RATE_LIMIT: 更长延迟(5s → 10s → 20s)
|
|
72
|
+
* - PERMANENT: 立即抛出,不重试
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const result = await retryWithBackoff(
|
|
76
|
+
* () => fetchExternalApi(),
|
|
77
|
+
* { maxRetries: 3, onRetry: (n, err, ms) => console.warn(`重试 #${n}: ${err.message} (${ms}ms)`) }
|
|
78
|
+
* );
|
|
79
|
+
*/
|
|
80
|
+
export async function retryWithBackoff<T>(
|
|
81
|
+
fn: () => Promise<T>,
|
|
82
|
+
options: RetryOptions = {},
|
|
83
|
+
): Promise<T> {
|
|
84
|
+
const {
|
|
85
|
+
maxRetries = 3,
|
|
86
|
+
baseDelayMs = 1000,
|
|
87
|
+
rateLimitMultiplier = 5,
|
|
88
|
+
maxDelayMs = 30_000,
|
|
89
|
+
onRetry,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
let lastError: unknown;
|
|
93
|
+
|
|
94
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
return await fn();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastError = error;
|
|
99
|
+
const errorType = classifyError(error);
|
|
100
|
+
|
|
101
|
+
if (errorType === ErrorType.PERMANENT) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (attempt >= maxRetries) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let delayMs: number;
|
|
109
|
+
if (errorType === ErrorType.RATE_LIMIT) {
|
|
110
|
+
delayMs = Math.min(baseDelayMs * rateLimitMultiplier * Math.pow(2, attempt), maxDelayMs);
|
|
111
|
+
} else {
|
|
112
|
+
delayMs = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 随机抖动(±20%),避免雷群效应
|
|
116
|
+
const jitter = delayMs * 0.2 * (Math.random() * 2 - 1);
|
|
117
|
+
delayMs = Math.round(delayMs + jitter);
|
|
118
|
+
|
|
119
|
+
onRetry?.(attempt + 1, error as Error, delayMs);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw lastError;
|
|
125
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 响应工具模块
|
|
3
|
+
*
|
|
4
|
+
* 提供统一的 ok/fail 响应格式,以及常用的请求解析工具。
|
|
5
|
+
* 泛型设计:兼容任意 Hono AppEnv 类型,无需绑定特定项目。
|
|
6
|
+
*
|
|
7
|
+
* 来源:eshop/xtools/vcode 三项目 lib/http.ts 合并
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Context } from "hono";
|
|
11
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
12
|
+
import type { RateLimitResult } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 成功响应 — 统一格式 { ok: true, ...data }
|
|
16
|
+
*/
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
export function ok(
|
|
19
|
+
c: Context<any>,
|
|
20
|
+
data: Record<string, unknown>,
|
|
21
|
+
status: ContentfulStatusCode = 200,
|
|
22
|
+
) {
|
|
23
|
+
return c.json({ ok: true, ...data } as Record<string, unknown>, status);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 失败响应 — 统一格式 { ok: false, error: message }
|
|
28
|
+
*/
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
export function fail(
|
|
31
|
+
c: Context<any>,
|
|
32
|
+
message: string,
|
|
33
|
+
status: number = 400,
|
|
34
|
+
details?: unknown,
|
|
35
|
+
) {
|
|
36
|
+
return c.json(
|
|
37
|
+
{ ok: false, error: message, ...(details ? { details } : {}) } as Record<string, unknown>,
|
|
38
|
+
status as ContentfulStatusCode,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 限流失败响应 — 429 + 标准 RateLimit 头
|
|
44
|
+
*
|
|
45
|
+
* 返回标准 HTTP 429 响应,包含以下头信息:
|
|
46
|
+
* - Retry-After: 秒数(建议客户端等待时间)
|
|
47
|
+
* - X-RateLimit-Limit: 窗口内允许的最大请求数
|
|
48
|
+
* - X-RateLimit-Remaining: 窗口内剩余请求数(0)
|
|
49
|
+
* - X-RateLimit-Reset: 窗口重置的 Unix 时间戳
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const result = await rateLimiter.check(key, limit, windowMs);
|
|
54
|
+
* if (!result.ok) {
|
|
55
|
+
* return failRateLimit(c, result, limit);
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
export function failRateLimit(
|
|
61
|
+
c: Context<any>,
|
|
62
|
+
result: RateLimitResult,
|
|
63
|
+
limit: number,
|
|
64
|
+
) {
|
|
65
|
+
const retryAfterSeconds = Math.ceil((result.resetMs || 0) / 1000);
|
|
66
|
+
const resetTimestamp = Math.ceil((Date.now() + (result.resetMs || 0)) / 1000);
|
|
67
|
+
|
|
68
|
+
return c.json(
|
|
69
|
+
{ ok: false, error: result.message || "请求过于频繁,请稍后再试" },
|
|
70
|
+
429,
|
|
71
|
+
{
|
|
72
|
+
"Retry-After": String(retryAfterSeconds),
|
|
73
|
+
"X-RateLimit-Limit": String(limit),
|
|
74
|
+
"X-RateLimit-Remaining": "0",
|
|
75
|
+
"X-RateLimit-Reset": String(resetTimestamp),
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 获取站点域名 — 优先使用环境变量 APP_ORIGIN,降级使用请求 URL
|
|
82
|
+
*/
|
|
83
|
+
export function getOrigin<E extends { Bindings: { APP_ORIGIN?: string } }>(c: Context<E>): string {
|
|
84
|
+
return c.env.APP_ORIGIN || new URL(c.req.url).origin;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 安全读取 JSON body — 解析失败返回 undefined(避免非 JSON 请求体导致 400)
|
|
89
|
+
*
|
|
90
|
+
* 支持泛型,让调用方可以明确 JSON 形状,避免下游 zod safeParse 的 unknown 类型报错。
|
|
91
|
+
* 默认类型为 unknown,保持向后兼容。
|
|
92
|
+
*/
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
export async function safeJsonBody<T = unknown>(c: Context<any>): Promise<T> {
|
|
95
|
+
return c.req.json().catch(() => undefined) as Promise<T>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 联系方式脱敏 — 用于管理端展示
|
|
100
|
+
*
|
|
101
|
+
* - 邮箱:ab***@example.com
|
|
102
|
+
* - 手机/其他:ab***cd
|
|
103
|
+
* - 长度 ≤ 4:***
|
|
104
|
+
*/
|
|
105
|
+
export function maskContact(value: string): string {
|
|
106
|
+
const text = value.trim();
|
|
107
|
+
if (text.length <= 4) return "***";
|
|
108
|
+
if (text.includes("@") && !text.startsWith("@")) {
|
|
109
|
+
const [name, domain] = text.split("@");
|
|
110
|
+
return `${name.slice(0, 2)}***@${domain}`;
|
|
111
|
+
}
|
|
112
|
+
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 标准化编码 — trim + lowercase(用于优惠码/折扣码等)
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeCode(value?: string): string {
|
|
119
|
+
return (value || "").trim().toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* CSV 注入防护 — 对导出值做安全转义
|
|
124
|
+
*
|
|
125
|
+
* 如果值以 = + - @ \t \n 开头,前置制表符阻止公式注入。
|
|
126
|
+
* 来源:eshop admin 订单导出
|
|
127
|
+
*/
|
|
128
|
+
export function csvEscape(value: unknown): string {
|
|
129
|
+
const str = String(value ?? "");
|
|
130
|
+
if (/^[=+\-@\t\n]/.test(str)) {
|
|
131
|
+
return `\t${str}`;
|
|
132
|
+
}
|
|
133
|
+
return str;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 将对象数组导出为 CSV 字符串
|
|
138
|
+
*/
|
|
139
|
+
export function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
|
|
140
|
+
const header = columns.join(",");
|
|
141
|
+
const body = rows.map((row) =>
|
|
142
|
+
columns.map((col) => {
|
|
143
|
+
const val = csvEscape(row[col]);
|
|
144
|
+
return val.includes(",") || val.includes('"') || val.includes("\n")
|
|
145
|
+
? `"${val.replace(/"/g, '""')}"`
|
|
146
|
+
: val;
|
|
147
|
+
}).join(","),
|
|
148
|
+
).join("\n");
|
|
149
|
+
return `${header}\n${body}`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 幂等性模块 — 防止同一请求被重复处理
|
|
3
|
+
*
|
|
4
|
+
* 使用 (key, action) 复合键作为幂等标识。
|
|
5
|
+
* 核心设计:原子 UPSERT + 非空哨兵值,消除 TOCTOU 竞态。
|
|
6
|
+
*
|
|
7
|
+
* 流程:
|
|
8
|
+
* 1. checkIdempotency() — 原子 UPSERT,返回 shouldProceed
|
|
9
|
+
* 2. shouldProceed === false → 返回缓存响应
|
|
10
|
+
* 3. shouldProceed === true → 执行业务逻辑 → saveIdempotentResponse()
|
|
11
|
+
*
|
|
12
|
+
* 来源:eshop src/lib/idempotency.ts(三项目中唯一实现)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { idempotencyKeys } from "./db/schema";
|
|
16
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
17
|
+
|
|
18
|
+
const PENDING_SENTINEL = "__pending__";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 通用 Drizzle 数据库接口(仅约束幂等模块需要的方法)
|
|
22
|
+
*/
|
|
23
|
+
interface DbLike {
|
|
24
|
+
insert: (table: typeof idempotencyKeys) => {
|
|
25
|
+
values: (data: { key: string; action: string; resourceId: string; responseJson: string; createdAt: string }) => {
|
|
26
|
+
onConflictDoUpdate: (opts: {
|
|
27
|
+
target: [typeof idempotencyKeys.key, typeof idempotencyKeys.action];
|
|
28
|
+
set: Record<string, unknown>;
|
|
29
|
+
}) => {
|
|
30
|
+
returning: (cols: { responseJson: typeof idempotencyKeys.responseJson }) => Promise<{ responseJson: string }[]>;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
select: (cols: { responseJson: typeof idempotencyKeys.responseJson }) => {
|
|
35
|
+
from: (table: typeof idempotencyKeys) => {
|
|
36
|
+
where: (cond: unknown) => {
|
|
37
|
+
limit: (n: number) => Promise<{ responseJson: string }[]>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 原子检查幂等性
|
|
45
|
+
*
|
|
46
|
+
* INSERT ON CONFLICT UPDATE RETURNING 是 SQLite 原子操作。
|
|
47
|
+
* 并发请求中只有一个获得 shouldProceed=true。
|
|
48
|
+
*
|
|
49
|
+
* @returns shouldProceed=true 时应执行业务逻辑;false 时 cachedResponse 含之前缓存的响应
|
|
50
|
+
*/
|
|
51
|
+
export async function checkIdempotency(
|
|
52
|
+
db: DbLike,
|
|
53
|
+
key: string,
|
|
54
|
+
action: string,
|
|
55
|
+
): Promise<{ shouldProceed: boolean; cachedResponse: string | null }> {
|
|
56
|
+
const [row] = await db
|
|
57
|
+
.insert(idempotencyKeys)
|
|
58
|
+
.values({
|
|
59
|
+
key,
|
|
60
|
+
action,
|
|
61
|
+
resourceId: "",
|
|
62
|
+
responseJson: PENDING_SENTINEL,
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
})
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: [idempotencyKeys.key, idempotencyKeys.action],
|
|
67
|
+
set: { responseJson: sql`idempotency_keys.response_json` },
|
|
68
|
+
})
|
|
69
|
+
.returning({ responseJson: idempotencyKeys.responseJson });
|
|
70
|
+
|
|
71
|
+
const shouldProceed = row?.responseJson === PENDING_SENTINEL;
|
|
72
|
+
const cachedResponse = shouldProceed
|
|
73
|
+
? null
|
|
74
|
+
: row?.responseJson === PENDING_SENTINEL
|
|
75
|
+
? null
|
|
76
|
+
: row?.responseJson ?? null;
|
|
77
|
+
|
|
78
|
+
return { shouldProceed, cachedResponse };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 保存幂等响应
|
|
83
|
+
*
|
|
84
|
+
* 在 checkIdempotency 返回 shouldProceed=true 并执行业务逻辑后调用。
|
|
85
|
+
*/
|
|
86
|
+
export async function saveIdempotentResponse(
|
|
87
|
+
db: DbLike,
|
|
88
|
+
key: string,
|
|
89
|
+
action: string,
|
|
90
|
+
resourceId: string,
|
|
91
|
+
response: unknown,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await db
|
|
94
|
+
.insert(idempotencyKeys)
|
|
95
|
+
.values({
|
|
96
|
+
key,
|
|
97
|
+
action,
|
|
98
|
+
resourceId,
|
|
99
|
+
responseJson: JSON.stringify(response),
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
})
|
|
102
|
+
.onConflictDoUpdate({
|
|
103
|
+
target: [idempotencyKeys.key, idempotencyKeys.action],
|
|
104
|
+
set: {
|
|
105
|
+
responseJson: JSON.stringify(response),
|
|
106
|
+
resourceId,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 查询已缓存的幂等响应(只读,不创建记录)
|
|
113
|
+
*
|
|
114
|
+
* @deprecated 使用 checkIdempotency() 替代
|
|
115
|
+
*/
|
|
116
|
+
export async function getIdempotentResponse(
|
|
117
|
+
db: DbLike,
|
|
118
|
+
key: string,
|
|
119
|
+
action: string,
|
|
120
|
+
): Promise<{ responseJson: string } | null> {
|
|
121
|
+
const [row] = await db
|
|
122
|
+
.select({ responseJson: idempotencyKeys.responseJson })
|
|
123
|
+
.from(idempotencyKeys)
|
|
124
|
+
.where(and(eq(idempotencyKeys.key, key), eq(idempotencyKeys.action, action)))
|
|
125
|
+
.limit(1);
|
|
126
|
+
return row || null;
|
|
127
|
+
}
|