实现Issue226阶段1客户端请求统一标记

在fetchClientHttp统一注入X-Genarrative-Client标记

补充Web与Tauri HTTP transport及主站路径定向测试

保留Authorization、响应包络和X-Request-ID等原有Header

同步更新Issue226阶段1实施状态与验收记录
This commit is contained in:
2026-09-01 13:06:51 +00:00
parent a2896a28a9
commit 8c0af3d8b8
4 changed files with 129 additions and 6 deletions
@@ -2,6 +2,8 @@ import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
export type ClientServerPreset = 'release' | 'dev' | 'custom';
@@ -125,6 +127,12 @@ type ClientHttpTarget = {
url: string;
};
function withAgcClientMarker(init: RequestInit): RequestInit {
const headers = new Headers(init.headers);
headers.set(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE);
return { ...init, headers };
}
function currentClientHttpContext(): ClientHttpContext {
return {
isDevelopment: import.meta.env.DEV,
@@ -177,8 +185,9 @@ export async function fetchClientHttp(
? currentContext
: { ...currentContext, serverBaseUrl },
);
const markedInit = withAgcClientMarker(init);
if (target.transport === 'tauri-http') {
return tauriHttpFetch(target.url, init);
return tauriHttpFetch(target.url, markedInit);
}
return fetch(target.url, init);
return fetch(target.url, markedInit);
}
@@ -1,8 +1,16 @@
import { afterEach, describe, expect, it } from 'vitest';
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
} from '../../../packages/shared/src/http';
import {
AGC_CLIENT_MARKER_HEADER,
AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
fetchClientHttp,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
@@ -11,8 +19,106 @@ import {
setClientServerSelection,
} from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
describe('AGC client HTTP transport', () => {
afterEach(() => resetClientServerSelectionForTests());
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
resetClientServerSelectionForTests();
});
it('adds the AGC marker while preserving and overriding request headers', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
const inputHeaders = new Headers({
Authorization: 'Bearer fixture-token',
'X-Request-ID': 'request-123',
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
[AGC_CLIENT_MARKER_HEADER]: 'caller-value',
});
const init: RequestInit = {
method: 'POST',
headers: inputHeaders,
body: '{}',
credentials: 'same-origin',
};
await fetchClientHttp('/api/auth/me', init);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [target, forwardedInit] = fetchMock.mock.calls[0] as [
string,
RequestInit,
];
const forwardedHeaders = new Headers(forwardedInit.headers);
expect(target).toBe('/api/auth/me');
expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe(
AGC_CLIENT_MARKER_VALUE,
);
expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token');
expect(forwardedHeaders.get('X-Request-ID')).toBe('request-123');
expect(forwardedHeaders.get(API_RESPONSE_ENVELOPE_HEADER)).toBe(
API_RESPONSE_ENVELOPE_VERSION,
);
expect(forwardedInit.method).toBe('POST');
expect(forwardedInit.body).toBe('{}');
expect(forwardedInit.credentials).toBe('same-origin');
expect(inputHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe('caller-value');
});
it.each([
'/api/auth/me',
'/api/profile/dashboard',
'/api/editor/projects',
'/api/assets/read-bytes?objectKey=fixture',
])('marks %s through the shared Web transport', async (url) => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await fetchClientHttp(url, {});
const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(
new Headers(forwardedInit.headers).get(AGC_CLIENT_MARKER_HEADER),
).toBe(AGC_CLIENT_MARKER_VALUE);
});
it('adds the AGC marker to the Tauri HTTP transport', async () => {
const tauriFetchMock = vi.mocked(tauriHttpFetch);
tauriFetchMock.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubEnv('MODE', 'production');
vi.stubEnv('DEV', false);
vi.stubGlobal('window', {
__TAURI__: {},
location: { protocol: 'tauri:' },
});
await fetchClientHttp(
'/api/auth/me',
{ headers: { Authorization: 'Bearer fixture-token' } },
{ serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL },
);
expect(tauriFetchMock).toHaveBeenCalledTimes(1);
const [target, forwardedInit] = tauriFetchMock.mock.calls[0] as [
string,
RequestInit,
];
const forwardedHeaders = new Headers(forwardedInit.headers);
expect(target).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`);
expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe(
AGC_CLIENT_MARKER_VALUE,
);
expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token');
});
it('keeps local development requests on the Vite API proxy', () => {
expect(
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本方案实际实施范围
- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施
状态:阶段 0 已完成,待阶段 1 实施<br>
状态:阶段 1 已完成,待阶段 2 实施<br>
本方案不包含代码修改
## 1. 一句话交付结果
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本计划全部实施范围
- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现
当前状态:阶段 0 已完成;阶段 1 尚未开始。阶段 0 的证据与验收记录见
当前状态:阶段 1 已完成;阶段 2 尚未开始。阶段 0 的证据与验收记录见
[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。
## 1. 交付目标
@@ -125,6 +125,14 @@ TS 定向测试必须证明:
- 没有在业务 API 文件中出现重复 Header 注入。
- 相关 TS 定向测试通过。
### 5.4 阶段 1 实施记录
- `clientHttp.ts` 新增固定 Header 常量,并在 `fetchClientHttp` 内通过 `Headers` 合并并覆盖同名调用方 Header。
- Web `fetch` 与 Tauri HTTP plugin 都接收同一份带标记的 `RequestInit`;原始 `RequestInit.headers` 不被修改。
- `clientHttp.test.ts` 已覆盖 `/api/auth/*`、`/api/profile/*`、`/api/editor/*`、`/api/assets/*`,并分别验证 Web/Tauri transport、Authorization、envelope Header、`X-Request-ID` 和同名 Header 覆盖。
- 阶段 1 验证命令:`npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts`(14 tests passed);`npm run ai-game-creator-shell:typecheck` 通过。
- `appUpdate.ts` 未接入 `fetchClientHttp`,更新下载边界保持不变。
## 6. 阶段 2:Rust 主站 Client Builder factory
### 6.1 工作内容