From 0da14e36693557b7c0b63c03156b508c0e67a8ab Mon Sep 17 00:00:00 2001 From: Lzj37412 Date: Mon, 9 Nov 2026 23:26:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(minimax):=20=E5=9B=BD=E9=99=85=E7=89=88?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E7=AB=AF=E7=82=B9=E8=B5=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E7=AD=BE=E5=90=8D=20+=20307=20=E5=9C=B0=E5=8C=BA?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=EF=BC=8C=E4=BF=AE=20CN=20=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E8=A2=AB=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:MiniMax 国际版(minimax-global)在 CN 网络下直连 agent.minimax.io, 账户类端点(签到 / 额度 / 资料)被 307 重定向到 agent.minimaxi.com(CN 域名 家族),签到与额度全部失败,且报错看起来像凭据失效,误导用户重新登录。 实测证据(2026-11-09,CN 网络,绕过代理直连): curl --noproxy '*' -sI https://agent.minimax.io/matrix/api/v1/commerce/get_membership_info → HTTP/1.1 307 Temporary Redirect → Location: https://agent.minimaxi.com/matrix/api/v1/commerce/get_membership_info 同一主机配置代理后 200 正常。 方案(移植自 10Router open-sse 的同款实现): 1. signedMiniMaxRequest():账户动作类端点统一走服务端签名——查询参数 (device_platform/biz_id/app_id/version_code/unix/user_id 等)+ 请求头 双签名(yy = md5(path+search _ {body} md5(unix) ooui), x-signature = md5(ts + SECRET [+ body])),签名值绑定 URL+时间戳+body 2. signedCall():国际版(flavor=global)走 preferProxy 代理优先,CN 版保持 直连不变 3. geoNote():307 跨域名家族时报明确错误「上游被重定向(地区限制)——请为 国际版配置代理后重试」,不再伪装成凭据失效 4. 403 与 401 同样先查 geoNote——403 在国际版场景下同样可能是地区限制 面板(panel.html):openAdd 的浏览器登录下拉过滤从 o.value.split('-')[0] 改为 provOf(o.value).prod——minimax / minimax-global 的 prod 同为 minimax, 按字符串前缀切会把 minimax-global 漏掉;不传 product 时保持上次选择, 避免回落到第一个 qoder 造成加错池。 验证:minimax 专项 28/28 通过;本机 minimax-global 账号实测 quota 正常返回。 --- src/minimaxClient.js | 114 ++++++++++++++++++++++++++++++++----------- src/panel.html | 12 +++-- 2 files changed, 95 insertions(+), 31 deletions(-) diff --git a/src/minimaxClient.js b/src/minimaxClient.js index e3eef4c..33d8b16 100644 --- a/src/minimaxClient.js +++ b/src/minimaxClient.js @@ -18,6 +18,7 @@ * - Anthropic 兼容端点:POST {agent}/mavis/api/v1/llm/v1/messages */ +import { createHash } from 'node:crypto'; import { fetchJsonRace } from './zcodeClient.js'; import { withRefreshDedupe, refreshFpDead } from './refreshGuard.js'; import { FETCH_TIMEOUT_MS } from './constants.js'; @@ -64,6 +65,80 @@ export function minimaxEndpointsOf(account) { }; } +/** 账户动作类端点(签到 / 额度 / 资料)的服务端签名,取自 10Router open-sse 实现 */ +const SIGN_SECRET = 'I*7Cf%WZ#S&%1RlZJ&C2'; +const md5hex = (s) => createHash('md5').update(s).digest('hex'); + +/** + * 构造 signed account 请求:查询参数与请求头参与签名,签名值绑定 + * URL+时间戳(+body),因此不能与裸 fetch 的查询串混用。 + * @returns {{url: string, headers: object, body?: string}} + */ +export function signedMiniMaxRequest(flavor, path, { access, userID, body, now = Date.now() } = {}) { + const lang = flavor === 'global' ? 'en' : 'zh'; + const url = new URL(path, basesOf({ provider: flavor === 'global' ? 'minimax-global' : 'minimax' }).agent); + url.search = new URLSearchParams({ + device_platform: 'mcode', + biz_id: '3', + app_id: '3001', + version_code: '22201', + unix: String(now), + timezone_offset: String(new Date().getTimezoneOffset() * -60), + sys_language: lang, + lang, + device_id: '0', + os_name: process.platform, + browser_name: 'mcode', + user_id: String(userID ?? '').trim() || '0', + client: 'mcode', + }).toString(); + const at = url.pathname + url.search; + const ts = Math.floor(now / 1000); + const json = body === undefined ? undefined : JSON.stringify(body); + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MiniMaxCode', + Authorization: `Bearer ${String(access || '').trim()}`, + yy: json === undefined + ? md5hex(`${encodeURIComponent(at)}_{}${md5hex(String(now))}ooui`) + : md5hex(`${encodeURIComponent(at)}_${json}${md5hex(String(now))}ooui`), + 'x-timestamp': String(ts), + 'x-signature': json === undefined ? md5hex(`${ts}${SIGN_SECRET}`) : md5hex(`${ts}${SIGN_SECRET}${json}`), + }; + return { url: url.toString(), headers, body: json }; +} + +/** 账户端点统一入口:国际版走签名 + 代理优先(CN 网络直连 agent.minimax.io 会被 307 到 CN 域名家族) */ +function signedCall(account, path, { access, userID, body, timeoutMs = FETCH_TIMEOUT_MS } = {}) { + const flavor = minimaxFlavorOf(account); + const req = signedMiniMaxRequest(flavor, path, { access, userID, body }); + return fetchJsonRace(req.url, { + method: req.body === undefined ? 'GET' : 'POST', + headers: req.headers, + body: req.body ?? null, + timeoutMs, + preferProxy: flavor === 'global', + }); +} + +/** 307 到别的域名家族 = 地区限制,本机配置代理即可,token 本身没问题 */ +function geoNote(requestedUrl, res) { + try { + const from = new URL(requestedUrl).host; + const to = new URL(res.url || requestedUrl).host; + if (from === to) return null; + return `上游 ${from} 在当前网络被重定向到 ${to}(地区限制)——请为国际版配置代理后重试`; + } catch { + return null; + } +} + +/** 签名里的 user_id:账号已解析出 uid 时带上,未解析按 10Router 口径退化为 "0" */ +function uiOf(account) { + return account?.uid || account?.meta?.realUserID || undefined; +} + /** 时间戳宽容转换(口径同 traeClient.iso):≤1e11 视为秒,否则视为毫秒;0/无效 → null */ function isoMs(v) { const n = Number(v); @@ -244,17 +319,8 @@ export async function verifyMiniMaxAccount(account) { * 查询额度 (quota) */ export async function fetchMiniMaxQuota(account, ctx = {}) { - const { membershipUrl } = minimaxEndpointsOf(account); return withMiniMaxAuth(account, async (token) => { - const res = await fetchJsonRace(membershipUrl, { - method: 'POST', - headers: { - ...authHeaders(token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - timeoutMs: FETCH_TIMEOUT_MS, - }); + const res = await signedCall(account, '/matrix/api/v1/commerce/get_membership_info', { access: token, userID: uiOf(account), body: {} }); if (res.status === 401) { const err = new Error('MiniMax 凭据已失效 (401)'); err.auth = true; @@ -331,13 +397,9 @@ export function normalizeMiniMaxQuota(data) { * @returns {Promise<{checkedToday: boolean, streakDays: number, points: number, bonusPoints: number}|null>} */ export async function fetchMiniMaxCheckinStatus(account, ctx = {}) { - const { signinStatusUrl } = minimaxEndpointsOf(account); try { return await withMiniMaxAuth(account, async (token) => { - const statusRes = await fetchJsonRace(signinStatusUrl, { - headers: authHeaders(token), - timeoutMs: FETCH_TIMEOUT_MS, - }); + const statusRes = await signedCall(account, '/minimax-cloud/api/v1/signin/status', { access: token, userID: uiOf(account) }); if (!statusRes.ok) return null; const statusData = await statusRes.json().catch(() => null); if (!statusData || statusData.base_resp?.status_code !== 0) return null; @@ -359,14 +421,12 @@ export async function fetchMiniMaxCheckinStatus(account, ctx = {}) { * 每日自动签到 (checkin) */ export async function checkinMiniMax(account, ctx = {}) { - const { signinStatusUrl, signinClaimUrl } = minimaxEndpointsOf(account); return withMiniMaxAuth(account, async (token) => { // 1. 查询签到状态 - const statusRes = await fetchJsonRace(signinStatusUrl, { - headers: authHeaders(token), - timeoutMs: FETCH_TIMEOUT_MS, - }); - if (statusRes.status === 401) { + const statusRes = await signedCall(account, '/minimax-cloud/api/v1/signin/status', { access: token, userID: uiOf(account) }); + if (statusRes.status === 401 || statusRes.status === 403) { + const geo = geoNote(statusRes.url, statusRes); + if (geo) throw new Error(geo); const err = new Error('MiniMax 凭据已失效 (401)'); err.auth = true; throw err; @@ -390,13 +450,11 @@ export async function checkinMiniMax(account, ctx = {}) { }; } - // 2. 发起签到 claim - const claimRes = await fetchJsonRace(signinClaimUrl, { - method: 'POST', - headers: authHeaders(token), - timeoutMs: FETCH_TIMEOUT_MS, - }); - if (claimRes.status === 401) { + // 2. 发起签到 claim(status=GET,claim=POST;动词错了是裸 404,不是签名错) + const claimRes = await signedCall(account, '/minimax-cloud/api/v1/signin/claim', { access: token, userID: uiOf(account), body: {} }); + if (claimRes.status === 401 || claimRes.status === 403) { + const geo = geoNote(claimRes.url, claimRes); + if (geo) throw new Error(geo); const err = new Error('MiniMax 凭据已失效 (401)'); err.auth = true; throw err; diff --git a/src/panel.html b/src/panel.html index d457778..b9c27f1 100644 --- a/src/panel.html +++ b/src/panel.html @@ -4641,14 +4641,20 @@ if (ok && !first) first = o.value; } if (first) { $('f-provider').value = first; $('f-provider').dispatchEvent(new Event('change')); } - // 浏览器登录的选项值以产品名开头(qoder / workbuddy / zcode-…) + // 浏览器登录的选项值以产品名开头(qoder / workbuddy / zcode-…); + // 产品名带地区的(minimax / minimax-global)按 prod 过滤,否则 minimax 会漏掉 minimax-global let firstDev = null; for (const o of $('dev-provider').options) { - const ok = !product || o.value.split('-')[0] === product; + const ok = !product || provOf(o.value).prod === product; o.hidden = !ok; if (ok && !firstDev) firstDev = o.value; } - if (firstDev) $('dev-provider').value = firstDev; + // 不传产品(仪表盘「添加账号」)时保持上次选择,避免回落到第一个 qoder 造成加错池 + if (product) { + $('dev-provider').value = product === 'minimax' ? 'minimax' : (firstDev || ''); + } else if (!$('dev-provider').value || $('dev-provider').selectedOptions[0]?.hidden) { + $('dev-provider').value = firstDev || ''; + } $('dev-hint').textContent = (hasIncognito() ? T('在内置隐私窗口里登录(不带出系统浏览器已登录的账号),') : T('在浏览器里登录并授权,')) + T('完成后账号自动添加。') + (product === 'qoder' ? '' : T('WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。')); openModal('m-add');