在 /ongeki/settings(音击设置)点"导出档案",下载到的 ongeki_<extId>_exported.json 里是网页源码,不是玩家 JSON。同一功能在 /chuni/v2/setting 和 /mai2/setting 都是正常的。
复现(main @ 90ed95b,网站线上版本):
- 打开
/ongeki/settings,点底部"导出";
- 得到一个 1796 字节的
ongeki_<extId>_exported.json;
- 打开看,内容以
<!doctype html> 开头,是 SPA 的 index.html。
我拿两份真实下载文件比了一遍:
| 文件 |
字节 |
开头 |
能否 JSON.parse |
ongeki_*_exported.json |
1796 |
<!doctype html> |
否(Unexpected token '<') |
chusan_*_exported.json |
68100 |
{"gameId":"SDHD","userData":{… |
是 |
ongeki 那份的字节数与仓库 dist/index.html 完全一致(都是 1796),\r\n / 裸 \n / 裸 \r 的出现次数也逐项相同(38 / 40 / 39),唯一差别是 vite 的产物哈希(线上 index-bhne9qTR.js,我本地构建 index-B2FdUPgg.js)。也就是说服务端返回的就是那份 SPA 外壳。
再直接打了一遍服务器确认(不带任何凭据的 GET,只看响应):
请求(https://portal.naominet.live) |
响应 |
/api/game/ongeki/export |
401 + application/json ← 接口存在,只是要 Bearer |
/ongeki/api/game/ongeki/export |
200 + text/html + 1796 字节 ← 就是上面那条 bug URL |
/no/such/path/at/all |
同一份 1796 字节的 HTML |
三点因此确定:音击导出接口本身没问题(401 说明它存在),这一条完全在前端;那 1796 字节的外壳对任意未知路径都一样,这正是请求打错地方还能拿到 200、一点报错都没有的原因;以及我手上那份导出文件与服务器对 bug URL 的响应逐字节相同(1796 / CRLF 38 / LF 40 / CR 39,两侧 bundle 哈希同为 bhne9qTR,与当前线上构建一致)。
根因在 src/features/ongeki/OngekiSettingPage.tsx:57:
const resp = await rawFetch('api/game/ongeki/export');
rawFetch(src/lib/api/client.ts:64-66)不做任何前缀拼接,是"原样透传"的:
export async function rawFetch(url: string, init: RequestInit = {}): Promise<Response> {
return fetch(url, { ...init, headers: { ...authHeaders(), ...(init.headers as object) } });
}
前缀拼接在 buildUrl() 里(client.ts:34-43,API_SERVER = '/',见 :14),只有 api.get/post/put/delete 和 api.blob 会经过它:
blob: async (path: string, params?: QueryParams): Promise<Blob> => {
const resp = await rawFetch(buildUrl(path, params)); // ← 先拼成 /api/...
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
return resp.blob();
},
另外两处导出都走了 api.blob,所以路径是对的:
| 页面 |
调用 |
ChuniV2SettingPage.tsx:109 |
api.blob('api/game/chuni/v2/export', { aimeId }) |
Maimai2SettingPage.tsx:216 |
api.blob('api/game/maimai2/export', { aimeId }) |
OngekiSettingPage.tsx:57 |
rawFetch('api/game/ongeki/export'),原样交给 fetch |
不带前导斜杠的字符串会被当成相对 URL,按当前文档 URL 解析(index.html 里没有 <base> 标签)。当前页是 /ongeki/settings,所以 settings 这一段被替换掉了:
new URL('api/game/ongeki/export', 'https://portal.naominet.live/ongeki/settings')
→ https://portal.naominet.live/ongeki/api/game/ongeki/export
请求打到一个不存在的路径上,服务端按 SPA 规则回了 index.html(HTTP 200)。
之所以一声不吭,是因为 OngekiSettingPage.tsx:58 的守卫只看 HTTP 状态:
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
200 + text/html 对它来说是完全成功的响应,于是 resp.blob() 把 HTML 存成了 .json,界面上还照常弹了"正在下载档案"。
这是移植时丢的:旧版 Angular 显式拼了前缀(backup 分支 src/app/sega/ongeki/ongeki-setting/ongeki-setting.component.ts:88):
const url = this.apiServer + 'api/game/ongeki/export'; // apiServer = '/',见 src/environments/environment.prod.ts:2
const headers = {Authorization: `Bearer ${this.accountService.currentAccountValue.accessToken}`};
this.http.get(url, {headers, responseType: 'blob'}).subscribe(...)
新版把 this.apiServer + 去掉了,字符串字面量原封不动地留着,也没改成 api.blob,于是一条绝对路径变成了相对路径。
最小改动是补前导斜杠:
- const resp = await rawFetch('api/game/ongeki/export');
+ const resp = await rawFetch('/api/game/ongeki/export');
更建议直接对齐另外两处,统一走 api.blob():
- const resp = await rawFetch('api/game/ongeki/export');
- if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
- const blob = await resp.blob();
+ const blob = await api.blob('api/game/ongeki/export');
补一句:不建议顺手把 rawFetch 改成自动拼前缀。Maimai2SettingPage.tsx:112 用它上传头像,传的是 /Maimai2Servlet/... 这种绝对路径;如果 rawFetch 变成 API_SERVER + url,'/' + '/Maimai2Servlet/...' 会拼成协议相对 URL //Maimai2Servlet/...,反而坏掉。它保持原样透传是合理的,问题在调用方没给绝对路径——所以也可以考虑在 rawFetch 上加一行注释把「必须传绝对路径」写清楚,避免下次再踩。
最后说明一下影响面,免得排查时扩大范围:这不是一类问题,全仓所有裸 fetch / rawFetch 调用的字面量路径我都逐条看过了,其余的都带前导斜杠(Maimai2SettingPage.tsx:112 的 /Maimai2Servlet/...、AdminPage.tsx:122 的 /api/auth/signout),ContributorsPage.tsx:47 用的是完整 https URL。舞萌与中二的导出也一并核了:两者走 api.blob → buildUrl,实际请求分别是 /api/game/maimai2/export?aimeId=… 和 /api/game/chuni/v2/export?aimeId=…,这两个接口同样返回 401(存在)。所以坏的只有音击这一处。
在
/ongeki/settings(音击设置)点"导出档案",下载到的ongeki_<extId>_exported.json里是网页源码,不是玩家 JSON。同一功能在/chuni/v2/setting和/mai2/setting都是正常的。复现(
main@90ed95b,网站线上版本):/ongeki/settings,点底部"导出";ongeki_<extId>_exported.json;<!doctype html>开头,是 SPA 的index.html。我拿两份真实下载文件比了一遍:
JSON.parseongeki_*_exported.json<!doctype html>Unexpected token '<')chusan_*_exported.json{"gameId":"SDHD","userData":{…ongeki 那份的字节数与仓库
dist/index.html完全一致(都是 1796),\r\n/ 裸\n/ 裸\r的出现次数也逐项相同(38 / 40 / 39),唯一差别是 vite 的产物哈希(线上index-bhne9qTR.js,我本地构建index-B2FdUPgg.js)。也就是说服务端返回的就是那份 SPA 外壳。再直接打了一遍服务器确认(不带任何凭据的 GET,只看响应):
https://portal.naominet.live)/api/game/ongeki/export401+application/json← 接口存在,只是要 Bearer/ongeki/api/game/ongeki/export200+text/html+ 1796 字节 ← 就是上面那条 bug URL/no/such/path/at/all三点因此确定:音击导出接口本身没问题(401 说明它存在),这一条完全在前端;那 1796 字节的外壳对任意未知路径都一样,这正是请求打错地方还能拿到 200、一点报错都没有的原因;以及我手上那份导出文件与服务器对 bug URL 的响应逐字节相同(
1796 / CRLF 38 / LF 40 / CR 39,两侧 bundle 哈希同为bhne9qTR,与当前线上构建一致)。根因在
src/features/ongeki/OngekiSettingPage.tsx:57:rawFetch(src/lib/api/client.ts:64-66)不做任何前缀拼接,是"原样透传"的:前缀拼接在
buildUrl()里(client.ts:34-43,API_SERVER = '/',见:14),只有api.get/post/put/delete和api.blob会经过它:另外两处导出都走了
api.blob,所以路径是对的:ChuniV2SettingPage.tsx:109api.blob('api/game/chuni/v2/export', { aimeId })Maimai2SettingPage.tsx:216api.blob('api/game/maimai2/export', { aimeId })OngekiSettingPage.tsx:57rawFetch('api/game/ongeki/export'),原样交给fetch不带前导斜杠的字符串会被当成相对 URL,按当前文档 URL 解析(
index.html里没有<base>标签)。当前页是/ongeki/settings,所以settings这一段被替换掉了:请求打到一个不存在的路径上,服务端按 SPA 规则回了
index.html(HTTP 200)。之所以一声不吭,是因为
OngekiSettingPage.tsx:58的守卫只看 HTTP 状态:200 +
text/html对它来说是完全成功的响应,于是resp.blob()把 HTML 存成了.json,界面上还照常弹了"正在下载档案"。这是移植时丢的:旧版 Angular 显式拼了前缀(
backup分支src/app/sega/ongeki/ongeki-setting/ongeki-setting.component.ts:88):新版把
this.apiServer +去掉了,字符串字面量原封不动地留着,也没改成api.blob,于是一条绝对路径变成了相对路径。最小改动是补前导斜杠:
更建议直接对齐另外两处,统一走
api.blob():补一句:不建议顺手把
rawFetch改成自动拼前缀。Maimai2SettingPage.tsx:112用它上传头像,传的是/Maimai2Servlet/...这种绝对路径;如果rawFetch变成API_SERVER + url,'/' + '/Maimai2Servlet/...'会拼成协议相对 URL//Maimai2Servlet/...,反而坏掉。它保持原样透传是合理的,问题在调用方没给绝对路径——所以也可以考虑在rawFetch上加一行注释把「必须传绝对路径」写清楚,避免下次再踩。最后说明一下影响面,免得排查时扩大范围:这不是一类问题,全仓所有裸
fetch/rawFetch调用的字面量路径我都逐条看过了,其余的都带前导斜杠(Maimai2SettingPage.tsx:112的/Maimai2Servlet/...、AdminPage.tsx:122的/api/auth/signout),ContributorsPage.tsx:47用的是完整 https URL。舞萌与中二的导出也一并核了:两者走api.blob→buildUrl,实际请求分别是/api/game/maimai2/export?aimeId=…和/api/game/chuni/v2/export?aimeId=…,这两个接口同样返回 401(存在)。所以坏的只有音击这一处。