// fmode-login-proxy v0.1.0 — 登录接口代理(绕开平台网关的多值 CORS) // // ## 为什么需要它 // // Android App 打包后 WebView 的 origin 是 `https://localhost`,**没有 nginx 同源代理** // (Web 端有 `/api-platform/**` 反代,反代会清空上游 ACAO 头,所以 Web 端从没暴露)。 // // 实测 2026-11-15:平台网关 `/api/apig/message`(登录发验证码)会**随机返回两条** // `Access-Control-Allow-Origin`: // // 第 1、2 次: access-control-allow-origin: * ← 正常 // 第 3 次: access-control-allow-origin: https://localhost ← 中间件 A 回显 // access-control-allow-origin: * ← 中间件 B 固定 // // 浏览器规定 ACAO 只能有一个值 → 直接判定非法 → 登录随机失败。 // 根因是**平台网关两层各加了一次头**,代码不在我们能改的地方。 // // ## 本函数怎么绕 // // 服务端之间 HTTP 调用**不经过浏览器 CORS 检查**。 // 浏览器 → /api/functions(实测稳定单值 ACAO)→ 本函数 → 服务端 fetch apig/message // App 侧只面对 `/api/functions` 这一个稳定接口。 // // ## 调用 // // POST /api/functions {id, params:{action:'sendCode', mobile}} // → {code:200} 发送成功 // → {code:<平台码>, msg} 平台业务错误(手机号格式等),原样透传 // // POST /api/functions {id, params:{action:'login', mobile, code}} // → {code:200, token, me:{...}} 登录成功 // → {code:<平台码>, msg} 失败原因 // // ## 安全约束 // // ① **不接受任意 URL**:只允许调这两个固定路径,不做开放代理 // ② **company 固定**:由服务端决定,不信任客户端传入 // ③ **不记录 token**:日志里只打 code,绝不打印 token / 验证码 // ④ **限流**:同手机号 60 秒内最多 1 次发码(防止被当成短信轰炸跳板) // ⑤ **超时**:10 秒,避免函数挂死 var API_ORIGIN = 'https://server.fmode.cn'; var SEND_CODE_PATH = '/api/apig/message'; var LOGIN_PATH = '/api/fmode/mobile'; /** * 🔴 `LOGIN_COMPANY` 必须是**真实 Company 的 objectId**,不是 appId。 * * 实测 2026-11-15: * company = 'ncloudmaster'(Parse appId) → 接口挂起 20 秒后超时(HTTP 000) * company = 'E4KpGvTEto'(飞码 Company) → 0.59s 返回 {"code":1,"data":{"msg":"发送成功"}} * * 「超时」而不是「报错」很容易被误判成网络问题/接口挂了 —— 实际是参数不对。 * 与前端 `parse-api.ts` 的 `LOGIN_COMPANY` 保持同值。 */ var LOGIN_COMPANY = 'E4KpGvTEto'; var FETCH_TIMEOUT_MS = 10000; /** 同手机号发码冷却(毫秒) */ var CODE_COOLDOWN_MS = 60000; function _ok(response, data) { var body = { code: 200 }; if (data) for (var k in data) body[k] = data[k]; response.status(200).json(body); } function _fail(response, code, msg) { response.status(200).json({ code: code, msg: msg }); } function _str(v, max) { if (v === undefined || v === null) return ''; var s = String(v).trim(); return max && s.length > max ? s.slice(0, max) : s; } /** 手机号:只接受 11 位数字(大陆),防止被拿来扫号 */ function _isMobile(s) { return /^1\d{10}$/.test(s); } /** 带超时的服务端 fetch(平台 Node 环境兼容 AbortController 不可用时退化) */ function _fetchWithTimeout(url, opts) { return new Promise(function (resolve, reject) { var done = false; var timer = setTimeout(function () { if (done) return; done = true; reject(new Error('timeout')); }, FETCH_TIMEOUT_MS); fetch(url, opts).then( function (res) { if (done) return; done = true; clearTimeout(timer); resolve(res); }, function (e) { if (done) return; done = true; clearTimeout(timer); reject(e); } ); }); } /** 读 JSON,失败时返回 null(平台可能回 HTML 错误页) */ function _readJson(res) { return res.text().then(function (t) { try { return JSON.parse(t); } catch (e) { return null; } }); } /** * 发码冷却 —— 用**进程内内存**实现(2026-11-15) * * 🔴 为什么不用 Parse 表记录: * 初版写 `Parse.Object('MetaloginCooldown').save({useMasterKey:true})`, * 实测报 `This user is not allowed to access non-existent class: MetaloginCooldown` * —— 云函数沙箱**不允许自动建类**,必须先由平台建好表才行。 * 为一个 60 秒的冷却去申请建表不值得,改用内存。 * * 局限(如实说明):云函数多实例时各自记各自的,冷却不完全生效; * 实例重启即清空。**真正的防刷在短信服务商侧**,这里只挡「用户手滑连点」。 */ var _codeStamps = {}; function _checkCooldown(mobile) { var at = _codeStamps[mobile]; if (!at) return false; if (Date.now() - at < CODE_COOLDOWN_MS) return true; delete _codeStamps[mobile]; return false; } function _stampCooldown(mobile) { _codeStamps[mobile] = Date.now(); /* 顺手清理过期条目,避免长期运行把内存撑大 */ var now = Date.now(); for (var k in _codeStamps) { if (now - _codeStamps[k] > CODE_COOLDOWN_MS * 10) delete _codeStamps[k]; } } // ---------- action: 发验证码 ---------- async function _sendCode(response, mobile) { if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号'); try { var cooling = _checkCooldown(mobile); if (cooling) return _fail(response, 429, '发送太频繁,请 1 分钟后再试'); _stampCooldown(mobile); var res = await _fetchWithTimeout(API_ORIGIN + SEND_CODE_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mobile: mobile, company: LOGIN_COMPANY }), }); var d = await _readJson(res); if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试'); // 平台约定:code === 1 才算发送成功 if (d.code !== 1) { return _fail(response, Number(d.code) || 500, d.msg || d.mess || '发送失败'); } return _ok(response); } catch (e) { return _fail( response, 502, e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试' ); } } // ---------- action: 登录兑换 ---------- async function _login(response, mobile, code) { if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号'); if (!code) return _fail(response, 400, '请输入验证码'); var url = API_ORIGIN + LOGIN_PATH + '?mobile=' + encodeURIComponent(mobile) + '&code=' + encodeURIComponent(code) + '&company=' + encodeURIComponent(LOGIN_COMPANY); try { var res = await _fetchWithTimeout(url, { method: 'GET' }); var d = await _readJson(res); if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试'); var token = d.data && d.data.token; // 平台约定:code === 200 && data.token if (d.code !== 200 || !token) { return _fail(response, Number(d.code) || 401, d.msg || d.mess || '登录失败'); } // 🔴 不打印 token,只回传给客户端 return _ok(response, { token: token }); } catch (e) { return _fail( response, 502, e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试' ); } } // ==================== 入口 ==================== // // 🔴 平台的执行器**不提供** `Parse.Cloud.define`(实测 2026-11-15: // 写成 define 会报 `Parse.Cloud.define is not a function`)。 // 真实约定是**顶层定义 `handler(request, response)`**, // 入参由平台注入——`params` 是全局变量,`response` 必须显式 `.status().json()`。 // 与 fmode-file-read / fmode-file-hub 同一写法。 function handler(request, response) { (async function () { var p = params || {}; var action = _str(p.action, 32); var mobile = _str(p.mobile, 20); if (action === 'sendCode') { await _sendCode(response, mobile); return; } if (action === 'login') { await _login(response, mobile, _str(p.code, 10)); return; } return _fail(response, 400, '未知操作:' + action); })().catch(function (e) { _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown')); }); }