wkgeo-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +681 -0
- package/SKILL.md +598 -0
- package/lib/api.mjs +178 -0
- package/lib/args.mjs +196 -0
- package/lib/cli.mjs +134 -0
- package/lib/commands/apps.mjs +116 -0
- package/lib/commands/auth.mjs +974 -0
- package/lib/commands/billing.mjs +421 -0
- package/lib/commands/index.mjs +160 -0
- package/lib/commands/orders.mjs +444 -0
- package/lib/commands/sites.mjs +140 -0
- package/lib/commands/skill.mjs +119 -0
- package/lib/config.mjs +260 -0
- package/lib/credentials.mjs +160 -0
- package/lib/device-store.mjs +132 -0
- package/lib/device.mjs +427 -0
- package/lib/errors.mjs +268 -0
- package/lib/format.mjs +204 -0
- package/lib/http.mjs +71 -0
- package/lib/index.mjs +39 -0
- package/lib/login.mjs +1332 -0
- package/lib/page.mjs +396 -0
- package/lib/pkce.mjs +48 -0
- package/lib/proxy.mjs +454 -0
- package/lib/qr.mjs +31 -0
- package/lib/runtime.mjs +287 -0
- package/package.json +51 -0
- package/wkgeo.mjs +17 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订单命令:`products list` / `orders create` / `orders pay` / `orders list`
|
|
3
|
+
*
|
|
4
|
+
* ## 「钱包余额支付」在本项目里到底发生在哪一步(重要,别被命令名骗了)
|
|
5
|
+
*
|
|
6
|
+
* 服务端 `placeOrder()` 的行为是:**下单时就先用余额抵扣**,
|
|
7
|
+
* 余额足够 → `moveWallet(-金额)` + `fulfillOrder()` → 直接返回 `paid: true`。
|
|
8
|
+
* 也就是说"用钱包支付"是**下单动作的一部分**,不是独立的一步。
|
|
9
|
+
*
|
|
10
|
+
* 所以本 CLI 的口径:
|
|
11
|
+
* * `orders create` = 下单 + (余额够就)完成支付,返回值里 `paid` 说明结果;
|
|
12
|
+
* * `orders pay <id>` = 对**还没支付**的订单做"查单/收敛状态"(服务端 checkOrder):
|
|
13
|
+
* 微信侧已支付而本地还是 pending → 就地履约;否则告诉你还在等支付。
|
|
14
|
+
* * CLI **永远不会**自己去扫微信码 / 发起任何真实扣款。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 没有做"把余额补到一个已存在的 pending 订单上"的命令 —— 服务端**没有这个 action**
|
|
17
|
+
* (`placeOrder` 里余额抵扣只在建单那一刻发生)。要做需要服务端新增接口,
|
|
18
|
+
* 这属于"要主线程决定"的事,已在报告里列出。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { UsageError, CliError, EXIT } from '../errors.mjs';
|
|
22
|
+
import { formatMoney, formatTime } from '../format.mjs';
|
|
23
|
+
import { renderQrOptional } from '../qr.mjs';
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/* products list */
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
|
|
29
|
+
export const productsListCommand = {
|
|
30
|
+
name: 'products list',
|
|
31
|
+
summary: '可购买的产品/加量包(用来找 --product 该填什么)',
|
|
32
|
+
usage: 'wkgeo products list',
|
|
33
|
+
async run(ctx) {
|
|
34
|
+
const { out } = ctx;
|
|
35
|
+
const res = await ctx.callData('listProducts', {});
|
|
36
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map((p) => {
|
|
37
|
+
const params = p.params || {};
|
|
38
|
+
return {
|
|
39
|
+
code: p.code,
|
|
40
|
+
name: p.name,
|
|
41
|
+
kind: p.kind,
|
|
42
|
+
unit: p.unit,
|
|
43
|
+
price_cents: p.price_cents,
|
|
44
|
+
price_text: formatMoney(p.price_cents),
|
|
45
|
+
quota_key: params.quota_key || null,
|
|
46
|
+
quota_multiplier: params.quota_multiplier ?? null,
|
|
47
|
+
min_qty: p.min_qty ?? null,
|
|
48
|
+
max_qty: p.max_qty ?? null,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const data = { rows };
|
|
52
|
+
if (!out.json) {
|
|
53
|
+
out.head(`可购买的产品(${rows.length} 项)`);
|
|
54
|
+
out.table(
|
|
55
|
+
[
|
|
56
|
+
{ title: '产品代码', key: 'code', width: 24 },
|
|
57
|
+
{ title: '名称', key: 'name', width: 20 },
|
|
58
|
+
{ title: '类型', key: 'kind', width: 14 },
|
|
59
|
+
{ title: '单价', key: 'price_text', width: 10 },
|
|
60
|
+
{ title: '单位', key: 'unit', width: 8 },
|
|
61
|
+
{ title: '配额键', key: 'quota_key', width: 16 },
|
|
62
|
+
{ title: '倍率', key: 'quota_multiplier', width: 6 },
|
|
63
|
+
],
|
|
64
|
+
rows,
|
|
65
|
+
);
|
|
66
|
+
out.info(' 下单示例:wkgeo orders create --product <产品代码> --units 1');
|
|
67
|
+
}
|
|
68
|
+
ctx.done(data);
|
|
69
|
+
return data;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
/* orders create */
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
|
|
77
|
+
export const ordersCreateCommand = {
|
|
78
|
+
name: 'orders create',
|
|
79
|
+
summary: '下单(余额足够时**当场用钱包余额付清**;不会发起真实微信扣款)',
|
|
80
|
+
usage:
|
|
81
|
+
'wkgeo orders create --product <产品代码> --units <份数> [--allow-wechat]\n' +
|
|
82
|
+
'wkgeo orders create --quota <配额键> --amount <基础单位数> [--allow-wechat]',
|
|
83
|
+
flags: {
|
|
84
|
+
product: { type: 'string', desc: '产品代码或配额键(见 `wkgeo products list`)' },
|
|
85
|
+
units: { type: 'string', desc: '购买份数(默认 1,按产品单位计价)' },
|
|
86
|
+
quota: { type: 'string', desc: '直接指定配额键(如 storage_mb)' },
|
|
87
|
+
amount: { type: 'string', desc: '直接指定基础单位数量(配合 --quota)' },
|
|
88
|
+
'allow-wechat': {
|
|
89
|
+
type: 'boolean',
|
|
90
|
+
desc: '余额不足时仍创建订单(会返回微信 code_url,**需要用户自己扫码**;CLI 不会扣款)',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
async run(ctx) {
|
|
94
|
+
const { out, flags } = ctx;
|
|
95
|
+
|
|
96
|
+
if (!flags.product && !flags.quota) {
|
|
97
|
+
throw new UsageError('需要 --product <产品代码> 或 --quota <配额键>(用 `wkgeo products list` 查可用产品)');
|
|
98
|
+
}
|
|
99
|
+
if (flags.product && flags.quota) {
|
|
100
|
+
throw new UsageError('--product 与 --quota 不能同时使用');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let quotaKey;
|
|
104
|
+
let quotaAmount;
|
|
105
|
+
let units = null;
|
|
106
|
+
let estimateCents = null;
|
|
107
|
+
let productInfo = null;
|
|
108
|
+
|
|
109
|
+
if (flags.product) {
|
|
110
|
+
units = flags.units === undefined ? 1 : Number(flags.units);
|
|
111
|
+
if (!Number.isInteger(units) || units <= 0) throw new UsageError(`--units 必须是正整数,收到:${flags.units}`);
|
|
112
|
+
|
|
113
|
+
const catalog = await ctx.callData('listProducts', {});
|
|
114
|
+
const list = (catalog && Array.isArray(catalog.rows) ? catalog.rows : []).map((p) => ({
|
|
115
|
+
...p,
|
|
116
|
+
params: p.params || {},
|
|
117
|
+
}));
|
|
118
|
+
const hit =
|
|
119
|
+
list.find((p) => p.code === flags.product) ||
|
|
120
|
+
list.find((p) => p.params.quota_key === flags.product && p.kind === 'resource');
|
|
121
|
+
|
|
122
|
+
if (!hit) {
|
|
123
|
+
throw new CliError(`找不到产品(或配额键)「${flags.product}」`, {
|
|
124
|
+
kind: 'not_found',
|
|
125
|
+
code: 404,
|
|
126
|
+
hint: `用 \`wkgeo products list\` 看可用产品代码;也可用 --quota <配额键> --amount <数量> 直接按配额下单`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (hit.kind !== 'resource' || !hit.params.quota_key) {
|
|
130
|
+
throw new CliError(`产品「${hit.code}」是 ${hit.kind} 类型,当前 CLI 只支持"加量包/资源型"下单`, {
|
|
131
|
+
kind: 'unsupported',
|
|
132
|
+
code: 400,
|
|
133
|
+
hint: '订阅套餐请在网页后台自助购买(金额较大且会改订阅权益)',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const mult = Number(hit.params.quota_multiplier) || 1;
|
|
137
|
+
quotaKey = hit.params.quota_key;
|
|
138
|
+
quotaAmount = units * mult;
|
|
139
|
+
estimateCents = units * (Number(hit.price_cents) || 0);
|
|
140
|
+
productInfo = {
|
|
141
|
+
code: hit.code,
|
|
142
|
+
name: hit.name,
|
|
143
|
+
unit: hit.unit || '份',
|
|
144
|
+
unit_price_cents: Number(hit.price_cents) || 0,
|
|
145
|
+
quota_multiplier: mult,
|
|
146
|
+
};
|
|
147
|
+
} else {
|
|
148
|
+
quotaKey = flags.quota;
|
|
149
|
+
quotaAmount = Number(flags.amount);
|
|
150
|
+
if (!Number.isInteger(quotaAmount) || quotaAmount <= 0) {
|
|
151
|
+
throw new UsageError(`--amount 必须是正整数(基础单位数量),收到:${flags.amount}`);
|
|
152
|
+
}
|
|
153
|
+
units = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---- 钱包预检:余额不足时**默认拒绝**,而不是悄悄建一张要扫码的单 ---- */
|
|
157
|
+
let balanceBefore = null;
|
|
158
|
+
try {
|
|
159
|
+
const w = await ctx.callData('getMyWallet', { limit: 1 });
|
|
160
|
+
balanceBefore = Number(w && w.balance_cents) || 0;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
out.warn(`读取钱包余额失败(继续下单):${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
balanceBefore !== null &&
|
|
166
|
+
estimateCents !== null &&
|
|
167
|
+
balanceBefore < estimateCents &&
|
|
168
|
+
flags.allowWechat !== true
|
|
169
|
+
) {
|
|
170
|
+
throw new CliError(
|
|
171
|
+
`钱包余额不足:本单需要 ${formatMoney(estimateCents)},当前余额 ${formatMoney(balanceBefore)}`,
|
|
172
|
+
{
|
|
173
|
+
kind: 'insufficient_balance',
|
|
174
|
+
code: 'insufficient_balance',
|
|
175
|
+
hint:
|
|
176
|
+
'CLI **不会**发起真实微信支付。请先在网页充够余额再下单;' +
|
|
177
|
+
'如果确实要用微信扫码,加 --allow-wechat 创建"待支付"订单(CLI 只打印支付链接,不扣款,也不替你确认支付)',
|
|
178
|
+
exitCode: EXIT.ERROR,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const res = await ctx.callData('createQuotaOrder', { quota_key: quotaKey, quota_amount: quotaAmount });
|
|
184
|
+
const paid = !!(res && res.paid);
|
|
185
|
+
const data = {
|
|
186
|
+
order_id: (res && res.order_id) || null,
|
|
187
|
+
out_trade_no: (res && res.out_trade_no) || null,
|
|
188
|
+
paid,
|
|
189
|
+
amount_cents: Number(res && res.amount_cents) || null,
|
|
190
|
+
amount_text: res && res.amount_cents !== undefined ? formatMoney(res.amount_cents) : null,
|
|
191
|
+
wallet_paid_cents: Number(res && res.wallet_paid_cents) || 0,
|
|
192
|
+
pay_amount_cents: res && res.pay_amount_cents !== undefined ? Number(res.pay_amount_cents) : null,
|
|
193
|
+
code_url: (res && res.code_url) || null,
|
|
194
|
+
expires_at_minutes: (res && res.expires_at_minutes) || null,
|
|
195
|
+
message: (res && res.message) || null,
|
|
196
|
+
product: productInfo,
|
|
197
|
+
quota: { quota_key: quotaKey, quota_amount: quotaAmount, units },
|
|
198
|
+
balance_before_cents: balanceBefore,
|
|
199
|
+
balance_after_cents:
|
|
200
|
+
balanceBefore !== null && paid ? balanceBefore - (Number(res && res.wallet_paid_cents) || 0) : balanceBefore,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (!out.json) {
|
|
204
|
+
out.head('下单结果');
|
|
205
|
+
out.line(` 订单号:${data.order_id}`);
|
|
206
|
+
out.line(` 商户单号:${data.out_trade_no || '-'}`);
|
|
207
|
+
out.line(` 金额:${data.amount_text}`);
|
|
208
|
+
out.line(` 钱包抵扣:${formatMoney(data.wallet_paid_cents)}`);
|
|
209
|
+
if (data.balance_before_cents !== null) {
|
|
210
|
+
out.line(` 余额:${formatMoney(data.balance_before_cents)}${paid ? ` → ${formatMoney(data.balance_after_cents)}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
out.line('');
|
|
213
|
+
if (paid) {
|
|
214
|
+
out.ok(`订单已用**钱包余额**支付完成${data.message ? `(${data.message})` : ''}`);
|
|
215
|
+
} else {
|
|
216
|
+
out.warn('订单已创建,但**尚未支付**(余额不足以覆盖全款)');
|
|
217
|
+
out.line(` 待支付金额:${formatMoney(data.pay_amount_cents)}`);
|
|
218
|
+
if (data.code_url) {
|
|
219
|
+
out.line('');
|
|
220
|
+
out.line(' 微信支付链接(需要用手机扫码/打开):');
|
|
221
|
+
out.line(` ${data.code_url}`);
|
|
222
|
+
const qrShown = await renderQrOptional(data.code_url, out);
|
|
223
|
+
if (!qrShown) {
|
|
224
|
+
out.info(' (未安装可选的 qrcode-terminal,只打印链接;装了会自动画二维码)');
|
|
225
|
+
}
|
|
226
|
+
out.line('');
|
|
227
|
+
out.line(` 支付完成后运行:wkgeo orders pay ${data.order_id}`);
|
|
228
|
+
}
|
|
229
|
+
out.info(' CLI 不会发起真实微信扣款,也不会替你确认支付。');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
ctx.done(data);
|
|
233
|
+
return data;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
/* orders check / orders pay(同一实现,两个名字) */
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 轮询支付结果 + 展示最新余额。
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ 这里是**只读**语义:`checkOrder` 只会问微信"这笔单付了没",
|
|
245
|
+
* 服务端在"微信侧已支付但本地还是 pending"时就地履约 —— 这是补状态,不是发起支付。
|
|
246
|
+
* CLI **绝不**模拟支付成功、也绝不自动扣款。
|
|
247
|
+
*/
|
|
248
|
+
async function checkOrderImpl(ctx, orderId) {
|
|
249
|
+
const { out } = ctx;
|
|
250
|
+
const res = await ctx.callData('checkOrder', { order_id: orderId });
|
|
251
|
+
const status = (res && res.status) || 'unknown';
|
|
252
|
+
const paid = !!(res && res.paid);
|
|
253
|
+
|
|
254
|
+
// 已支付时顺手把最新余额带出来(老板要的"成功后显示新余额")
|
|
255
|
+
let balanceCents = null;
|
|
256
|
+
if (paid) {
|
|
257
|
+
try {
|
|
258
|
+
const w = await ctx.callData('getMyWallet', { limit: 1 });
|
|
259
|
+
balanceCents = Number((w && w.balance_cents) || 0);
|
|
260
|
+
} catch {
|
|
261
|
+
/* 余额拿不到不影响"已支付"这个结论 */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const data = {
|
|
266
|
+
order_id: orderId,
|
|
267
|
+
status,
|
|
268
|
+
paid,
|
|
269
|
+
fulfilled: !!(res && res.fulfilled),
|
|
270
|
+
trade_state: (res && res.trade_state) || null,
|
|
271
|
+
code_url: (res && res.code_url) || null,
|
|
272
|
+
balance_cents: balanceCents,
|
|
273
|
+
balance_text: balanceCents === null ? null : formatMoney(balanceCents),
|
|
274
|
+
order: (res && res.order) || null,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (!out.json) {
|
|
278
|
+
if (paid) {
|
|
279
|
+
out.ok(`订单已支付:${orderId}(状态 ${status}${data.fulfilled ? ',已履约' : ''})`);
|
|
280
|
+
if (balanceCents !== null) out.line(` 当前余额:${data.balance_text}`);
|
|
281
|
+
} else if (status === 'pending') {
|
|
282
|
+
out.warn(`订单仍未支付:${orderId}(微信侧状态 ${data.trade_state || 'NOTPAY'})`);
|
|
283
|
+
if (data.code_url) {
|
|
284
|
+
out.line(` 支付链接:${data.code_url}`);
|
|
285
|
+
await renderQrOptional(data.code_url, out);
|
|
286
|
+
}
|
|
287
|
+
out.info(' 请在手机上完成支付后再运行本命令确认;CLI 不会替你付款,也不会模拟支付成功。');
|
|
288
|
+
} else {
|
|
289
|
+
out.line(`订单状态:${status}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
ctx.done(data);
|
|
293
|
+
return data;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const ordersCheckCommand = {
|
|
297
|
+
name: 'orders check',
|
|
298
|
+
summary: '查询某笔订单的支付结果(微信侧已支付则就地履约),并显示最新余额',
|
|
299
|
+
usage: 'wkgeo orders check <订单ID>',
|
|
300
|
+
args: [{ name: 'orderId', required: true, desc: '订单 ID' }],
|
|
301
|
+
run: (ctx) => checkOrderImpl(ctx, ctx.positional.orderId),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** `orders pay` 与 `orders check` 是**同一实现**:保留 pay 这个名字是因为
|
|
305
|
+
* 老板给的任务表里写的是 `orders pay <orderId>`(核心:完成"支付操作")。
|
|
306
|
+
* 真实语义在下单那一步就支付完成了,这里只是查/收敛状态,详见本文件顶部说明。 */
|
|
307
|
+
export const ordersPayCommand = {
|
|
308
|
+
name: 'orders pay',
|
|
309
|
+
summary: '(`orders check` 的别名)确认/收敛订单支付状态并显示最新余额',
|
|
310
|
+
usage: 'wkgeo orders pay <订单ID>',
|
|
311
|
+
args: [{ name: 'orderId', required: true, desc: '订单 ID' }],
|
|
312
|
+
run: (ctx) => checkOrderImpl(ctx, ctx.positional.orderId),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ */
|
|
316
|
+
/* orders get */
|
|
317
|
+
/* ------------------------------------------------------------------ */
|
|
318
|
+
|
|
319
|
+
export const ordersGetCommand = {
|
|
320
|
+
name: 'orders get',
|
|
321
|
+
summary: '看某笔订单的详情(在自己的订单列表里查找,不触发任何微信查询)',
|
|
322
|
+
usage: 'wkgeo orders get <订单ID> [--max-scan 1000]',
|
|
323
|
+
args: [{ name: 'orderId', required: true, desc: '订单 ID' }],
|
|
324
|
+
flags: {
|
|
325
|
+
'max-scan': { type: 'string', desc: '最多翻查多少条历史订单(默认 1000)' },
|
|
326
|
+
},
|
|
327
|
+
async run(ctx) {
|
|
328
|
+
const { out, flags } = ctx;
|
|
329
|
+
const orderId = ctx.positional.orderId;
|
|
330
|
+
const maxScan = Math.min(Math.max(Number(flags.maxScan) || 1000, 100), 5000);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 为什么是"翻列表"而不是专用接口:
|
|
334
|
+
* 服务端**没有** `getMyOrder(id)` 这个 action(只有 `checkOrder`)。
|
|
335
|
+
* 而 `checkOrder` 对 pending 订单会去问微信 —— 一个"看详情"的命令不该有外部副作用,
|
|
336
|
+
* 所以这里只翻自己的订单列表(`listMyOrders`,每页 100)。
|
|
337
|
+
*/
|
|
338
|
+
let found = null;
|
|
339
|
+
let scanned = 0;
|
|
340
|
+
for (let offset = 0; offset < maxScan; offset += 100) {
|
|
341
|
+
const res = await ctx.callData('listMyOrders', { limit: 100, offset });
|
|
342
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []);
|
|
343
|
+
scanned += rows.length;
|
|
344
|
+
found = rows.find((r) => r.id === orderId || r.out_trade_no === orderId) || null;
|
|
345
|
+
if (found || rows.length < 100) break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!found) {
|
|
349
|
+
throw new CliError(`在你最近的 ${scanned} 条订单里没找到「${orderId}」`, {
|
|
350
|
+
kind: 'not_found',
|
|
351
|
+
code: 404,
|
|
352
|
+
hint: `该订单可能更早(可加 --max-scan 扩大范围),或不是当前账号的订单;要看实时支付状态可用 \`wkgeo orders check ${orderId}\``,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const data = {
|
|
357
|
+
order: {
|
|
358
|
+
id: found.id,
|
|
359
|
+
out_trade_no: found.out_trade_no,
|
|
360
|
+
kind: found.kind,
|
|
361
|
+
status: found.status,
|
|
362
|
+
amount_cents: Number(found.amount_cents) || 0,
|
|
363
|
+
amount_text: formatMoney(found.amount_cents),
|
|
364
|
+
wallet_paid_cents: Number(found.wallet_paid_cents) || 0,
|
|
365
|
+
wallet_paid_text: formatMoney(found.wallet_paid_cents),
|
|
366
|
+
currency: found.currency || null,
|
|
367
|
+
transaction_id: found.transaction_id || null,
|
|
368
|
+
paid_at: found.paid_at || null,
|
|
369
|
+
expires_at: found.expires_at || null,
|
|
370
|
+
created_at: found.created_at || null,
|
|
371
|
+
payload: found.payload || {},
|
|
372
|
+
},
|
|
373
|
+
scanned,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (!out.json) {
|
|
377
|
+
const o = data.order;
|
|
378
|
+
out.head('订单详情');
|
|
379
|
+
out.line(` 订单号:${o.id}`);
|
|
380
|
+
out.line(` 商户单号:${o.out_trade_no || '-'}`);
|
|
381
|
+
out.line(` 类型/状态:${o.kind} / ${o.status}`);
|
|
382
|
+
out.line(` 金额:${o.amount_text}(余额支付 ${o.wallet_paid_text})`);
|
|
383
|
+
out.line(` 创建时间:${formatTime(o.created_at)}`);
|
|
384
|
+
out.line(` 支付时间:${formatTime(o.paid_at)}`);
|
|
385
|
+
out.line(` 微信交易号:${o.transaction_id || '-'}`);
|
|
386
|
+
out.line(` 商品明细:${JSON.stringify(o.payload)}`);
|
|
387
|
+
}
|
|
388
|
+
ctx.done(data);
|
|
389
|
+
return data;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/* ------------------------------------------------------------------ */
|
|
394
|
+
/* orders list */
|
|
395
|
+
/* ------------------------------------------------------------------ */
|
|
396
|
+
|
|
397
|
+
export const ordersListCommand = {
|
|
398
|
+
name: 'orders list',
|
|
399
|
+
summary: '我的订单列表',
|
|
400
|
+
usage: 'wkgeo orders list [--limit 20] [--offset 0] [--status paid]',
|
|
401
|
+
flags: {
|
|
402
|
+
limit: { type: 'string', desc: '返回条数(1~100,默认 20)' },
|
|
403
|
+
offset: { type: 'string', desc: '偏移(默认 0)' },
|
|
404
|
+
status: { type: 'string', desc: '只看某个状态(pending/paid/refunded/failed/closed)' },
|
|
405
|
+
},
|
|
406
|
+
async run(ctx) {
|
|
407
|
+
const { out, flags } = ctx;
|
|
408
|
+
const payload = {};
|
|
409
|
+
if (flags.limit !== undefined) payload.limit = Number(flags.limit);
|
|
410
|
+
if (flags.offset !== undefined) payload.offset = Number(flags.offset);
|
|
411
|
+
const res = await ctx.callData('listMyOrders', payload);
|
|
412
|
+
let rows = (res && Array.isArray(res.rows) ? res.rows : []).map((r) => ({
|
|
413
|
+
id: r.id,
|
|
414
|
+
out_trade_no: r.out_trade_no,
|
|
415
|
+
kind: r.kind,
|
|
416
|
+
status: r.status,
|
|
417
|
+
amount_cents: Number(r.amount_cents) || 0,
|
|
418
|
+
amount_text: formatMoney(r.amount_cents),
|
|
419
|
+
wallet_paid_cents: Number(r.wallet_paid_cents) || 0,
|
|
420
|
+
wallet_paid_text: formatMoney(r.wallet_paid_cents),
|
|
421
|
+
paid_at: r.paid_at || null,
|
|
422
|
+
created_at: r.created_at,
|
|
423
|
+
}));
|
|
424
|
+
if (flags.status) rows = rows.filter((r) => r.status === flags.status);
|
|
425
|
+
const data = { rows, total: flags.status ? rows.length : (res && res.total) || rows.length };
|
|
426
|
+
|
|
427
|
+
if (!out.json) {
|
|
428
|
+
out.head(`我的订单(${data.total} 条${flags.status ? `,已按状态 ${flags.status} 过滤` : ''})`);
|
|
429
|
+
out.table(
|
|
430
|
+
[
|
|
431
|
+
{ title: '订单号', key: 'id', width: 38 },
|
|
432
|
+
{ title: '类型', key: 'kind', width: 10 },
|
|
433
|
+
{ title: '状态', key: 'status', width: 10 },
|
|
434
|
+
{ title: '金额', key: 'amount_text', width: 10 },
|
|
435
|
+
{ title: '余额支付', key: 'wallet_paid_text', width: 10 },
|
|
436
|
+
{ title: '创建时间', key: 'created_at', format: (v) => formatTime(v), width: 20 },
|
|
437
|
+
],
|
|
438
|
+
rows,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
ctx.done(data);
|
|
442
|
+
return data;
|
|
443
|
+
},
|
|
444
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 站点命令:`sites list` / `sites create` / `sites delete`
|
|
3
|
+
*
|
|
4
|
+
* 归属口径(**服务端是权威,CLI 只做复核**):
|
|
5
|
+
* 服务端 `handleCreateSite` 把 `owner_user_id` 和 `created_by` 都写成**令牌所属用户**,
|
|
6
|
+
* 客户端传什么都不作数。CLI 建完会回读一次并核对,避免"以为建在自己名下其实不是"。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 已知服务端行为(与 OAuth 无关,是既有实现,踩过):
|
|
9
|
+
* `data_scope != 'self'` 的账号(如 owner/admin)**不传 `customer_id` 建站必 400
|
|
10
|
+
* 「无法确定所属客户」**。所以这里检测到这个错误时给出 `--customer` 的明确指引,
|
|
11
|
+
* 而不是让用户对着 400 发呆。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CliError } from '../errors.mjs';
|
|
15
|
+
import { formatTime } from '../format.mjs';
|
|
16
|
+
|
|
17
|
+
function normalizeSite(res) {
|
|
18
|
+
const s = res || {};
|
|
19
|
+
return {
|
|
20
|
+
id: s.id || null,
|
|
21
|
+
name: s.name || null,
|
|
22
|
+
status: s.status || null,
|
|
23
|
+
domain: s.domain || null,
|
|
24
|
+
domains: Array.isArray(s.domains) ? s.domains : [],
|
|
25
|
+
owner_user_id: s.owner_user_id ? String(s.owner_user_id) : null,
|
|
26
|
+
created_by: s.created_by ? String(s.created_by) : null,
|
|
27
|
+
created_at: s.created_at || null,
|
|
28
|
+
expires_at: s.expires_at || null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const sitesListCommand = {
|
|
33
|
+
name: 'sites list',
|
|
34
|
+
summary: '我的站点列表',
|
|
35
|
+
usage: 'wkgeo sites list [--limit 50] [--offset 0]',
|
|
36
|
+
flags: {
|
|
37
|
+
limit: { type: 'string', desc: '返回条数(1~200,默认 50)' },
|
|
38
|
+
offset: { type: 'string', desc: '偏移(默认 0)' },
|
|
39
|
+
},
|
|
40
|
+
async run(ctx) {
|
|
41
|
+
const { out } = ctx;
|
|
42
|
+
const payload = {};
|
|
43
|
+
if (ctx.flags.limit !== undefined) payload.limit = Number(ctx.flags.limit);
|
|
44
|
+
if (ctx.flags.offset !== undefined) payload.offset = Number(ctx.flags.offset);
|
|
45
|
+
const res = await ctx.callData('listSites', payload);
|
|
46
|
+
const rows = (res && Array.isArray(res.rows) ? res.rows : []).map(normalizeSite);
|
|
47
|
+
const data = { rows, total: (res && res.total) || rows.length };
|
|
48
|
+
|
|
49
|
+
if (!out.json) {
|
|
50
|
+
out.head(`站点列表(共 ${data.total} 个${data.total > rows.length ? `,本页 ${rows.length} 条` : ''})`);
|
|
51
|
+
out.table(
|
|
52
|
+
[
|
|
53
|
+
{ title: '名称', key: 'name', width: 28 },
|
|
54
|
+
{ title: '站点 ID', key: 'id', width: 38 },
|
|
55
|
+
{ title: '状态', key: 'status', width: 10 },
|
|
56
|
+
{ title: '主域名', key: 'domain', width: 24 },
|
|
57
|
+
{ title: '创建时间', key: 'created_at', format: (v) => formatTime(v), width: 20 },
|
|
58
|
+
],
|
|
59
|
+
rows,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
ctx.done(data);
|
|
63
|
+
return data;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const sitesCreateCommand = {
|
|
68
|
+
name: 'sites create',
|
|
69
|
+
summary: '建一个属于当前账号的站点(返回站点 id)',
|
|
70
|
+
usage: 'wkgeo sites create --name <名称> [--customer <客户ID>] [--template <模板ID>] [--domain <域名>]',
|
|
71
|
+
flags: {
|
|
72
|
+
name: { type: 'string', required: true, desc: '站点名称' },
|
|
73
|
+
customer: { type: 'string', desc: '所属客户 ID(data_scope != self 的账号必须传,如 owner/admin)' },
|
|
74
|
+
template: { type: 'string', desc: '模板 ID' },
|
|
75
|
+
domain: { type: 'string', desc: '主域名(可选,会真实绑定域名资源,谨慎使用)' },
|
|
76
|
+
},
|
|
77
|
+
async run(ctx) {
|
|
78
|
+
const { out, flags } = ctx;
|
|
79
|
+
const payload = { name: flags.name };
|
|
80
|
+
if (flags.customer) payload.customer_id = flags.customer;
|
|
81
|
+
if (flags.template) payload.template_id = flags.template;
|
|
82
|
+
if (flags.domain) payload.domains = [flags.domain];
|
|
83
|
+
|
|
84
|
+
let res;
|
|
85
|
+
try {
|
|
86
|
+
res = await ctx.callData('createSite', payload);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// 把"服务端 400 的一句话"翻译成"下一步怎么办"(这条错误很常见,值得专门处理)
|
|
89
|
+
if (e instanceof CliError && String(e.message || '').includes('无法确定所属客户')) {
|
|
90
|
+
e.hint =
|
|
91
|
+
'当前账号的 data_scope 不是 self(比如 owner/admin),服务端要求显式指定客户:' +
|
|
92
|
+
'加 `--customer <客户ID>` 重试(客户 ID 可在网页后台的客户列表里取)';
|
|
93
|
+
}
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const site = normalizeSite(res);
|
|
98
|
+
// 复核归属:owner_user_id 必须是当前登录用户(服务端就是这么写的,这里是"证据")
|
|
99
|
+
let creds = null;
|
|
100
|
+
try {
|
|
101
|
+
creds = ctx.requireAuth();
|
|
102
|
+
} catch {
|
|
103
|
+
/* 拿不到也继续,只影响复核 */
|
|
104
|
+
}
|
|
105
|
+
const meId = creds && creds.account ? String(creds.account.sub || '') : '';
|
|
106
|
+
const ownerOk = !meId || site.owner_user_id === meId;
|
|
107
|
+
const data = { site, owner_matches_current_user: meId ? ownerOk : null, current_user_id: meId || null };
|
|
108
|
+
|
|
109
|
+
if (!out.json) {
|
|
110
|
+
out.ok(`站点已创建:${site.name}`);
|
|
111
|
+
out.line(` 站点 ID:${site.id}`);
|
|
112
|
+
out.line(` 状态:${site.status}`);
|
|
113
|
+
out.line(` 归属用户:${site.owner_user_id}${meId ? (ownerOk ? '(= 当前账号 ✔)' : ` ⚠️ 与当前账号 ${meId} 不一致,请核查`) : ''}`);
|
|
114
|
+
}
|
|
115
|
+
if (meId && !ownerOk) {
|
|
116
|
+
out.warn(`站点归属(${site.owner_user_id}) ≠ 当前账号(${meId}),请核查`);
|
|
117
|
+
}
|
|
118
|
+
ctx.done(data);
|
|
119
|
+
return data;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const sitesDeleteCommand = {
|
|
124
|
+
name: 'sites delete',
|
|
125
|
+
summary: '删除站点(服务端是**软删除**:进回收站,可还原)',
|
|
126
|
+
usage: 'wkgeo sites delete <站点ID>',
|
|
127
|
+
args: [{ name: 'id', required: true, desc: '站点 ID' }],
|
|
128
|
+
async run(ctx) {
|
|
129
|
+
const { out } = ctx;
|
|
130
|
+
const id = ctx.positional.id;
|
|
131
|
+
const res = await ctx.callData('deleteSite', { id });
|
|
132
|
+
const data = { id, deleted: !!(res && res.deleted), soft: res ? res.soft !== false : true };
|
|
133
|
+
if (!out.json) {
|
|
134
|
+
out.ok(`站点已移入回收站(软删除,可在后台还原):${id}`);
|
|
135
|
+
out.info(' 注意:软删除不会释放域名等外部资源;彻底删除需要管理员在后台操作。');
|
|
136
|
+
}
|
|
137
|
+
ctx.done(data);
|
|
138
|
+
return data;
|
|
139
|
+
},
|
|
140
|
+
};
|