wkgeo-cli 0.2.0 → 0.2.2
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 +171 -36
- package/SKILL.md +173 -29
- package/lib/args.mjs +5 -0
- package/lib/cli.mjs +5 -0
- package/lib/cms.mjs +317 -0
- package/lib/commands/auth.mjs +129 -93
- package/lib/commands/billing.mjs +641 -421
- package/lib/commands/cms.mjs +1265 -0
- package/lib/commands/import.mjs +164 -0
- package/lib/commands/index.mjs +83 -4
- package/lib/config.mjs +82 -22
- package/lib/device.mjs +22 -5
- package/lib/index.mjs +5 -0
- package/lib/local-import.mjs +185 -0
- package/lib/login.mjs +97 -85
- package/lib/oauth-urls.mjs +163 -0
- package/lib/runtime.mjs +81 -0
- package/lib/site-state.mjs +137 -0
- package/package.json +1 -1
package/lib/commands/billing.mjs
CHANGED
|
@@ -1,421 +1,641 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 钱包命令:`wallet` / `wallet txns` / `wallet recharge`
|
|
3
|
-
*
|
|
4
|
-
* 这是"钱包余额支付"链路的**观察窗口**:每次下单/支付/退款后都用它复核余额,
|
|
5
|
-
* 报告里的"事前/事后一分不差"就是拿它的数字对比的。
|
|
6
|
-
*
|
|
7
|
-
* ⚠️ 关于"钱包流水"的接口事实(已核对服务端代码):
|
|
8
|
-
* * `getMyWallet` 自己就带了最近流水(`data.rows`,按时间倒序,limit 1~100)——
|
|
9
|
-
* **普通用户看自己的流水就是走它**,这就是 `wallet txns` 的实现。
|
|
10
|
-
* * `adminListWalletTxns` 是**管理员**接口(能力 `billing:manage`),
|
|
11
|
-
* OAuth 令牌默认拿不到,也不该拿 —— 所以 CLI **不会**去调它。
|
|
12
|
-
* * 服务端**没有**"我的流水分页/按时间筛选"的独立接口(只有 limit,没有 offset)。
|
|
13
|
-
* 这是个真实缺口,已写进报告。
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { withTrailingSlash } from '../config.mjs';
|
|
17
|
-
import { UsageError, CliError, EXIT } from '../errors.mjs';
|
|
18
|
-
import { formatMoney, formatTime } from '../format.mjs';
|
|
19
|
-
import { openUrlForUser, browserPasteText, browserOpenedText, browserFailedHintLines } from '../login.mjs';
|
|
20
|
-
import { renderQrOptional } from '../qr.mjs';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* 拼出「我们自己的网上收银台」地址:`<webBase
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
pay
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 钱包命令:`wallet` / `wallet txns` / `wallet recharge`
|
|
3
|
+
*
|
|
4
|
+
* 这是"钱包余额支付"链路的**观察窗口**:每次下单/支付/退款后都用它复核余额,
|
|
5
|
+
* 报告里的"事前/事后一分不差"就是拿它的数字对比的。
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 关于"钱包流水"的接口事实(已核对服务端代码):
|
|
8
|
+
* * `getMyWallet` 自己就带了最近流水(`data.rows`,按时间倒序,limit 1~100)——
|
|
9
|
+
* **普通用户看自己的流水就是走它**,这就是 `wallet txns` 的实现。
|
|
10
|
+
* * `adminListWalletTxns` 是**管理员**接口(能力 `billing:manage`),
|
|
11
|
+
* OAuth 令牌默认拿不到,也不该拿 —— 所以 CLI **不会**去调它。
|
|
12
|
+
* * 服务端**没有**"我的流水分页/按时间筛选"的独立接口(只有 limit,没有 offset)。
|
|
13
|
+
* 这是个真实缺口,已写进报告。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { withTrailingSlash } from '../config.mjs';
|
|
17
|
+
import { UsageError, CliError, EXIT } from '../errors.mjs';
|
|
18
|
+
import { formatMoney, formatTime } from '../format.mjs';
|
|
19
|
+
import { openUrlForUser, browserPasteText, browserOpenedText, browserFailedHintLines } from '../login.mjs';
|
|
20
|
+
import { renderQrOptional } from '../qr.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 拼出「我们自己的网上收银台」地址:`<webBase>/?order_id=<id>&token=<支付令牌>`
|
|
24
|
+
*
|
|
25
|
+
* ★ 2026-10-02(第二次拍板):收银台站去掉了冗余的 `/pay` 前缀 —— **收银台就是站根 `/`**
|
|
26
|
+
* (`https://pay.wkgeo.com/?order_id=…`,原来是 `…/?order_id=…`)。
|
|
27
|
+
*
|
|
28
|
+
* ## 为什么必须走这里、不许在命令里手拼
|
|
29
|
+
*
|
|
30
|
+
* 1. **尾斜杠必须在 `?` 之前**(本仓库铁律,踩过线上事故):
|
|
31
|
+
* 前端是静态导出,产物是"每个路由一个目录"(`dist-pay/xxx/index.html`),
|
|
32
|
+
* 托管侧(OSS 静态托管 / 阿里云 ESA)对**不带尾斜杠**的目录路径会返回 302,
|
|
33
|
+
* 而这个 302 **不携带原始 query** —— 于是 `?order_id=…` 在浏览器里
|
|
34
|
+
* 根本到不了页面,用户看到的是一个"没有订单号的收银台"。
|
|
35
|
+
* 所以路径一律走 `withTrailingSlash()`(全项目唯一构造入口,见 `config.mjs`);
|
|
36
|
+
* 收银台搬到站根之后这条对它不再适用,但拼法照旧过一道(对 `/` 幂等)。
|
|
37
|
+
* 2. 参数一律走 `URLSearchParams` 编码:手拼一旦漏编码,
|
|
38
|
+
* 订单号里任何特殊字符都会把后面的参数吃掉(登录回跳那条链踩过同样的坑)。
|
|
39
|
+
* ⚠️ 令牌是 **base64url**,含 `-` / `_` / `.`,看着"很安全",但**也必须编码** ——
|
|
40
|
+
* 别因为"它字面上没特殊字符"就手拼。
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ 老板定的产品流程就是这条:**在网上生成订单 → 打开我们网站的收银台页面**,
|
|
43
|
+
* 而不是"在本地搞一个二维码让别人扫"。
|
|
44
|
+
*
|
|
45
|
+
* ## ★ 2026-10-02 新增第三个参数:订单支付令牌(`token`)
|
|
46
|
+
* 老板原话:「你拿到 token 之后,**以那个 token 为基础来进行支付**,
|
|
47
|
+
* 而不是还要他用**用户名那一套**来进行支付。」
|
|
48
|
+
*
|
|
49
|
+
* 现场:用户是在 **AI 客户端右侧的内置浏览器**里付这一单,那里**通常没有网页登录态**
|
|
50
|
+
* (甚至跟他平时登录后台的账号不是同一个)。服务端在下单时把"这一单是谁的"签成
|
|
51
|
+
* 一把**只对这一单有效**的令牌(`createRechargeOrder` 的 `pay_token`)交给我们,
|
|
52
|
+
* 收银台凭它就能读这一单的支付参数 —— **不需要网页登录**。
|
|
53
|
+
* ⚠️ 令牌只授权"读这一单的支付参数":读不到钱包、读不到订单列表、不能下单、不能取消。
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ 幂等/兼容:服务端**没给**令牌(老版本云函数未部署 / 密钥未配置)时,
|
|
56
|
+
* `payUrl` 照旧只有 `order_id` —— 那条路**仍然要求网页登录**,
|
|
57
|
+
* 行为与改造前逐字一致(不因为"新功能没上线"就把命令搞失败)。
|
|
58
|
+
*
|
|
59
|
+
* @param {string} webBase 前端基址。★ 2026-10-02 起传的是 `config.payWebBase`
|
|
60
|
+
* (`https://pay.wkgeo.com`),老站基址只是兜底
|
|
61
|
+
* @param {string} orderId 订单号
|
|
62
|
+
* @param {string} [payToken] 订单支付令牌(`pay_token`),没有就不带这个参数
|
|
63
|
+
* @returns {string|null} 订单号缺失时返回 null(没订单号就拼不出能用的收银台地址)
|
|
64
|
+
*/
|
|
65
|
+
export function buildPayUrl(webBase, orderId, payToken) {
|
|
66
|
+
const id = orderId === undefined || orderId === null ? '' : String(orderId).trim();
|
|
67
|
+
if (!id) return null;
|
|
68
|
+
const base = String(webBase || '').replace(/\/+$/, '');
|
|
69
|
+
const params = { order_id: id };
|
|
70
|
+
const token =
|
|
71
|
+
payToken === undefined || payToken === null ? '' : String(payToken).trim();
|
|
72
|
+
if (token) params.token = token;
|
|
73
|
+
const query = new URLSearchParams(params).toString();
|
|
74
|
+
// ★ 2026-10-02(第二次拍板):收银台站去掉冗余的 `/pay` 前缀 —— **收银台就是站根 `/`**
|
|
75
|
+
// (原来是 `${base}/pay`)。`withTrailingSlash('https://pay.wkgeo.com')` 补出
|
|
76
|
+
// `https://pay.wkgeo.com/`,正好是收银台首页;尾斜杠在 `?` 之前这条规矩也仍然成立。
|
|
77
|
+
// ⚠️ 站根是桶里真实存在的 `index.html`(不是"目录路径"),不会再触发
|
|
78
|
+
// 下面第 1 条那个"目录 302 丢 query"的坑。
|
|
79
|
+
return withTrailingSlash(base) + `?${query}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 拼「独立充值页」地址:`<收银台基址>/recharge/?ticket=<一次性凭证>`
|
|
84
|
+
*
|
|
85
|
+
* ★ 2026-10-02(方案 A,老板拍板):收银台站上新增了一个**独立充值页** ——
|
|
86
|
+
* 用户在那一页**自己选金额**(¥10 / ¥20 快捷,或自定义;最低 ¥9.90、不设上限),
|
|
87
|
+
* 提交后由页面用 ticket 建单、再跳到收银台扫码支付,**全程不需要登录会员中心**。
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ 为什么地址里只能是 **ticket** 而不是长期 OAuth 令牌:
|
|
90
|
+
* 地址会进浏览器历史、Referer、各级网关日志。ticket 是**一次性 + 10 分钟**的窄凭证
|
|
91
|
+
* (只够建一笔充值单),长期令牌一旦从这些地方漏出去就是 30 天的全量授权。
|
|
92
|
+
* ⚠️ 尾斜杠必须落在 `?` 之前(本仓库铁律,踩过线上事故):
|
|
93
|
+
* 前端产物是"每个路由一个目录"(`dist-pay/recharge/index.html`),
|
|
94
|
+
* 托管侧对**不带尾斜杠**的目录路径会 302,而那个 302 **不携带原始 query** ——
|
|
95
|
+
* 于是 `?ticket=…` 根本到不了页面,用户看到的是一个"没有凭证的充值页"。
|
|
96
|
+
* 所以路径**必须**过 `withTrailingSlash()`(全项目唯一构造入口,见 `config.mjs`)。
|
|
97
|
+
* ⚠️ 参数一律走 `URLSearchParams` 编码:ticket 是 base64url(含 `-` `_`),
|
|
98
|
+
* 手拼看着"很安全",但这类豁免迟早会在别的字符上出事。
|
|
99
|
+
*
|
|
100
|
+
* @param {string} webBase 收银台站基址(`config.payWebBase`,老配置对象里是 `webBase`)
|
|
101
|
+
* @param {string} ticket 一次性充值凭证(`createRechargeTicket` 返回的 `ticket`)
|
|
102
|
+
* @returns {string|null} 没有 ticket 就返回 null(没凭证就拼不出能用的地址)
|
|
103
|
+
*/
|
|
104
|
+
export function buildRechargeTicketUrl(webBase, ticket) {
|
|
105
|
+
const t = ticket === undefined || ticket === null ? '' : String(ticket).trim();
|
|
106
|
+
if (!t) return null;
|
|
107
|
+
const base = String(webBase || '').replace(/\/+$/, '');
|
|
108
|
+
const query = new URLSearchParams({ ticket: t }).toString();
|
|
109
|
+
// `/recharge` 是收银台站上的一级路径(构建期由 `SITES.pay.rewritePaths` 把
|
|
110
|
+
// `/pay/recharge` 改写成 `/recharge`;子域已说明是哪个站,路径不再重复 `/pay`)。
|
|
111
|
+
return `${withTrailingSlash(`${base}/recharge`)}?${query}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const KIND_LABEL = {
|
|
115
|
+
recharge: '充值',
|
|
116
|
+
pay: '支付',
|
|
117
|
+
refund: '退款',
|
|
118
|
+
adjust: '调账',
|
|
119
|
+
grant: '赠送',
|
|
120
|
+
withdraw: '提现',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function normalizeTxn(r) {
|
|
124
|
+
const amount = Number(r.amount_cents) || 0;
|
|
125
|
+
return {
|
|
126
|
+
id: r.id ?? null,
|
|
127
|
+
kind: r.kind || null,
|
|
128
|
+
kind_label: KIND_LABEL[r.kind] || r.kind || null,
|
|
129
|
+
amount_cents: amount,
|
|
130
|
+
amount_text: `${amount >= 0 ? '+' : '-'}${formatMoney(Math.abs(amount))}`,
|
|
131
|
+
balance_after_cents: r.balance_after === undefined || r.balance_after === null ? null : Number(r.balance_after),
|
|
132
|
+
balance_after_text: r.balance_after === undefined || r.balance_after === null ? '-' : formatMoney(r.balance_after),
|
|
133
|
+
order_id: r.order_id || null,
|
|
134
|
+
remark: r.remark || null,
|
|
135
|
+
created_at: r.created_at || null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function loadWallet(ctx, limit) {
|
|
140
|
+
const res = await ctx.callData('getMyWallet', { limit });
|
|
141
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map(normalizeTxn);
|
|
142
|
+
return { balance_cents: Number((res && res.balance_cents) || 0), rows };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const TXN_COLUMNS = [
|
|
146
|
+
{ title: '时间', key: 'created_at', format: (v) => formatTime(v), width: 20 },
|
|
147
|
+
{ title: '类型', key: 'kind_label', width: 8 },
|
|
148
|
+
{ title: '金额', key: 'amount_text', width: 12 },
|
|
149
|
+
{ title: '余额后', key: 'balance_after_text', width: 12 },
|
|
150
|
+
{ title: '备注', key: 'remark', width: 34 },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
/* wallet(总览) */
|
|
155
|
+
/* ------------------------------------------------------------------ */
|
|
156
|
+
|
|
157
|
+
export const walletCommand = {
|
|
158
|
+
name: 'wallet',
|
|
159
|
+
summary: '查钱包余额与最近流水(加 --entitlements 看生效中的权益)',
|
|
160
|
+
usage: 'wkgeo wallet [--limit 10] [--entitlements]',
|
|
161
|
+
flags: {
|
|
162
|
+
limit: { type: 'string', desc: '流水条数(1~100,默认 10)' },
|
|
163
|
+
entitlements: { type: 'boolean', desc: '同时列出我的权益(加量包/套餐生效情况)' },
|
|
164
|
+
},
|
|
165
|
+
async run(ctx) {
|
|
166
|
+
const { out, flags } = ctx;
|
|
167
|
+
const limit = flags.limit !== undefined ? Number(flags.limit) : 10;
|
|
168
|
+
const wallet = await loadWallet(ctx, limit);
|
|
169
|
+
|
|
170
|
+
let entitlements = null;
|
|
171
|
+
if (flags.entitlements) {
|
|
172
|
+
try {
|
|
173
|
+
const e = await ctx.callData('listMyEntitlements', {});
|
|
174
|
+
entitlements = (e && e.rows) || [];
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// 权益接口未部署/无权限时不该让"查余额"这个核心动作失败
|
|
177
|
+
out.warn(`读取权益失败(不影响余额):${err.message}`);
|
|
178
|
+
entitlements = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const data = {
|
|
183
|
+
balance_cents: wallet.balance_cents,
|
|
184
|
+
balance_text: formatMoney(wallet.balance_cents),
|
|
185
|
+
rows: wallet.rows,
|
|
186
|
+
entitlements: entitlements
|
|
187
|
+
? entitlements.map((x) => ({
|
|
188
|
+
quota_key: x.quota_key || null,
|
|
189
|
+
name: x.name || null,
|
|
190
|
+
amount: x.amount ?? null,
|
|
191
|
+
used: x.used ?? null,
|
|
192
|
+
status: x.status || null,
|
|
193
|
+
expires_at: x.expires_at || null,
|
|
194
|
+
}))
|
|
195
|
+
: undefined,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (!out.json) {
|
|
199
|
+
out.head('钱包');
|
|
200
|
+
out.line(` 余额:${data.balance_text}`);
|
|
201
|
+
out.line('');
|
|
202
|
+
out.line('最近流水:');
|
|
203
|
+
out.table(TXN_COLUMNS, wallet.rows);
|
|
204
|
+
if (entitlements) {
|
|
205
|
+
out.line('');
|
|
206
|
+
out.line('生效中的权益:');
|
|
207
|
+
out.table(
|
|
208
|
+
[
|
|
209
|
+
{ title: '配额', key: 'quota_key', width: 18 },
|
|
210
|
+
{ title: '数量', key: 'amount', width: 12 },
|
|
211
|
+
{ title: '状态', key: 'status', width: 10 },
|
|
212
|
+
{ title: '到期', key: 'expires_at', format: (v) => formatTime(v), width: 20 },
|
|
213
|
+
],
|
|
214
|
+
entitlements,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
ctx.done(data);
|
|
219
|
+
return data;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/* ------------------------------------------------------------------ */
|
|
224
|
+
/* wallet txns */
|
|
225
|
+
/* ------------------------------------------------------------------ */
|
|
226
|
+
|
|
227
|
+
export const walletTxnsCommand = {
|
|
228
|
+
name: 'wallet txns',
|
|
229
|
+
summary: '钱包流水(走"我的钱包"接口自带的流水,普通用户即可读)',
|
|
230
|
+
usage: 'wkgeo wallet txns [--limit 20]',
|
|
231
|
+
flags: {
|
|
232
|
+
limit: { type: 'string', desc: '流水条数(1~100,默认 20)' },
|
|
233
|
+
},
|
|
234
|
+
async run(ctx) {
|
|
235
|
+
const { out, flags } = ctx;
|
|
236
|
+
const limit = flags.limit !== undefined ? Number(flags.limit) : 20;
|
|
237
|
+
const wallet = await loadWallet(ctx, limit);
|
|
238
|
+
const data = {
|
|
239
|
+
balance_cents: wallet.balance_cents,
|
|
240
|
+
balance_text: formatMoney(wallet.balance_cents),
|
|
241
|
+
rows: wallet.rows,
|
|
242
|
+
total: wallet.rows.length,
|
|
243
|
+
note: '服务端当前只支持 limit,没有 offset/时间筛选(我的流水缺口)',
|
|
244
|
+
};
|
|
245
|
+
if (!out.json) {
|
|
246
|
+
out.head(`钱包流水(最近 ${wallet.rows.length} 条)`);
|
|
247
|
+
out.line(` 当前余额:${data.balance_text}`);
|
|
248
|
+
out.line('');
|
|
249
|
+
out.table(TXN_COLUMNS, wallet.rows);
|
|
250
|
+
}
|
|
251
|
+
ctx.done(data);
|
|
252
|
+
return data;
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/* ------------------------------------------------------------------ */
|
|
257
|
+
/* wallet recharge */
|
|
258
|
+
/* ------------------------------------------------------------------ */
|
|
259
|
+
|
|
260
|
+
export const walletRechargeCommand = {
|
|
261
|
+
name: 'wallet recharge',
|
|
262
|
+
summary:
|
|
263
|
+
'充值下单:创建一笔微信 Native 待支付订单,**并打开我们网站的收银台页面**扫码支付(不会自动扣款)',
|
|
264
|
+
usage: 'wkgeo wallet recharge --amount-cents <分> [--no-browser] [--open-browser] [--dry-run]',
|
|
265
|
+
flags: {
|
|
266
|
+
'amount-cents': { type: 'string', required: true, desc: '充值金额,单位**分**(最低 990 = ¥9.90;**不设上限**)' },
|
|
267
|
+
'no-browser': { type: 'boolean', desc: '不自动打开浏览器(自动化/无桌面环境用;地址仍会打印并放在 pay_url 里)' },
|
|
268
|
+
/**
|
|
269
|
+
* ★ 老板最终拍板走**传统路线**:默认**总是尝试打开**系统浏览器(人在终端与 AI 场景都一样),
|
|
270
|
+
* 所以本开关现在**与默认行为等价**。保留它只为两件事:
|
|
271
|
+
* 1. **显式语义**(脚本里写"我就是要弹"比依赖默认值更清楚);
|
|
272
|
+
* 2. **兼容**既有脚本(不许删)。
|
|
273
|
+
* ⚠️ `--no-browser` 优先(关掉就是关掉,见 login.mjs 的 resolveBrowserIntent)。
|
|
274
|
+
*/
|
|
275
|
+
'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
|
|
276
|
+
'dry-run': { type: 'boolean', desc: '只打印将要做什么,不真的创建订单' },
|
|
277
|
+
},
|
|
278
|
+
async run(ctx) {
|
|
279
|
+
const { out, flags } = ctx;
|
|
280
|
+
const amountCents = Number(flags.amountCents);
|
|
281
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
282
|
+
throw new UsageError(`--amount-cents 必须是正整数(单位:分),收到:${flags.amountCents}`);
|
|
283
|
+
}
|
|
284
|
+
if (amountCents < 990) throw new UsageError('充值金额不能低于 ¥9.90(990 分)');
|
|
285
|
+
// ⚠️ 单笔**不设业务上限**(老板 2026-10-02);只保留安全整数兜底,避免溢出
|
|
286
|
+
|
|
287
|
+
if (flags.dryRun === true) {
|
|
288
|
+
const data = {
|
|
289
|
+
dry_run: true,
|
|
290
|
+
amount_cents: amountCents,
|
|
291
|
+
amount_text: formatMoney(amountCents),
|
|
292
|
+
created: false,
|
|
293
|
+
/** 真跑时会给出的收银台地址(彩排阶段还没有订单号,所以是 null) */
|
|
294
|
+
pay_url: null,
|
|
295
|
+
};
|
|
296
|
+
if (!out.json) {
|
|
297
|
+
out.head('充值(彩排,未创建订单)');
|
|
298
|
+
out.line(` 将创建一笔 ¥${(amountCents / 100).toFixed(2)} 的微信 Native 待支付订单`);
|
|
299
|
+
out.info(' 去掉 --dry-run 即真正创建;创建后 CLI 会**打开我们网站的收银台页面**让你扫码支付(不会自动扣款)。');
|
|
300
|
+
}
|
|
301
|
+
ctx.done(data);
|
|
302
|
+
return data;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* ⚠️ 这里会**在微信侧创建一笔真实的待支付商户订单**(不扣款、不自动支付)。
|
|
307
|
+
* 这是"老板要测充值"必须的能力,但绝不能在自动化测试里跑 ——
|
|
308
|
+
* 测试只在离线桩里验证请求体,真钱流程由人手动完成。
|
|
309
|
+
*/
|
|
310
|
+
const res = await ctx.callData('createRechargeOrder', { amount_cents: amountCents });
|
|
311
|
+
const orderId = (res && res.order_id) || null;
|
|
312
|
+
/**
|
|
313
|
+
* ★ 我们自己的**网上收银台**地址(老板定的产品流程:走网上充值页面,
|
|
314
|
+
* 而不是在本地生成一个二维码页面让人扫)。
|
|
315
|
+
* 拼装口径与尾斜杠规则见本文件顶部的 `buildPayUrl()`。
|
|
316
|
+
*
|
|
317
|
+
* ★ 第三个参数 = 服务端在**下单那一刻**签发的订单支付令牌(`pay_token`):
|
|
318
|
+
* 带上它,收银台在**没有网页登录态**的 AI 端内置浏览器里也能付这一单
|
|
319
|
+
* (老板:"以那个 token 为基础来进行支付,而不是还要他用用户名那一套")。
|
|
320
|
+
* ⚠️ 老服务端没这个字段时就是 `undefined` ⇒ 地址退回只有 `order_id` 的老形态。
|
|
321
|
+
*
|
|
322
|
+
* ★ 2026-10-02(三站拆分):基址改用**收银台子域** `config.payWebBase`
|
|
323
|
+
* (`https://pay.wkgeo.com`)。`config.webBase` 只作为老配置对象的兜底
|
|
324
|
+
* (联调时 `WKONGAI_WEB_BASE` 总覆盖会把两者设成同一个值)。
|
|
325
|
+
*/
|
|
326
|
+
const payUrl = buildPayUrl(
|
|
327
|
+
ctx.config && (ctx.config.payWebBase || ctx.config.webBase),
|
|
328
|
+
orderId,
|
|
329
|
+
res && res.pay_token,
|
|
330
|
+
);
|
|
331
|
+
const data = {
|
|
332
|
+
order_id: orderId,
|
|
333
|
+
out_trade_no: (res && res.out_trade_no) || null,
|
|
334
|
+
paid: !!(res && res.paid),
|
|
335
|
+
amount_cents: Number((res && res.amount_cents) || amountCents),
|
|
336
|
+
amount_text: formatMoney((res && res.amount_cents) || amountCents),
|
|
337
|
+
pay_amount_cents: res && res.pay_amount_cents !== undefined ? Number(res.pay_amount_cents) : null,
|
|
338
|
+
/** 微信侧的支付链接:**保留给自动化**,但人看的流程走 pay_url(下面那行) */
|
|
339
|
+
code_url: (res && res.code_url) || null,
|
|
340
|
+
/** ★ 收银台地址:人点它、自动化也可以直接开它 */
|
|
341
|
+
pay_url: payUrl,
|
|
342
|
+
expires_at_minutes: (res && res.expires_at_minutes) || null,
|
|
343
|
+
wallet_paid_cents: Number((res && res.wallet_paid_cents) || 0),
|
|
344
|
+
open_browser: { attempted: false, opened: false, tried: [], url: payUrl || '', skipped_reason: null },
|
|
345
|
+
/** ★ 给 AI 端"照着念"的一句话(下面 openUrlForUser 之后会按实际情况覆盖) */
|
|
346
|
+
paste_text: payUrl ? browserPasteText(payUrl, { kind: 'pay' }) : null,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 自动打开收银台。
|
|
351
|
+
*
|
|
352
|
+
* ⚠️ 复用 `login.mjs` 的 `openUrlForUser`(多策略 + 结果归一化 + ★「默认总是尝试打开」闸门),
|
|
353
|
+
* **不是**在这里再写一份 spawn —— 那几个坑(cmd start 的引号、URL 里的 `&`
|
|
354
|
+
* 被 shell 吃掉、error 事件没挂)在那边已经踩过并修过,复制一份必然会再踩。
|
|
355
|
+
* ⚠️ 打开失败**绝不允许**让命令失败:地址已经完整打印(人类可读)并放在
|
|
356
|
+
* `--json` 的 `pay_url` 里,手动复制照样能完成支付。
|
|
357
|
+
* ⚠️ 闸门在 `openUrlForUser` **内部**:这里不再自己判断"要不要开"(除了 payUrl 有没有),
|
|
358
|
+
* 否则总有一天会出现"授权不弹、支付还弹"。
|
|
359
|
+
*/
|
|
360
|
+
if (payUrl) {
|
|
361
|
+
data.open_browser = await openUrlForUser(payUrl, {
|
|
362
|
+
deps: {
|
|
363
|
+
openBrowser: ctx.openBrowser,
|
|
364
|
+
spawnImpl: ctx.spawnImpl,
|
|
365
|
+
platform: ctx.platform,
|
|
366
|
+
verbose: !!(ctx.config && ctx.config.verbose),
|
|
367
|
+
/** ★ 与 login 同源的四个事实(见 login.mjs 的 resolveBrowserIntent) */
|
|
368
|
+
isTTY: ctx.isTTY === true,
|
|
369
|
+
json: out.json === true,
|
|
370
|
+
forceOpen: flags.openBrowser === true,
|
|
371
|
+
noBrowser: flags.noBrowser === true,
|
|
372
|
+
},
|
|
373
|
+
log: (m) => out.info(m),
|
|
374
|
+
});
|
|
375
|
+
data.paste_text = browserPasteText(payUrl, { kind: 'pay' });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!out.json) {
|
|
379
|
+
out.head('充值订单已创建');
|
|
380
|
+
out.line(` 订单号:${data.order_id}`);
|
|
381
|
+
out.line(` 金额:${data.amount_text}`);
|
|
382
|
+
out.line(` 有效期:${data.expires_at_minutes ?? '?'} 分钟`);
|
|
383
|
+
if (payUrl) {
|
|
384
|
+
out.line('');
|
|
385
|
+
out.line(' 收银台(我们网站的充值页面,请在这里扫码支付):');
|
|
386
|
+
out.line(` ${payUrl}`);
|
|
387
|
+
if (flags.noBrowser === true) {
|
|
388
|
+
out.info(' (已按 --no-browser 跳过自动打开)请把上面这一整行复制到浏览器地址栏打开。');
|
|
389
|
+
} else if (data.open_browser.opened) {
|
|
390
|
+
// ★ 成功:明说"已经打开了"(老板最终拍板走传统路线:弹系统浏览器)
|
|
391
|
+
out.line(browserOpenedText({ kind: 'pay' }));
|
|
392
|
+
} else {
|
|
393
|
+
/**
|
|
394
|
+
* 打开失败:与 login 同一段文案(`browserFailedHintLines`)——
|
|
395
|
+
* 1. **醒目**:说清"没能自动打开",别让用户以为还在打开中;
|
|
396
|
+
* 2. **可操作**:把**完整地址再打一遍**,并说清动作是"复制→粘贴到地址栏";
|
|
397
|
+
* 3. 说清打开之后干什么(用微信扫码)。
|
|
398
|
+
*/
|
|
399
|
+
for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: ctx.isTTY !== true })) {
|
|
400
|
+
out.line(line);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* ★ 2026-10-02(老板):**支付完成之后那个页面会自己关掉** —— 提前交代一句。
|
|
406
|
+
* 为什么要说:页面"自己没了"时,用户第一反应是"是不是崩了 / 是不是没付成功";
|
|
407
|
+
* 提前说一句,这件事就变成"预期之内"。
|
|
408
|
+
* ⚠️ 后半句"没关就手动关掉即可"**不能省**:`window.close()` 只对**脚本打开的**窗口
|
|
409
|
+
* 有效,收银台是导航过去的 ⇒ **大概率关不掉**(详见
|
|
410
|
+
* `admin-web/src/pages/billing/pay` 的「尝试自动关闭」注释)。
|
|
411
|
+
*/
|
|
412
|
+
out.info('(支付成功后页面会自动尝试关闭;没关就手动关掉即可。)');
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 终端二维码:**只在网页打不开时**作为兜底。
|
|
416
|
+
* ⚠️ 老板明确否掉了"本地生成二维码让别人扫"当**主流程**的做法:
|
|
417
|
+
* 二维码只是收银台页面内部的一个展示件;终端里画码这条路必须降级,
|
|
418
|
+
* 而且要说清"正常应该在我们网站的收银台页面上扫"。
|
|
419
|
+
*/
|
|
420
|
+
const browserNotOpened = data.open_browser.attempted && !data.open_browser.opened;
|
|
421
|
+
if (browserNotOpened && data.code_url) {
|
|
422
|
+
out.line('');
|
|
423
|
+
out.info(' 兜底:网页确实打不开时,也可以直接用微信扫下面这个二维码。');
|
|
424
|
+
out.info(' (正常流程请在上面那个收银台页面里扫码支付。)');
|
|
425
|
+
const qrShown = await renderQrOptional(data.code_url, out);
|
|
426
|
+
if (!qrShown) {
|
|
427
|
+
out.info(' (未安装可选的 qrcode-terminal,无法在终端画码;请用上面的收银台地址付款)');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
out.line('');
|
|
431
|
+
out.line(` 支付完成后运行:wkgeo orders check ${data.order_id} # 查支付结果与最新余额`);
|
|
432
|
+
} else if (data.code_url) {
|
|
433
|
+
out.warn('没有拼出收银台地址(订单号缺失),只能给出微信支付链接');
|
|
434
|
+
out.line(` ${data.code_url}`);
|
|
435
|
+
} else {
|
|
436
|
+
out.warn('服务端没有返回 code_url(可能微信支付参数未配置),请到网页后台核对');
|
|
437
|
+
}
|
|
438
|
+
out.info(' CLI 不会替你付款,也不会模拟支付成功。');
|
|
439
|
+
} else if (payUrl) {
|
|
440
|
+
/**
|
|
441
|
+
* ★ `--json` / 非 TTY 时,**人看的那段提示不能消失**:走 stderr(`out.notice`),
|
|
442
|
+
* stdout 依然只有机器可读 JSON;结构化字段(`pay_url` / `paste_text` / `open_browser`)
|
|
443
|
+
* 照样都在 stdout 里。
|
|
444
|
+
*
|
|
445
|
+
* ★ 老板最终拍板走传统路线后,这里的分支变成"如实播报结果":
|
|
446
|
+
* * `--no-browser` → 明说跳过了(地址仍要打印,用户得能复制);
|
|
447
|
+
* * 打开成功 → 说一句"已为你打开浏览器";
|
|
448
|
+
* * 打开失败 → ⚠️ 那段**含完整地址**的醒目提示(AI 端/用户就靠它手动打开)。
|
|
449
|
+
* ⚠️ 失败提示**不能只给 JSON**:真客户不会去 `pay_url` 字段里翻地址。
|
|
450
|
+
*/
|
|
451
|
+
if (flags.noBrowser === true) {
|
|
452
|
+
out.notice('(已按 --no-browser 跳过自动打开)请手动复制 pay_url 到浏览器地址栏打开。');
|
|
453
|
+
} else if (data.open_browser.opened) {
|
|
454
|
+
out.notice(browserOpenedText({ kind: 'pay' }));
|
|
455
|
+
} else {
|
|
456
|
+
for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: true })) out.notice(line);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
ctx.done(data);
|
|
460
|
+
return data;
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/* ------------------------------------------------------------------ */
|
|
465
|
+
/* wallet recharge-link(独立充值页) */
|
|
466
|
+
/* ------------------------------------------------------------------ */
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* `wallet recharge-link` —— 生成**独立充值页**链接(AI/CLI 把链接交给用户)。
|
|
470
|
+
*
|
|
471
|
+
* ## 与 `wallet recharge` 的分工(★ 两条路都存在,别搞混)
|
|
472
|
+
* * `wallet recharge --amount-cents`:**金额在命令行里定死**,CLI 直接建单,
|
|
473
|
+
* 再打开收银台扫码。适合"用户已经说了充 100 块"的场景。
|
|
474
|
+
* * `wallet recharge-link`(本命令):**不建单、不定金额**,只换一张一次性 ticket
|
|
475
|
+
* 并给出链接。用户在**收银台站的独立充值页**上自己选金额(¥10 / ¥20 快捷,
|
|
476
|
+
* 或自定义;最低 ¥9.90、**不设上限**),提交后由那一页建单并跳到收银台。
|
|
477
|
+
* 老板原话:「我们希望是有一个页面,然后呢直接选择金额,选择支付就行了……
|
|
478
|
+
* 他先选择金额之后,先创建订单,然后再跳到收银台,**不能说是登录到管理员账号里面再去支付**。」
|
|
479
|
+
*
|
|
480
|
+
* ⚠️ 本命令**不改** `wallet recharge` 的任何行为与参数(那条路已上线,脚本依赖它)。
|
|
481
|
+
*
|
|
482
|
+
* ## 默认**不**自动打开浏览器(与 `wallet recharge` 的默认相反,这是刻意的)
|
|
483
|
+
* 这条命令的典型调用者是 **AI**:它要把链接**交给用户**(发在对话里 / 做成可点的卡片),
|
|
484
|
+
* 而 CLI 往往跑在**服务器 / 容器**里 —— 在那里弹一个浏览器既弹不出来、也没有意义。
|
|
485
|
+
* 所以默认只打印链接;真要打开就显式 `--open-browser`(走 `login.mjs` 那套既有实现,
|
|
486
|
+
* 不在这里另写一份 spawn)。`--no-browser` 优先级最高(关掉就是关掉)。
|
|
487
|
+
*
|
|
488
|
+
* ## 一句话交接物
|
|
489
|
+
* `data.pay_url`(`--json` 里也有)+ `data.paste_text`(给 AI 照着念的那句)。
|
|
490
|
+
* ⚠️ **绝不能**把长期 OAuth 令牌放进这个地址:地址里的 `ticket` 是一次性的、
|
|
491
|
+
* 10 分钟失效、只能建一笔充值单(见 `buildRechargeTicketUrl()` 的说明)。
|
|
492
|
+
*/
|
|
493
|
+
export const walletRechargeLinkCommand = {
|
|
494
|
+
name: 'wallet recharge-link',
|
|
495
|
+
summary:
|
|
496
|
+
'生成「独立充值页」链接:用户在收银台上**自己选金额**并支付(无需登录会员中心;不扣款)',
|
|
497
|
+
usage: 'wkgeo wallet recharge-link [--open-browser] [--no-browser]',
|
|
498
|
+
flags: {
|
|
499
|
+
'open-browser': {
|
|
500
|
+
type: 'boolean',
|
|
501
|
+
desc: '在系统浏览器里打开这个链接(**默认不打开**:本命令的交接物是链接本身,给 AI/用户用)',
|
|
502
|
+
},
|
|
503
|
+
'no-browser': {
|
|
504
|
+
type: 'boolean',
|
|
505
|
+
desc: '明确不打开浏览器(优先于 --open-browser;默认行为本来就不打开)',
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
async run(ctx) {
|
|
509
|
+
const { out, flags } = ctx;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* ⚠️ 这一步会**在库里插一行一次性凭证**(10 分钟后自动失效,无副作用、不花钱)。
|
|
513
|
+
* 它**不会**建订单、不会调微信、不会扣款 —— 建单发生在用户提交充值页表单的那一刻。
|
|
514
|
+
*/
|
|
515
|
+
const res = await ctx.callData('createRechargeTicket', {});
|
|
516
|
+
const ticket = res && res.ticket ? String(res.ticket) : '';
|
|
517
|
+
if (!ticket) {
|
|
518
|
+
throw new CliError(
|
|
519
|
+
'服务端没有返回一次性凭证(ticket)—— 可能 data 云函数还没部署到支持「独立充值页」的版本。',
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 收银台基址:与 `wallet recharge` 同源(`config.payWebBase`,老配置对象兜底 `webBase`)。
|
|
525
|
+
* 联调时 `WKONGAI_WEB_BASE` 这个**总覆盖**会把两者设成同一个值。
|
|
526
|
+
*/
|
|
527
|
+
const payUrl = buildRechargeTicketUrl(
|
|
528
|
+
ctx.config && (ctx.config.payWebBase || ctx.config.webBase),
|
|
529
|
+
ticket,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 「照着念」的那句话 —— **按"有没有真的打开浏览器"分两种**(见下面 `paste_text` 的说明)。
|
|
534
|
+
*
|
|
535
|
+
* @param {boolean} opened 只在**确实打开成功**时传 true
|
|
536
|
+
*/
|
|
537
|
+
const handoffText = (opened) => {
|
|
538
|
+
if (!payUrl) return null;
|
|
539
|
+
if (opened) return browserPasteText(payUrl, { kind: 'pay' });
|
|
540
|
+
return `充值链接(10 分钟内有效、一次性,用户在网页上自己选金额):${payUrl}`;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const data = {
|
|
544
|
+
ticket,
|
|
545
|
+
/** ★ 交接物:发给用户的充值链接(他打开后自己选金额) */
|
|
546
|
+
pay_url: payUrl,
|
|
547
|
+
expires_at: (res && res.expires_at) || null,
|
|
548
|
+
expires_at_minutes: (res && res.expires_at_minutes) || 10,
|
|
549
|
+
/** 默认不打开;只有显式 `--open-browser` 才会改成 attempted/opened 的实况 */
|
|
550
|
+
open_browser: {
|
|
551
|
+
attempted: false,
|
|
552
|
+
opened: false,
|
|
553
|
+
tried: [],
|
|
554
|
+
url: payUrl || '',
|
|
555
|
+
skipped_reason: flags.noBrowser === true ? 'no_browser' : null,
|
|
556
|
+
},
|
|
557
|
+
/**
|
|
558
|
+
* 给 AI"照着念"的一句话。
|
|
559
|
+
*
|
|
560
|
+
* ⚠️ **只有真打开了浏览器**才能说"已为你打开浏览器"(`browserPasteText` 那句就是这么说
|
|
561
|
+
* 的)—— 本命令默认**不打开**,默认那句必须是"链接在这儿,你发给用户"。
|
|
562
|
+
* 否则 AI 会把"已为你打开浏览器"原样转述给用户,而用户机器上什么都没弹出来,
|
|
563
|
+
* 他会以为链接坏了(这正是 login/wallet recharge 那边反复修过的文案坑)。
|
|
564
|
+
*/
|
|
565
|
+
paste_text: handoffText(false),
|
|
566
|
+
/**
|
|
567
|
+
* 本命令默认**不弹浏览器**(理由见上面那段注释),所以额外给一句**机器可读**的口径,
|
|
568
|
+
* 免得 AI 把 `skipped_reason: null` 误读成"我们试过但没打开"。
|
|
569
|
+
*/
|
|
570
|
+
open_browser_default_off: true,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 打开浏览器:**只有显式 `--open-browser` 才走这里**。
|
|
575
|
+
* ⚠️ 仍然复用 `openUrlForUser`(多策略 + `resolveBrowserIntent` 闸门 + 结果归一化),
|
|
576
|
+
* 不在这里另写一份 spawn —— 那几个坑(cmd start 的引号、URL 里的 `&` 被 shell 吃掉、
|
|
577
|
+
* error 事件没挂)在那边已经踩过并修过。
|
|
578
|
+
* ⚠️ 打开失败**绝不允许**让命令失败:链接已经打印出来,手动复制照样能完成充值。
|
|
579
|
+
*/
|
|
580
|
+
if (payUrl && flags.openBrowser === true) {
|
|
581
|
+
data.open_browser = await openUrlForUser(payUrl, {
|
|
582
|
+
deps: {
|
|
583
|
+
openBrowser: ctx.openBrowser,
|
|
584
|
+
spawnImpl: ctx.spawnImpl,
|
|
585
|
+
platform: ctx.platform,
|
|
586
|
+
verbose: !!(ctx.config && ctx.config.verbose),
|
|
587
|
+
/** 与 login / wallet recharge 同源的四个事实(见 resolveBrowserIntent) */
|
|
588
|
+
isTTY: ctx.isTTY === true,
|
|
589
|
+
json: out.json === true,
|
|
590
|
+
forceOpen: true,
|
|
591
|
+
noBrowser: flags.noBrowser === true,
|
|
592
|
+
},
|
|
593
|
+
log: (m) => out.info(m),
|
|
594
|
+
});
|
|
595
|
+
// ⚠️ 只有**确实打开了**才换成"已为你打开浏览器…"那句(否则与用户的屏幕对不上)
|
|
596
|
+
data.paste_text = handoffText(data.open_browser.opened === true);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!out.json) {
|
|
600
|
+
out.head('独立充值页链接已生成');
|
|
601
|
+
out.line(` 有效期:${data.expires_at_minutes} 分钟(一次性,用掉即失效)`);
|
|
602
|
+
out.line('');
|
|
603
|
+
out.line(' 充值链接(用户在网页上自己选金额后支付):');
|
|
604
|
+
out.line(` ${payUrl}`);
|
|
605
|
+
out.line('');
|
|
606
|
+
out.info(' 把上面这一整行发给用户:他打开后选金额(最低 ¥9.90,不设上限)→ 微信扫码支付。');
|
|
607
|
+
out.info(' (不需要登录会员中心;本命令不会代替他付款,也不会模拟支付成功。)');
|
|
608
|
+
if (flags.noBrowser === true) {
|
|
609
|
+
out.info(' (已按 --no-browser 跳过自动打开)');
|
|
610
|
+
} else if (data.open_browser.attempted === false) {
|
|
611
|
+
out.info(' (默认不自动打开浏览器;需要在这台机器上弹出,请加 --open-browser)');
|
|
612
|
+
} else if (data.open_browser.opened) {
|
|
613
|
+
out.line(browserOpenedText({ kind: 'pay' }));
|
|
614
|
+
} else {
|
|
615
|
+
for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: ctx.isTTY !== true })) {
|
|
616
|
+
out.line(line);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
/**
|
|
621
|
+
* ⚠️ `--json` / 非 TTY(AI 场景)下,**人看的那段提示不能消失**:走 stderr(`out.notice`),
|
|
622
|
+
* stdout 依然只有机器可读 JSON。AI 端就靠这段(含完整地址)把链接转述给用户。
|
|
623
|
+
*/
|
|
624
|
+
out.notice(`充值链接(${data.expires_at_minutes} 分钟内有效、一次性):${payUrl}`);
|
|
625
|
+
if (flags.openBrowser !== true) {
|
|
626
|
+
out.notice(
|
|
627
|
+
'(默认不自动打开浏览器;需要在这台机器上弹出请加 --open-browser。把上面的链接发给用户即可。)',
|
|
628
|
+
);
|
|
629
|
+
} else if (data.open_browser.opened) {
|
|
630
|
+
out.notice(browserOpenedText({ kind: 'pay' }));
|
|
631
|
+
} else {
|
|
632
|
+
for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: true })) {
|
|
633
|
+
out.notice(line);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
ctx.done(data);
|
|
639
|
+
return data;
|
|
640
|
+
},
|
|
641
|
+
};
|