docs/examples: 扫码登录改为官方 POST+local_token_list+iLink-App-Id/ClientVersion 头 + 完整状态机 + 换码竞态警告

This commit is contained in:
AskaEth committed 2026-09-08 02:46:07 +08:00
1 parent 38b776705d
commit 8a36915377
6 files changed
+566 -96

No files matched your search

+83 -13
View File
@@ -5,6 +5,7 @@
// 注意: 为保持零依赖,JSON 解析用最朴素的正则提取,仅适合演示。
// 正式项目请换 Jackson / Gson,并按 docs/04 的模块划分组织代码。
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -17,6 +18,8 @@ import java.util.regex.*;
public class IlinkBridge {
static final String BASE_URL = "https://ilinkai.weixin.qq.com";
// 扫码轮询的 base;收到 scaned_but_redirect 时切换,其余 API 仍走固定 BASE_URL。
static String apiBaseUrl = BASE_URL;
static final Path STATE_FILE = Path.of("bridge-state.json");
// ---------- 状态: bot_token / 游标 / context_token(极简 key=value 存储) ----------
@@ -33,14 +36,19 @@ public class IlinkBridge {
STATE_FILE.toFile().setReadable(true, true); // 近似 600
}
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ----------
// 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token 时不带 Authorization,
// 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。
static HttpRequest.Builder req(String url) {
var b = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(50))
.header("Content-Type", "application/json")
.header("AuthorizationType", "ilink_bot_token")
.header("X-WECHAT-UIN", Base64.getEncoder().encodeToString(
Integer.toString(new SecureRandom().nextInt() >>> 1).getBytes()));
Integer.toString(new SecureRandom().nextInt() >>> 1).getBytes()))
.header("iLink-App-Id", "bot")
// 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1")
.header("iLink-App-ClientVersion", "132104");
String token = state.getProperty("bot_token");
if (token != null) b.header("Authorization", "Bearer " + token);
return b;
@@ -68,23 +76,85 @@ public class IlinkBridge {
return out;
}
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
// ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ----------
static String fetchQrcode() throws Exception {
// 官方为 POST + body {"local_token_list":[...]},非裸 GET。首次绑定传空数组;
// 若已持久化其它 bot_token 可收集传入(最多 10 个),官方据此识别触发 binded_redirect。
return post("/ilink/bot/get_bot_qrcode?bot_type=3", "{\"local_token_list\":[]}");
}
// ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ----------
static void login() throws Exception {
var qr = client.send(req(BASE_URL + "/ilink/bot/get_bot_qrcode?bot_type=3")
.GET().build(), HttpResponse.BodyHandlers.ofString()).body();
// 状态机: wait / scaned / scaned_but_redirect / need_verifycode /
// verify_code_blocked / binded_redirect / confirmed / expired
// 两大坑(务必读 docs/02 §4.5):
// ① 码服务端 TTL≈60s,生成后须在窗口内扫(超窗服务端自返回 expired,此处换新码)。
// ② 「换码竞态」:一旦收到 scaned(或 scaned_but_redirect / need_verifycode),
// 绝不能再换码——换码会砍掉"扫码→确认"会话,微信端显示成功但后端永远收不到 confirmed。
var qr = fetchQrcode();
String qrcode = pick(qr, "qrcode");
if (qrcode == null) throw new RuntimeException("获取二维码失败");
System.out.println("请用微信扫码: " + pick(qr, "qrcode_img_content"));
System.out.println("(二维码内容即上面的 liteapp URL,请用二维码库编码展示;勿把 qrcode 字段当二维码)");
long deadline = System.currentTimeMillis() + 8 * 60_000;
String verifyCode = null;
var sc = new Scanner(System.in);
while (System.currentTimeMillis() < deadline) {
var s = client.send(req(BASE_URL + "/ilink/bot/get_qrcode_status?qrcode=" + qrcode)
.header("iLink-App-ClientVersion", "1").GET().build(),
String path = "/ilink/bot/get_qrcode_status?qrcode="
+ URLEncoder.encode(qrcode, StandardCharsets.UTF_8);
if (verifyCode != null)
path += "&verify_code=" + URLEncoder.encode(verifyCode, StandardCharsets.UTF_8);
String s = client.send(req(apiBaseUrl + path).GET().build(),
HttpResponse.BodyHandlers.ofString()).body();
if ("confirmed".equals(pick(s, "status"))) { // wait → scaned → confirmed
state.setProperty("bot_token", pick(s, "bot_token"));
state.setProperty("bot_id", pick(s, "ilink_bot_id"));
saveState();
System.out.println("登录成功, bot: " + state.getProperty("bot_id"));
return;
String status = pick(s, "status");
switch (status == null ? "" : status) {
case "wait":
System.out.println("等待扫码...");
break;
case "scaned":
verifyCode = null;
System.out.println("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)");
break;
case "scaned_but_redirect":
verifyCode = null;
String host = pick(s, "redirect_host");
if (host != null && !host.isEmpty()) {
apiBaseUrl = "https://" + host;
System.out.println("IDC 重定向,切换轮询至 " + apiBaseUrl);
}
break;
case "need_verifycode":
System.out.print("请在手机微信上输入显示的数字后回车: ");
System.out.flush();
verifyCode = sc.nextLine().trim();
continue;
case "verify_code_blocked":
verifyCode = null;
System.out.println("配对码多次输入错误,重新获取二维码");
qr = fetchQrcode();
qrcode = pick(qr, "qrcode");
System.out.println("新二维码: " + pick(qr, "qrcode_img_content"));
continue;
case "binded_redirect":
System.out.println("该 bot 已绑定过本实例,视为连接成功");
return;
case "confirmed":
state.setProperty("bot_token", pick(s, "bot_token"));
state.setProperty("bot_id", pick(s, "ilink_bot_id"));
if (pick(s, "baseurl") != null) state.setProperty("baseurl", pick(s, "baseurl"));
saveState();
System.out.println("登录成功, bot: " + state.getProperty("bot_id"));
return;
case "expired":
// 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换
verifyCode = null;
System.out.println("二维码已过期,重新获取");
qr = fetchQrcode();
qrcode = pick(qr, "qrcode");
System.out.println("新二维码: " + pick(qr, "qrcode_img_content"));
continue;
default:
System.out.println("未知状态: " + status);
}
Thread.sleep(1000);
}