Lesson 02 · 请求链路

一次请求的完整生命

从一个下拉框,到 GM 服务。 中间有四个地方会悄悄改你的请求。

基线:2026-08-12 的 gameboxclient 工作区快照。

先复习:上一课的两个点

1. 界面上按钮点了没反应,DevTools 控制台干干净净。下一步最该做什么?

2. 为什么在这个项目的渲染层里能直接写 import Fs from 'node:fs'

先说人话:这次要追的是什么

策划打开「NPC」页,从地图下拉框里选了一张地图,列表刷新出这张地图上的所有 NPC。

就这么一个动作。但如果有一天他跟你说「选了地图但列表是空的」, 你需要知道这中间有几个环节可能出问题。 这一课就是把这条链路完整走一遍。

先看图

这条链路画在架构图 FIG.04, 可以逐步播放。建议先看一遍图,再回来看代码 —— 图给的是骨架, 下面给的是每一节骨头上的行号。

第 0 步:先确认用户点的是什么

这一步听起来多余,但它是本项目最容易翻车的地方。页面上有两个东西看起来都像「搜索」, 只有一个会真的发请求。

打开 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() 一眼就能分清谁碰网络。

给你的实操规矩(这条会救你很多次): 描述用户动作之前,先在模板里找到那个事件绑定。 不要从函数名猜。

第 1 步:页面 → api 层

页面不直接用 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

小技巧:在 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,代码里推不出来。 这一课能给你的是「参数确实是空的」这个事实,剩下的要看实际响应。

第 2 步:api → axios 封装

request.post 来自 src/config/axios/index.ts, 是一层很薄的包装:把 { url, method, params, data, headersType } 转成 axios 的调用形式,顺便塞一个默认的 Content-Type: application/json

真正干活的是 servicesrc/config/axios/service.ts:22):

const service: AxiosInstance = axios.create({
  baseURL: BASE_PATH_URL,        // import.meta.env.VITE_BASEPATH
  timeout: SERVICE_TIMEOUT       // 2 分钟
})
注意超时是 2 分钟config.tsrequest_timeout: 2 * 60 * 1000)。 对配置表这种可能返回上万条的接口是合理的, 但代价是:接口挂了,用户要盯着转圈整整两分钟才看到「请求超时」。

第 3 步:请求拦截器 —— 四件事

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/'
}

三个问题:

规范做法:环境判定用显式的环境标识(一个 env: 'test' | 'prod' 字段),不要靠子串匹配; 地址走配置下发(这个项目已经有 /user/info 下发机制了, VITE_API_BASEPATHVITE_API_BASEPATH_DEV 本来就在下发内容里);默认值选安全的那个。

排障提示:用户说「我在测试服改的,怎么正式服也变了」—— 先看 getPcode() 那一串兜底里,实际生效的是哪一个。 在控制台敲 localStorage.getItem('pCode') 最快。

第 4 步:响应拦截器

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 就是干这个的。

⚠️ 隔离

成功码有两个:2001 result_codeconfig.ts 里是 200, 但拦截器额外接受了一个硬编码的 1

大概率是某些老接口返回 code: 1,加了个兼容。 这类「魔法数兼容」的问题在于没人知道哪些接口属于哪一类, 以后再有第三种也只能继续加。

规范做法:要么统一服务端返回, 要么把兼容规则写成一张明确的表(哪些 URL 走旧协议),别散在条件判断里。

把整条链串起来

用户换地图下拉 → @change="search"Npc/index.vue:117

byMapIdGetNpc() 组装 body,塞进 cmd: 'npc_search_ByMapId'serverIdapi/game/index.ts:14

request.post → axios 实例 (config/axios/index.ts

请求拦截器加 Authorization、算 pcode、 加密 excelToken按 pcode 决定打测试还是正式service.ts:115

POST /1000y/engine/cmd 上路

响应拦截器检查业务码,成功返回 response.dataservice.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 里看 mapIdserverId serverId 是 undefined → 用户没选服
4 服务端返回了什么 Response 面板 返回了数据但界面空 → 问题在 ⑦⑧,是前端过滤逻辑
5 凭据齐吗 JSON.parse(localStorage.getItem('GameENV')) 空的 → 登录没成功或服务端没下发,见课 01

真机验证

把结论记进你自己的分诊表

这一课产出了三条可复用的检查点,去 故障分诊表 看它们被记成了什么样子。 分诊表是逐课累积的,到课 09 会攒成一张完整的表。

站点版本 319ca82 · 2026-08-13 内容基线 gameboxclient @ 2026-08-12