feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁单条历史付费 / 私密消息
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user