afdian-kit 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 laoer536
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # afdian-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 爱发电通用工具库:**价格算法 / 验签 / 下单跳转 / 配置查询**。零依赖、无数据库、Web Crypto 验签(Node 24 / Cloudflare Workers / 浏览器通用)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add afdian-kit
9
+ ```
10
+
11
+ ## 配置:项目根创建 `afdian.config.ts`
12
+
13
+ ```ts
14
+ import { defineConfig } from "afdian-kit";
15
+
16
+ export default defineConfig({
17
+ plans: { // 订阅方案(月费)
18
+ membership: {
19
+ plan_id: "cc9ba47ec47b11f1b1ea5254001e7c00",
20
+ name: "忍3作战室会员",
21
+ monthly: 1200, // 月单价(分)
22
+ discount: true, // 官方折扣开关
23
+ },
24
+ },
25
+ products: { // 固定商品
26
+ starter_pack: { plan_id: "xxxx", name: "新手礼包", price: 9900 },
27
+ },
28
+ // publicKey?: "..." // 可选:覆盖内置官方公钥
29
+ });
30
+ ```
31
+
32
+ - **Node 环境**:方法首次调用时自动从 cwd 逐级向上发现该文件并加载(进程内缓存),无需任何初始化代码
33
+ - **Workers/打包器环境**:入口处一次性 `configure(config)`(如 `configure((await import("./afdian.config")).default)`)
34
+
35
+ ## 方法
36
+
37
+ ```ts
38
+ import {
39
+ price, listPlans, listProducts, getProduct,
40
+ verify, resolveOrder, buildUrl,
41
+ configure, defineConfig,
42
+ } from "afdian-kit";
43
+
44
+ // 价格(金额单位:分)
45
+ await price("membership", { months: 6 });
46
+ // → { kind:"plan", amount:6768, original:7200, rate:0.06, months:6, name:"忍3作战室会员", ... }
47
+ await price("starter_pack");
48
+ // → { kind:"product", amount:9900, fixed:true, ... }
49
+
50
+ // 订阅列表(含六档价格表,可按区间过滤)
51
+ await listPlans(); // tiers: 1/3/6/12/24/36 六档
52
+ await listPlans({ from: 6, to: 12 }); // 只要 6、12 两档
53
+
54
+ // 商品查询
55
+ await listProducts(); // [{ key, plan_id, name, price }]
56
+ await getProduct("starter_pack"); // 未配置 → null
57
+
58
+ // 验签(async —— Web Crypto 仅异步 API;不依赖 config)
59
+ await verify(rawBody); // → { valid:true, type, order } | { valid:false, reason }
60
+ await verify(rawBody, { publicKey }); // 显式覆盖公钥(轮换/测试)
61
+
62
+ // 订单辅助(金额核对 / 跳转)
63
+ await resolveOrder(order); // plan_id → { key, kind, expectedAmount } | null
64
+ await buildUrl({ entry: "membership", userId: "<站内用户id>", months: 6 });
65
+ // → https://afdian.com/order/create?plan_id=..&product_type=0&month=6&custom_order_id=..
66
+
67
+ // 可插拔幂等编排(kit 不带存储,store 由应用实现注入)
68
+ import { createWebhookHandler } from "afdian-kit";
69
+ const handler = createWebhookHandler({
70
+ store, // { begin(no)→"ok"|"inflight"|"completed", complete(no) }
71
+ onPaid: async ({ order, resolution, key, outTradeNo }) => { /* 应用只写履约 */ },
72
+ onRejected: (info) => { /* 留档:unknown plan / amount mismatch / unverifiable amount */ },
73
+ // checkAmount?: boolean 默认 true —— 签名金额 vs 配置应收核对
74
+ // publicKey?: string 透传给 verify
75
+ });
76
+ // 路由里:return Response.json(await handler(await request.text()))
77
+ // 流程:验签 → 方案匹配 → 幂等拦截 → 金额核对 → onPaid → 终态
78
+ // onPaid 抛错 → ec:500 借平台重推,begin 返回 "inflight" 允许重试;重复推送 → "completed" 拦截
79
+ ```
80
+
81
+ ### 价格规则(官方钉死)
82
+
83
+ - 可购档位**仅** `1 / 3 / 6 / 12 / 24 / 36` 个月,档外抛 `RangeError`
84
+ - 折扣率:`1:0, 3:0, 6:6%, 12:10%, 24:14%, 36:16%`;`discount: false` 时全部为 0
85
+ - 金额 = `Math.round(monthly × months × (1 − rate))`,单位分
86
+ - 固定商品按标价,不参与折扣
87
+
88
+ ## 验签说明
89
+
90
+ - `sign_str = out_trade_no + user_id + plan_id + total_amount`(依次拼接,无分隔符)
91
+ - RSA-SHA256,公钥默认内置官方 PEM,`data.sign` 为 base64
92
+ - 未在签名内的字段(`month`、`custom_order_id` 等)**不可作为金额/时长依据** —— 用 `resolveOrder()` 拿 `expectedAmount` 做核对
93
+
94
+ ## 开发
95
+
96
+ ```bash
97
+ pnpm install
98
+ pnpm build # tsdown 构建 → dist/(ESM + d.ts)
99
+ pnpm dev # tsdown --watch
100
+ pnpm check # 构建 + 自检(真实报文验签 / 六档价格断言 / 档外抛错 / handler 幂等编排 / 冒烟)
101
+ pnpm check:pack # 打包产物自检(pnpm pack 解包 → 结构断言 / 运行时全量用例 / 消费方 d.ts 类型检查)
102
+ ```
103
+
104
+ ### 参考 store 实现(应用侧,以 D1 为例)
105
+
106
+ ```ts
107
+ const store = {
108
+ async begin(no) {
109
+ // 用状态表实现原子认领:none → inflight;返回既有状态
110
+ // INSERT INTO webhook_events(out_trade_no, status) VALUES (?1, 'inflight')
111
+ // ON CONFLICT(out_trade_no) DO NOTHING → changes>0 即 "ok"
112
+ // 否则 SELECT status → 'inflight' | 'completed'
113
+ },
114
+ async complete(no) {
115
+ // UPDATE webhook_events SET status='completed' WHERE out_trade_no=?
116
+ },
117
+ };
118
+ ```
119
+
120
+ 严格一次的重要副作用(如给用户叠时长),建议在 `onPaid` 内再加单语句原子闸
121
+ (例如在用户表维护 `last_applied_out_trade_no`,单条 UPDATE 内比对并写入)——
122
+ 因为「onPaid 成功后、complete 前崩溃」的窗口里,平台重推会再次进入 `onPaid`。
@@ -0,0 +1,271 @@
1
+ //#region src/types.d.ts
2
+ /** 订阅方案(月费模式)配置项 */
3
+ interface PlanEntry {
4
+ /** 爱发电方案 ID */
5
+ plan_id: string;
6
+ /** 方案名称(用于站内展示) */
7
+ name: string;
8
+ /** 月单价,单位「分」 */
9
+ monthly: number;
10
+ /** 是否启用官方折扣(关闭时按 月单价×月数 原价) */
11
+ discount: boolean;
12
+ }
13
+ /** 固定商品配置项 */
14
+ interface ProductEntry {
15
+ /** 爱发电方案 ID */
16
+ plan_id: string;
17
+ /** 商品名称(用于站内展示) */
18
+ name: string;
19
+ /** 固定售价,单位「分」 */
20
+ price: number;
21
+ }
22
+ /** afdian.config.ts 的导出结构 */
23
+ interface AfdianConfig {
24
+ plans: Record<string, PlanEntry>;
25
+ products: Record<string, ProductEntry>;
26
+ /** 覆盖内置官方公钥(轮换/测试场景,PEM 格式) */
27
+ publicKey?: string;
28
+ }
29
+ /** 爱发电订单(验签后返回;4 个签名字段必填,其余随报文而定) */
30
+ interface AfdianOrder {
31
+ /** 签名字段:爱发电订单号(幂等键) */
32
+ out_trade_no: string;
33
+ /** 签名字段:下单用户 ID */
34
+ user_id: string;
35
+ /** 签名字段:方案 ID */
36
+ plan_id: string;
37
+ /** 签名字段:实付金额(分字符串,如 "12.00") */
38
+ total_amount: string;
39
+ show_amount?: string;
40
+ month?: number;
41
+ status?: number;
42
+ remark?: string;
43
+ redeem_id?: string;
44
+ /** 0 常规方案(订阅) 1 售卖方案(商品) */
45
+ product_type?: number;
46
+ discount?: string;
47
+ sku_detail?: unknown[];
48
+ create_time?: number;
49
+ remote_id?: string;
50
+ user_name?: string;
51
+ plan_title?: string;
52
+ user_private_id?: string;
53
+ title?: string;
54
+ address_person?: string;
55
+ address_phone?: string;
56
+ address_address?: string;
57
+ /** 站内自定义订单标识(下单 URL 传入,原样带回) */
58
+ custom_order_id?: string;
59
+ }
60
+ /** verify() 结果 */
61
+ type VerifyResult = {
62
+ valid: false;
63
+ reason: "bad json" | "missing fields" | "bad sign";
64
+ } | {
65
+ valid: true;
66
+ type: string;
67
+ order: AfdianOrder;
68
+ };
69
+ /** 单档价格计算结果 */
70
+ interface TierPrice {
71
+ months: number;
72
+ /** 应收(分) */
73
+ amount: number;
74
+ /** 折扣前原价(分) */
75
+ original: number;
76
+ /** 折扣率 */
77
+ rate: number;
78
+ }
79
+ /** price() 订阅方案结果 */
80
+ interface PlanPriceResult {
81
+ key: string;
82
+ kind: "plan";
83
+ name: string;
84
+ plan_id: string;
85
+ months: number;
86
+ amount: number;
87
+ original: number;
88
+ rate: number;
89
+ }
90
+ /** price() 固定商品结果 */
91
+ interface ProductPriceResult {
92
+ key: string;
93
+ kind: "product";
94
+ name: string;
95
+ plan_id: string;
96
+ amount: number;
97
+ fixed: true;
98
+ }
99
+ type PriceResult = PlanPriceResult | ProductPriceResult;
100
+ /** listPlans() 单项 */
101
+ interface PlanInfo {
102
+ key: string;
103
+ plan_id: string;
104
+ name: string;
105
+ monthly: number;
106
+ discount: boolean;
107
+ tiers: TierPrice[];
108
+ }
109
+ /** listProducts()/getProduct() 单项 */
110
+ interface ProductInfo {
111
+ key: string;
112
+ plan_id: string;
113
+ name: string;
114
+ price: number;
115
+ }
116
+ /** resolveOrder() 结果 */
117
+ interface OrderResolution {
118
+ key: string;
119
+ kind: "plan" | "product";
120
+ entry: PlanEntry | ProductEntry;
121
+ /** 该单应收(分);订阅按报文 month 反查,month 非法档位时为 null */
122
+ expectedAmount: number | null;
123
+ }
124
+ //#endregion
125
+ //#region src/config.d.ts
126
+ /** 配置文件类型标注(无运行时逻辑) */
127
+ export declare function defineConfig(config: AfdianConfig): AfdianConfig;
128
+ /** 程序化配置:调用后跳过自动发现(Workers/打包器环境用) */
129
+ export declare function configure(config: AfdianConfig): void;
130
+ /** 重置缓存(测试用) */
131
+ export declare function resetConfig(): void;
132
+ //#endregion
133
+ //#region src/pricing.d.ts
134
+ /** 官方可购档位(一次性购买仅此六档,钉死) */
135
+ export declare const MONTH_TIERS: readonly [1, 3, 6, 12, 24, 36];
136
+ /** 官方折扣表(钉死) */
137
+ export declare const DISCOUNTS: Readonly<Record<number, number>>;
138
+ /**
139
+ * 订阅方案价格计算(纯函数)
140
+ * 金额 = Math.round(monthly × months × (1 − rate)),单位分
141
+ */
142
+ export declare function calcPlanPrice({ monthly, months, discount }: {
143
+ monthly: number;
144
+ months: number;
145
+ discount: boolean;
146
+ }): {
147
+ amount: number;
148
+ original: number;
149
+ rate: number;
150
+ };
151
+ /** 计算某方案全部档位价格 */
152
+ export declare function calcTiers({ monthly, discount }: {
153
+ monthly: number;
154
+ discount: boolean;
155
+ }): {
156
+ amount: number;
157
+ original: number;
158
+ rate: number;
159
+ months: 1 | 3 | 6 | 12 | 24 | 36;
160
+ }[];
161
+ /** 订阅方案价格(key 未配置则抛错;product 忽略 months) */
162
+ export declare function price(key: string, opts?: {
163
+ months?: number;
164
+ }): Promise<PriceResult>;
165
+ /** 订阅方案列表(含逐档价格表,可按档位区间过滤) */
166
+ export declare function listPlans(opts?: {
167
+ from?: number;
168
+ to?: number;
169
+ }): Promise<PlanInfo[]>;
170
+ /** 固定商品列表 */
171
+ export declare function listProducts(): Promise<ProductInfo[]>;
172
+ /** 单个固定商品(未配置返回 null) */
173
+ export declare function getProduct(key: string): Promise<ProductInfo | null>;
174
+ //#endregion
175
+ //#region src/verify.d.ts
176
+ /**
177
+ * 爱发电 webhook 验签
178
+ * sign_str = out_trade_no + user_id + plan_id + total_amount 依次无分隔符拼接
179
+ * RSA-SHA256(RSASSA-PKCS1-v1_5),data.sign 为 base64 签名
180
+ *
181
+ * 注意:Web Crypto(crypto.subtle)仅有异步 API,任何环境都无法同步验签,
182
+ * 因此 verify 为 async。不依赖 config,公钥通过第二参显式传入(默认官方公钥)。
183
+ */
184
+ /** 爱发电官方公钥(默认) */
185
+ export declare const AFDIAN_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwdaCg1Bt+UKZKs0R54y\nlYnuANma49IpgoOwNmk3a0rhg/PQuhUJ0EOZSowIC44l0K3+fqGns3Ygi4AfmEfS\n4EKbdk1ahSxu7Zkp2rHMt+R9GarQFQkwSS/5x1dYiHNVMiR8oIXDgjmvxuNes2Cr\n8fw9dEF0xNBKdkKgG2qAawcN1nZrdyaKWtPVT9m2Hl0ddOO9thZmVLFOb9NVzgYf\njEgI+KWX6aY19Ka/ghv/L4t1IXmz9pctablN5S0CRWpJW3Cn0k6zSXgjVdKm4uN7\njRlgSRaf/Ind46vMCm3N2sgwxu/g3bnooW+db0iLo13zzuvyn727Q3UDQ0MmZcEW\nMQIDAQAB\n-----END PUBLIC KEY-----";
186
+ /** 组装签名串(与官方文档一致:四字段依次拼接,无分隔符) */
187
+ export declare function buildSignStr(order: Pick<AfdianOrder, "out_trade_no" | "user_id" | "plan_id" | "total_amount">): string;
188
+ /**
189
+ * 验证 webhook 报文签名,返回验证之后的数据
190
+ *
191
+ * @param payload 完整报文(字符串或已解析对象,需含 data.sign 与 data.order)
192
+ * @param opts.publicKey 覆盖公钥(PEM;轮换/测试用 —— 显式传参,不读 config,无隐式顺序)
193
+ */
194
+ export declare function verify(payload: string | object, opts?: {
195
+ publicKey?: string;
196
+ }): Promise<VerifyResult>;
197
+ //#endregion
198
+ //#region src/order.d.ts
199
+ /**
200
+ * 按 order.plan_id 匹配配置并给出该单应收金额(应用做金额核对的辅助)
201
+ * 匹配不到返回 null(应用记 rejected)
202
+ */
203
+ export declare function resolveOrder(order: Pick<AfdianOrder, "plan_id" | "month">): Promise<OrderResolution | null>;
204
+ /**
205
+ * 生成爱发电下单跳转 URL
206
+ * - plans entry:product_type=0,months 默认 1(非法档位抛错)
207
+ * - products entry:product_type=1,不带 month
208
+ */
209
+ export declare function buildUrl(opts: {
210
+ entry: string;
211
+ userId: string;
212
+ months?: number;
213
+ }): Promise<string>;
214
+ //#endregion
215
+ //#region src/handler.d.ts
216
+ /**
217
+ * 可插拔幂等编排(kit 依旧不带任何存储 —— store 由应用实现并注入)
218
+ *
219
+ * 流程:verify → resolveOrder(方案匹配) → store.begin(幂等拦截)
220
+ * → 金额核对 → onPaid(应用履约) → store.complete
221
+ *
222
+ * 幂等语义(begin 的三种返回):
223
+ * - "ok" 首次见,进入处理
224
+ * - "inflight" 上次处理中断(崩溃/失败后平台重推)→ 允许重试履约
225
+ * - "completed" 已终态(履约成功或核对拒绝)→ 拦截,ec:200 不重推
226
+ *
227
+ * onPaid 抛错:不 complete,返回 ec:500 → 平台重推 → begin 得 "inflight" → 重试
228
+ * 崩溃窗口(onPaid 成功后、complete 前):重推会再次调用 onPaid ——
229
+ * 对时效不敏感的履约天然安全;严格一次的副作用需应用侧自备幂等
230
+ * (如用户表维护 last_applied_out_trade_no,单条 UPDATE 内比对并写入的原子闸)。
231
+ */
232
+ /** 幂等存储接口:应用实现(D1/Redis/…),唯一键约束保证 begin 的原子性 */
233
+ interface IdempotencyStore {
234
+ /** 认领订单号:首次 "ok";已处理中 "inflight";已终态 "completed" */
235
+ begin(outTradeNo: string): Promise<"ok" | "inflight" | "completed">;
236
+ /** 标记终态(履约成功,或核对类拒绝) */
237
+ complete(outTradeNo: string): Promise<void>;
238
+ }
239
+ /** 核对失败信息(供应用留档/人工处理) */
240
+ interface RejectedInfo {
241
+ reason: "unknown plan" | "unverifiable amount" | "amount mismatch";
242
+ order: AfdianOrder;
243
+ expectedAmount: number | null;
244
+ }
245
+ interface WebhookHandlerDeps {
246
+ store: IdempotencyStore;
247
+ /** 履约回调(唯一必须实现的业务逻辑) */
248
+ onPaid: (ctx: {
249
+ order: AfdianOrder;
250
+ resolution: OrderResolution;
251
+ key: string;
252
+ outTradeNo: string;
253
+ }) => Promise<void>;
254
+ /** 核对失败钩子(留档;返回后 handler 一律 ec:200 不重推) */
255
+ onRejected?: (info: RejectedInfo) => Promise<void> | void;
256
+ /** 金额核对开关,默认开启(关闭后 expectedAmount 不再校验,仅高级场景使用) */
257
+ checkAmount?: boolean;
258
+ /** 覆盖公钥(透传给 verify) */
259
+ publicKey?: string;
260
+ }
261
+ interface WebhookEc {
262
+ ec: 200 | 500;
263
+ em: string;
264
+ }
265
+ /**
266
+ * 创建 webhook 处理器:入参报文(字符串/对象),返回爱发电契约的 ec 响应体。
267
+ * 应用侧自行包一层 HTTP:`return Response.json(await handler(await req.text()))`
268
+ */
269
+ export declare function createWebhookHandler(deps: WebhookHandlerDeps): (payload: string | object) => Promise<WebhookEc>;
270
+ //#endregion
271
+ export type { AfdianConfig, AfdianOrder, IdempotencyStore, OrderResolution, PlanEntry, PlanInfo, PlanPriceResult, PriceResult, ProductEntry, ProductInfo, ProductPriceResult, RejectedInfo, TierPrice, VerifyResult, WebhookEc, WebhookHandlerDeps };
package/dist/index.js ADDED
@@ -0,0 +1,424 @@
1
+ //#region src/config.ts
2
+ /**
3
+ * 配置加载:
4
+ * - configure(config) 程序化一次配置(Workers/打包器环境入口)
5
+ * - 自动发现 Node 下从 cwd 逐级向上找 afdian.config.*,import 执行取 default,进程内缓存
6
+ * - 依赖 config 的方法为 async;verify 不依赖 config(见 spec §7)
7
+ */
8
+ const CONFIG_FILENAMES = [
9
+ "afdian.config.ts",
10
+ "afdian.config.mts",
11
+ "afdian.config.mjs",
12
+ "afdian.config.js"
13
+ ];
14
+ let cached = null;
15
+ let loading = null;
16
+ /** 配置文件类型标注(无运行时逻辑) */
17
+ function defineConfig(config) {
18
+ return config;
19
+ }
20
+ /** 程序化配置:调用后跳过自动发现(Workers/打包器环境用) */
21
+ function configure(config) {
22
+ cached = validate(config);
23
+ }
24
+ /** 重置缓存(测试用) */
25
+ function resetConfig() {
26
+ cached = null;
27
+ loading = null;
28
+ }
29
+ function validate(raw) {
30
+ const cfg = raw;
31
+ if (!cfg || typeof cfg !== "object") throw new Error("afdian.config 必须导出配置对象(export default defineConfig({...}))");
32
+ if (!cfg.plans || typeof cfg.plans !== "object") throw new Error("afdian.config 缺少 plans 配置(订阅方案)");
33
+ if (!cfg.products || typeof cfg.products !== "object") throw new Error("afdian.config 缺少 products 配置(固定商品)");
34
+ return cfg;
35
+ }
36
+ function isNode() {
37
+ return typeof globalThis.process?.versions?.node === "string";
38
+ }
39
+ /** 拼接的动态 import:绕过打包器静态分析(Workers 构建不会解析 node 内置模块) */
40
+ async function importNodeBuiltin(name) {
41
+ return await import("node:" + name);
42
+ }
43
+ /** 获取配置(内部统一入口):已配置直接返回,否则自动发现 */
44
+ async function getConfig() {
45
+ if (cached) return cached;
46
+ if (loading) return loading;
47
+ loading = discover().finally(() => {
48
+ loading = null;
49
+ });
50
+ return loading;
51
+ }
52
+ async function discover() {
53
+ if (!isNode()) throw new Error("自动发现 afdian.config 仅支持 Node 环境;Workers/打包器环境请先调用 configure(config)");
54
+ const fs = await importNodeBuiltin("fs");
55
+ const path = await importNodeBuiltin("path");
56
+ const url = await importNodeBuiltin("url");
57
+ let dir = globalThis.process.cwd();
58
+ for (;;) {
59
+ for (const name of CONFIG_FILENAMES) {
60
+ const full = path.join(dir, name);
61
+ if (fs.existsSync(full)) {
62
+ const cfg = validate((await import(url.pathToFileURL(full).href)).default);
63
+ cached = cfg;
64
+ return cfg;
65
+ }
66
+ }
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) break;
69
+ dir = parent;
70
+ }
71
+ throw new Error("未找到 afdian.config.ts(从当前目录逐级向上查找)。请在项目根创建它,或调用 configure(config)");
72
+ }
73
+ //#endregion
74
+ //#region src/pricing.ts
75
+ /** 官方可购档位(一次性购买仅此六档,钉死) */
76
+ const MONTH_TIERS = [
77
+ 1,
78
+ 3,
79
+ 6,
80
+ 12,
81
+ 24,
82
+ 36
83
+ ];
84
+ /** 官方折扣表(钉死) */
85
+ const DISCOUNTS = {
86
+ 1: 0,
87
+ 3: 0,
88
+ 6: .06,
89
+ 12: .1,
90
+ 24: .14,
91
+ 36: .16
92
+ };
93
+ const TIER_HINT = "1/3/6/12/24/36";
94
+ /**
95
+ * 订阅方案价格计算(纯函数)
96
+ * 金额 = Math.round(monthly × months × (1 − rate)),单位分
97
+ */
98
+ function calcPlanPrice({ monthly, months, discount }) {
99
+ if (!Number.isInteger(months) || months <= 0) throw new RangeError(`months 必须为正整数,收到: ${months}`);
100
+ if (!(months in DISCOUNTS)) throw new RangeError(`官方仅支持 ${TIER_HINT} 个月档位,收到: ${months}`);
101
+ const rate = discount ? DISCOUNTS[months] : 0;
102
+ const original = monthly * months;
103
+ return {
104
+ amount: Math.round(original * (1 - rate)),
105
+ original,
106
+ rate
107
+ };
108
+ }
109
+ /** 计算某方案全部档位价格 */
110
+ function calcTiers({ monthly, discount }) {
111
+ return MONTH_TIERS.map((months) => ({
112
+ months,
113
+ ...calcPlanPrice({
114
+ monthly,
115
+ months,
116
+ discount
117
+ })
118
+ }));
119
+ }
120
+ /** 订阅方案价格(key 未配置则抛错;product 忽略 months) */
121
+ async function price(key, opts) {
122
+ const cfg = await getConfig();
123
+ const plan = cfg.plans[key];
124
+ if (plan) {
125
+ const months = opts?.months ?? 1;
126
+ const { amount, original, rate } = calcPlanPrice({
127
+ monthly: plan.monthly,
128
+ months,
129
+ discount: plan.discount
130
+ });
131
+ return {
132
+ key,
133
+ kind: "plan",
134
+ name: plan.name,
135
+ plan_id: plan.plan_id,
136
+ months,
137
+ amount,
138
+ original,
139
+ rate
140
+ };
141
+ }
142
+ const product = cfg.products[key];
143
+ if (product) return {
144
+ key,
145
+ kind: "product",
146
+ name: product.name,
147
+ plan_id: product.plan_id,
148
+ amount: product.price,
149
+ fixed: true
150
+ };
151
+ throw new Error(`未找到配置项: ${key}(检查 afdian.config 的 plans/products)`);
152
+ }
153
+ /** 订阅方案列表(含逐档价格表,可按档位区间过滤) */
154
+ async function listPlans(opts) {
155
+ const cfg = await getConfig();
156
+ return Object.entries(cfg.plans).map(([key, plan]) => ({
157
+ key,
158
+ plan_id: plan.plan_id,
159
+ name: plan.name,
160
+ monthly: plan.monthly,
161
+ discount: plan.discount,
162
+ tiers: calcTiers(plan).filter((t) => (opts?.from === void 0 || t.months >= opts.from) && (opts?.to === void 0 || t.months <= opts.to))
163
+ }));
164
+ }
165
+ /** 固定商品列表 */
166
+ async function listProducts() {
167
+ const cfg = await getConfig();
168
+ return Object.entries(cfg.products).map(([key, product]) => ({
169
+ key,
170
+ plan_id: product.plan_id,
171
+ name: product.name,
172
+ price: product.price
173
+ }));
174
+ }
175
+ /** 单个固定商品(未配置返回 null) */
176
+ async function getProduct(key) {
177
+ const product = (await getConfig()).products[key];
178
+ if (!product) return null;
179
+ return {
180
+ key,
181
+ plan_id: product.plan_id,
182
+ name: product.name,
183
+ price: product.price
184
+ };
185
+ }
186
+ //#endregion
187
+ //#region src/verify.ts
188
+ /**
189
+ * 爱发电 webhook 验签
190
+ * sign_str = out_trade_no + user_id + plan_id + total_amount 依次无分隔符拼接
191
+ * RSA-SHA256(RSASSA-PKCS1-v1_5),data.sign 为 base64 签名
192
+ *
193
+ * 注意:Web Crypto(crypto.subtle)仅有异步 API,任何环境都无法同步验签,
194
+ * 因此 verify 为 async。不依赖 config,公钥通过第二参显式传入(默认官方公钥)。
195
+ */
196
+ /** 爱发电官方公钥(默认) */
197
+ const AFDIAN_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
198
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwdaCg1Bt+UKZKs0R54y
199
+ lYnuANma49IpgoOwNmk3a0rhg/PQuhUJ0EOZSowIC44l0K3+fqGns3Ygi4AfmEfS
200
+ 4EKbdk1ahSxu7Zkp2rHMt+R9GarQFQkwSS/5x1dYiHNVMiR8oIXDgjmvxuNes2Cr
201
+ 8fw9dEF0xNBKdkKgG2qAawcN1nZrdyaKWtPVT9m2Hl0ddOO9thZmVLFOb9NVzgYf
202
+ jEgI+KWX6aY19Ka/ghv/L4t1IXmz9pctablN5S0CRWpJW3Cn0k6zSXgjVdKm4uN7
203
+ jRlgSRaf/Ind46vMCm3N2sgwxu/g3bnooW+db0iLo13zzuvyn727Q3UDQ0MmZcEW
204
+ MQIDAQAB
205
+ -----END PUBLIC KEY-----`;
206
+ /** 组装签名串(与官方文档一致:四字段依次拼接,无分隔符) */
207
+ function buildSignStr(order) {
208
+ return `${order.out_trade_no}${order.user_id}${order.plan_id}${order.total_amount}`;
209
+ }
210
+ function pemToDer(pem) {
211
+ const body = pem.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\s+/g, "");
212
+ const raw = atob(body);
213
+ const bytes = new Uint8Array(raw.length);
214
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
215
+ return bytes.buffer;
216
+ }
217
+ /**
218
+ * 验证 webhook 报文签名,返回验证之后的数据
219
+ *
220
+ * @param payload 完整报文(字符串或已解析对象,需含 data.sign 与 data.order)
221
+ * @param opts.publicKey 覆盖公钥(PEM;轮换/测试用 —— 显式传参,不读 config,无隐式顺序)
222
+ */
223
+ async function verify(payload, opts) {
224
+ let parsed;
225
+ if (typeof payload === "string") try {
226
+ parsed = JSON.parse(payload);
227
+ } catch {
228
+ return {
229
+ valid: false,
230
+ reason: "bad json"
231
+ };
232
+ }
233
+ else parsed = payload;
234
+ const order = parsed.data?.order;
235
+ const sign = parsed.data?.sign;
236
+ if (!order || !sign) return {
237
+ valid: false,
238
+ reason: "missing fields"
239
+ };
240
+ if (![
241
+ "out_trade_no",
242
+ "user_id",
243
+ "plan_id",
244
+ "total_amount"
245
+ ].every((f) => typeof order[f] === "string")) return {
246
+ valid: false,
247
+ reason: "missing fields"
248
+ };
249
+ try {
250
+ const key = await crypto.subtle.importKey("spki", pemToDer(opts?.publicKey ?? AFDIAN_PUBLIC_KEY), {
251
+ name: "RSASSA-PKCS1-v1_5",
252
+ hash: "SHA-256"
253
+ }, false, ["verify"]);
254
+ const sigRaw = atob(sign);
255
+ const sig = new Uint8Array(sigRaw.length);
256
+ for (let i = 0; i < sigRaw.length; i++) sig[i] = sigRaw.charCodeAt(i);
257
+ const data = new TextEncoder().encode(buildSignStr(order));
258
+ if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, sig, data)) return {
259
+ valid: false,
260
+ reason: "bad sign"
261
+ };
262
+ } catch {
263
+ return {
264
+ valid: false,
265
+ reason: "bad sign"
266
+ };
267
+ }
268
+ return {
269
+ valid: true,
270
+ type: parsed.data?.type ?? "order",
271
+ order
272
+ };
273
+ }
274
+ //#endregion
275
+ //#region src/order.ts
276
+ /**
277
+ * 按 order.plan_id 匹配配置并给出该单应收金额(应用做金额核对的辅助)
278
+ * 匹配不到返回 null(应用记 rejected)
279
+ */
280
+ async function resolveOrder(order) {
281
+ const cfg = await getConfig();
282
+ const planHit = Object.entries(cfg.plans).find(([, entry]) => entry.plan_id === order.plan_id);
283
+ if (planHit) {
284
+ const [key, entry] = planHit;
285
+ let expectedAmount;
286
+ try {
287
+ expectedAmount = calcPlanPrice({
288
+ monthly: entry.monthly,
289
+ months: order.month ?? 1,
290
+ discount: entry.discount
291
+ }).amount;
292
+ } catch {
293
+ expectedAmount = null;
294
+ }
295
+ return {
296
+ key,
297
+ kind: "plan",
298
+ entry,
299
+ expectedAmount
300
+ };
301
+ }
302
+ const productHit = Object.entries(cfg.products).find(([, entry]) => entry.plan_id === order.plan_id);
303
+ if (productHit) {
304
+ const [key, entry] = productHit;
305
+ return {
306
+ key,
307
+ kind: "product",
308
+ entry,
309
+ expectedAmount: entry.price
310
+ };
311
+ }
312
+ return null;
313
+ }
314
+ /**
315
+ * 生成爱发电下单跳转 URL
316
+ * - plans entry:product_type=0,months 默认 1(非法档位抛错)
317
+ * - products entry:product_type=1,不带 month
318
+ */
319
+ async function buildUrl(opts) {
320
+ const cfg = await getConfig();
321
+ const plan = cfg.plans[opts.entry];
322
+ if (plan) {
323
+ const months = opts.months ?? 1;
324
+ calcPlanPrice({
325
+ monthly: plan.monthly,
326
+ months,
327
+ discount: plan.discount
328
+ });
329
+ return `https://afdian.com/order/create?${new URLSearchParams({
330
+ plan_id: plan.plan_id,
331
+ product_type: "0",
332
+ month: String(months),
333
+ custom_order_id: opts.userId
334
+ })}`;
335
+ }
336
+ const product = cfg.products[opts.entry];
337
+ if (product) return `https://afdian.com/order/create?${new URLSearchParams({
338
+ plan_id: product.plan_id,
339
+ product_type: "1",
340
+ custom_order_id: opts.userId
341
+ })}`;
342
+ throw new Error(`未找到配置项: ${opts.entry}`);
343
+ }
344
+ //#endregion
345
+ //#region src/handler.ts
346
+ /**
347
+ * 创建 webhook 处理器:入参报文(字符串/对象),返回爱发电契约的 ec 响应体。
348
+ * 应用侧自行包一层 HTTP:`return Response.json(await handler(await req.text()))`
349
+ */
350
+ function createWebhookHandler(deps) {
351
+ return async (payload) => {
352
+ const verified = await verify(payload, deps.publicKey ? { publicKey: deps.publicKey } : void 0);
353
+ if (!verified.valid) return {
354
+ ec: 500,
355
+ em: verified.reason
356
+ };
357
+ const order = verified.order;
358
+ const outTradeNo = order.out_trade_no;
359
+ const resolution = await resolveOrder(order);
360
+ if (!resolution) {
361
+ await deps.onRejected?.({
362
+ reason: "unknown plan",
363
+ order,
364
+ expectedAmount: null
365
+ });
366
+ return {
367
+ ec: 200,
368
+ em: "unknown plan"
369
+ };
370
+ }
371
+ if (await deps.store.begin(outTradeNo) === "completed") return {
372
+ ec: 200,
373
+ em: "duplicate"
374
+ };
375
+ if (deps.checkAmount !== false) {
376
+ const actual = Math.round(parseFloat(order.total_amount) * 100);
377
+ const expected = resolution.expectedAmount;
378
+ if (expected === null) {
379
+ await deps.store.complete(outTradeNo);
380
+ await deps.onRejected?.({
381
+ reason: "unverifiable amount",
382
+ order,
383
+ expectedAmount: expected
384
+ });
385
+ return {
386
+ ec: 200,
387
+ em: "unverifiable amount"
388
+ };
389
+ }
390
+ if (Number.isNaN(actual) || actual !== expected) {
391
+ await deps.store.complete(outTradeNo);
392
+ await deps.onRejected?.({
393
+ reason: "amount mismatch",
394
+ order,
395
+ expectedAmount: expected
396
+ });
397
+ return {
398
+ ec: 200,
399
+ em: "amount mismatch"
400
+ };
401
+ }
402
+ }
403
+ try {
404
+ await deps.onPaid({
405
+ order,
406
+ resolution,
407
+ key: resolution.key,
408
+ outTradeNo
409
+ });
410
+ } catch {
411
+ return {
412
+ ec: 500,
413
+ em: "onPaid failed"
414
+ };
415
+ }
416
+ await deps.store.complete(outTradeNo);
417
+ return {
418
+ ec: 200,
419
+ em: ""
420
+ };
421
+ };
422
+ }
423
+ //#endregion
424
+ export { AFDIAN_PUBLIC_KEY, DISCOUNTS, MONTH_TIERS, buildSignStr, buildUrl, calcPlanPrice, calcTiers, configure, createWebhookHandler, defineConfig, getProduct, listPlans, listProducts, price, resetConfig, resolveOrder, verify };
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "afdian-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "爱发电通用工具库:价格算法 / 验签 / 下单跳转 / 配置查询(零依赖 · 无数据库)",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsdown",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit",
19
+ "check": "tsdown && node scripts/check.mjs",
20
+ "check:pack": "node scripts/check-pack.mjs",
21
+ "dev": "tsdown --watch"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/laoer536/afdian-kit.git"
26
+ },
27
+ "author": "laoer536",
28
+ "homepage": "https://github.com/laoer536/afdian-kit#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/laoer536/afdian-kit/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=24"
34
+ },
35
+ "keywords": [
36
+ "afdian",
37
+ "ifdian",
38
+ "webhook",
39
+ "payment",
40
+ "subscription"
41
+ ],
42
+ "license": "MIT",
43
+ "devDependencies": {
44
+ "tsdown": "^0.23.1",
45
+ "typescript": "^5.9.0"
46
+ }
47
+ }