| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- // 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'));
- });
- }
|