feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { ExceptionHandler } from "@/core/errors";
import { ApiError } from "../api_result";
import { getCharacterErrorCode } from "../character_error_code";
describe("getCharacterErrorCode", () => {
it("reads nested backend detail through AppException causes", () => {
const error = ExceptionHandler.toError(
new ApiError("HTTP_ERROR", "Mismatch", 409, {
detail: { errorCode: "CHARACTER_MISMATCH" },
}),
);
expect(getCharacterErrorCode(error)).toBe("CHARACTER_MISMATCH");
});
it("ignores unrelated backend codes", () => {
expect(
getCharacterErrorCode(
new ApiError("HTTP_ERROR", "No", 400, {
detail: { errorCode: "OTHER_ERROR" },
}),
),
).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ChatSyncRequestSchema,
SendMessageRequestSchema,
UnlockHistoryRequestSchema,
UnlockPrivateRequestSchema,
@@ -13,9 +14,10 @@ vi.mock("../http_client", () => ({
}));
import { ChatApi } from "../chat_api";
import { CharacterApi } from "../character_api";
import { PrivateRoomApi } from "../private_room_api";
const CHARACTER_ID = "character_elio";
const CHARACTER_ID = "elio";
describe("multi-character API contract", () => {
beforeEach(() => {
@@ -59,6 +61,45 @@ describe("multi-character API contract", () => {
});
});
it("loads the chat character catalog", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { items: [], defaultCharacterId: "elio" },
});
await new CharacterApi().getChatCharacters();
expect(httpClientMock).toHaveBeenCalledWith("/api/characters", {
query: { capability: "chat" },
});
});
it("loads previews and syncs guest history", async () => {
const api = new ChatApi();
httpClientMock
.mockResolvedValueOnce({ success: true, data: { items: [] } })
.mockResolvedValueOnce({ success: true, data: {} });
await api.getPreviews();
const request = ChatSyncRequestSchema.parse({
characterId: CHARACTER_ID,
messages: [
{
role: "user",
content: "Hello",
timestamp: "2026-07-20T00:00:00.000Z",
},
],
});
await api.syncGuestHistory(request);
expect(httpClientMock).toHaveBeenNthCalledWith(1, "/api/chat/previews", {});
expect(httpClientMock).toHaveBeenNthCalledWith(2, "/api/chat/sync", {
method: "POST",
body: request,
});
});
it("forwards request cancellation to the HTTP client", async () => {
const controller = new AbortController();
httpClientMock.mockResolvedValue({
+4 -1
View File
@@ -24,5 +24,8 @@
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }
"feedback": { "method": "post", "path": "/api/feedback" },
"characters": { "method": "get", "path": "/api/characters" },
"chatPreviews": { "method": "get", "path": "/api/chat/previews" },
"chatSync": { "method": "post", "path": "/api/chat/sync" }
}
+7
View File
@@ -99,4 +99,11 @@ export class ApiPath {
// ============ 用户反馈相关 ============
static readonly feedback = apiContract.feedback.path;
// ============ 角色目录相关 ============
static readonly characters = apiContract.characters.path;
static readonly chatPreviews = apiContract.chatPreviews.path;
static readonly chatSync = apiContract.chatSync.path;
}
+19
View File
@@ -0,0 +1,19 @@
import {
CharacterListResponse,
CharacterListResponseSchema,
} from "@/data/schemas/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi {
async getChatCharacters(): Promise<CharacterListResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters, {
query: { capability: "chat" },
});
return CharacterListResponseSchema.parse(unwrap(env));
}
}
export const characterApi = new CharacterApi();
@@ -0,0 +1,36 @@
import { ApiError } from "./api_result";
export const CHARACTER_ERROR_CODES = [
"CHARACTER_DISABLED",
"CHARACTER_NOT_FOUND",
"CHARACTER_MISMATCH",
"CHARACTER_STATE_UNAVAILABLE",
] as const;
export type CharacterErrorCode = (typeof CHARACTER_ERROR_CODES)[number];
export function getCharacterErrorCode(error: unknown): CharacterErrorCode | null {
let current = error;
for (let depth = 0; depth < 5 && current; depth += 1) {
if (current instanceof ApiError) {
const code = readErrorCode(current.details);
if (isCharacterErrorCode(code)) return code;
}
current = current instanceof Error ? current.cause : null;
}
return null;
}
function readErrorCode(value: unknown): unknown {
if (!value || typeof value !== "object") return null;
const data = value as Record<string, unknown>;
if (typeof data.errorCode === "string") return data.errorCode;
return readErrorCode(data.detail) ?? readErrorCode(data.data);
}
function isCharacterErrorCode(value: unknown): value is CharacterErrorCode {
return (
typeof value === "string" &&
(CHARACTER_ERROR_CODES as readonly string[]).includes(value)
);
}
+23
View File
@@ -7,8 +7,11 @@
import {
ChatHistoryResponse,
ChatHistoryResponseSchema,
ChatPreviewsResponse,
ChatPreviewsResponseSchema,
ChatSendResponse,
ChatSendResponseSchema,
ChatSyncRequest,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
@@ -55,6 +58,26 @@ export class ChatApi {
);
}
async getPreviews(
options?: { signal?: AbortSignal },
): Promise<ChatPreviewsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatPreviews, {
...(options?.signal ? { signal: options.signal } : {}),
});
return ChatPreviewsResponseSchema.parse(unwrap(env));
}
async syncGuestHistory(
body: ChatSyncRequest,
options?: { signal?: AbortSignal },
): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
method: "POST",
body,
...(options?.signal ? { signal: options.signal } : {}),
}).then(unwrap);
}
/**
* 解锁单条历史付费 / 私密消息
*/
+2
View File
@@ -6,6 +6,8 @@ export * from "./api_path";
export * from "./api_result";
export * from "./auth_api";
export * from "./chat_api";
export * from "./character_api";
export * from "./character_error_code";
export * from "./feedback_api";
export * from "./http_client";
export * from "./metrics_api";
+3 -4
View File
@@ -1,4 +1,3 @@
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import {
PrivateAlbumsResponse,
PrivateAlbumsResponseSchema,
@@ -12,19 +11,19 @@ import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export interface GetPrivateAlbumsInput {
characterId?: string;
characterId: string;
limit?: number;
}
export class PrivateRoomApi {
async getAlbums(
input: GetPrivateAlbumsInput = {},
input: GetPrivateAlbumsInput,
): Promise<PrivateAlbumsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateRoomAlbums,
{
query: {
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
characterId: input.characterId,
limit: input.limit ?? 20,
},
},