Lesson 02 · 请求链路
从一个下拉框,到 GM 服务。 中间有四个地方会悄悄改你的请求。
gameboxclient 工作区快照。
1. 界面上按钮点了没反应,DevTools 控制台干干净净。下一步最该做什么?
答案:B。「渲染层控制台没错误」不等于「没出错」。
主进程的错误在启动 Electron 的那个终端里,子进程的错误连终端都没有。
A 不算错,但顺序反了 —— 先判断层,再选工具,能省掉大量无效插桩。
2. 为什么在这个项目的渲染层里能直接写
import Fs from 'node:fs'?
答案:C。electron/main/config/options.ts:6-8 把
nodeIntegration 打开、contextIsolation 关掉。
B 是标准做法但本项目没有 —— preload 的构建在
vite.config.ts:58-72 整段被注释了,那个文件从没被加载过。
这是最容易记反的一条。
策划打开「NPC」页,从地图下拉框里选了一张地图,列表刷新出这张地图上的所有 NPC。
就这么一个动作。但如果有一天他跟你说「选了地图但列表是空的」, 你需要知道这中间有几个环节可能出问题。 这一课就是把这条链路完整走一遍。
先看图
这条链路画在架构图 FIG.04, 可以逐步播放。建议先看一遍图,再回来看代码 —— 图给的是骨架, 下面给的是每一节骨头上的行号。
这一步听起来多余,但它是本项目最容易翻车的地方。页面上有两个东西看起来都像「搜索」, 只有一个会真的发请求。
打开
src/views/GameEditor/Npc/index.vue,先看模板里的事件绑定:
| 界面元素 | 绑的是 | 会发请求吗 |
|---|---|---|
| 「搜索」按钮(Npc/index.vue:24) | @click="searchNpc" |
不会。searchNpc() (:84)只是在已经拿到的 npcList 上做
filter,纯内存操作
|
| NPC 名称下拉、类型复选框 | @change="searchNpc" |
不会,同上 |
| 地图下拉框 |
@change="search" / @clear="search"
|
会。search() (:117)里 await byMapIdGetNpc(...)
|
| 页面刚打开 | onBeforeMount(:149) |
会,而且发两次。先
getExcelProperty 拉地图列表,再调
search() 拉 NPC
|
❌ 别学
两个只差三个字母的函数,语义完全不同。
search() 发网络请求,searchNpc()
只做本地过滤, 而且 search() 的最后一行还会调
searchNpc() (Npc/index.vue:145)。
规范做法:命名要体现副作用。
fetchNpcListByMap() 和 filterNpcList()
一眼就能分清谁碰网络。
给你的实操规矩(这条会救你很多次): 描述用户动作之前,先在模板里找到那个事件绑定。 不要从函数名猜。
页面不直接用 axios。它调 src/api/ 下的封装:
// src/views/GameEditor/Npc/index.vue:130
const res = await byMapIdGetNpc({
mapId: mapID.value,
pageSize: 9999,
pageNum: 1,
type: 0
})
而 byMapIdGetNpc 长这样(src/api/game/index.ts:14):
export async function byMapIdGetNpc(params: GetNpcApiModel) {
const { serverId, ...data } = params
return request.post({
url: '/1000y/engine/cmd',
data: {
type: 1,
...data,
reverse: true,
serverId: serverId || getStorage('defaultServerId'),
cmd: 'npc_search_ByMapId' // ← 关键在这
}
})
}
这里有两件事值得停下来看:
cmd 分发,但它是少数派
byMapIdGetNpc 打的是 /1000y/engine/cmd,
服务端靠请求体里的 cmd 字段决定干什么。
但别把这条推广成「所有请求都这样」 —— 统计
src/api/ 下全部 url: 字面量:
| 风格 | 端点 | 处数 | 怎么看出它在干什么 |
|---|---|---|---|
| 具名端点 |
/1000y/sj/packServer ·
/1000y/sj/gameNoticeAdd ·
/1000y/sj/export …
|
61 | 看 URL 就够了 |
cmd 分发 |
/1000y/engine/cmd(读)·
/1000y/sj/cmd(写)
|
4 + 5 |
要展开 Payload 看 cmd
|
| 非业务 |
/user/login · /user/info ·
/document/* …
|
6 | 看 URL |
全项目一共 15 个不同的 cmd 值,常见的这几个:
cmd |
做什么 | 打到哪个端点 | 定义在 |
|---|---|---|---|
npc_search_ByMapId |
按地图查 NPC | engine/cmd |
api/game/index.ts:14 |
excel_search_name |
查某张表有哪些字段 / 数据 | engine/cmd |
api/game/index.ts:35 |
excel_search_DateById |
按 id 查一条数据 | engine/cmd |
api/game/index.ts |
excel_modify |
写入:改一条配置数据 |
sj/cmd
|
api/gm/index.ts:259 |
excel_save_to_oss |
写入后自动跟着发的镜像同步,见下方红框 |
sj/cmd
|
api/gm/index.ts:284 |
excel_delete |
删一条数据 | sj/cmd |
api/gm/index.ts |
排障顺序:先看 URL,再决定要不要看 cmd
打开 DevTools Network 面板,第一眼看 Request URL:
/1000y/sj/ 后面跟具体动作名 → 看名字就知道在干什么
/cmd 结尾 →
这类请求长得一模一样,必须点开 Payload 看 cmd
小技巧:在 Network 搜索框直接搜 excel_modify,
可以筛出所有配置表写操作 —— 排查「数据被改坏了」时特别有用。
❌ 一次保存 = 两个请求
excel_modify()(api/gm/index.ts:259-296) 里,第一个请求发出去之后挂了个 .finally() ——
不管第一个成没成功,都会再发一个
cmd: 'excel_save_to_oss',最后 return Promise.all([第一个, 第二个])。
Promise.all
有一个失败就整体失败。所以界面弹「保存失败」时,
可能第一步早就写进去了,只是 OSS 镜像没同步。
看到保存失败,先回读再说,别直接重试 —— 重试会二次写入。完整的三种情况见 架构图 FIG.04 后面那张表。
serverId 有个静默兜底
serverId: serverId || getStorage('defaultServerId') ——
调用方没传就用本地存的默认服。
这意味着「选服」会影响用到这个兜底的那些请求
(不是全部 —— 只有把 serverId 放进请求体的那些)。
如果用户报「数据不对」,先问他选的哪台服。 这个值没选过的话可能是
undefined,请求照发。
至于服务端收到一个空 serverId 后具体返回什么
—— 是空数组、报错、还是默认服的数据 ——
只能看 Network 的 Response,代码里推不出来。
这一课能给你的是「参数确实是空的」这个事实,剩下的要看实际响应。
request.post 来自
src/config/axios/index.ts, 是一层很薄的包装:把
{ url, method, params, data, headersType }
转成 axios 的调用形式,顺便塞一个默认的
Content-Type: application/json。
真正干活的是 service(src/config/axios/service.ts:22):
const service: AxiosInstance = axios.create({
baseURL: BASE_PATH_URL, // import.meta.env.VITE_BASEPATH
timeout: SERVICE_TIMEOUT // 2 分钟
})
config.ts 里
request_timeout: 2 * 60 * 1000)。
对配置表这种可能返回上万条的接口是合理的,
但代价是:接口挂了,用户要盯着转圈整整两分钟才看到「请求超时」。
src/config/axios/service.ts:115:
service.interceptors.request.use((config) => {
const token = getStorage(appStore.getToken)
token && config.headers.setAuthorization(`Bearer ${token}`) // ①
handleGetParams(config) // ②
handlePostParams(config) // ③
handle1000yRequest(config) // ④
return config
})
① 和 ② ③ 都是常规操作(拼 GET 查询串、按 Content-Type 决定要不要
qs.stringify)。
值得细看的是 ④。
handle1000yRequest:只对含 1000y 的 URL 生效
src/config/axios/service.ts:88,它做三件事:
算出 pcode。调 getPcode()(:30) —— 依次尝试 getStorage('pCode')、
gameEnv.getEnvKey('VITE_API_PCODE')、
getStorage('GameENV')['VITE_API_PCODE']、 构建期的
ENV.VITE_API_PCODE,取第一个非空的。
加密成 excelToken 塞进请求头。
Crypto.default.encrypt(pcode)(AES), 结果写进
config.headers.excelToken。
这是服务端的鉴权凭据之一 —— pcode 不对,请求会被拒。
按 pcode 换服务器。getBaseURL(pcode) (:79)看 pcode
里有没有 _dev 这个子串:有就打测试
GM,没有就打正式 GM。
❌ 别学
「打测试服还是正式服」由一次字符串包含判断决定, 而且两个域名硬编码在源码里。
// src/config/axios/service.ts:79
function getBaseURL(pcode: string): string {
if (pcode.includes('_dev')) {
return 'http://common-gm-460qntest.3975app.com/n1000y-api/'
}
return 'http://common-gm-460qnprod.3975app.com/n1000y-api/'
}
三个问题:
_dev 后缀 —— 请求就直接打到正式服。
这个默认方向是反的:不确定时应该落在最安全的环境,不是最危险的。
http 不是 https。
带着 token 和 excelToken 明文过网络。
规范做法:环境判定用显式的环境标识(一个
env: 'test' | 'prod' 字段),不要靠子串匹配;
地址走配置下发(这个项目已经有 /user/info 下发机制了,
VITE_API_BASEPATH 和 VITE_API_BASEPATH_DEV
本来就在下发内容里);默认值选安全的那个。
排障提示:用户说「我在测试服改的,怎么正式服也变了」——
先看 getPcode() 那一串兜底里,实际生效的是哪一个。
在控制台敲 localStorage.getItem('pCode') 最快。
src/config/axios/service.ts:131:
service.interceptors.response.use((response) => {
if (response.config.responseType === 'blob') return response
if (response.config.responseType === 'stream') return response
if (response.data.code === result_code || response.data.code === 1) {
return response.data // ← 注意:返回的是 data,不是 response
}
return Promise.reject(response) // ← 业务码不对 = reject
})
两个必须记住的行为:
成功时返回的是 response.data,不是完整
response。
所以页面里写的 res.data?.data 里那两个 data ——
第一个是业务包裹层,第二个才是真正的数组。看着别扭,但是对的。
业务码不是 200 或 1 就 reject。
也就是说 HTTP 200 但业务失败,也会走到你的 catch 里。
Npc/index.vue:139-142 的 catch 就是干这个的。
⚠️ 隔离
成功码有两个:200 和 1。
result_code 在 config.ts 里是 200,
但拦截器额外接受了一个硬编码的 1。
大概率是某些老接口返回 code: 1,加了个兼容。
这类「魔法数兼容」的问题在于没人知道哪些接口属于哪一类, 以后再有第三种也只能继续加。
规范做法:要么统一服务端返回, 要么把兼容规则写成一张明确的表(哪些 URL 走旧协议),别散在条件判断里。
① 用户换地图下拉 →
@change="search" (Npc/index.vue:117)
② byMapIdGetNpc() 组装 body,塞进
cmd: 'npc_search_ByMapId' 和 serverId (api/game/index.ts:14)
③ request.post → axios 实例 (config/axios/index.ts)
④ 请求拦截器加 Authorization、算 pcode、
加密 excelToken、按 pcode 决定打测试还是正式
(service.ts:115)
⑤ POST /1000y/engine/cmd 上路
⑥ 响应拦截器检查业务码,成功返回
response.data(service.ts:131)
⑦ 页面把结果排序后写进 Pinia:
ViewData.NPC.npcList = res.data?.data.sort(...)
(Npc/index.vue:136)
⑧ 再调 searchNpc() 做本地过滤 → 界面更新
(Npc/index.vue:145)
现在你有了完整链路,可以把这个模糊报障拆成有序的检查点:
| # | 检查 | 怎么查 | 是它的话 |
|---|---|---|---|
| 1 | 请求发出去了吗 | Network 里搜 cmd |
没发 → 回到第 0 步,用户点的可能是纯本地过滤的那个 |
| 2 | 打到哪台服务器了 |
看请求的 Request URL 域名,qntest 还是
qnprod
|
错了 → 查 pcode:localStorage.getItem('pCode') |
| 3 | 参数对吗 | Payload 里看 mapId 和 serverId |
serverId 是 undefined → 用户没选服 |
| 4 | 服务端返回了什么 | Response 面板 | 返回了数据但界面空 → 问题在 ⑦⑧,是前端过滤逻辑 |
| 5 | 凭据齐吗 |
JSON.parse(localStorage.getItem('GameENV'))
|
空的 → 登录没成功或服务端没下发,见课 01 |
/game/npc。观察:页面一打开就发了至少两个请求, 先
excel_search_name(拉地图列表)后
npc_search_ByMapId
cmd 字段。 确认两个请求的 URL 完全一样,只有 cmd 不同
excelToken, 确认它是一串密文而不是明文 pcode
qntest 还是 qnprod。
然后在控制台敲 localStorage.getItem('pCode'),
验证它里面有没有 _dev
—— 两者应该对得上
把结论记进你自己的分诊表
这一课产出了三条可复用的检查点,去 故障分诊表 看它们被记成了什么样子。 分诊表是逐课累积的,到课 09 会攒成一张完整的表。
站点版本 319ca82 · 2026-08-13
内容基线 gameboxclient @ 2026-08-12