Forráskód Böngészése

fix(cloud): restore Xiaoshu SDK and transaction compatibility in isolated workers

彭峰 1 órája
szülő
commit
31b76f0a66

+ 48 - 0
docs/operations/multi-client-runtime-incident-2026-11-14.md

@@ -0,0 +1,48 @@
+# 2026-11-14 登录、课表及复习计划故障
+
+所有时间为北京时间。用户确认维护方正在维护共享平台;按此前授权,本次只修改小树,不修改公共框架、其他租户、DNS、负载均衡或共享进程。
+
+## 已确认的应用错误及修复
+
+08:29–08:33 的真实诊断发现,小树请求已由 `/opt/future-server/.modules/fmode-multiapp-api.worker.min.cjs` 执行。与此前完整 SDK 的执行方式不同,此环境:
+
+- `request.user` 是普通对象,缺少 `.fetch()` / `.get()`;两个网关的隔离克隆均复现 `TypeError: current.fetch is not a function`。
+- `Parse.User` 只是 `{className: '_User'}`,不是可构造的用户类,缺少原业务登录等 SDK 方法。
+- `Psql` 是只提供部分查询方法的包装,底层 `Psql.db` 才是包含事务接口的 pg-promise 实例。
+- worker 源码及 workerData 验证为按租户隔离;本租户 companyId=`7pIbDBJmKx`、appId=`7pIbDBJmKx_main`。
+
+08:38:27,在一个事务中为小树 5 个现有网关添加兼容适配,更新前逐项校验原代码 MD5,保留现有业务源码与历史热修复,更新后回读代码 SHA-256 验证一致:
+
+- `xiaoshu/app/gateway`
+- `xiaoshu/admin/gateway`
+- `xiaoshu/ops/gateway`、`gateway-v2`、`gateway-v3`
+
+适配仅在公司、appId、密钥匹配、worker token、回环 URL 和事务接口检查通过后启用。使用该小树 worker 内安装的完整 Parse SDK,以 worker token 请求本机数据接口;将原普通身份对象恢复为 SDK 对象,并由原鉴权代码继续 fetch 和检查权限。当前调用的局部 Parse/Psql 绑定重新指向完整 SDK 和原数据库实例;SDK 配置仅影响此小树 worker,不跨租户。旧的完整 SDK 环境直接保持原状。没有改密码、充值、扣课或排课数据,也没有重启共享服务。
+
+部署生成器同步接入适配,避免后续正常发布覆盖修复。首次隔离试验发现 Parse 8.7 包导出要求 CoreManager.js 扩展名,修正后才上线;该试验失败未影响正式网关。
+
+## 已完成的验证
+
+- 兼容适配、门店权限、开课网关及课堂流程共 71 项测试通过;11 个云函数定义语法校验通过;独立代码审查完成。
+- 隔离网关:后台版本读取 HTTP 200 / success=true;移动版本读取 HTTP 200 / retcode=0。
+- 正式网关:钱老师(ID 69)的业务统计、陪练课表、今日复习计划分别成功返回,样本约 8.3、9.9、7.8 秒。
+- 学生“张雨涵大学”密码登录成功,返回其本人会话;随后本人账户读取成功。登录样本 2.8 秒、账户读取 3.1 秒。
+- 钱老师本人已有会话的账户读取通过,身份与角色正确。
+- 原样保留并回读确认 5 个正式网关代码,完整基线及发布回执保存在私有证据目录。
+
+## 未完成与当前阻塞
+
+1. 后台课表曾出现 45 秒无响应;隔离分析另捕获 `timeout exceeded when trying to connect`,业务查询阶段约 5 秒。小树新 worker 的业务池 max=2、connectionTimeoutMillis=5000。一次数据库采样显示全库 41/192 个连接,本租户 7 个连接、1 个活跃;尚不能仅凭这些样本认定最终瓶颈,未盲目调整连接池。入口恢复后需继续复验课表及并发等待。
+2. 约 08:46 起,小树域名连续出现 HTTP 503 HTML:`No server is available to handle this request.` DNS 当前别名为 server.fmode.cn,同指向 139.159.253.131。对照发现公共域名 /health、/ 返回 200,而小树域名 /health、/parse/health、/ 返回 503。因此应优先检查小树域名的 Host/SNI 路由、后端成员和健康检查,不能笼统认定整个公共服务都已停止。
+3. 浏览器完整三端验收被上述入口故障中断;此前成功的接口样本不能代替维护结束后的整体验收。用户已确认维护方正在维护并将联系对方。
+4. 未修改公共入口配置;不得把本次应用适配上线表述为全站已经完全恢复。
+
+## 维护方协作重点
+
+- 恢复 `server.xiaoshu.pro` 的有效后端及健康检查;验证真实 Parse 用户会话、小树云函数与浏览器跨域预检,不要只看公共域名 health=200。
+- 保留小树现有 5 个网关的 `XIAOSHU_ISOLATED_RUNTIME_COMPAT_V1` 适配,或交付完整等价的 SDK/用户对象/事务兼容层后再撤除。
+- 复验后台课表的连接等待,保护线上教学请求;给出变更时间线及恢复通知。
+
+私有证据:`/Users/mac/.local/state/xiaoshu/qa/2026-11-14-multi-client-outage/`。包含原网关备份、事务发布回执、错误栈、worker 接口摘录、真实读取结果;会话和原代码均未提交 Git。
+
+回退仅允许将上述 5 个对象的 code 从已发布 SHA-256 对应版本恢复到私有备份,并校验当前版本未被他人更改。当前新运行环境下直接回退会重新引发 SDK 不兼容,故不可未经维护方协调就撤销适配。

+ 5 - 0
scripts/deploy-admin-functions.mjs

@@ -1,4 +1,5 @@
 #!/usr/bin/env node
+import { withXiaoshuRuntime } from './lib/xiaoshu-runtime-compat.mjs';
 import { teacherEarningsSupport } from './teacher-earnings-transform.mjs';
 
 import { readFileSync } from 'node:fs';
@@ -4490,6 +4491,10 @@ async function upsert(definition) {
   return { action: 'created', objectId: created.objectId };
 }
 
+for (const definition of definitions) {
+  if (/^xiaoshu\/(?:app|ops|admin)\/gateway(?:-v[0-9]+)?$/.test(definition.path)) definition.code = withXiaoshuRuntime(definition.code);
+}
+
 const selectedDefinitions = onlyName ? definitions.filter((definition) => definition.name === onlyName || definition.path === onlyName) : definitions;
 if (!selectedDefinitions.length) throw new Error(`没有找到云函数:${onlyName}`);
 for (const definition of selectedDefinitions) validateDefinition(definition);

+ 59 - 0
scripts/lib/xiaoshu-runtime-compat.mjs

@@ -0,0 +1,59 @@
+// The multi-app worker provides a deliberately small REST facade, not the Parse SDK.
+// Adapt only Xiaoshu gateway-local bindings inside its own isolated worker.
+export async function resolveXiaoshuRuntime(nativeParse, nativePsql, request, loadEnvironment) {
+  if (typeof nativeParse.User === 'function' && typeof nativePsql.tx === 'function') {
+    return { Parse: nativeParse, Psql: nativePsql };
+  }
+  const load = loadEnvironment || (async () => {
+    const { workerData } = await import('node:worker_threads');
+    const { createRequire } = await import('node:module');
+    const require = createRequire('/opt/future-server/package.json');
+    const SDK = require('parse/node');
+    const coreModule = require('parse/lib/node/CoreManager.js');
+    return { SDK, CoreManager: coreModule.default || coreModule, workerData };
+  });
+  const { SDK, CoreManager, workerData } = await load();
+  const db = nativePsql.db;
+  if (workerData?.companyId !== '7pIbDBJmKx' || workerData.config?.appId !== '7pIbDBJmKx_main'
+      || nativeParse.applicationId !== workerData.config.appId
+      || !nativeParse.masterKey || nativeParse.masterKey !== workerData.config.masterKey
+      || typeof workerData.token !== 'string' || !workerData.token
+      || !/^http:\/\/127\.0\.0\.1:\d+\/data\/?$/.test(nativeParse.serverURL)
+      || typeof db?.tx !== 'function') {
+    throw new Error('Unsupported Xiaoshu isolated runtime');
+  }
+  // One tenant per worker thread; these SDK settings never cross tenant workers.
+  SDK.initialize(nativeParse.applicationId, undefined, nativeParse.masterKey);
+  SDK.serverURL = nativeParse.serverURL;
+  CoreManager.set('REQUEST_HEADERS', { 'x-fmode-worker-token': workerData.token });
+  CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1);
+  for (const [field, className] of [['user', '_User'], ['company', 'Company'], ['account', 'Account']]) {
+    const value = request[field];
+    if (!value) continue;
+    const json = typeof value.toJSON === 'function' ? value.toJSON() : value;
+    if (!json.objectId) throw new Error('Invalid Xiaoshu runtime identity');
+    request[field] = SDK.Object.fromJSON({ ...json, className });
+  }
+  return { Parse: SDK, Psql: db };
+}
+
+export function withXiaoshuRuntime(code) {
+  const marker = '// XIAOSHU_ISOLATED_RUNTIME_COMPAT_V1';
+  if (code.includes(marker)) return code;
+  const pattern = /async function handler\s*\(/g;
+  if ([...code.matchAll(pattern)].length !== 1) throw new Error('Expected exactly one async handler');
+  return code.replace(pattern, 'async function xiaoshuBusinessHandler(') + '\n' + marker + '\n'
+    + resolveXiaoshuRuntime.toString() + `
+async function handler(request, response) {
+  try {
+    const runtime = await resolveXiaoshuRuntime(Parse, Psql, request);
+    Parse = runtime.Parse;
+    Psql = runtime.Psql;
+  } catch (error) {
+    console.error('[XiaoshuRuntime] ' + String(error?.message || error));
+    return response.status(503).json({ success: false, error: 'runtime_compatibility_error', message: '小树服务暂时不可用,请稍后重试', retcode: -1, retmsg: '小树服务暂时不可用,请稍后重试', result: null });
+  }
+  return xiaoshuBusinessHandler(request, response);
+}
+`;
+}

+ 8 - 0
scripts/tests/xiaoshu-runtime-compat.test.mjs

@@ -0,0 +1,8 @@
+import test from 'node:test';import assert from 'node:assert/strict';
+import {resolveXiaoshuRuntime,withXiaoshuRuntime} from '../lib/xiaoshu-runtime-compat.mjs';
+const fixture=()=>{const calls=[];const sdk={initialize:(...args)=>calls.push(['initialize',...args]),Object:{fromJSON:j=>({id:j.objectId,fetch:async()=>{},get:k=>j[k],toJSON:()=>j})},User:function(){}};const core={set:(...args)=>calls.push(['set',...args])};const workerData={companyId:'7pIbDBJmKx',token:'worker-secret',config:{appId:'7pIbDBJmKx_main',masterKey:'master'}};const native={applicationId:'7pIbDBJmKx_main',masterKey:'master',serverURL:'http://127.0.0.1:12345/data',User:{className:'_User'}};const db={tx:async work=>work(db),query:async()=>[]};return{calls,sdk,core,workerData,native,db,load:async()=>({SDK:sdk,CoreManager:core,workerData})};};
+test('existing full SDK and transaction service pass through without reconfiguration',async()=>{const Parse={User:function(){}},Psql={tx(){}};const r=await resolveXiaoshuRuntime(Parse,Psql,{},()=>{throw Error('must not load');});assert.equal(r.Parse,Parse);assert.equal(r.Psql,Psql);});
+test('isolated worker restores rich user and exact transaction context without changing shared facades',async()=>{const f=fixture(),raw={objectId:'user1',className:'_User',username:'student'},request={user:raw};const facade={db:f.db};const r=await resolveXiaoshuRuntime(f.native,facade,request,f.load);assert.equal(r.Parse,f.sdk);assert.equal(r.Psql,f.db);assert.equal(request.user.id,'user1');assert.equal(request.user.get('username'),'student');assert.equal(typeof request.user.fetch,'function');assert.equal(typeof f.native.User,'object');assert.equal(facade.db,f.db);assert.equal(raw.fetch,undefined);assert.ok(f.calls.some(c=>c[0]==='set'&&c[1]==='REQUEST_HEADERS'&&c[2]['x-fmode-worker-token']==='worker-secret'));assert.equal(f.sdk.serverURL,f.native.serverURL);await r.Psql.tx(async tx=>assert.equal(tx,f.db));});
+test('login without a prior session is left unauthenticated until real login succeeds',async()=>{const f=fixture(),request={};await resolveXiaoshuRuntime(f.native,{db:f.db},request,f.load);assert.equal(request.user,undefined);});
+test('foreign app, missing worker token and unsupported SQL context fail before SDK reconfiguration',async()=>{for(const mutate of [f=>f.workerData.companyId='other',f=>f.workerData.config.appId='other',f=>f.workerData.config.masterKey='wrong',f=>f.workerData.token='',f=>f.native.serverURL='https://outside.example/data',f=>f.db={}]){const f=fixture();mutate(f);await assert.rejects(resolveXiaoshuRuntime(f.native,{db:f.db},{},f.load));assert.equal(f.calls.length,0);}});
+test('wrapper preserves handler return and only replaces local bindings',async()=>{const code=withXiaoshuRuntime('async function handler(request,response){return {ok:Parse.User===request.expectedUser,db:Psql};}');const P={User:function(){}},db={tx(){}};const run=new Function('Parse','Psql','request','response',code+'\nreturn handler(request,response);');assert.deepEqual(await run(P,db,{expectedUser:P.User},{}),{ok:true,db});assert.equal(withXiaoshuRuntime(code),code);assert.throws(()=>withXiaoshuRuntime('const other=1'),/handler/);});