@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,506 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createProviderRegistry,
|
|
4
|
+
AlipayProvider,
|
|
5
|
+
alipayFactory,
|
|
6
|
+
StripeProvider,
|
|
7
|
+
stripeFactory,
|
|
8
|
+
Trc20Provider,
|
|
9
|
+
trc20Factory,
|
|
10
|
+
signRSA2,
|
|
11
|
+
verifyRSA2,
|
|
12
|
+
} from "../index";
|
|
13
|
+
import type { ProviderFactory, PaymentProvider } from "../index";
|
|
14
|
+
|
|
15
|
+
// ── 测试用 RSA 密钥对(2048-bit,仅用于测试) ──
|
|
16
|
+
const TEST_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
|
|
17
|
+
MIIEpAIBAAKCAQEA2a2rwplBQLHgHnYj+EXqSM3Ww8kOzE5LIQpMbrkHMLTq8R+
|
|
18
|
+
S6mNxDfOB4e7jLRn7bVB3G9CLfHVE+Z4e2rVWOCmWbqS8J4pRiVf11mZl8aG9b89
|
|
19
|
+
O0L6VfK5jH2G1KxY8b0qR7F5u3n5p0S4yK7dX5cW6vH3kM7nL5gN5jP9rQ0xT5u
|
|
20
|
+
R8vK2wN8jL5hP7rT3xU6uS9wL1jM5iO7kR2hT8uV4xY0vN3jK6hP9rT5xU8uS1w
|
|
21
|
+
M4jN6iO8kR3hT9uV5xY1vN4jK7hP0rT6xU9uS2wM5jN7iO9kR4hT0uV6xY2vN5j
|
|
22
|
+
K8hP1rT7xU0vS3wM6jN8iO0kR5hT1uV7xY3vN6jK9hP2rT8xU1vS4wM7jN9iO1kR
|
|
23
|
+
6hT2uV8xY4vN7jK0hP3rT9xU2vS5wM8jN0iO2kR7hT3uV9xY5vN8jK1hP4rT0xU
|
|
24
|
+
3vS6wM9jN1iO3kR8hT4uV0xY6vN9jK2hP5rT1xU4vS7wM0jN2iO4kR9hT5uV1xY
|
|
25
|
+
7vN0jK3hP6rT2xU5vS8wM1jN3iO5kR0hT6uV2xY8vN1jK4hP7rT3xU6vS9wIDAQAB
|
|
26
|
+
AoIBAC5RgZ+hBx7xHNaMpqnnfXPlM7rEsWhG2CjQIFQh9BFY6F6jC6qLOi9vY3u
|
|
27
|
+
8NvY+8YjJnM2Z8TbP+GnT7aR7xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5
|
|
28
|
+
G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF
|
|
29
|
+
8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4v
|
|
30
|
+
D3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J
|
|
31
|
+
+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+
|
|
32
|
+
5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF
|
|
33
|
+
8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4vD3PqF8xH+5G7J+L4v
|
|
34
|
+
-----END RSA PRIVATE KEY-----`;
|
|
35
|
+
|
|
36
|
+
// 使用 Web Crypto API 生成真实测试密钥对
|
|
37
|
+
async function generateTestKeys() {
|
|
38
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
39
|
+
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
|
40
|
+
true,
|
|
41
|
+
["sign", "verify"],
|
|
42
|
+
);
|
|
43
|
+
const privDer = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
|
|
44
|
+
const pubDer = new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey));
|
|
45
|
+
|
|
46
|
+
function toBase64(arr: Uint8Array) {
|
|
47
|
+
let s = "";
|
|
48
|
+
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
|
|
49
|
+
return btoa(s);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const privPem = `-----BEGIN RSA PRIVATE KEY-----\n${toBase64(privDer)}\n-----END RSA PRIVATE KEY-----`;
|
|
53
|
+
const pubPem = `-----BEGIN RSA PUBLIC KEY-----\n${toBase64(pubDer)}\n-----END RSA PUBLIC KEY-----`;
|
|
54
|
+
return { privPem, pubPem };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 模拟 PaymentProvider ──
|
|
58
|
+
function mockProvider(name: string): PaymentProvider {
|
|
59
|
+
return {
|
|
60
|
+
name,
|
|
61
|
+
displayName: name,
|
|
62
|
+
supportedCurrencies: ["CNY"],
|
|
63
|
+
createPayment: vi.fn(),
|
|
64
|
+
verifyCallback: vi.fn(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 模拟 Factory ──
|
|
69
|
+
function mockFactory(name: string, priority: number, available: boolean): ProviderFactory {
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
priority,
|
|
73
|
+
isAvailable: () => available,
|
|
74
|
+
create: () => mockProvider(name),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ═══════════════════════════════════════════════════════════════
|
|
79
|
+
describe("ProviderRegistry", () => {
|
|
80
|
+
it("空的 factories → 空注册表", () => {
|
|
81
|
+
const reg = createProviderRegistry({}, []);
|
|
82
|
+
expect(reg.list()).toEqual([]);
|
|
83
|
+
expect(reg.selectOnline()).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("单一可用 factory → 注册成功", () => {
|
|
87
|
+
const reg = createProviderRegistry({}, [mockFactory("alipay", 100, true)]);
|
|
88
|
+
expect(reg.list()).toEqual(["alipay"]);
|
|
89
|
+
expect(reg.get("alipay")).toBeDefined();
|
|
90
|
+
expect(reg.get("alipay")?.name).toBe("alipay");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("不可用 factory → 不注册", () => {
|
|
94
|
+
const reg = createProviderRegistry({}, [mockFactory("alipay", 100, false)]);
|
|
95
|
+
expect(reg.list()).toEqual([]);
|
|
96
|
+
expect(reg.get("alipay")).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("多个可用 factory → 按优先级选择", () => {
|
|
100
|
+
const reg = createProviderRegistry({}, [
|
|
101
|
+
mockFactory("wechat", 200, true),
|
|
102
|
+
mockFactory("alipay", 100, true),
|
|
103
|
+
]);
|
|
104
|
+
const selected = reg.selectOnline();
|
|
105
|
+
expect(selected?.name).toBe("alipay"); // priority 100 < 200
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("高优先级不可用 → 选低优先级", () => {
|
|
109
|
+
const reg = createProviderRegistry({}, [
|
|
110
|
+
mockFactory("wechat", 100, false),
|
|
111
|
+
mockFactory("alipay", 200, true),
|
|
112
|
+
]);
|
|
113
|
+
const selected = reg.selectOnline();
|
|
114
|
+
expect(selected?.name).toBe("alipay");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("全部不可用 → selectOnline 返回 null", () => {
|
|
118
|
+
const reg = createProviderRegistry({}, [
|
|
119
|
+
mockFactory("wechat", 100, false),
|
|
120
|
+
mockFactory("alipay", 200, false),
|
|
121
|
+
]);
|
|
122
|
+
expect(reg.selectOnline()).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("get 不存在的 provider → undefined", () => {
|
|
126
|
+
const reg = createProviderRegistry({}, [mockFactory("alipay", 100, true)]);
|
|
127
|
+
expect(reg.get("wechat")).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════
|
|
132
|
+
describe("alipayFactory", () => {
|
|
133
|
+
it("env 有全部配置 → isAvailable = true", () => {
|
|
134
|
+
const env = { ALIPAY_APP_ID: "app", ALIPAY_PRIVATE_KEY: "key", ALIPAY_PUBLIC_KEY: "pub" };
|
|
135
|
+
expect(alipayFactory.isAvailable(env)).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("env 缺少 ALIPAY_APP_ID → isAvailable = false", () => {
|
|
139
|
+
expect(alipayFactory.isAvailable({ ALIPAY_PRIVATE_KEY: "k", ALIPAY_PUBLIC_KEY: "p" })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("env 缺少 ALIPAY_PRIVATE_KEY → isAvailable = false", () => {
|
|
143
|
+
expect(alipayFactory.isAvailable({ ALIPAY_APP_ID: "a", ALIPAY_PUBLIC_KEY: "p" })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("env 缺少 ALIPAY_PUBLIC_KEY → isAvailable = false", () => {
|
|
147
|
+
expect(alipayFactory.isAvailable({ ALIPAY_APP_ID: "a", ALIPAY_PRIVATE_KEY: "k" })).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("create 返回 AlipayProvider 实例", () => {
|
|
151
|
+
const env = { ALIPAY_APP_ID: "app123", ALIPAY_PRIVATE_KEY: "priv", ALIPAY_PUBLIC_KEY: "pub" };
|
|
152
|
+
const provider = alipayFactory.create(env);
|
|
153
|
+
expect(provider).toBeInstanceOf(AlipayProvider);
|
|
154
|
+
expect(provider.name).toBe("alipay");
|
|
155
|
+
expect(provider.supportedCurrencies).toContain("CNY");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════
|
|
160
|
+
describe("RSA2 签名", () => {
|
|
161
|
+
let privPem: string;
|
|
162
|
+
let pubPem: string;
|
|
163
|
+
|
|
164
|
+
beforeEach(async () => {
|
|
165
|
+
const keys = await generateTestKeys();
|
|
166
|
+
privPem = keys.privPem;
|
|
167
|
+
pubPem = keys.pubPem;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("signRSA2 返回 Base64 字符串", async () => {
|
|
171
|
+
const sig = await signRSA2({ a: "1", b: "2" }, privPem);
|
|
172
|
+
expect(typeof sig).toBe("string");
|
|
173
|
+
expect(sig.length).toBeGreaterThan(0);
|
|
174
|
+
// Base64 格式
|
|
175
|
+
expect(sig).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("sign + verify 往返验证", async () => {
|
|
179
|
+
const params = { method: "test", app_id: "123", amount: "100" };
|
|
180
|
+
const sig = await signRSA2(params, privPem);
|
|
181
|
+
const paramsWithSign = { ...params, sign: sig };
|
|
182
|
+
const valid = await verifyRSA2(paramsWithSign, pubPem);
|
|
183
|
+
expect(valid).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("篡改参数后验证失败", async () => {
|
|
187
|
+
const params = { method: "test", app_id: "123", amount: "100" };
|
|
188
|
+
const sig = await signRSA2(params, privPem);
|
|
189
|
+
const tampered = { ...params, amount: "999", sign: sig };
|
|
190
|
+
const valid = await verifyRSA2(tampered, pubPem);
|
|
191
|
+
expect(valid).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("无 sign 字段 → verifyRSA2 返回 false", async () => {
|
|
195
|
+
const valid = await verifyRSA2({ a: "1" }, pubPem);
|
|
196
|
+
expect(valid).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("相同参数签名一致(确定性)", async () => {
|
|
200
|
+
const params = { x: "1", y: "2" };
|
|
201
|
+
const sig1 = await signRSA2(params, privPem);
|
|
202
|
+
const sig2 = await signRSA2(params, privPem);
|
|
203
|
+
expect(sig1).toBe(sig2);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ═══════════════════════════════════════════════════════════════
|
|
208
|
+
describe("AlipayProvider", () => {
|
|
209
|
+
it("属性正确", () => {
|
|
210
|
+
const provider = new AlipayProvider({
|
|
211
|
+
appId: "test-app",
|
|
212
|
+
privateKey: "key",
|
|
213
|
+
alipayPublicKey: "pub",
|
|
214
|
+
});
|
|
215
|
+
expect(provider.name).toBe("alipay");
|
|
216
|
+
expect(provider.displayName).toBe("支付宝当面付");
|
|
217
|
+
expect(provider.supportedCurrencies).toEqual(["CNY"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("verifyCallback 签名无效时抛错", async () => {
|
|
221
|
+
const provider = new AlipayProvider({
|
|
222
|
+
appId: "test-app",
|
|
223
|
+
privateKey: "key",
|
|
224
|
+
alipayPublicKey: TEST_PRIVATE_KEY, // 故意用错误 key
|
|
225
|
+
});
|
|
226
|
+
await expect(
|
|
227
|
+
provider.verifyCallback({ sign: "invalid", app_id: "test-app", trade_status: "TRADE_SUCCESS" }),
|
|
228
|
+
).rejects.toThrow();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ═══════════════════════════════════════════════════════════════
|
|
233
|
+
describe("stripeFactory", () => {
|
|
234
|
+
it("env 有全部配置 → isAvailable = true", () => {
|
|
235
|
+
const env = { STRIPE_SECRET_KEY: "sk_test", STRIPE_WEBHOOK_SECRET: "whsec_test" };
|
|
236
|
+
expect(stripeFactory.isAvailable(env)).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("env 缺少 STRIPE_SECRET_KEY → isAvailable = false", () => {
|
|
240
|
+
expect(stripeFactory.isAvailable({ STRIPE_WEBHOOK_SECRET: "whsec" })).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("env 缺少 STRIPE_WEBHOOK_SECRET → isAvailable = false", () => {
|
|
244
|
+
expect(stripeFactory.isAvailable({ STRIPE_SECRET_KEY: "sk" })).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("create 返回 StripeProvider 实例", () => {
|
|
248
|
+
const env = { STRIPE_SECRET_KEY: "sk_test", STRIPE_WEBHOOK_SECRET: "whsec_test" };
|
|
249
|
+
const provider = stripeFactory.create(env);
|
|
250
|
+
expect(provider).toBeInstanceOf(StripeProvider);
|
|
251
|
+
expect(provider.name).toBe("stripe");
|
|
252
|
+
expect(provider.supportedCurrencies).toContain("USD");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════
|
|
257
|
+
describe("StripeProvider", () => {
|
|
258
|
+
it("属性正确", () => {
|
|
259
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
260
|
+
expect(provider.name).toBe("stripe");
|
|
261
|
+
expect(provider.displayName).toBe("Stripe");
|
|
262
|
+
expect(provider.supportedCurrencies).toEqual(["USD", "EUR", "GBP", "CAD", "AUD", "JPY"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("createPayment 构建正确的 URLSearchParams 并调用 Stripe API", async () => {
|
|
266
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
267
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
268
|
+
ok: true,
|
|
269
|
+
json: async () => ({ id: "cs_test_123", url: "https://checkout.stripe.com/c/test_123" }),
|
|
270
|
+
});
|
|
271
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
272
|
+
|
|
273
|
+
const result = await provider.createPayment({
|
|
274
|
+
orderNo: "ORD001",
|
|
275
|
+
amountCents: 2999,
|
|
276
|
+
currency: "USD",
|
|
277
|
+
notifyUrl: "https://example.com/pay/callback",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(result.redirectUrl).toBe("https://checkout.stripe.com/c/test_123");
|
|
281
|
+
expect(result.providerTradeNo).toBe("cs_test_123");
|
|
282
|
+
|
|
283
|
+
// 验证 fetch 调用参数
|
|
284
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
285
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
286
|
+
expect(callArgs[0]).toBe("https://api.stripe.com/v1/checkout/sessions");
|
|
287
|
+
expect(callArgs[1].method).toBe("POST");
|
|
288
|
+
expect(callArgs[1].headers["Authorization"]).toBe("Bearer sk_test");
|
|
289
|
+
expect(callArgs[1].body).toContain("ORD001");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("createPayment API 错误时抛错", async () => {
|
|
293
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
294
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
295
|
+
ok: false,
|
|
296
|
+
status: 401,
|
|
297
|
+
text: async () => "Unauthorized",
|
|
298
|
+
}) as unknown as typeof fetch;
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
provider.createPayment({
|
|
302
|
+
orderNo: "ORD001",
|
|
303
|
+
amountCents: 2999,
|
|
304
|
+
currency: "USD",
|
|
305
|
+
notifyUrl: "https://example.com/pay/callback",
|
|
306
|
+
}),
|
|
307
|
+
).rejects.toThrow("Stripe API error: 401");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("createPayment 无返回 URL 时抛错", async () => {
|
|
311
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
312
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
313
|
+
ok: true,
|
|
314
|
+
json: async () => ({ id: "cs_test_123" }), // 无 url 字段
|
|
315
|
+
}) as unknown as typeof fetch;
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
provider.createPayment({
|
|
319
|
+
orderNo: "ORD001",
|
|
320
|
+
amountCents: 2999,
|
|
321
|
+
currency: "USD",
|
|
322
|
+
notifyUrl: "https://example.com/pay/callback",
|
|
323
|
+
}),
|
|
324
|
+
).rejects.toThrow("Stripe returned no checkout URL");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("verifyCallback 验证签名并通过", async () => {
|
|
328
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
329
|
+
const validPayload = JSON.stringify({
|
|
330
|
+
type: "checkout.session.completed",
|
|
331
|
+
data: {
|
|
332
|
+
object: {
|
|
333
|
+
metadata: { order_no: "ORD001" },
|
|
334
|
+
amount_total: 2999,
|
|
335
|
+
currency: "usd",
|
|
336
|
+
payment_intent: "pi_test_123",
|
|
337
|
+
id: "cs_test_123",
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// 构建合法的 Stripe-Signature: t=<timestamp>,v1=<hmac>
|
|
343
|
+
const webhookSecret = "whsec_test";
|
|
344
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
345
|
+
const encoder = new TextEncoder();
|
|
346
|
+
const signedPayload = `${timestamp}.${validPayload}`;
|
|
347
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(webhookSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
348
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(signedPayload));
|
|
349
|
+
const hexSig = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
350
|
+
|
|
351
|
+
const result = await provider.verifyCallback({
|
|
352
|
+
_raw_body: validPayload,
|
|
353
|
+
_stripe_signature: `t=${timestamp},v1=${hexSig}`,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect(result.orderNo).toBe("ORD001");
|
|
357
|
+
expect(result.amountCents).toBe(2999);
|
|
358
|
+
expect(result.currency).toBe("USD");
|
|
359
|
+
expect(result.providerTradeNo).toBe("pi_test_123");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("verifyCallback 签名无效时抛错", async () => {
|
|
363
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
364
|
+
await expect(
|
|
365
|
+
provider.verifyCallback({
|
|
366
|
+
_raw_body: JSON.stringify({ type: "checkout.session.completed", data: { object: {} } }),
|
|
367
|
+
_stripe_signature: "t=1234567890,v1=invalid",
|
|
368
|
+
}),
|
|
369
|
+
).rejects.toThrow("Stripe webhook signature invalid");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("verifyCallback 缺少签名头时抛错", async () => {
|
|
373
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
374
|
+
await expect(
|
|
375
|
+
provider.verifyCallback({}),
|
|
376
|
+
).rejects.toThrow("Stripe callback missing signature headers");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("verifyCallback 事件类型错误时抛错", async () => {
|
|
380
|
+
const provider = new StripeProvider("sk_test", "whsec_test");
|
|
381
|
+
const payload = JSON.stringify({ type: "payment_intent.succeeded", data: { object: {} } });
|
|
382
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
383
|
+
const encoder = new TextEncoder();
|
|
384
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
385
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode("whsec_test"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
386
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(signedPayload));
|
|
387
|
+
const hexSig = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
provider.verifyCallback({ _raw_body: payload, _stripe_signature: `t=${timestamp},v1=${hexSig}` }),
|
|
391
|
+
).rejects.toThrow("Unexpected Stripe event type");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ═══════════════════════════════════════════════════════════════
|
|
396
|
+
describe("trc20Factory", () => {
|
|
397
|
+
it("env 有全部配置 → isAvailable = true", () => {
|
|
398
|
+
const env = { TRC20_WALLET_ADDRESS: "TXYZ123", TRONGRID_API_KEY: "key123" };
|
|
399
|
+
expect(trc20Factory.isAvailable(env)).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("env 缺少 TRC20_WALLET_ADDRESS → isAvailable = false", () => {
|
|
403
|
+
expect(trc20Factory.isAvailable({ TRONGRID_API_KEY: "key" })).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("env 缺少 TRONGRID_API_KEY → isAvailable = false", () => {
|
|
407
|
+
expect(trc20Factory.isAvailable({ TRC20_WALLET_ADDRESS: "addr" })).toBe(false);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("create 返回 Trc20Provider 实例", () => {
|
|
411
|
+
const env = { TRC20_WALLET_ADDRESS: "TXYZ123", TRONGRID_API_KEY: "key123" };
|
|
412
|
+
const provider = trc20Factory.create(env);
|
|
413
|
+
expect(provider).toBeInstanceOf(Trc20Provider);
|
|
414
|
+
expect(provider.name).toBe("usdt_trc20");
|
|
415
|
+
expect(provider.supportedCurrencies).toContain("USDT");
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ═══════════════════════════════════════════════════════════════
|
|
420
|
+
describe("Trc20Provider", () => {
|
|
421
|
+
it("属性正确", () => {
|
|
422
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
423
|
+
expect(provider.name).toBe("usdt_trc20");
|
|
424
|
+
expect(provider.displayName).toBe("USDT (TRC20)");
|
|
425
|
+
expect(provider.supportedCurrencies).toEqual(["USDT"]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("createPayment 返回地址、金额和 Memo", async () => {
|
|
429
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
430
|
+
const result = await provider.createPayment({
|
|
431
|
+
orderNo: "P1A2B3C4",
|
|
432
|
+
amountCents: 5000, // $50.00
|
|
433
|
+
currency: "USDT",
|
|
434
|
+
notifyUrl: "https://example.com/pay/callback",
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(result.raw).toBeDefined();
|
|
438
|
+
expect(result.raw!.address).toBe("TXYZ123");
|
|
439
|
+
expect(result.raw!.amount).toBe("50.000000");
|
|
440
|
+
// P1A2B3C4 中的数字是 1,2,3,4 → "1234" → padStart 8 → "00001234"
|
|
441
|
+
expect(result.raw!.memo).toBe("00001234");
|
|
442
|
+
expect(result.raw!.network).toBe("TRC20");
|
|
443
|
+
expect(result.raw!.warnings).toHaveLength(3);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("createPayment 从订单号提取数字 Memo", async () => {
|
|
447
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
448
|
+
// ORD-00001234 → 数字部分 00001234 → 最后8位 "00001234"
|
|
449
|
+
const result = await provider.createPayment({
|
|
450
|
+
orderNo: "ORD-00001234",
|
|
451
|
+
amountCents: 1000,
|
|
452
|
+
currency: "USDT",
|
|
453
|
+
notifyUrl: "https://example.com/pay/callback",
|
|
454
|
+
});
|
|
455
|
+
expect(result.raw!.memo).toBe("00001234");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("verifyCallback 始终抛错", async () => {
|
|
459
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
460
|
+
await expect(provider.verifyCallback({})).rejects.toThrow("USDT_TRC20 does not support HTTP callbacks");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("queryStatus 链上无交易时返回 paid=false", async () => {
|
|
464
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
465
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
466
|
+
ok: true,
|
|
467
|
+
json: async () => ({ data: [], total: 0 }),
|
|
468
|
+
}) as unknown as typeof fetch;
|
|
469
|
+
|
|
470
|
+
const result = await provider.queryStatus("ORD-00001234");
|
|
471
|
+
expect(result.paid).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("queryStatus 链上有匹配交易时返回 paid=true", async () => {
|
|
475
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
476
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
477
|
+
ok: true,
|
|
478
|
+
json: async () => ({
|
|
479
|
+
data: [
|
|
480
|
+
{
|
|
481
|
+
transaction_id: "txn_abc123",
|
|
482
|
+
value: "50000000", // 50 USDT (6 decimals)
|
|
483
|
+
token_info: { symbol: "USDT", decimals: 6 },
|
|
484
|
+
block_timestamp: Date.now(),
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
total: 1,
|
|
488
|
+
}),
|
|
489
|
+
}) as unknown as typeof fetch;
|
|
490
|
+
|
|
491
|
+
const result = await provider.queryStatus("ORD-00001234");
|
|
492
|
+
expect(result.paid).toBe(true);
|
|
493
|
+
expect(result.providerTradeNo).toBe("txn_abc123");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("queryStatus API 错误时返回 paid=false", async () => {
|
|
497
|
+
const provider = new Trc20Provider("TXYZ123", "key123");
|
|
498
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
499
|
+
ok: false,
|
|
500
|
+
status: 429, // rate limit
|
|
501
|
+
}) as unknown as typeof fetch;
|
|
502
|
+
|
|
503
|
+
const result = await provider.queryStatus("ORD-00001234");
|
|
504
|
+
expect(result.paid).toBe(false);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 支付功能模块 — 类型定义
|
|
3
|
+
*
|
|
4
|
+
* 纯接口文件,零运行时依赖。
|
|
5
|
+
* 所有支付 Provider 实现都必须实现 PaymentProvider 接口。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
9
|
+
// 支付操作数据类型
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
11
|
+
|
|
12
|
+
export interface CreatePaymentInput {
|
|
13
|
+
orderNo: string;
|
|
14
|
+
amountCents: number;
|
|
15
|
+
currency: string;
|
|
16
|
+
notifyUrl: string;
|
|
17
|
+
returnUrl?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
metadata?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CreatePaymentResult {
|
|
23
|
+
providerTradeNo?: string;
|
|
24
|
+
qrCode?: string;
|
|
25
|
+
redirectUrl?: string;
|
|
26
|
+
raw?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CallbackResult {
|
|
30
|
+
orderNo: string;
|
|
31
|
+
providerTradeNo: string;
|
|
32
|
+
amountCents: number;
|
|
33
|
+
currency: string;
|
|
34
|
+
paidAt: string;
|
|
35
|
+
raw?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface QueryStatusResult {
|
|
39
|
+
paid: boolean;
|
|
40
|
+
providerTradeNo?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RefundInput {
|
|
44
|
+
providerTradeNo: string;
|
|
45
|
+
refundCents: number;
|
|
46
|
+
reason?: string;
|
|
47
|
+
refundNo?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RefundResult {
|
|
51
|
+
success: boolean;
|
|
52
|
+
providerRefundNo?: string;
|
|
53
|
+
status: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
57
|
+
// PaymentProvider 接口
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
export interface PaymentProvider {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly displayName: string;
|
|
63
|
+
readonly supportedCurrencies: string[];
|
|
64
|
+
createPayment(input: CreatePaymentInput): Promise<CreatePaymentResult>;
|
|
65
|
+
verifyCallback(params: Record<string, string>): Promise<CallbackResult>;
|
|
66
|
+
queryStatus?(tradeNo: string): Promise<QueryStatusResult>;
|
|
67
|
+
refund?(input: RefundInput): Promise<RefundResult>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
// Provider 注册表类型
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
export interface ProviderRegistry {
|
|
75
|
+
get(name: string): PaymentProvider | undefined;
|
|
76
|
+
selectOnline(): PaymentProvider | null;
|
|
77
|
+
list(): string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProviderFactory {
|
|
81
|
+
name: string;
|
|
82
|
+
priority: number;
|
|
83
|
+
isAvailable(env: Record<string, unknown>): boolean;
|
|
84
|
+
create(env: Record<string, unknown>): PaymentProvider;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 从数据库解密后的配置创建 Provider。
|
|
88
|
+
* 可选方法——未实现时,注册表会将 config 合入 env 再调用 create()。
|
|
89
|
+
*
|
|
90
|
+
* @param config - 解密后的扁平配置对象(键名与 env var 一致,如 { ZPAY_PID: "xxx" })
|
|
91
|
+
*/
|
|
92
|
+
fromDbConfig?(config: Record<string, unknown>): PaymentProvider;
|
|
93
|
+
}
|