Lesson 02 · 请求链路

一次请求的完整生命

从一个下拉框,到 /1000y/engine/cmd。 中间有四个地方会悄悄改你的请求。

基线: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'      // ← 关键在这
    }
  })
}

这里有两件事值得停下来看:

① URL 是固定的,动作藏在 cmd

这不是 REST。几乎所有业务请求都打到同一个地址 /1000y/engine/cmd,服务端靠请求体里的 cmd 字段决定干什么:

cmd 做什么 定义在
npc_search_ByMapId 按地图查 NPC api/game/index.ts:14
excel_search_name 查某张表有哪些字段 / 数据 api/game/index.ts:35
excel_search_DateById 按 id 查一条数据 api/game/index.ts
search_excel_max_id 查当前最大 id(新增数据时用) api/game/index.ts
excel_modify 写入:改一条配置数据 api/gm/index.ts:259

这条对排障的影响非常大

打开 DevTools Network 面板,你会看到一整屏长得一模一样的请求: 全是 POST cmd,状态全是 200。

想知道某个请求在干什么,必须点开它、切到 Payload、看 cmd 字段。按 URL 筛选在这个项目里毫无用处。

小技巧:在 Network 的搜索框里直接搜 excel_modify, 可以筛出所有写操作 —— 排查「数据被改坏了」时特别有用。

serverId 有个静默兜底

serverId: serverId || getStorage('defaultServerId') —— 调用方没传就用本地存的默认服。

这意味着「选服」这个动作会影响之后所有请求。 如果用户报「数据不对」,先问他选的哪台服。 这个值没选过的话可能是 undefined,请求照发, 服务端返回空 —— 界面上看起来就是「列表是空的」

第 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 会攒成一张完整的表。