@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.
Files changed (169) hide show
  1. package/README.md +88 -0
  2. package/dist/features/anti-abuse/index.d.ts +62 -0
  3. package/dist/features/anti-abuse/index.d.ts.map +1 -0
  4. package/dist/features/anti-abuse/index.js +139 -0
  5. package/dist/features/anti-abuse/index.js.map +1 -0
  6. package/dist/features/email/index.d.ts +68 -0
  7. package/dist/features/email/index.d.ts.map +1 -0
  8. package/dist/features/email/index.js +120 -0
  9. package/dist/features/email/index.js.map +1 -0
  10. package/dist/features/payment/fetch-utils.d.ts +17 -0
  11. package/dist/features/payment/fetch-utils.d.ts.map +1 -0
  12. package/dist/features/payment/fetch-utils.js +56 -0
  13. package/dist/features/payment/fetch-utils.js.map +1 -0
  14. package/dist/features/payment/index.d.ts +20 -0
  15. package/dist/features/payment/index.d.ts.map +1 -0
  16. package/dist/features/payment/index.js +21 -0
  17. package/dist/features/payment/index.js.map +1 -0
  18. package/dist/features/payment/providers/alipay.d.ts +30 -0
  19. package/dist/features/payment/providers/alipay.d.ts.map +1 -0
  20. package/dist/features/payment/providers/alipay.js +149 -0
  21. package/dist/features/payment/providers/alipay.js.map +1 -0
  22. package/dist/features/payment/providers/stripe.d.ts +34 -0
  23. package/dist/features/payment/providers/stripe.d.ts.map +1 -0
  24. package/dist/features/payment/providers/stripe.js +168 -0
  25. package/dist/features/payment/providers/stripe.js.map +1 -0
  26. package/dist/features/payment/providers/trc20.d.ts +24 -0
  27. package/dist/features/payment/providers/trc20.d.ts.map +1 -0
  28. package/dist/features/payment/providers/trc20.js +96 -0
  29. package/dist/features/payment/providers/trc20.js.map +1 -0
  30. package/dist/features/payment/registry.d.ts +43 -0
  31. package/dist/features/payment/registry.d.ts.map +1 -0
  32. package/dist/features/payment/registry.js +65 -0
  33. package/dist/features/payment/registry.js.map +1 -0
  34. package/dist/features/payment/types.d.ts +72 -0
  35. package/dist/features/payment/types.d.ts.map +1 -0
  36. package/dist/features/payment/types.js +8 -0
  37. package/dist/features/payment/types.js.map +1 -0
  38. package/dist/features/thompson-router/index.d.ts +101 -0
  39. package/dist/features/thompson-router/index.d.ts.map +1 -0
  40. package/dist/features/thompson-router/index.js +186 -0
  41. package/dist/features/thompson-router/index.js.map +1 -0
  42. package/dist/features/webhook/index.d.ts +76 -0
  43. package/dist/features/webhook/index.d.ts.map +1 -0
  44. package/dist/features/webhook/index.js +127 -0
  45. package/dist/features/webhook/index.js.map +1 -0
  46. package/dist/src/audit.d.ts +45 -0
  47. package/dist/src/audit.d.ts.map +1 -0
  48. package/dist/src/audit.js +40 -0
  49. package/dist/src/audit.js.map +1 -0
  50. package/dist/src/auth/jwt.d.ts +33 -0
  51. package/dist/src/auth/jwt.d.ts.map +1 -0
  52. package/dist/src/auth/jwt.js +87 -0
  53. package/dist/src/auth/jwt.js.map +1 -0
  54. package/dist/src/auth/password.d.ts +26 -0
  55. package/dist/src/auth/password.d.ts.map +1 -0
  56. package/dist/src/auth/password.js +52 -0
  57. package/dist/src/auth/password.js.map +1 -0
  58. package/dist/src/bootstrap.d.ts +74 -0
  59. package/dist/src/bootstrap.d.ts.map +1 -0
  60. package/dist/src/bootstrap.js +231 -0
  61. package/dist/src/bootstrap.js.map +1 -0
  62. package/dist/src/cache.d.ts +52 -0
  63. package/dist/src/cache.d.ts.map +1 -0
  64. package/dist/src/cache.js +76 -0
  65. package/dist/src/cache.js.map +1 -0
  66. package/dist/src/config.d.ts +83 -0
  67. package/dist/src/config.d.ts.map +1 -0
  68. package/dist/src/config.js +96 -0
  69. package/dist/src/config.js.map +1 -0
  70. package/dist/src/crypto.d.ts +33 -0
  71. package/dist/src/crypto.d.ts.map +1 -0
  72. package/dist/src/crypto.js +87 -0
  73. package/dist/src/crypto.js.map +1 -0
  74. package/dist/src/db/connection.d.ts +53 -0
  75. package/dist/src/db/connection.d.ts.map +1 -0
  76. package/dist/src/db/connection.js +104 -0
  77. package/dist/src/db/connection.js.map +1 -0
  78. package/dist/src/db/index.d.ts +6 -0
  79. package/dist/src/db/index.d.ts.map +1 -0
  80. package/dist/src/db/index.js +6 -0
  81. package/dist/src/db/index.js.map +1 -0
  82. package/dist/src/db/schema.d.ts +649 -0
  83. package/dist/src/db/schema.d.ts.map +1 -0
  84. package/dist/src/db/schema.js +76 -0
  85. package/dist/src/db/schema.js.map +1 -0
  86. package/dist/src/error.d.ts +47 -0
  87. package/dist/src/error.d.ts.map +1 -0
  88. package/dist/src/error.js +94 -0
  89. package/dist/src/error.js.map +1 -0
  90. package/dist/src/http.d.ts +83 -0
  91. package/dist/src/http.d.ts.map +1 -0
  92. package/dist/src/http.js +116 -0
  93. package/dist/src/http.js.map +1 -0
  94. package/dist/src/idempotency.d.ts +78 -0
  95. package/dist/src/idempotency.d.ts.map +1 -0
  96. package/dist/src/idempotency.js +84 -0
  97. package/dist/src/idempotency.js.map +1 -0
  98. package/dist/src/index.d.ts +31 -0
  99. package/dist/src/index.d.ts.map +1 -0
  100. package/dist/src/index.js +45 -0
  101. package/dist/src/index.js.map +1 -0
  102. package/dist/src/logger.d.ts +31 -0
  103. package/dist/src/logger.d.ts.map +1 -0
  104. package/dist/src/logger.js +45 -0
  105. package/dist/src/logger.js.map +1 -0
  106. package/dist/src/middleware/admin-auth.d.ts +38 -0
  107. package/dist/src/middleware/admin-auth.d.ts.map +1 -0
  108. package/dist/src/middleware/admin-auth.js +55 -0
  109. package/dist/src/middleware/admin-auth.js.map +1 -0
  110. package/dist/src/middleware/api-key-auth.d.ts +42 -0
  111. package/dist/src/middleware/api-key-auth.d.ts.map +1 -0
  112. package/dist/src/middleware/api-key-auth.js +104 -0
  113. package/dist/src/middleware/api-key-auth.js.map +1 -0
  114. package/dist/src/middleware/index.d.ts +3 -0
  115. package/dist/src/middleware/index.d.ts.map +1 -0
  116. package/dist/src/middleware/index.js +3 -0
  117. package/dist/src/middleware/index.js.map +1 -0
  118. package/dist/src/rate-limit.d.ts +54 -0
  119. package/dist/src/rate-limit.d.ts.map +1 -0
  120. package/dist/src/rate-limit.js +134 -0
  121. package/dist/src/rate-limit.js.map +1 -0
  122. package/dist/src/security.d.ts +78 -0
  123. package/dist/src/security.d.ts.map +1 -0
  124. package/dist/src/security.js +175 -0
  125. package/dist/src/security.js.map +1 -0
  126. package/dist/src/types.d.ts +64 -0
  127. package/dist/src/types.d.ts.map +1 -0
  128. package/dist/src/types.js +8 -0
  129. package/dist/src/types.js.map +1 -0
  130. package/features/anti-abuse/index.ts +180 -0
  131. package/features/anti-abuse/tests/index.test.ts +50 -0
  132. package/features/email/index.ts +172 -0
  133. package/features/email/tests/index.test.ts +44 -0
  134. package/features/payment/fetch-utils.ts +65 -0
  135. package/features/payment/index.ts +39 -0
  136. package/features/payment/providers/alipay.ts +171 -0
  137. package/features/payment/providers/stripe.ts +192 -0
  138. package/features/payment/providers/trc20.ts +115 -0
  139. package/features/payment/registry.ts +87 -0
  140. package/features/payment/tests/index.test.ts +506 -0
  141. package/features/payment/types.ts +93 -0
  142. package/features/telegram-miniapp/index.ts +109 -0
  143. package/features/telegram-miniapp/tests/index.test.ts +11 -0
  144. package/features/thompson-router/index.ts +243 -0
  145. package/features/thompson-router/tests/index.test.ts +93 -0
  146. package/features/webhook/index.ts +183 -0
  147. package/features/webhook/tests/index.test.ts +21 -0
  148. package/package.json +202 -0
  149. package/src/audit.ts +70 -0
  150. package/src/auth/jwt.ts +114 -0
  151. package/src/auth/password.ts +75 -0
  152. package/src/bootstrap.ts +322 -0
  153. package/src/cache.ts +78 -0
  154. package/src/config.ts +134 -0
  155. package/src/crypto.ts +106 -0
  156. package/src/db/connection.ts +127 -0
  157. package/src/db/index.ts +6 -0
  158. package/src/db/schema.ts +90 -0
  159. package/src/error.ts +125 -0
  160. package/src/http.ts +150 -0
  161. package/src/idempotency.ts +127 -0
  162. package/src/index.ts +85 -0
  163. package/src/logger.ts +63 -0
  164. package/src/middleware/admin-auth.ts +71 -0
  165. package/src/middleware/api-key-auth.ts +164 -0
  166. package/src/middleware/index.ts +2 -0
  167. package/src/rate-limit.ts +167 -0
  168. package/src/security.ts +219 -0
  169. package/src/types.ts +70 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * telegram-miniapp — Telegram Mini App 前端集成
3
+ *
4
+ * 提供 Vue 3 Composable,用于在 Telegram Mini App 中:
5
+ * - 检测运行平台(Telegram / H5 × Mobile / Desktop)
6
+ * - 应用 Telegram 主题变量到 CSS
7
+ * - 控制 MainButton / BackButton
8
+ * - 获取用户信息
9
+ *
10
+ * 来源:eshop frontend/src/composables/useTelegram.ts + usePlatform.ts
11
+ *
12
+ * 注意:此插件为前端代码,仅在 Vue 3 项目中使用。
13
+ * 非 Vue 项目可参考实现自行适配。
14
+ */
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════════
17
+ // 平台检测(纯函数,无 Vue 依赖)
18
+ // ═══════════════════════════════════════════════════════════════════════════════
19
+
20
+ export type Platform = "telegram-mobile" | "telegram-desktop" | "h5-mobile" | "h5-desktop";
21
+
22
+ export interface PlatformInfo {
23
+ platform: Platform;
24
+ isTelegram: boolean;
25
+ isMobile: boolean;
26
+ isDesktop: boolean;
27
+ }
28
+
29
+ /**
30
+ * 检测当前运行平台
31
+ */
32
+ export function detectPlatform(): PlatformInfo {
33
+ const isTg = typeof window !== "undefined" && !!(window as any).Telegram?.WebApp;
34
+ const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
35
+ const isMobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
36
+
37
+ const platform: Platform = isTg
38
+ ? isMobileUA ? "telegram-mobile" : "telegram-desktop"
39
+ : isMobileUA ? "h5-mobile" : "h5-desktop";
40
+
41
+ return {
42
+ platform,
43
+ isTelegram: isTg,
44
+ isMobile: isMobileUA,
45
+ isDesktop: !isMobileUA,
46
+ };
47
+ }
48
+
49
+ // ═══════════════════════════════════════════════════════════════════════════════
50
+ // Telegram 主题映射
51
+ // ═══════════════════════════════════════════════════════════════════════════════
52
+
53
+ /** Telegram 主题参数 → CSS 变量映射表 */
54
+ export const THEME_MAP: Record<string, string> = {
55
+ bg_color: "--tg-bg",
56
+ text_color: "--tg-text",
57
+ hint_color: "--tg-hint",
58
+ link_color: "--tg-link",
59
+ button_color: "--tg-btn",
60
+ button_text_color: "--tg-btn-text",
61
+ secondary_bg_color: "--tg-secondary-bg",
62
+ header_bg_color: "--tg-header-bg",
63
+ bottom_bar_bg_color: "--tg-bottom-bar-bg",
64
+ top_bar_bg_color: "--tg-top-bar-bg",
65
+ destructive_text_color: "--tg-destructive",
66
+ section_bg_color: "--tg-section-bg",
67
+ };
68
+
69
+ /**
70
+ * 将 Telegram 主题参数应用到 CSS 变量
71
+ */
72
+ export function applyTelegramTheme(params: Record<string, string>): void {
73
+ if (typeof document === "undefined") return;
74
+ const root = document.documentElement;
75
+ for (const [tgKey, cssVar] of Object.entries(THEME_MAP)) {
76
+ const value = params[tgKey];
77
+ if (value) root.style.setProperty(cssVar, value);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 初始化 Telegram WebApp SDK
83
+ *
84
+ * @returns Telegram WebApp 实例或 null(非 Telegram 环境)
85
+ */
86
+ export function initTelegramWebApp(): any | null {
87
+ if (typeof window === "undefined") return null;
88
+ const webApp = (window as any).Telegram?.WebApp;
89
+ if (!webApp) return null;
90
+
91
+ webApp.ready();
92
+ webApp.expand();
93
+ applyTelegramTheme(webApp.themeParams || {});
94
+
95
+ webApp.onEvent?.("themeChanged", () => {
96
+ applyTelegramTheme(webApp.themeParams || {});
97
+ });
98
+
99
+ return webApp;
100
+ }
101
+
102
+ /**
103
+ * 获取 Telegram 用户信息(从 initDataUnsafe)
104
+ */
105
+ export function getTelegramUser(): { id: number; firstName: string; lastName?: string; username?: string } | null {
106
+ if (typeof window === "undefined") return null;
107
+ const webApp = (window as any).Telegram?.WebApp;
108
+ return webApp?.initDataUnsafe?.user ?? null;
109
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectPlatform } from "../index";
3
+
4
+ describe("detectPlatform", () => {
5
+ it("非浏览器环境返回 h5-desktop", () => {
6
+ // Node.js 测试环境无 window/navigator
7
+ const info = detectPlatform();
8
+ expect(info.isTelegram).toBe(false);
9
+ expect(info.platform).toBe("h5-desktop");
10
+ });
11
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * thompson-router — Thompson Sampling 智能路由插件
3
+ *
4
+ * 基于贝叶斯多臂老虎机算法的渠道选择器:
5
+ * - 自动探索-利用平衡(新渠道多尝试,好渠道多使用)
6
+ * - Beta 分布建模成功率(α=成功次数, β=失败次数)
7
+ * - 自动故障转移(选中渠道失败时切换到下一候选)
8
+ * - 可选多维度评分(成功率+价格+延迟+容量+趋势)
9
+ *
10
+ * 来源:vcode src/services/channel-router.ts + channel-scorer.ts
11
+ */
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════════
14
+ // 类型定义
15
+ // ═══════════════════════════════════════════════════════════════════════════════
16
+
17
+ export interface ChannelCandidate {
18
+ id: string;
19
+ name: string;
20
+ /** Beta 分布参数 α(成功次数 + 先验) */
21
+ alpha: number;
22
+ /** Beta 分布参数 β(失败次数 + 先验) */
23
+ beta: number;
24
+ /** 是否启用 */
25
+ enabled: boolean;
26
+ /** 优先级权重(越小越优先,用于同分时排序) */
27
+ priority?: number;
28
+ /** 扩展元数据 */
29
+ metadata?: Record<string, unknown>;
30
+ }
31
+
32
+ export interface RouteDecision {
33
+ /** 选中的渠道 ID */
34
+ channelId: string;
35
+ /** 渠道名称 */
36
+ channelName: string;
37
+ /** 采样得分 */
38
+ score: number;
39
+ /** 候选数量 */
40
+ candidateCount: number;
41
+ /** 决策时间 */
42
+ timestamp: string;
43
+ }
44
+
45
+ export interface ThompsonRouterConfig {
46
+ /** Beta 分布先验参数(默认 α=1, β=1,即均匀分布) */
47
+ priorAlpha?: number;
48
+ priorBeta?: number;
49
+ /** 最小样本量(低于此值的渠道会被优先探索) */
50
+ minSamples?: number;
51
+ }
52
+
53
+ // ═══════════════════════════════════════════════════════════════════════════════
54
+ // 统计采样工具
55
+ // ═══════════════════════════════════════════════════════════════════════════════
56
+
57
+ /** Marsaglia-Tsang 方法生成 Gamma 分布随机数 */
58
+ function sampleGamma(shape: number): number {
59
+ if (shape < 1) {
60
+ return sampleGamma(shape + 1) * Math.pow(Math.random(), 1 / shape);
61
+ }
62
+ const d = shape - 1 / 3;
63
+ const c = 1 / Math.sqrt(9 * d);
64
+ while (true) {
65
+ let x: number;
66
+ let v: number;
67
+ do {
68
+ x = gaussianRandom();
69
+ v = 1 + c * x;
70
+ } while (v <= 0);
71
+ v = v * v * v;
72
+ const u = Math.random();
73
+ if (u < 1 - 0.0331 * x * x * x * x) return d * v;
74
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
75
+ }
76
+ }
77
+
78
+ /** Box-Muller 变换生成标准正态分布 */
79
+ function gaussianRandom(): number {
80
+ let u = 0, v = 0;
81
+ while (u === 0) u = Math.random();
82
+ while (v === 0) v = Math.random();
83
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
84
+ }
85
+
86
+ /** 从 Beta(α, β) 分布采样 */
87
+ function sampleBeta(alpha: number, beta: number): number {
88
+ const x = sampleGamma(alpha);
89
+ const y = sampleGamma(beta);
90
+ return x / (x + y);
91
+ }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════════
94
+ // ThompsonRouter
95
+ // ═══════════════════════════════════════════════════════════════════════════════
96
+
97
+ /**
98
+ * Thompson Sampling 渠道路由器
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { ThompsonRouter } from "@usethink/cf-core/plugins/thompson-router";
103
+ *
104
+ * const router = new ThompsonRouter();
105
+ *
106
+ * // 从数据库加载候选渠道
107
+ * const candidates: ChannelCandidate[] = [
108
+ * { id: "5sim", name: "5sim.net", alpha: 15, beta: 3, enabled: true },
109
+ * { id: "grizzly", name: "GrizzlySMS", alpha: 10, beta: 5, enabled: true },
110
+ * ];
111
+ *
112
+ * // 选择最佳渠道
113
+ * const decision = router.select(candidates);
114
+ * console.log(decision.channelName); // 大概率选中 5sim(成功率更高)
115
+ *
116
+ * // 反馈结果(更新 Beta 参数)
117
+ * router.recordSuccess(candidates, "5sim"); // α += 1
118
+ * router.recordFailure(candidates, "5sim"); // β += 1
119
+ * ```
120
+ */
121
+ export class ThompsonRouter {
122
+ readonly name = "thompson-router";
123
+ readonly version = "0.1.0";
124
+
125
+ private config: Required<ThompsonRouterConfig>;
126
+
127
+ constructor(config: ThompsonRouterConfig = {}) {
128
+ this.config = {
129
+ priorAlpha: config.priorAlpha ?? 1,
130
+ priorBeta: config.priorBeta ?? 1,
131
+ minSamples: config.minSamples ?? 5,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Thompson Sampling 选择最佳渠道
137
+ */
138
+ select(candidates: ChannelCandidate[]): RouteDecision {
139
+ const eligible = candidates.filter((c) => c.enabled);
140
+ if (eligible.length === 0) {
141
+ throw new Error("No eligible channel candidates");
142
+ }
143
+
144
+ if (eligible.length === 1) {
145
+ return {
146
+ channelId: eligible[0].id,
147
+ channelName: eligible[0].name,
148
+ score: 1,
149
+ candidateCount: 1,
150
+ timestamp: new Date().toISOString(),
151
+ };
152
+ }
153
+
154
+ // 优先探索样本不足的渠道
155
+ const underExplored = eligible.filter(
156
+ (c) => (c.alpha + c.beta) < this.config.minSamples + this.config.priorAlpha + this.config.priorBeta,
157
+ );
158
+ const pool = underExplored.length > 0 ? underExplored : eligible;
159
+
160
+ // 对每个候选渠道从 Beta(α, β) 采样
161
+ let bestCandidate = pool[0];
162
+ let bestScore = -1;
163
+
164
+ for (const candidate of pool) {
165
+ const alpha = candidate.alpha + this.config.priorAlpha;
166
+ const beta = candidate.beta + this.config.priorBeta;
167
+ const score = sampleBeta(alpha, beta);
168
+
169
+ if (score > bestScore || (score === bestScore && (candidate.priority ?? 100) < (bestCandidate.priority ?? 100))) {
170
+ bestScore = score;
171
+ bestCandidate = candidate;
172
+ }
173
+ }
174
+
175
+ return {
176
+ channelId: bestCandidate.id,
177
+ channelName: bestCandidate.name,
178
+ score: bestScore,
179
+ candidateCount: eligible.length,
180
+ timestamp: new Date().toISOString(),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * 选择渠道并支持自动故障转移
186
+ */
187
+ selectWithFailover(
188
+ candidates: ChannelCandidate[],
189
+ maxAttempts = 3,
190
+ ): RouteDecision[] {
191
+ const decisions: RouteDecision[] = [];
192
+ const remaining = [...candidates];
193
+
194
+ for (let i = 0; i < Math.min(maxAttempts, remaining.length); i++) {
195
+ const decision = this.select(remaining);
196
+ decisions.push(decision);
197
+ // 移除已选渠道,下次选择时跳过
198
+ const idx = remaining.findIndex((c) => c.id === decision.channelId);
199
+ if (idx >= 0) remaining.splice(idx, 1);
200
+ if (remaining.length === 0) break;
201
+ }
202
+
203
+ return decisions;
204
+ }
205
+
206
+ /**
207
+ * 记录成功(更新 α 参数)
208
+ */
209
+ recordSuccess(candidates: ChannelCandidate[], channelId: string): void {
210
+ const channel = candidates.find((c) => c.id === channelId);
211
+ if (channel) channel.alpha += 1;
212
+ }
213
+
214
+ /**
215
+ * 记录失败(更新 β 参数)
216
+ */
217
+ recordFailure(candidates: ChannelCandidate[], channelId: string): void {
218
+ const channel = candidates.find((c) => c.id === channelId);
219
+ if (channel) channel.beta += 1;
220
+ }
221
+ }
222
+
223
+ // ═══════════════════════════════════════════════════════════════════════════════
224
+ // Wilson Score 成功率估计(小样本更可靠)
225
+ // ═══════════════════════════════════════════════════════════════════════════════
226
+
227
+ /**
228
+ * Wilson Score Interval — 小样本成功率估计
229
+ *
230
+ * 比简单 success/total 更可靠,被 Reddit/Google 等用于排序。
231
+ *
232
+ * @param successes 成功次数
233
+ * @param total 总次数
234
+ * @param z 置信水平(默认 1.96 = 95%)
235
+ */
236
+ export function wilsonScore(successes: number, total: number, z = 1.96): number {
237
+ if (total === 0) return 0;
238
+ const p = successes / total;
239
+ const denominator = 1 + z * z / total;
240
+ const centre = p + z * z / (2 * total);
241
+ const adjustment = z * Math.sqrt((p * (1 - p) + z * z / (4 * total)) / total);
242
+ return (centre - adjustment) / denominator;
243
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ThompsonRouter, wilsonScore } from "../index";
3
+ import type { ChannelCandidate } from "../index";
4
+
5
+ describe("ThompsonRouter", () => {
6
+ const candidates: ChannelCandidate[] = [
7
+ { id: "ch-a", name: "Channel A", alpha: 20, beta: 2, enabled: true },
8
+ { id: "ch-b", name: "Channel B", alpha: 5, beta: 15, enabled: true },
9
+ { id: "ch-c", name: "Channel C", alpha: 1, beta: 1, enabled: true },
10
+ ];
11
+
12
+ it("单渠道直接返回", () => {
13
+ const router = new ThompsonRouter();
14
+ const single = [{ ...candidates[0] }];
15
+ const decision = router.select(single);
16
+ expect(decision.channelId).toBe("ch-a");
17
+ expect(decision.candidateCount).toBe(1);
18
+ });
19
+
20
+ it("过滤禁用渠道", () => {
21
+ const router = new ThompsonRouter();
22
+ const mixed = [
23
+ { id: "disabled", name: "Disabled", alpha: 100, beta: 0, enabled: false },
24
+ { id: "enabled", name: "Enabled", alpha: 1, beta: 1, enabled: true },
25
+ ];
26
+ const decision = router.select(mixed);
27
+ expect(decision.channelId).toBe("enabled");
28
+ });
29
+
30
+ it("无可用渠道抛错", () => {
31
+ const router = new ThompsonRouter();
32
+ expect(() => router.select([
33
+ { id: "x", name: "X", alpha: 1, beta: 1, enabled: false },
34
+ ])).toThrow("No eligible");
35
+ });
36
+
37
+ it("selectWithFailover 返回多个决策", () => {
38
+ const router = new ThompsonRouter();
39
+ const decisions = router.selectWithFailover([...candidates], 2);
40
+ expect(decisions.length).toBe(2);
41
+ expect(decisions[0].channelId).not.toBe(decisions[1].channelId);
42
+ });
43
+
44
+ it("recordSuccess 增加 alpha", () => {
45
+ const router = new ThompsonRouter();
46
+ const c = [{ id: "x", name: "X", alpha: 5, beta: 5, enabled: true }];
47
+ router.recordSuccess(c, "x");
48
+ expect(c[0].alpha).toBe(6);
49
+ });
50
+
51
+ it("recordFailure 增加 beta", () => {
52
+ const router = new ThompsonRouter();
53
+ const c = [{ id: "x", name: "X", alpha: 5, beta: 5, enabled: true }];
54
+ router.recordFailure(c, "x");
55
+ expect(c[0].beta).toBe(6);
56
+ });
57
+
58
+ it("高成功率渠道被选中概率更大", () => {
59
+ const router = new ThompsonRouter();
60
+ const high = { id: "high", name: "High", alpha: 100, beta: 5, enabled: true };
61
+ const low = { id: "low", name: "Low", alpha: 5, beta: 100, enabled: true };
62
+ let highCount = 0;
63
+ for (let i = 0; i < 100; i++) {
64
+ const d = router.select([{ ...high }, { ...low }]);
65
+ if (d.channelId === "high") highCount++;
66
+ }
67
+ // Thompson Sampling 应该大部分时间选中高成功率渠道
68
+ expect(highCount).toBeGreaterThan(70);
69
+ });
70
+ });
71
+
72
+ describe("wilsonScore", () => {
73
+ it("无数据返回 0", () => {
74
+ expect(wilsonScore(0, 0)).toBe(0);
75
+ });
76
+
77
+ it("100% 成功率", () => {
78
+ const score = wilsonScore(100, 100);
79
+ expect(score).toBeGreaterThan(0.95);
80
+ });
81
+
82
+ it("小样本比大样本保守", () => {
83
+ const small = wilsonScore(3, 3); // 100% but only 3 samples
84
+ const large = wilsonScore(100, 100); // 100% with 100 samples
85
+ expect(small).toBeLessThan(large);
86
+ });
87
+
88
+ it("50% 成功率", () => {
89
+ const score = wilsonScore(50, 100);
90
+ expect(score).toBeGreaterThan(0.35);
91
+ expect(score).toBeLessThan(0.55);
92
+ });
93
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * webhook — Webhook 通知插件
3
+ *
4
+ * 支持:
5
+ * - 多 URL 通知(逗号分隔)
6
+ * - HMAC-SHA256 签名
7
+ * - 5s 超时 + 最多 2 次重试
8
+ * - 事件类型过滤
9
+ *
10
+ * 来源:xtools src/services/webhook.ts
11
+ */
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════════
14
+ // 类型定义
15
+ // ═══════════════════════════════════════════════════════════════════════════════
16
+
17
+ export interface WebhookConfig {
18
+ /** 通知 URL(支持多个,逗号分隔) */
19
+ urls: string;
20
+ /** HMAC-SHA256 签名密钥(可选) */
21
+ secret?: string;
22
+ /** 请求超时(毫秒,默认 5000) */
23
+ timeoutMs?: number;
24
+ /** 最大重试次数(默认 2) */
25
+ maxRetries?: number;
26
+ }
27
+
28
+ export interface WebhookPayload {
29
+ /** 事件类型 */
30
+ event: string;
31
+ /** 事件时间 ISO 8601 */
32
+ timestamp: string;
33
+ /** 事件数据 */
34
+ data: Record<string, unknown>;
35
+ }
36
+
37
+ export interface WebhookResult {
38
+ ok: boolean;
39
+ url: string;
40
+ status?: number;
41
+ error?: string;
42
+ }
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════════════
45
+ // WebhookService
46
+ // ═══════════════════════════════════════════════════════════════════════════════
47
+
48
+ /**
49
+ * Webhook 通知服务
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { WebhookService } from "@usethink/cf-core/plugins/webhook";
54
+ *
55
+ * const webhook = new WebhookService({
56
+ * urls: "https://hooks.example.com/a,https://hooks.example.com/b",
57
+ * secret: "my-secret",
58
+ * });
59
+ *
60
+ * await webhook.notify("order.paid", { orderId: "123", amount: 9900 });
61
+ * ```
62
+ */
63
+ export class WebhookService {
64
+ readonly name = "webhook";
65
+ readonly version = "0.1.0";
66
+
67
+ private urls: string[];
68
+ private secret?: string;
69
+ private timeoutMs: number;
70
+ private maxRetries: number;
71
+
72
+ constructor(config: WebhookConfig) {
73
+ this.urls = config.urls
74
+ .split(",")
75
+ .map((u) => u.trim())
76
+ .filter((u) => u.startsWith("http"));
77
+ this.secret = config.secret;
78
+ this.timeoutMs = config.timeoutMs ?? 5000;
79
+ this.maxRetries = config.maxRetries ?? 2;
80
+ }
81
+
82
+ /**
83
+ * 发送通知到所有 URL
84
+ */
85
+ async notify(event: string, data: Record<string, unknown>): Promise<WebhookResult[]> {
86
+ if (this.urls.length === 0) return [];
87
+
88
+ const payload: WebhookPayload = {
89
+ event,
90
+ timestamp: new Date().toISOString(),
91
+ data,
92
+ };
93
+
94
+ const results: WebhookResult[] = [];
95
+
96
+ for (const url of this.urls) {
97
+ const result = await this.sendToUrl(url, payload);
98
+ results.push(result);
99
+ }
100
+
101
+ return results;
102
+ }
103
+
104
+ /**
105
+ * 发送通知到单个 URL
106
+ */
107
+ private async sendToUrl(url: string, payload: WebhookPayload): Promise<WebhookResult> {
108
+ const body = JSON.stringify(payload);
109
+
110
+ const headers: Record<string, string> = {
111
+ "Content-Type": "application/json",
112
+ "X-Webhook-Event": payload.event,
113
+ "X-Webhook-Timestamp": payload.timestamp,
114
+ };
115
+
116
+ if (this.secret) {
117
+ const signature = await this.sign(body);
118
+ headers["X-Webhook-Signature"] = signature;
119
+ }
120
+
121
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
122
+ try {
123
+ const controller = new AbortController();
124
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
125
+
126
+ const res = await fetch(url, {
127
+ method: "POST",
128
+ headers,
129
+ body,
130
+ signal: controller.signal,
131
+ });
132
+
133
+ clearTimeout(timeout);
134
+
135
+ if (res.ok) {
136
+ return { ok: true, url, status: res.status };
137
+ }
138
+
139
+ if (res.status >= 400 && res.status < 500) {
140
+ return { ok: false, url, status: res.status, error: `HTTP ${res.status}` };
141
+ }
142
+
143
+ if (attempt < this.maxRetries) {
144
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
145
+ continue;
146
+ }
147
+
148
+ return { ok: false, url, status: res.status, error: `HTTP ${res.status} after ${this.maxRetries + 1} attempts` };
149
+ } catch (err) {
150
+ const errMsg = err instanceof Error ? err.message : String(err);
151
+ if (attempt < this.maxRetries) {
152
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
153
+ continue;
154
+ }
155
+ return { ok: false, url, error: errMsg };
156
+ }
157
+ }
158
+
159
+ return { ok: false, url, error: "unexpected" };
160
+ }
161
+
162
+ /**
163
+ * HMAC-SHA256 签名
164
+ */
165
+ private async sign(body: string): Promise<string> {
166
+ const key = await crypto.subtle.importKey(
167
+ "raw",
168
+ new TextEncoder().encode(this.secret!),
169
+ { name: "HMAC", hash: "SHA-256" },
170
+ false,
171
+ ["sign"],
172
+ );
173
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
174
+ return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("");
175
+ }
176
+
177
+ /**
178
+ * 是否有可用的 URL
179
+ */
180
+ get isConfigured(): boolean {
181
+ return this.urls.length > 0;
182
+ }
183
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { WebhookService } from "../index";
3
+
4
+ describe("WebhookService", () => {
5
+ it("无 URL 时不发送", async () => {
6
+ const svc = new WebhookService({ urls: "" });
7
+ expect(svc.isConfigured).toBe(false);
8
+ const results = await svc.notify("test", { foo: "bar" });
9
+ expect(results).toEqual([]);
10
+ });
11
+
12
+ it("过滤无效 URL", () => {
13
+ const svc = new WebhookService({ urls: "not-a-url, https://valid.com/hook" });
14
+ expect(svc.isConfigured).toBe(true);
15
+ });
16
+
17
+ it("全无效 URL 时 isConfigured=false", () => {
18
+ const svc = new WebhookService({ urls: "not-a-url, also-not" });
19
+ expect(svc.isConfigured).toBe(false);
20
+ });
21
+ });