@jiaoyubaocn/workbuddy-order 1.1.0 → 1.2.1
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 +92 -6
- package/cli.js +89 -5
- package/lib/auth.js +1 -0
- package/lib/tools.js +63 -7
- package/lib/wxauthpage.js +149 -0
- package/lib/wxclient.js +113 -0
- package/lib/wxlogin.js +274 -0
- package/package.json +1 -1
- package/server.js +94 -0
- package/vendor/qrcode.min.js +1 -0
package/README.md
CHANGED
|
@@ -33,27 +33,34 @@ workbuddy-order/
|
|
|
33
33
|
|
|
34
34
|
零依赖 Node 18+ 包,装完得到两个全局命令:`jyb-order`(CLI)、`jyb-order-mcp`(MCP stdio 服务)。
|
|
35
35
|
|
|
36
|
+
- 包名:`@jiaoyubaocn/workbuddy-order`
|
|
37
|
+
- 包页:https://www.npmjs.com/package/@jiaoyubaocn/workbuddy-order
|
|
38
|
+
|
|
36
39
|
### 首次发布(维护者,本机执行一次)
|
|
37
40
|
|
|
38
41
|
```bash
|
|
39
|
-
npm login # 浏览器登录 npmjs.com
|
|
40
|
-
publish.cmd #
|
|
42
|
+
npm login # 浏览器登录 npmjs.com(账号 joysiftcn,需对 @jiaoyubaocn 组织有写权限)
|
|
43
|
+
publish.cmd # 跑冒烟测试 → 发布到 @jiaoyubaocn
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
`publish.cmd` 内部等价于:
|
|
44
47
|
|
|
45
48
|
```bash
|
|
46
|
-
npm pkg set name
|
|
49
|
+
npm pkg set name=@jiaoyubaocn/workbuddy-order
|
|
47
50
|
npm test
|
|
48
51
|
npm publish --access public # scoped 包不带 --access public 会被拒绝(402)
|
|
49
52
|
```
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
**2FA 注意**:账号开启两步验证后,普通 Publish token 会被 403 拒绝(`Two-factor authentication or granular access token with bypass 2fa enabled is required`)。必须使用 **Automation** 类型的 Classic token(或勾选 Bypass 2FA 的 Granular token),或发布时加 `--otp=<6位验证码>`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm publish --access public --otp=123456
|
|
58
|
+
```
|
|
52
59
|
|
|
53
60
|
### 员工安装(一条命令)
|
|
54
61
|
|
|
55
62
|
```bash
|
|
56
|
-
npm install -g
|
|
63
|
+
npm install -g @jiaoyubaocn/workbuddy-order
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
装完直接可用,无需再配路径:
|
|
@@ -113,13 +120,92 @@ node cli.js orders --days=7 --json
|
|
|
113
120
|
> 只保存密码的 MD5,不保存明文。可用 `--no-save` 完全不落盘。
|
|
114
121
|
> 若已有浏览器中的 `admuserpassport`,可 `node cli.js login --passport=xxxx` 直接保存。
|
|
115
122
|
|
|
123
|
+
## 微信扫码登录(网页授权)
|
|
124
|
+
|
|
125
|
+
除帐号密码外,支持用微信扫码登录(101000 的 `checkway=20` + `wxwebcode`)。后台自己用 code 换 openid,本地不接触公众号 secret。
|
|
126
|
+
|
|
127
|
+
### 一条命令完成(扫完自动登录)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
jyb-order login --wx
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
它会生成一次性 state、打开扫码页、等扫码。手机授权后页面把 code 回传给服务端,
|
|
134
|
+
终端 2 秒内自动取回并完成登录,**不需要复制粘贴**。
|
|
135
|
+
|
|
136
|
+
扫码页:`https://m.jiaoyubao.cn/manage/admincli/wxlogin.html`
|
|
137
|
+
(单文件静态页,源码与上传说明见 `deploy/wx-admincli/`)
|
|
138
|
+
|
|
139
|
+
链路:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
jyb-order login --wx
|
|
143
|
+
└─ 生成 state → 打开 wxlogin.html?state=xxx
|
|
144
|
+
└─ 手机扫码 → 微信授权 → 页面回调 ?code=xxx&state=xxx
|
|
145
|
+
└─ 页面 POST /mcp/wx/handoff(一次性票据,5 分钟)
|
|
146
|
+
└─ CLI 轮询 GET /mcp/wx/poll?state=xxx 取走 code
|
|
147
|
+
└─ 本地调 101000 → passport → 保存凭证
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 微信绑定了多个后台帐号
|
|
151
|
+
|
|
152
|
+
第一步不会直接登录,而是列出候选:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
该微信绑定了 6 个后台帐号,请选择要登录的一个:
|
|
156
|
+
1) admin 管理员
|
|
157
|
+
2) lijingjing_cw 李靖靖
|
|
158
|
+
...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
再执行 `jyb-order login --select=2`(序号或 usercode 都行)。选择状态在本地缓存 10 分钟,用过即失效。
|
|
162
|
+
|
|
163
|
+
二次提交的协议实测结论(2026-11-07,admin 帐号 / 6 个候选):后台要求
|
|
164
|
+
**session cookie + usercode + 原始 code** 三样齐全,少了 code 会返回「参数错误!」。
|
|
165
|
+
CLI 按这个顺序尝试,另外两种组合(只带 session、无 session 直连)作为兼容兜底。
|
|
166
|
+
|
|
167
|
+
> 踩过的坑:`ip` 传空字符串会让 101000 直接返回「参数错误!」,
|
|
168
|
+
> 必须是真实 IP(未配置时用 `127.0.0.1` 兜底)。
|
|
169
|
+
>
|
|
170
|
+
> 另一个坑:`101001`(passport 校验)实测**任何**参数组合都返回「无效参数!」——
|
|
171
|
+
> passport 单独、加 ip、加 usercode、空 passport、仅 ip 五种全试过,一律同样报错。
|
|
172
|
+
> 说明这个 action 不吃这套协议,已弃用。`whoami --verify` 改为发起一次
|
|
173
|
+
> 400450 最小查询(近 1 天)来判断凭证是否真的能用,这才是可信的验证。
|
|
174
|
+
|
|
175
|
+
### 兜底:手动粘贴
|
|
176
|
+
|
|
177
|
+
扫码页不可用(没上传、被拦)时,这两条仍能走通:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
jyb-order login --wx --no-open # 只打印地址,不自动开浏览器
|
|
181
|
+
jyb-order login --wx-code="<code 或整条回调网址>" # 手动提交授权码
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 配置项
|
|
185
|
+
|
|
186
|
+
| 变量 | 默认值 | 说明 |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `JYB_WX_PAGE` | `https://m.jiaoyubao.cn/manage/admincli/wxlogin.html` | 扫码页地址 |
|
|
189
|
+
| `JYB_WX_POLL` | `https://apidata-admin.jiaoyubao.cn/mcp/wx/poll` | CLI 取码端点 |
|
|
190
|
+
| `JYB_WX_TIMEOUT` | `180` | 等待扫码的秒数 |
|
|
191
|
+
| `JYB_WX_ORIGIN` | `https://m.jiaoyubao.cn` | 服务端允许的回传来源(CORS) |
|
|
192
|
+
|
|
193
|
+
也可用 `login --wx --page=<url> --timeout=60` 临时覆盖前两项。
|
|
194
|
+
|
|
195
|
+
### 已知限制
|
|
196
|
+
|
|
197
|
+
- **code 一次性、约 5 分钟有效**:拿到就要用,不能重复提交。
|
|
198
|
+
- **票据只存服务端内存**:服务重启会丢,重新扫码即可,不影响已登录凭证。
|
|
199
|
+
- **凭证过期不能自动续登**:微信登录没有密码可复用,过期后需重新扫码(密码登录仍会自动续登)。
|
|
200
|
+
- **PC 端必须扫码**:网页授权只能在微信客户端内打开,这是微信的限制,不是实现取舍。
|
|
201
|
+
|
|
116
202
|
## 命令
|
|
117
203
|
|
|
118
204
|
| 命令 | 说明 |
|
|
119
205
|
| --- | --- |
|
|
120
206
|
| `login` | 登录并保存凭证;`--user --password [--ip] [--expires=1440] [--no-save]`,或 `--passport=值` |
|
|
121
207
|
| `logout` | 清除本地 passport(保留帐号,下次自动重登) |
|
|
122
|
-
| `whoami` | 查看登录状态、帐号、passport 脱敏值、剩余时间;`--verify`
|
|
208
|
+
| `whoami` | 查看登录状态、帐号、passport 脱敏值、剩余时间;`--verify` 用一次最小业务查询(400450,近 1 天)实测凭证是否可用 |
|
|
123
209
|
| `orders` | 查询订单列表(400450) |
|
|
124
210
|
| `count` | 只取记录总数(400450 `page=0`) |
|
|
125
211
|
| `dict` | 字典查询与中文转编号 |
|
package/cli.js
CHANGED
|
@@ -10,6 +10,9 @@ const dict = require("./lib/dict");
|
|
|
10
10
|
const order = require("./lib/order");
|
|
11
11
|
const http = require("./lib/http");
|
|
12
12
|
const fmt = require("./lib/format");
|
|
13
|
+
const wxlogin = require("./lib/wxlogin");
|
|
14
|
+
const wxclient = require("./lib/wxclient");
|
|
15
|
+
const wxauthpage = require("./lib/wxauthpage");
|
|
13
16
|
|
|
14
17
|
var VERSION = "1.1.0";
|
|
15
18
|
try { VERSION = require("./package.json").version || VERSION; } catch (e) { /* 保持默认 */ }
|
|
@@ -88,7 +91,7 @@ function fail(e, args) {
|
|
|
88
91
|
}
|
|
89
92
|
err("错误[" + code + "] " + msg);
|
|
90
93
|
if (e && e.code === "AUTH") {
|
|
91
|
-
err("排查:执行 node cli.js login --user=帐号 --password=密码 --ip=你的IP
|
|
94
|
+
err("排查:执行 node cli.js login --user=帐号 --password=密码 --ip=你的IP 重新登录(也可微信扫码:login --wx-code=<code>);或设置 JYB_USERCODE/JYB_PASSWORD/JYB_PASSPORT。");
|
|
92
95
|
}
|
|
93
96
|
process.exitCode = exitCodeOf(e);
|
|
94
97
|
}
|
|
@@ -102,6 +105,8 @@ function helpText() {
|
|
|
102
105
|
"",
|
|
103
106
|
"命令:",
|
|
104
107
|
" login 登录并保存凭证 --user=帐号 --password=密码 [--ip=1.2.3.4] [--expires=1440] [--no-save]",
|
|
108
|
+
" 微信扫码登录 --wx 查看授权页;--wx-code=code 提交授权码",
|
|
109
|
+
" 微信绑定多帐号时选择 --select=序号或usercode",
|
|
105
110
|
" 也可直接保存已有凭证 --passport=admuserpassport 的值",
|
|
106
111
|
" logout 清除本地凭证",
|
|
107
112
|
" whoami 查看当前登录身份与凭证剩余时间 [--verify]",
|
|
@@ -158,7 +163,78 @@ function dictHelpText() {
|
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/* ---------- 命令 ---------- */
|
|
166
|
+
function printWxDone(rec, via) {
|
|
167
|
+
out("登录成功" + (rec.cname ? ":" + rec.cname : ""));
|
|
168
|
+
out("帐号 : " + rec.usercode);
|
|
169
|
+
out("方式 : 微信授权" + (via ? "(" + via + ")" : ""));
|
|
170
|
+
out("passport: " + http.maskPassport(rec.passport) + "(完整值已写入凭证文件)");
|
|
171
|
+
out("有效期 : " + rec.expiresMinutes + " 分钟,至 " + new Date(rec.passportExpireAt).toLocaleString());
|
|
172
|
+
out("凭证文件: " + auth.configFile());
|
|
173
|
+
out("");
|
|
174
|
+
out("提示:微信授权登录的凭证过期后不能自动续登(没有密码可复用),需要重新扫码。");
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
async function cmdLogin(args) {
|
|
178
|
+
/* ---------- 微信网页授权登录 ---------- */
|
|
179
|
+
var wxCode = args.wx_code || args.wxcode || args.code;
|
|
180
|
+
|
|
181
|
+
/* 扫码自动模式:生成一次性 state → 打开扫码页 → 轮询服务端取回 code */
|
|
182
|
+
if (isOn(args.wx) && !wxCode && !args.select) {
|
|
183
|
+
var state = wxclient.newState();
|
|
184
|
+
var url = wxclient.pageUrl(state, { page: args.page ? String(args.page) : "" });
|
|
185
|
+
var waitSec = Number(args.timeout || process.env.JYB_WX_TIMEOUT || 180);
|
|
186
|
+
|
|
187
|
+
out("微信扫码登录");
|
|
188
|
+
out(" 扫码页:" + url);
|
|
189
|
+
if (!isOn(args.no_open)) {
|
|
190
|
+
var opened = await wxclient.openTarget(url);
|
|
191
|
+
out(" 浏览器:" + (opened ? "已尝试用默认浏览器打开(没弹出就手动复制上面的地址)" : "自动打开失败,请手动复制上面的地址"));
|
|
192
|
+
}
|
|
193
|
+
out("");
|
|
194
|
+
out("用手机微信「扫一扫」页面上的二维码。授权完成后终端会自动继续,");
|
|
195
|
+
out("绑定多个帐号时会列出候选让你选。等待中(最长 " + waitSec + " 秒,Ctrl+C 取消)…");
|
|
196
|
+
|
|
197
|
+
var got = await wxclient.waitForCode(state, {
|
|
198
|
+
timeoutMs: waitSec * 1000,
|
|
199
|
+
onError: function (msg) { err("取码接口暂时不可用:" + msg + "(继续重试)"); },
|
|
200
|
+
onWait: function (left) { out(" …仍在等待,剩余 " + left + " 秒"); }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!got) {
|
|
204
|
+
out("");
|
|
205
|
+
err("等待超时:没有收到微信授权码。");
|
|
206
|
+
out("排查建议:");
|
|
207
|
+
out(" 1) 扫码页能打开吗?(打不开说明文件还没上传到 m.jiaoyubao.cn/manage/admincli/)");
|
|
208
|
+
out(" 2) 手机上是否停在「授权」页没点允许?");
|
|
209
|
+
out(" 3) 也可手动兜底:复制手机地址栏里的 code,执行 jyb-order login --wx-code=<code>");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
out("已收到授权码,正在登录…");
|
|
213
|
+
wxCode = got.code;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (wxCode || args.select) {
|
|
217
|
+
var wxo = {
|
|
218
|
+
ip: args.ip ? String(args.ip) : "",
|
|
219
|
+
expires: args.expires ? Number(args.expires) : 0,
|
|
220
|
+
save: !isOn(args.no_save)
|
|
221
|
+
};
|
|
222
|
+
if (args.select) {
|
|
223
|
+
var picked = await wxlogin.select(Object.assign({ usercode: String(args.select) }, wxo));
|
|
224
|
+
printWxDone(picked.rec, picked.via);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
var parsed = wxauthpage.parseCodeInput(wxCode);
|
|
228
|
+
if (parsed.state) { out("回调 state: " + parsed.state); }
|
|
229
|
+
var started = await wxlogin.start(Object.assign({ code: parsed.code }, wxo));
|
|
230
|
+
if (started.needSelect) {
|
|
231
|
+
out(wxlogin.candidatesText(started.candidates));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
printWxDone(started.rec, "");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
162
238
|
if (args.passport && !args.user && !args.usercode) {
|
|
163
239
|
var expireAt = Date.now() + (Number(args.expires) || auth.DEFAULT_EXPIRES) * 60 * 1000;
|
|
164
240
|
auth.saveConfig({
|
|
@@ -201,11 +277,14 @@ async function cmdWhoami(args) {
|
|
|
201
277
|
var verify = null;
|
|
202
278
|
if (isOn(args.verify) && info.loggedIn) {
|
|
203
279
|
var p = await auth.getPassport();
|
|
280
|
+
/* 101001 实测不可用:passport 单独、加 ip、加 usercode、空 passport、仅 ip
|
|
281
|
+
* 五种组合一律返回「无效参数!」(2026-11-07 全组合验证),说明该 action
|
|
282
|
+
* 不吃这套参数。改用一次最小业务查询(400450,近 1 天)做真实可用性验证。 */
|
|
204
283
|
try {
|
|
205
|
-
var
|
|
206
|
-
verify = { ok: true,
|
|
284
|
+
var probe = await order.count({ days: 1 }, { auth: { passport: p.passport, ip: p.ip } });
|
|
285
|
+
verify = { ok: true, via: "400450", totals: probe.totals || null };
|
|
207
286
|
} catch (e) {
|
|
208
|
-
verify = { ok: false, message: e.message };
|
|
287
|
+
verify = { ok: false, via: "400450", message: e.message };
|
|
209
288
|
}
|
|
210
289
|
}
|
|
211
290
|
if (isOn(args.json)) {
|
|
@@ -221,7 +300,12 @@ async function cmdWhoami(args) {
|
|
|
221
300
|
out("接口地址: " + info.apiHost);
|
|
222
301
|
out("凭证文件: " + info.configFile);
|
|
223
302
|
if (verify) {
|
|
224
|
-
|
|
303
|
+
if (verify.ok) {
|
|
304
|
+
out("凭证校验: 有效(400450 业务接口实测通过)");
|
|
305
|
+
} else {
|
|
306
|
+
out("凭证校验: 失败 - " + verify.message);
|
|
307
|
+
out(" 凭证过期请重新登录;网络类错误可直接重试。");
|
|
308
|
+
}
|
|
225
309
|
}
|
|
226
310
|
}
|
|
227
311
|
|
package/lib/auth.js
CHANGED
package/lib/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ const dict = require("./dict");
|
|
|
6
6
|
const order = require("./order");
|
|
7
7
|
const fmt = require("./format");
|
|
8
8
|
const http = require("./http");
|
|
9
|
+
const wxlogin = require("./wxlogin");
|
|
9
10
|
|
|
10
11
|
const STATE_DESC = "订单状态,中文或编号,多个用 | 分隔,例如 已报名|已放弃(编号来自 400457)";
|
|
11
12
|
|
|
@@ -88,16 +89,18 @@ const TOOLS = [
|
|
|
88
89
|
{
|
|
89
90
|
name: "login",
|
|
90
91
|
title: "登录教育宝后台",
|
|
91
|
-
description: "
|
|
92
|
-
+ "
|
|
92
|
+
description: "登录后台(101000),支持两种方式:①帐号密码:user + password;②微信扫码:wx_code(微信网页授权回调里的 code)。"
|
|
93
|
+
+ "若该微信绑定了多个后台帐号,会返回 needSelect 与候选列表,此时再调用本工具并传 select=<usercode 或序号> 完成选择。"
|
|
94
|
+
+ "凭证按用户独立保存并绑定当前会话,之后的查询都以该帐号的权限执行;每个人必须用自己有权限的帐号登录。",
|
|
93
95
|
inputSchema: {
|
|
94
96
|
type: "object",
|
|
95
97
|
properties: {
|
|
96
|
-
user: { type: "string", description: "
|
|
97
|
-
password: { type: "string", description: "
|
|
98
|
+
user: { type: "string", description: "后台登录帐号(与 wx_code 二选一)" },
|
|
99
|
+
password: { type: "string", description: "后台登录密码(与 user 搭配)" },
|
|
100
|
+
wx_code: { type: "string", description: "微信网页授权回调带回的 code,5 分钟内有效且只能用一次(与 user/password 二选一)" },
|
|
101
|
+
select: { type: "string", description: "微信绑定多个帐号时,选择要登录的帐号:usercode 或候选序号(1 开始)" },
|
|
98
102
|
ip: { type: "string", description: "可选,登录上报的 IP;HTTP 模式默认取客户端出口 IP" }
|
|
99
103
|
},
|
|
100
|
-
required: ["user", "password"],
|
|
101
104
|
additionalProperties: false
|
|
102
105
|
}
|
|
103
106
|
},
|
|
@@ -212,8 +215,59 @@ async function callTool(name, args, ctx) {
|
|
|
212
215
|
try {
|
|
213
216
|
/* ---- 登录 / 登出 / 状态:不要求已登录 ---- */
|
|
214
217
|
if (name === "login") {
|
|
218
|
+
var wxOpts = {};
|
|
219
|
+
if (a.ip && String(a.ip).trim()) { wxOpts.ip = String(a.ip).trim(); }
|
|
220
|
+
else if (isHttp && clientIp(ctx)) { wxOpts.ip = clientIp(ctx); }
|
|
221
|
+
if (isHttp) { wxOpts.store = "user"; }
|
|
222
|
+
|
|
223
|
+
/* 微信网页授权登录:wx_code(第一步)或 select(多帐号第二步) */
|
|
224
|
+
if (a.wx_code || a.select) {
|
|
225
|
+
if (a.select && !a.wx_code) {
|
|
226
|
+
var picked = await wxlogin.select(Object.assign({ usercode: String(a.select) }, wxOpts));
|
|
227
|
+
if (isHttp && ctx.sessionId) { sessionAuth.set(ctx.sessionId, picked.rec.usercode); }
|
|
228
|
+
return wrap("登录成功:" + picked.rec.usercode + (picked.rec.cname ? "(" + picked.rec.cname + ")" : "")
|
|
229
|
+
+ "\n登录方式: 微信授权(" + picked.via + ")"
|
|
230
|
+
+ "\npassport: " + http.maskPassport(picked.rec.passport)
|
|
231
|
+
+ "\n有效期: 约 " + picked.rec.expiresMinutes + " 分钟",
|
|
232
|
+
{
|
|
233
|
+
loggedIn: true,
|
|
234
|
+
authType: "wx",
|
|
235
|
+
usercode: picked.rec.usercode,
|
|
236
|
+
cname: picked.rec.cname,
|
|
237
|
+
passport: http.maskPassport(picked.rec.passport),
|
|
238
|
+
expireAt: new Date(picked.rec.passportExpireAt).toISOString(),
|
|
239
|
+
boundToSession: isHttp
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
var started = await wxlogin.start(Object.assign({ code: String(a.wx_code) }, wxOpts));
|
|
244
|
+
if (started.needSelect) {
|
|
245
|
+
return wrap(wxlogin.candidatesText(started.candidates), {
|
|
246
|
+
loggedIn: false,
|
|
247
|
+
needSelect: true,
|
|
248
|
+
candidates: started.candidates
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (isHttp && ctx.sessionId) { sessionAuth.set(ctx.sessionId, started.rec.usercode); }
|
|
252
|
+
return wrap("登录成功:" + started.rec.usercode + (started.rec.cname ? "(" + started.rec.cname + ")" : "")
|
|
253
|
+
+ "\n登录方式: 微信授权"
|
|
254
|
+
+ "\npassport: " + http.maskPassport(started.rec.passport)
|
|
255
|
+
+ "\n有效期: 约 " + started.rec.expiresMinutes + " 分钟"
|
|
256
|
+
+ "\n注意: 微信授权凭证过期后无法自动续登,需要重新扫码",
|
|
257
|
+
{
|
|
258
|
+
loggedIn: true,
|
|
259
|
+
authType: "wx",
|
|
260
|
+
usercode: started.rec.usercode,
|
|
261
|
+
cname: started.rec.cname,
|
|
262
|
+
passport: http.maskPassport(started.rec.passport),
|
|
263
|
+
expireAt: new Date(started.rec.passportExpireAt).toISOString(),
|
|
264
|
+
boundToSession: isHttp
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
215
268
|
if (!a.user || !a.password) {
|
|
216
|
-
return wrap("缺少参数:需要 user
|
|
269
|
+
return wrap("缺少参数:需要 user + password(帐号密码登录),或 wx_code(微信扫码登录的授权码)",
|
|
270
|
+
{ loggedIn: false }, true);
|
|
217
271
|
}
|
|
218
272
|
var loginOpts = {
|
|
219
273
|
usercode: String(a.user).trim(),
|
|
@@ -265,8 +319,10 @@ async function callTool(name, args, ctx) {
|
|
|
265
319
|
}
|
|
266
320
|
if (a.verify === true && info.loggedIn) {
|
|
267
321
|
var p = await auth.getPassport(aoStatus || {});
|
|
322
|
+
/* 101001 实测不可用(任何参数组合都返回「无效参数!」),
|
|
323
|
+
* 改用一次最小业务查询(400450,近 1 天)验证凭证是否真的能用。 */
|
|
268
324
|
try {
|
|
269
|
-
await
|
|
325
|
+
await order.count({ days: 1 }, { auth: { passport: p.passport, ip: p.ip } });
|
|
270
326
|
info.verified = true;
|
|
271
327
|
} catch (e2) {
|
|
272
328
|
info.verified = false;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* 微信网页授权:本地授权页生成
|
|
3
|
+
*
|
|
4
|
+
* 背景:公众号网页授权只能由微信客户端打开,PC 端必须扫码;
|
|
5
|
+
* 而 redirect_uri 的域名被限制在公众号「网页授权域名」(本项目为 m.jiaoyubao.cn),
|
|
6
|
+
* 我们无法在那边放页面,因此这里生成的页面只做两件事:展示二维码、告诉用户把回调地址复制回来。
|
|
7
|
+
* 授权完成后微信会跳到 m.jiaoyubao.cn/?code=xxx&state=xxx,地址栏里的 code 就是登录凭据。
|
|
8
|
+
*
|
|
9
|
+
* 页面完全离线:二维码库内联,不依赖 CDN。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
const { execFile } = require("child_process");
|
|
17
|
+
|
|
18
|
+
const DEFAULT_APPID = "wxdfff04c55ffbe491";
|
|
19
|
+
const DEFAULT_REDIRECT = "https://m.jiaoyubao.cn/";
|
|
20
|
+
|
|
21
|
+
function appid(opts) {
|
|
22
|
+
return (opts && opts.appid) || process.env.JYB_WX_APPID || DEFAULT_APPID;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function redirectUri(opts) {
|
|
26
|
+
return (opts && opts.redirect) || process.env.JYB_WX_REDIRECT || DEFAULT_REDIRECT;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function randomState() {
|
|
30
|
+
return crypto.randomBytes(8).toString("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* 生成微信网页授权链接(scope=snsapi_userinfo 才能拿到昵称头像) */
|
|
34
|
+
function authorizeUrl(opts) {
|
|
35
|
+
var o = opts || {};
|
|
36
|
+
var st = o.state || randomState();
|
|
37
|
+
var url = "https://open.weixin.qq.com/connect/oauth2/authorize"
|
|
38
|
+
+ "?appid=" + encodeURIComponent(appid(o))
|
|
39
|
+
+ "&redirect_uri=" + encodeURIComponent(redirectUri(o))
|
|
40
|
+
+ "&response_type=code&scope=snsapi_userinfo"
|
|
41
|
+
+ "&state=" + encodeURIComponent(st)
|
|
42
|
+
+ "#wechat_redirect";
|
|
43
|
+
return { url: url, state: st, appid: appid(o), redirect: redirectUri(o) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function esc(s) {
|
|
47
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildHtml(info) {
|
|
51
|
+
var lib = "";
|
|
52
|
+
try { lib = fs.readFileSync(path.join(__dirname, "..", "vendor", "qrcode.min.js"), "utf8"); } catch (e) { lib = ""; }
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
"<!DOCTYPE html>",
|
|
56
|
+
"<html lang=\"zh-CN\"><head><meta charset=\"utf-8\">",
|
|
57
|
+
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
|
|
58
|
+
"<title>微信扫码登录</title>",
|
|
59
|
+
"<style>",
|
|
60
|
+
"body{margin:0;font:15px/1.6 -apple-system,'Microsoft YaHei',sans-serif;background:#f5f6f8;color:#1f2329}",
|
|
61
|
+
".wrap{max-width:560px;margin:0 auto;padding:32px 20px 48px}",
|
|
62
|
+
"h1{font-size:20px;margin:0 0 6px}",
|
|
63
|
+
".sub{color:#646a73;margin:0 0 24px}",
|
|
64
|
+
".card{background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}",
|
|
65
|
+
"#qr{display:inline-block;padding:12px;background:#fff}",
|
|
66
|
+
"#qr img,#qr canvas{display:block}",
|
|
67
|
+
".tip{margin:16px 0 0;color:#646a73;font-size:13px}",
|
|
68
|
+
".step{margin:24px 0 0;text-align:left}",
|
|
69
|
+
".step h2{font-size:14px;margin:18px 0 6px;color:#1f2329}",
|
|
70
|
+
".step p{margin:0 0 8px;color:#4e5969}",
|
|
71
|
+
"code{background:#f2f3f5;border-radius:4px;padding:2px 6px;font-size:13px;word-break:break-all}",
|
|
72
|
+
".url{background:#f7f8fa;border:1px solid #e5e6eb;border-radius:8px;padding:10px;font-size:12px;color:#4e5969;text-align:left;word-break:break-all}",
|
|
73
|
+
".warn{margin-top:18px;background:#fff7e8;border:1px solid #ffe0a3;color:#8a5a00;border-radius:8px;padding:10px 12px;font-size:13px;text-align:left}",
|
|
74
|
+
"</style></head><body><div class=\"wrap\">",
|
|
75
|
+
"<h1>微信扫码登录</h1>",
|
|
76
|
+
"<p class=\"sub\">用手机微信扫下面的二维码,授权后把地址栏整条复制回终端。</p>",
|
|
77
|
+
"<div class=\"card\"><div id=\"qr\"></div>",
|
|
78
|
+
"<p class=\"tip\">二维码 5 分钟内有效,仅可使用一次</p></div>",
|
|
79
|
+
"<div class=\"step\">",
|
|
80
|
+
"<h2>1. 手机微信「扫一扫」</h2>",
|
|
81
|
+
"<p>扫码后会打开授权页,点「允许」。</p>",
|
|
82
|
+
"<h2>2. 复制地址栏</h2>",
|
|
83
|
+
"<p>授权后页面会跳到 <code>m.jiaoyubao.cn</code>,把地址栏整条网址复制下来(里面有 <code>code=</code> 参数)。</p>",
|
|
84
|
+
"<h2>3. 回到终端粘贴</h2>",
|
|
85
|
+
"<p>执行:<code>jyb-order login --wx-code=\"<粘贴整条网址>\"</code></p>",
|
|
86
|
+
"<p>只需 code 也行:<code>jyb-order login --wx-code=CODE</code></p>",
|
|
87
|
+
"</div>",
|
|
88
|
+
"<div class=\"warn\">授权码只能用一次,5 分钟内有效。若微信绑定了多个后台帐号,终端会列出候选,再用 <code>login --select=<序号></code> 选定。</div>",
|
|
89
|
+
"<div class=\"step\"><h2>备用:手动打开授权链接</h2>",
|
|
90
|
+
"<div class=\"url\">" + esc(info.url) + "</div></div>",
|
|
91
|
+
"</div>",
|
|
92
|
+
"<script>" + lib + "</script>",
|
|
93
|
+
"<script>",
|
|
94
|
+
"(function(){var box=document.getElementById('qr');",
|
|
95
|
+
"try{new QRCode(box,{text:" + JSON.stringify(info.url) + ",width:240,height:240,correctLevel:QRCode.CorrectLevel.M});}",
|
|
96
|
+
"catch(e){box.innerHTML='<p style=\"color:#c00\">二维码生成失败,请使用页面底部的授权链接</p>';}",
|
|
97
|
+
"})();",
|
|
98
|
+
"</script>",
|
|
99
|
+
"</body></html>"
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* 写到临时文件,返回路径(不覆盖同名,避免并发互踩) */
|
|
104
|
+
function writePage(opts) {
|
|
105
|
+
var info = authorizeUrl(opts);
|
|
106
|
+
var html = buildHtml(info);
|
|
107
|
+
var file = path.join(os.tmpdir(), "jyb-wx-auth-" + info.state + ".html");
|
|
108
|
+
fs.writeFileSync(file, html, "utf8");
|
|
109
|
+
return { file: file, url: info.url, state: info.state, appid: info.appid, redirect: info.redirect };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* 用默认浏览器打开(失败不抛错,调用方仍可手动打开路径) */
|
|
113
|
+
function openPage(file) {
|
|
114
|
+
return new Promise(function (resolve) {
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
execFile("cmd", ["/c", "start", "", file], { windowsHide: true }, function (e) {
|
|
117
|
+
resolve(!e);
|
|
118
|
+
});
|
|
119
|
+
} else if (process.platform === "darwin") {
|
|
120
|
+
execFile("open", [file], function (e) { resolve(!e); });
|
|
121
|
+
} else {
|
|
122
|
+
execFile("xdg-open", [file], function (e) { resolve(!e); });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* 从用户粘贴的内容里取 code / state:支持整条回调 URL,也支持裸 code */
|
|
128
|
+
function parseCodeInput(text) {
|
|
129
|
+
var s = String(text || "").trim();
|
|
130
|
+
if (!s) { return { code: "", state: "" }; }
|
|
131
|
+
if (s.indexOf("code=") < 0 && s.indexOf("&") < 0 && s.indexOf("/") < 0 && s.indexOf("?") < 0) {
|
|
132
|
+
return { code: s, state: "" };
|
|
133
|
+
}
|
|
134
|
+
var m = s.match(/[?&#]code=([^&#\s]+)/);
|
|
135
|
+
var st = s.match(/[?&#]state=([^&#\s]+)/);
|
|
136
|
+
return {
|
|
137
|
+
code: m ? decodeURIComponent(m[1]) : s,
|
|
138
|
+
state: st ? decodeURIComponent(st[1]) : ""
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
authorizeUrl: authorizeUrl,
|
|
144
|
+
writePage: writePage,
|
|
145
|
+
openPage: openPage,
|
|
146
|
+
parseCodeInput: parseCodeInput,
|
|
147
|
+
DEFAULT_APPID: DEFAULT_APPID,
|
|
148
|
+
DEFAULT_REDIRECT: DEFAULT_REDIRECT
|
|
149
|
+
};
|
package/lib/wxclient.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* 微信扫码登录的「网页回传」客户端
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* CLI 生成一次性 state → 打开/打印扫码页 URL(带着 state)
|
|
6
|
+
* 用户手机扫码 → 微信回调到页面 → 页面把 code POST 到服务端(/mcp/wx/handoff)
|
|
7
|
+
* CLI 轮询服务端(/mcp/wx/poll)取走 code → 本地调 101000 完成登录
|
|
8
|
+
*
|
|
9
|
+
* 环境变量:
|
|
10
|
+
* JYB_WX_PAGE 扫码页地址(默认 m.jiaoyubao.cn/manage/admincli/wxlogin.html)
|
|
11
|
+
* JYB_WX_POLL 取码端点(默认 https://apidata-admin.jiaoyubao.cn/mcp/wx/poll)
|
|
12
|
+
* JYB_WX_TIMEOUT 等待扫码的秒数(默认 180)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
const { exec } = require("child_process");
|
|
17
|
+
const http = require("./http");
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PAGE = "https://m.jiaoyubao.cn/manage/admincli/wxlogin.html";
|
|
20
|
+
const DEFAULT_POLL = "https://apidata-admin.jiaoyubao.cn/mcp/wx/poll";
|
|
21
|
+
|
|
22
|
+
function pageBase(opts) {
|
|
23
|
+
var o = opts || {};
|
|
24
|
+
return String(o.page || process.env.JYB_WX_PAGE || DEFAULT_PAGE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pollBase(opts) {
|
|
28
|
+
var o = opts || {};
|
|
29
|
+
return String(o.poll || process.env.JYB_WX_POLL || DEFAULT_POLL);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function newState() {
|
|
33
|
+
return crypto.randomBytes(16).toString("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pageUrl(state, opts) {
|
|
37
|
+
var base = pageBase(opts);
|
|
38
|
+
var sep = base.indexOf("?") >= 0 ? "&" : "?";
|
|
39
|
+
return base + sep + "state=" + encodeURIComponent(state);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function openTarget(target) {
|
|
43
|
+
return new Promise(function (resolve) {
|
|
44
|
+
var cmd;
|
|
45
|
+
if (process.platform === "win32") {
|
|
46
|
+
cmd = 'start "" "' + String(target).replace(/"/g, "") + '"';
|
|
47
|
+
} else if (process.platform === "darwin") {
|
|
48
|
+
cmd = 'open "' + String(target).replace(/"/g, "") + '"';
|
|
49
|
+
} else {
|
|
50
|
+
cmd = 'xdg-open "' + String(target).replace(/"/g, "") + '"';
|
|
51
|
+
}
|
|
52
|
+
exec(cmd, { timeout: 8000, windowsHide: true }, function (e) { resolve(!e); });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pollOnce(state, opts) {
|
|
57
|
+
var o = opts || {};
|
|
58
|
+
var url = pollBase(o) + (pollBase(o).indexOf("?") >= 0 ? "&" : "?") + "state=" + encodeURIComponent(state);
|
|
59
|
+
var ctrl = typeof AbortController === "function" ? new AbortController() : null;
|
|
60
|
+
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, Number(o.requestTimeout || 4000)) : null;
|
|
61
|
+
try {
|
|
62
|
+
var res = await fetch(url, {
|
|
63
|
+
method: "GET",
|
|
64
|
+
headers: { "User-Agent": "jyb-workbuddy-order/wx-poll", "Accept": "application/json" },
|
|
65
|
+
signal: ctrl ? ctrl.signal : undefined
|
|
66
|
+
});
|
|
67
|
+
var text = await res.text();
|
|
68
|
+
var data = http.parseJson(text);
|
|
69
|
+
if (!data) { return { ok: false, pending: true, error: "取码接口返回异常" }; }
|
|
70
|
+
return data;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, pending: true, error: (e && e.name === "AbortError") ? "请求超时" : (e && e.message) };
|
|
73
|
+
} finally {
|
|
74
|
+
if (timer) { clearTimeout(timer); }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* 轮询直到拿到 code 或超时;onWait 用于每轮刷新提示(可选) */
|
|
79
|
+
async function waitForCode(state, opts) {
|
|
80
|
+
var o = opts || {};
|
|
81
|
+
var timeoutMs = Number(o.timeoutMs || (Number(process.env.JYB_WX_TIMEOUT || 180) * 1000));
|
|
82
|
+
var intervalMs = Number(o.intervalMs || 2000);
|
|
83
|
+
var deadline = Date.now() + timeoutMs;
|
|
84
|
+
var lastErr = "";
|
|
85
|
+
var round = 0;
|
|
86
|
+
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
var r = await pollOnce(state, o);
|
|
89
|
+
round++;
|
|
90
|
+
if (r && r.ok && r.code) { return { code: String(r.code), at: r.at || Date.now() }; }
|
|
91
|
+
if (r && r.error) {
|
|
92
|
+
if (r.error !== lastErr) { lastErr = r.error; if (o.onError) { o.onError(r.error); } }
|
|
93
|
+
}
|
|
94
|
+
if (o.onWait && round % 5 === 0) {
|
|
95
|
+
var left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
|
|
96
|
+
o.onWait(left);
|
|
97
|
+
}
|
|
98
|
+
await new Promise(function (res) { setTimeout(res, intervalMs); });
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
DEFAULT_PAGE: DEFAULT_PAGE,
|
|
105
|
+
DEFAULT_POLL: DEFAULT_POLL,
|
|
106
|
+
newState: newState,
|
|
107
|
+
pageUrl: pageUrl,
|
|
108
|
+
pageBase: pageBase,
|
|
109
|
+
pollBase: pollBase,
|
|
110
|
+
pollOnce: pollOnce,
|
|
111
|
+
waitForCode: waitForCode,
|
|
112
|
+
openTarget: openTarget
|
|
113
|
+
};
|
package/lib/wxlogin.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* 微信网页授权登录(101000,checkway=20)
|
|
3
|
+
*
|
|
4
|
+
* 与帐号密码登录的区别:
|
|
5
|
+
* 1) 只传 wxwebcode(微信网页授权回调带回的 code),后台自己换 openid,本地不接触 secret
|
|
6
|
+
* 2) code 一次性:后台返回「绑定多帐号」时已消费掉,二次选择必须复用后台 session cookie
|
|
7
|
+
* 3) 多帐号返回:{"result":"10","description":"绑定多帐号!","data":[{usercode,username}]}
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* wx.start({ code }) → 直接登录,或返回 { needSelect:true, candidates }
|
|
11
|
+
* wx.select({ usercode }) → 从上一步的会话里选出帐号完成登录
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const auth = require("./auth");
|
|
17
|
+
const http = require("./http");
|
|
18
|
+
|
|
19
|
+
const SESSION_TTL = 10 * 60 * 1000; // 会话缓存 10 分钟
|
|
20
|
+
const USER_AGENT = "jyb-workbuddy-order/1.1";
|
|
21
|
+
|
|
22
|
+
function sessionFile() {
|
|
23
|
+
return path.join(auth.cacheDir(), "wx-login-session.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clearSession() {
|
|
27
|
+
try { fs.unlinkSync(sessionFile()); } catch (e) { /*ignore*/ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadSession() {
|
|
31
|
+
var s = auth.readJson(sessionFile(), null);
|
|
32
|
+
if (!s || !s.at) { return null; }
|
|
33
|
+
if (Date.now() - Number(s.at) > SESSION_TTL) { clearSession(); return null; }
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveSession(obj) {
|
|
38
|
+
auth.writeJson(sessionFile(), Object.assign({ at: Date.now() }, obj));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* 只保留 name=value,丢掉 Path/HttpOnly 等属性 */
|
|
42
|
+
function cookiePairs(list) {
|
|
43
|
+
var out = [];
|
|
44
|
+
(list || []).forEach(function (line) {
|
|
45
|
+
var first = String(line || "").split(";")[0].trim();
|
|
46
|
+
if (first && first.indexOf("=") > 0) { out.push(first); }
|
|
47
|
+
});
|
|
48
|
+
return out.join("; ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readSetCookie(res) {
|
|
52
|
+
if (typeof res.headers.getSetCookie === "function") {
|
|
53
|
+
return res.headers.getSetCookie();
|
|
54
|
+
}
|
|
55
|
+
var raw = res.headers.get("set-cookie");
|
|
56
|
+
if (!raw) { return []; }
|
|
57
|
+
return String(raw).split(/,(?=[^;]+?=)/);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* 直接调 101000(不用 http.callAction,因为要拿 Set-Cookie) */
|
|
61
|
+
async function call101000(params, cookie) {
|
|
62
|
+
var host = http.apiHost();
|
|
63
|
+
var key = http.buildKey(params);
|
|
64
|
+
var url = host + "/?action=101000&key=" + encodeURIComponent(key) + "&r=" + Date.now();
|
|
65
|
+
var headers = {
|
|
66
|
+
"User-Agent": USER_AGENT,
|
|
67
|
+
"Accept": "application/json, text/plain, */*"
|
|
68
|
+
};
|
|
69
|
+
if (cookie) { headers.Cookie = cookie; }
|
|
70
|
+
|
|
71
|
+
var ctrl = typeof AbortController === "function" ? new AbortController() : null;
|
|
72
|
+
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, Number(process.env.JYB_TIMEOUT || 15000)) : null;
|
|
73
|
+
var res, text;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(url, { method: "GET", headers: headers, signal: ctrl ? ctrl.signal : undefined });
|
|
76
|
+
text = await res.text();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (e && e.name === "AbortError") {
|
|
79
|
+
throw new http.ApiError("NETWORK", "登录接口超时,请检查网络或代理");
|
|
80
|
+
}
|
|
81
|
+
throw new http.ApiError("NETWORK", "登录接口请求失败:" + (e && e.message ? e.message : String(e)));
|
|
82
|
+
} finally {
|
|
83
|
+
if (timer) { clearTimeout(timer); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var data = http.parseJson(text);
|
|
87
|
+
if (!data) {
|
|
88
|
+
throw new http.ApiError("SERVER", "登录接口返回格式异常:" + String(text).slice(0, 160));
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
data: data,
|
|
92
|
+
result: String(data.result === undefined ? "" : data.result),
|
|
93
|
+
description: String(data.description || ""),
|
|
94
|
+
cookie: cookiePairs(readSetCookie(res)) || cookie || "",
|
|
95
|
+
setCookie: cookiePairs(readSetCookie(res)) || ""
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function errorFor(r) {
|
|
100
|
+
var map = {
|
|
101
|
+
"-31": "微信授权码无效或已过期(code 只能用一次、有效期约 5 分钟,请重新扫码)",
|
|
102
|
+
"-32": "该微信未绑定任何后台帐号,请先在后台绑定",
|
|
103
|
+
"-33": "微信授权校验失败,请重新扫码",
|
|
104
|
+
"-1": "该帐号无权登录后台",
|
|
105
|
+
"-2": "该帐号已被禁用",
|
|
106
|
+
"-3": "该帐号未开通后台权限"
|
|
107
|
+
};
|
|
108
|
+
return new http.ApiError("AUTH", map[r.result] || (r.description || ("登录失败 result=" + r.result)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function candidatesOf(data) {
|
|
112
|
+
var list = Array.isArray(data && data.data) ? data.data : [];
|
|
113
|
+
return list.map(function (it) {
|
|
114
|
+
return {
|
|
115
|
+
usercode: String(it.usercode || ""),
|
|
116
|
+
username: String(it.username || it.cname || it.usercode || "")
|
|
117
|
+
};
|
|
118
|
+
}).filter(function (it) { return it.usercode; });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildRec(p, raw) {
|
|
122
|
+
var now = Date.now();
|
|
123
|
+
return {
|
|
124
|
+
usercode: p.usercode,
|
|
125
|
+
cname: raw && raw.cname ? String(raw.cname) : "",
|
|
126
|
+
ip: p.ip,
|
|
127
|
+
baseUrl: http.apiHost(),
|
|
128
|
+
expiresMinutes: p.expires,
|
|
129
|
+
passport: String(raw.passport || ""),
|
|
130
|
+
loginAt: now,
|
|
131
|
+
passportExpireAt: now + p.expires * 60 * 1000,
|
|
132
|
+
authType: "wx"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function persist(rec, options) {
|
|
137
|
+
var o = options || {};
|
|
138
|
+
if (o.store === "user") { auth.writeJson(auth.userFile(rec.usercode), rec); }
|
|
139
|
+
else if (o.save !== false) { auth.saveConfig(rec); }
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* 第一步:用 code 登录;多帐号时返回候选 */
|
|
144
|
+
async function start(options) {
|
|
145
|
+
var o = options || {};
|
|
146
|
+
var code = String(o.code || "").trim();
|
|
147
|
+
if (!code) {
|
|
148
|
+
throw new http.ApiError("PARAM", "缺少微信授权 code(--wx-code)");
|
|
149
|
+
}
|
|
150
|
+
/* ip 不能传空串:101000 会把它当非法参数直接返回「参数错误」 */
|
|
151
|
+
var ip = String(o.ip || "").trim() || auth.resolveIp();
|
|
152
|
+
var expires = Number(o.expires) || Number(process.env.JYB_EXPIRES) || auth.DEFAULT_EXPIRES;
|
|
153
|
+
|
|
154
|
+
var r = await call101000({
|
|
155
|
+
checkway: "20",
|
|
156
|
+
wxwebcode: code,
|
|
157
|
+
usertype: "0",
|
|
158
|
+
source: "0",
|
|
159
|
+
ip: ip,
|
|
160
|
+
useragent: USER_AGENT,
|
|
161
|
+
expires: String(expires)
|
|
162
|
+
}, o.cookie || "");
|
|
163
|
+
|
|
164
|
+
if (r.result === "10") {
|
|
165
|
+
var list = candidatesOf(r.data);
|
|
166
|
+
if (!list.length) {
|
|
167
|
+
throw new http.ApiError("AUTH", "微信绑定了多个帐号,但接口未返回候选列表");
|
|
168
|
+
}
|
|
169
|
+
saveSession({
|
|
170
|
+
code: code,
|
|
171
|
+
cookie: r.cookie,
|
|
172
|
+
ip: ip,
|
|
173
|
+
expires: expires,
|
|
174
|
+
candidates: list
|
|
175
|
+
});
|
|
176
|
+
return { needSelect: true, candidates: list, description: r.description };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (r.result !== "1") { throw errorFor(r); }
|
|
180
|
+
var raw = r.data || {};
|
|
181
|
+
if (!raw.passport) {
|
|
182
|
+
throw new http.ApiError("AUTH", "登录接口未返回 passport,请确认该微信绑定的是教育宝后台用户(usertype=0)");
|
|
183
|
+
}
|
|
184
|
+
clearSession();
|
|
185
|
+
var rec = buildRec({ usercode: String(raw.usercode || o.usercode || ""), ip: ip, expires: expires }, raw);
|
|
186
|
+
return { needSelect: false, rec: persist(rec, o) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* 第二步:从候选里选一个帐号完成登录 */
|
|
190
|
+
async function select(options) {
|
|
191
|
+
var o = options || {};
|
|
192
|
+
var sel = String(o.usercode || "").trim();
|
|
193
|
+
if (!sel) { throw new http.ApiError("PARAM", "缺少 --select(要登录的 usercode 或候选序号)"); }
|
|
194
|
+
|
|
195
|
+
var s = loadSession();
|
|
196
|
+
if (!s) {
|
|
197
|
+
throw new http.ApiError("AUTH", "选择状态已失效(超过 10 分钟或已登录),请重新扫码获取 code");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
var chosen = null;
|
|
201
|
+
if (/^\d+$/.test(sel)) {
|
|
202
|
+
var idx = Number(sel) - 1;
|
|
203
|
+
chosen = s.candidates[idx] || null;
|
|
204
|
+
} else {
|
|
205
|
+
chosen = s.candidates.filter(function (c) {
|
|
206
|
+
return c.usercode.toLowerCase() === sel.toLowerCase();
|
|
207
|
+
})[0] || null;
|
|
208
|
+
}
|
|
209
|
+
if (!chosen) {
|
|
210
|
+
throw new http.ApiError("PARAM", "候选里没有这个帐号:" + sel
|
|
211
|
+
+ "(可选:" + s.candidates.map(function (c) { return c.usercode; }).join(" / ") + ")");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
var base = {
|
|
215
|
+
usercode: chosen.usercode,
|
|
216
|
+
checkway: "20",
|
|
217
|
+
usertype: "0",
|
|
218
|
+
source: "0",
|
|
219
|
+
ip: s.ip || auth.resolveIp(),
|
|
220
|
+
useragent: USER_AGENT,
|
|
221
|
+
expires: String(s.expires)
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/* 实测(2026-11-07,admin 帐号 / 6 个候选):后台靠「session cookie + usercode + 原 code」
|
|
225
|
+
* 识别微信身份。把已验证的组合排第一位,后两种作为兼容兜底。 */
|
|
226
|
+
var attempts = [
|
|
227
|
+
{ name: "session + usercode + 原code", params: Object.assign({ wxwebcode: s.code }, base), cookie: s.cookie },
|
|
228
|
+
{ name: "session + usercode", params: base, cookie: s.cookie },
|
|
229
|
+
{ name: "usercode 直连(无 session)", params: base, cookie: "" }
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
var tried = [];
|
|
233
|
+
for (var i = 0; i < attempts.length; i++) {
|
|
234
|
+
var a = attempts[i];
|
|
235
|
+
var r;
|
|
236
|
+
try {
|
|
237
|
+
r = await call101000(a.params, a.cookie);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
tried.push(a.name + " → 网络错误");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (r.result === "1" && r.data && r.data.passport) {
|
|
243
|
+
clearSession();
|
|
244
|
+
var rec = buildRec({ usercode: chosen.usercode, ip: s.ip || auth.resolveIp(), expires: s.expires }, r.data);
|
|
245
|
+
rec.cname = rec.cname || chosen.username;
|
|
246
|
+
return { rec: persist(rec, o), via: a.name, tried: tried };
|
|
247
|
+
}
|
|
248
|
+
tried.push(a.name + " → result=" + r.result + "(" + (r.description || "") + ")");
|
|
249
|
+
/* 若后台明确说 code 无效/需要重新授权,后面的尝试大概率同样无效,但继续跑完便于排错 */
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
throw new http.ApiError("AUTH", "已选定帐号 " + chosen.usercode + ",但登录未成功。尝试记录:\n "
|
|
253
|
+
+ tried.join("\n ")
|
|
254
|
+
+ "\n可能原因:后台二次提交需要 openid 或其它参数,请把这份记录发给管理员。");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* 打印候选列表(CLI/MCP 共用) */
|
|
258
|
+
function candidatesText(candidates) {
|
|
259
|
+
var lines = ["该微信绑定了 " + candidates.length + " 个后台帐号,请选择要登录的一个:"];
|
|
260
|
+
candidates.forEach(function (c, i) {
|
|
261
|
+
lines.push(" " + (i + 1) + ") " + c.usercode + " " + c.username);
|
|
262
|
+
});
|
|
263
|
+
lines.push("");
|
|
264
|
+
lines.push("再执行:jyb-order login --select=<序号或usercode>");
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
start: start,
|
|
270
|
+
select: select,
|
|
271
|
+
clearSession: clearSession,
|
|
272
|
+
loadSession: loadSession,
|
|
273
|
+
candidatesText: candidatesText
|
|
274
|
+
};
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -154,7 +154,101 @@ function runHttp(options) {
|
|
|
154
154
|
debug: debug
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
/* ---------- 微信扫码登录:网页 → 服务端 → CLI 的一次性票据通道 ----------
|
|
158
|
+
* 页面(默认 m.jiaoyubao.cn)扫码拿到 code 后 POST /mcp/wx/handoff;
|
|
159
|
+
* 本地 CLI 用同一个 state GET /mcp/wx/poll 取走 code 并在本地完成登录。
|
|
160
|
+
* 票据只存内存:一次性、5 分钟过期、先写先得、上限 2000 条。
|
|
161
|
+
*/
|
|
162
|
+
var wxTickets = new Map();
|
|
163
|
+
var WX_TTL = 5 * 60 * 1000;
|
|
164
|
+
var WX_MAX = 2000;
|
|
165
|
+
var WX_STATE_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
166
|
+
var WX_ORIGIN = process.env.JYB_WX_ORIGIN || "https://m.jiaoyubao.cn";
|
|
167
|
+
|
|
168
|
+
function wxPrune() {
|
|
169
|
+
var now = Date.now();
|
|
170
|
+
wxTickets.forEach(function (v, k) {
|
|
171
|
+
if (now - v.at > WX_TTL) { wxTickets.delete(k); }
|
|
172
|
+
});
|
|
173
|
+
if (wxTickets.size > WX_MAX) {
|
|
174
|
+
var keys = Array.from(wxTickets.keys()).slice(0, wxTickets.size - WX_MAX);
|
|
175
|
+
keys.forEach(function (k) { wxTickets.delete(k); });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function wxReply(res, status, obj) {
|
|
180
|
+
var headers = {
|
|
181
|
+
"Access-Control-Allow-Origin": WX_ORIGIN,
|
|
182
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
183
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
184
|
+
"Cache-Control": "no-store"
|
|
185
|
+
};
|
|
186
|
+
if (status === 204) { res.writeHead(204, headers); res.end(); return; }
|
|
187
|
+
var body = JSON.stringify(obj);
|
|
188
|
+
headers["Content-Type"] = "application/json; charset=utf-8";
|
|
189
|
+
headers["Content-Length"] = Buffer.byteLength(body);
|
|
190
|
+
res.writeHead(status, headers);
|
|
191
|
+
res.end(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function wxReadBody(req, limit, cb) {
|
|
195
|
+
var chunks = [], size = 0, done = false;
|
|
196
|
+
req.on("data", function (c) {
|
|
197
|
+
if (done) { return; }
|
|
198
|
+
size += c.length;
|
|
199
|
+
if (size > limit) { done = true; cb(new Error("body too large")); req.destroy(); return; }
|
|
200
|
+
chunks.push(c);
|
|
201
|
+
});
|
|
202
|
+
req.on("end", function () { if (!done) { done = true; cb(null, Buffer.concat(chunks).toString("utf8")); } });
|
|
203
|
+
req.on("error", function (e) { if (!done) { done = true; cb(e); } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* 返回 true 表示本函数已处理掉该请求,不要再交给 MCP */
|
|
207
|
+
function handleWxRoute(req, res) {
|
|
208
|
+
var raw = String(req.url || "");
|
|
209
|
+
var qm = raw.indexOf("?");
|
|
210
|
+
var pathname = qm >= 0 ? raw.slice(0, qm) : raw;
|
|
211
|
+
var isHandoff = pathname === basePath + "/wx/handoff" || pathname === basePath + "/wx/handoff/";
|
|
212
|
+
var isPoll = pathname === basePath + "/wx/poll" || pathname === basePath + "/wx/poll/";
|
|
213
|
+
if (!isHandoff && !isPoll) { return false; }
|
|
214
|
+
|
|
215
|
+
if (req.method === "OPTIONS") { wxReply(res, 204); return true; }
|
|
216
|
+
|
|
217
|
+
if (isPoll) {
|
|
218
|
+
if (req.method !== "GET") { wxReply(res, 405, { ok: false, error: "请用 GET" }); return true; }
|
|
219
|
+
var q = new URLSearchParams(qm >= 0 ? raw.slice(qm + 1) : "");
|
|
220
|
+
var st = String(q.get("state") || "").trim();
|
|
221
|
+
if (!WX_STATE_RE.test(st)) { wxReply(res, 400, { ok: false, error: "state 无效" }); return true; }
|
|
222
|
+
wxPrune();
|
|
223
|
+
var t = wxTickets.get(st);
|
|
224
|
+
if (!t) { wxReply(res, 200, { ok: false, pending: true }); return true; }
|
|
225
|
+
wxTickets.delete(st);
|
|
226
|
+
if (debug) { logLine("wx poll 取走 state=" + st.slice(0, 8) + "…"); }
|
|
227
|
+
wxReply(res, 200, { ok: true, code: t.code, at: t.at });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (req.method !== "POST") { wxReply(res, 405, { ok: false, error: "请用 POST" }); return true; }
|
|
232
|
+
wxReadBody(req, 4096, function (err, text) {
|
|
233
|
+
if (err) { wxReply(res, 413, { ok: false, error: "请求体过大" }); return; }
|
|
234
|
+
var body = null;
|
|
235
|
+
try { body = JSON.parse(text); } catch (e) { body = null; }
|
|
236
|
+
if (!body || typeof body !== "object") { wxReply(res, 400, { ok: false, error: "请求体不是合法 JSON" }); return; }
|
|
237
|
+
var state = String(body.state || "").trim();
|
|
238
|
+
var code = String(body.code || "").trim();
|
|
239
|
+
if (!WX_STATE_RE.test(state)) { wxReply(res, 400, { ok: false, error: "state 无效" }); return; }
|
|
240
|
+
if (!code || code.length > 512) { wxReply(res, 400, { ok: false, error: "code 无效" }); return; }
|
|
241
|
+
wxPrune();
|
|
242
|
+
if (wxTickets.has(state)) { wxReply(res, 409, { ok: false, error: "该 state 已收到过授权码" }); return; }
|
|
243
|
+
wxTickets.set(state, { code: code, at: Date.now() });
|
|
244
|
+
if (debug) { logLine("wx handoff 收取 state=" + state.slice(0, 8) + "… code_len=" + code.length); }
|
|
245
|
+
wxReply(res, 200, { ok: true });
|
|
246
|
+
});
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
157
250
|
var httpServer = http.createServer(function (req, res) {
|
|
251
|
+
if (handleWxRoute(req, res)) { return; }
|
|
158
252
|
try { server.handleRequest(req, res); } catch (e) {
|
|
159
253
|
if (!res.headersSent) {
|
|
160
254
|
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
|