From 96c6c7c914006c1dbe033cb4d7b40fb7c489a013 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sun, 28 Jun 2026 20:25:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(framework):=20decodeFrame=20=E6=94=B9?= =?UTF-8?q?=E5=8D=95=E5=B1=82(=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=9D=9E=E5=8F=8C=E5=B1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机联调发现:服务器实发单层 {app,route,rpc,data};旧客户端的'外层 data'是浏览器 MessageEvent(00_minhttp.js:266 ws.onmessage=config.onmessage),非协议层。框架传输层 已取 ev.data,decodeFrame 不应再剥一层。修正后 kick_server 等业务包能正确解析。 - decodeFrame 单层化 + 测试改单层帧 - 更正 docs/protocol/01 §3.2(双层→单层,附 MessageEvent 依据) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../assets/framework/net/envelope-codec.ts | 35 ++++++++----------- .../integration/login-flow.test.ts | 18 +++++----- .../net/envelope-codec.test.ts | 29 ++++++++------- .../framework-tests/net/net-client.test.ts | 22 ++++++------ docs/protocol/01-传输层与架构.md | 24 +++++++------ 5 files changed, 65 insertions(+), 63 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts b/cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts index 3b05d35..3afdbd2 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/net/envelope-codec.ts @@ -13,31 +13,26 @@ export type DecodeResult = | { kind: 'message'; message: InboundMessage } // 正常业务内层 | { kind: 'ignore' }; // 无法解析,安全忽略 -function parseMaybe(v: unknown): unknown { - return typeof v === 'string' ? JSON.parse(v) : v; -} - /** - * 解一帧原始字符串为分类结果。docs/protocol/01 §3.2 步骤 3–8。 + * 解一帧为分类结果。**服务器协议是单层 `{route, rpc, data}`**(可能带 `app`)。 + * 入参 frame 已是服务器实发帧——传输层已取 `MessageEvent.data`(旧 12_Logic.js 里被当成 + * "外层 data" 的其实是浏览器 WebSocket MessageEvent 这个壳,非协议层)。故此处不再剥 `.data`。 * 不做 TcpID/isSendLoginState/submit_error 过滤(那是连接级,见 net-client)。 */ export function decodeFrame(frame: string): DecodeResult { - let outer: any; - try { outer = JSON.parse(frame); } catch { return { kind: 'ignore' }; } - let data = outer?.data; + // 特殊串按原始帧判定(与旧 12_Logic.js 对 MessageEvent.data 的判断一致)。 + if (frame === 'webserve-服务器未工作') return { kind: 'serverDown' }; + if (frame.substr(0, 9) === '@toconcon') return { kind: 'handshake' }; - if (data === 'webserve-服务器未工作') return { kind: 'serverDown' }; + let msg: any; + try { msg = JSON.parse(frame); } catch { return { kind: 'ignore' }; } + // 兼容服务器偶发"再包一层字符串"(旧源码对每层都做 typeof=='string' 再 parse)。 + if (typeof msg === 'string') { + try { msg = JSON.parse(msg); } catch { return { kind: 'ignore' }; } + } + if (!msg || typeof msg !== 'object') return { kind: 'ignore' }; - if (typeof data === 'string') { - if (data.substr(0, 9) === '@toconcon') return { kind: 'handshake' }; - try { data = JSON.parse(data); } catch { return { kind: 'ignore' }; } - } - if (data && typeof data === 'object' && (data as any).com === '@serverheartbeat') { - return { kind: 'heartbeat' }; - } - const inner = parseMaybe(data) as InboundMessage | undefined; - if (inner && typeof inner === 'object' && 'route' in inner && 'rpc' in inner) { - return { kind: 'message', message: inner }; - } + if ((msg as any).com === '@serverheartbeat') return { kind: 'heartbeat' }; + if ('route' in msg && 'rpc' in msg) return { kind: 'message', message: msg as InboundMessage }; return { kind: 'ignore' }; } diff --git a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts index 00bfe63..ada7471 100644 --- a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts +++ b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts @@ -21,18 +21,18 @@ test('login 全流程:握手→登录→业务包,且 parseLoginResponse 识 client.start(); await t.flush(); - // 服务器先发握手包(应被忽略) - t.serverPush(JSON.stringify({ data: '@toconconHELLO' })); + // 服务器先发握手包(原始串,应被忽略) + t.serverPush('@toconconHELLO'); await t.flush(); // 客户端应已发出 player_login assert.equal(JSON.parse(t.sent[0]).rpc, 'player_login'); - // 服务器回 login 响应(带房间恢复 + 对局) - t.serverPush(JSON.stringify({ data: JSON.stringify({ + // 服务器回 login 响应(单层,带房间恢复 + 对局) + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 42, bean: 500, roomcode: 'ROOM1', isbattle: 1, deskinfo: { round: 3 } }, - }) })); + })); await t.flush(); assert.ok(loginData); @@ -43,10 +43,10 @@ test('login 全流程:握手→登录→业务包,且 parseLoginResponse 识 assert.equal(parsed.hasBattle, true); assert.deepEqual(parsed.deskinfo, { round: 3 }); - // 心跳包(忽略,不进 business) - t.serverPush(JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) })); - // 正常业务推送(门控已清,放行) - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } }) })); + // 心跳包(单层,忽略,不进 business) + t.serverPush(JSON.stringify({ com: '@serverheartbeat' })); + // 正常业务推送(单层,门控已清,放行) + t.serverPush(JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } })); await t.flush(); assert.equal(business.length, 1); diff --git a/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts b/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts index b3c8737..3ea7aea 100644 --- a/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts +++ b/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts @@ -7,29 +7,34 @@ test('encodeOutbound 产出单层信封字符串', () => { assert.deepEqual(JSON.parse(s), { app: 'youle', route: 'agent', rpc: 'player_login', data: { openid: 'o' } }); }); +// 服务器协议为单层:frame 即服务器实发帧(传输层已取 MessageEvent.data)。 + test('decodeFrame:握手包 @toconcon 识别为 handshake', () => { - const frame = JSON.stringify({ data: '@toconconXYZ...' }); - assert.deepEqual(decodeFrame(frame), { kind: 'handshake' }); + assert.deepEqual(decodeFrame('@toconconXYZ...'), { kind: 'handshake' }); }); test('decodeFrame:心跳包 @serverheartbeat 识别为 heartbeat', () => { - const frame = JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) }); + const frame = JSON.stringify({ com: '@serverheartbeat' }); assert.deepEqual(decodeFrame(frame), { kind: 'heartbeat' }); }); test('decodeFrame:特殊错误包 webserve-服务器未工作', () => { - const frame = JSON.stringify({ data: 'webserve-服务器未工作' }); - assert.deepEqual(decodeFrame(frame), { kind: 'serverDown' }); + assert.deepEqual(decodeFrame('webserve-服务器未工作'), { kind: 'serverDown' }); }); -test('decodeFrame:正常业务包解出内层 {route,rpc,data}', () => { +test('decodeFrame:正常业务包(单层)解出 {route,rpc,data}', () => { const inner = { route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }; - const frame = JSON.stringify({ data: JSON.stringify(inner) }); - assert.deepEqual(decodeFrame(frame), { kind: 'message', message: inner }); + assert.deepEqual(decodeFrame(JSON.stringify(inner)), { kind: 'message', message: inner }); }); -test('decodeFrame:内层为对象(非字符串)也能解', () => { - const inner = { route: 'room', rpc: 'other_join_room', data: { seat: 1 } }; - const frame = JSON.stringify({ data: inner }); - assert.deepEqual(decodeFrame(frame), { kind: 'message', message: inner }); +test('decodeFrame:带 app 字段的单层业务包(服务器实测格式)', () => { + const frame = JSON.stringify({ app: 'youle', route: 'agent', rpc: 'kick_server', data: { msg: '检查到新版本' } }); + const r = decodeFrame(frame); + assert.equal(r.kind, 'message'); + assert.equal(r.kind === 'message' && r.message.rpc, 'kick_server'); +}); + +test('decodeFrame:无法解析的帧安全忽略', () => { + assert.deepEqual(decodeFrame('not json'), { kind: 'ignore' }); + assert.deepEqual(decodeFrame(JSON.stringify({ foo: 1 })), { kind: 'ignore' }); }); diff --git a/cocoscreator_projects/framework-tests/net/net-client.test.ts b/cocoscreator_projects/framework-tests/net/net-client.test.ts index d7bdd87..d733363 100644 --- a/cocoscreator_projects/framework-tests/net/net-client.test.ts +++ b/cocoscreator_projects/framework-tests/net/net-client.test.ts @@ -53,15 +53,15 @@ test('isSendLoginState 门控:login 响应前的其它包被丢弃,login 响 client.start(); await t.flush(); // 登录响应前推一个业务包 → 应被门控丢弃 - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } })); await t.flush(); assert.equal(messages.length, 0); // 推 login 响应 → 放行,emit login,清门控 - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t.flush(); assert.equal(loginResp.playerid, 9); // 门控已清,后续业务包放行 - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 5 } })); await t.flush(); assert.equal(messages.length, 1); assert.equal(messages[0].rpc, 'update_bean'); @@ -76,10 +76,10 @@ test('握手包与心跳包被忽略且不进 message', async () => { client.start(); await t.flush(); // 先完成登录以清门控 - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t.flush(); - t.serverPush(JSON.stringify({ data: '@toconconABC' })); // 握手 - t.serverPush(JSON.stringify({ data: JSON.stringify({ com: '@serverheartbeat' }) })); // 心跳 + t.serverPush('@toconconABC'); // 握手(原始串) + t.serverPush(JSON.stringify({ com: '@serverheartbeat' })); // 心跳(单层) await t.flush(); assert.equal(messages.length, 0); }); @@ -93,7 +93,7 @@ test('收包超时 emit slow 并触发重连(onClose→新连接重发 login client.start(); await t1.flush(); // 先完成登录:清 4s 守护、feed 收包看门狗(否则 advance 会先撞上 loginGuard 而非 watchdog) - t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t1.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t1.flush(); // 30s 无收包 → watchdog → slow + 关闭当前连接 advance(30000); @@ -114,12 +114,12 @@ test('TcpID 去重:旧连接的残留包被丢弃', async () => { client.on('message', (m: any) => messages.push(m)); client.start(); await t1.flush(); - t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t1.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t1.flush(); // 触发重连切到 t2 advance(30000); await t1.flush(); advance(10000); await t2.flush(); // 旧连接 t1 仍推包 → 应被 TcpID 去重丢弃 - t1.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }) })); + t1.serverPush(JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } })); await t1.flush(); assert.equal(messages.length, 0); }); @@ -139,7 +139,7 @@ test('debug=true 记录 send 与 recv(注入 logger)', async () => { client.start(); await t.flush(); assert.ok(logs.some((l) => l[0] === '[net] →')); // 发出 player_login 被记录 - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t.flush(); assert.ok(logs.some((l) => l[0] === '[net] ←')); // 收包被记录 }); @@ -157,7 +157,7 @@ test('debug 默认关闭:logger 不被调用', async () => { client.setIdentity(IDENTITY); client.start(); await t.flush(); - t.serverPush(JSON.stringify({ data: JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } }) })); + t.serverPush(JSON.stringify({ route: 'agent', rpc: 'player_login', data: { state: 0, playerid: 9 } })); await t.flush(); assert.equal(logs.length, 0); }); diff --git a/docs/protocol/01-传输层与架构.md b/docs/protocol/01-传输层与架构.md index 99c34a2..5cb8595 100644 --- a/docs/protocol/01-传输层与架构.md +++ b/docs/protocol/01-传输层与架构.md @@ -74,39 +74,41 @@ var ws = new WebSocket("ws://" + config.ipport); // 00_minhttp.js:258,例: w - netType==0:`Net.ws_tcp.send(JSON.stringify(_msg))`(`09_Net.js:20`)。**单层**,直接发信封。 - netType==1:走 `Func.AjaxHttp2(GameData.Server, _msg, ...)`(`09_Net.js:24`),POST JSON。 -### 3.2 服务器 → 客户端(接收)—— WebSocket 路径:双层包装 +### 3.2 服务器 → 客户端(接收)—— WebSocket 路径:**单层**(「外层 data」是浏览器 MessageEvent,非协议层) -WebSocket 收包处理在 `12_Logic.js` 的 `this.onmessage`(定义于 **`12_Logic.js:119`**,路由分发在 **`12_Logic.js:258`**)。**收到的是双层结构**: +> **⚠️ 重要更正(2026-06-28 真机联调确认)**:早期本节误记为「双层 `{data:}`」。实测+源码核对确认:**服务器实发的协议帧是单层** `{route, rpc, data}`(实测还带 `app` 字段)。所谓"外层 `data`"是**浏览器 WebSocket `MessageEvent` 对象**,不是服务器的协议层。 + +依据 `00_minhttp.js:266` 的 `min_tcp()`:`ws.onmessage = config.onmessage;`——把**浏览器原生 `MessageEvent`** 直接交给 `12_Logic.js:119` 的 `this.onmessage(_msg)`。所以 `_msg` 是 `MessageEvent`,`_msg.data`(= `MessageEvent.data`)才是服务器实发帧(源码注释原文:「`msg.data` 才是服务器发过来的业务数据」`00_minhttp.js:265`)。`12_Logic.js` 里 `data=_msg.data`(137) 与 `_msg=_msg.data`(224) 读的是**同一个 `MessageEvent.data`**,只剥掉这一层浏览器事件壳,没有第二层。 ``` -原始帧(字符串) = JSON.stringify({ - "data": <内层> // 外层只有 data 字段 -}) - -<内层> 可能是字符串或对象,再解析后 = { +MessageEvent.data(= 服务器实发帧,单层)= { + "app": "youle", // 实测服务器回包带 app(与客户端发包同形) "route": "...", "rpc": "...", "data": { ... } // 真正的业务数据 } +// 握手包:MessageEvent.data 为原始串 "@toconcon...";心跳包:{ "com": "@serverheartbeat" } ``` +**新前端落地(YouleNexus)**:传输层(`CocosWebSocketTransport` / 联调用 `WsTransport`)已取 `ev.data`(= MessageEvent.data = 服务器单层帧)交给 `decodeFrame`,故 **`decodeFrame` 不得再剥一层 `.data`**——直接把 frame 当单层 `{route,rpc,data}` 解。(曾因沿用"双层"误解多剥一层,导致 `kick_server` 等业务包被误丢弃,真机联调暴露并已修正。) + 完整解包顺序(含三道前置过滤,务必照此实现,否则会误处理旧连接包/错误回包/握手心跳): 1. **旧连接去重**:`if(GameData.TcpID != this.id) return;`(`12_Logic.js:122`)——每次重连 `GameData.TcpID++`,每个 WebSocket 实例闭包持有自己的 `this.id`,只有最新连接的包才被处理,旧连接残留包直接丢弃。 2. 若网络状态关闭 `if(!GameData.netWorkSate) return;`(`12_Logic.js:127`)。 -3. `_msg = JSON.parse(原始帧)`(仅当是字符串,`12_Logic.js:133-136`)→ 取外层 `data = _msg.data`(`12_Logic.js:137`)。 +3. `_msg`(= MessageEvent;若传输直接给字符串则 `JSON.parse`,`12_Logic.js:133-136`)→ 取 `data = _msg.data`(= 服务器实发帧,`12_Logic.js:137`)。 4. **特殊错误包**:若 `data === "webserve-服务器未工作"` → 进入重发登录分支后 `return`(详见 4 节)。 5. 若 `data` 是字符串: - 若 `data.substr(0,9) === "@toconcon"` → **握手包,直接 `return` 忽略**(`12_Logic.js:176-179`)。 - - 否则 `data = JSON.parse(data)`(`12_Logic.js:180`)。 + - 否则 `data = JSON.parse(data)`(`12_Logic.js:180`)→ 得到单层 `{route,rpc,data}`。 6. **重置收包超时定时器**(仅 `!ConstVal.isGameHall` 时创建,见第 4 节)(`12_Logic.js:182-217`)。 7. 若 `data.com === "@serverheartbeat"` → **心跳包,直接 `return` 忽略,不回包**(`12_Logic.js:218-223`)。 -8. 令 `_msg = _msg.data`(取内层),必要时再 `JSON.parse`(`12_Logic.js:224-229`)→ 得到 `{route, rpc, data}`。 +8. 令 `_msg = _msg.data`(仍是同一个 `MessageEvent.data`),必要时再 `JSON.parse`(`12_Logic.js:224-229`)→ 得到 `{route, rpc, data}`。 9. **错误回包忽略**:`if(_msg.rpc == "submit_error") return;`(`12_Logic.js:235`)。 10. **登录态门控**(`isSendLoginState`):若 `GameData.isSendLoginState==true`(已发登录、等待 `player_login` 响应期间),则除 `player_login`(清门控)与 `kick_server` 外,**其它收包一律 `return` 丢弃**(`12_Logic.js:238-257`)。 11. 按 `_msg.route` 分发(见第 5 节,`12_Logic.js:258`)。 -> 注:源码外层与内层都做了 `typeof == "string"` 判断后再 `JSON.parse`,是为兼容服务器有时发对象、有时发字符串。新前端实现时对每层都「是字符串就 parse」最稳妥。 +> 注:源码对 `MessageEvent.data` 做了 `typeof == "string"` 判断后再 `JSON.parse`,是为兼容服务器有时发对象、有时发字符串(**同一层**的两种编码,非两层)。新前端「是字符串就 parse 一次」即可。 ### 3.3 服务器 → 客户端 —— HTTP(netType==1) 路径:单层、无信封过滤