refactor(chat): remove guest history synchronization
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"characterId": "elio",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi Elio.",
|
||||
"timestamp": "2026-06-23T02:10:00.000Z"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hi, I am right here with you.",
|
||||
"timestamp": "2026-06-23T02:10:04.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import type {
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatSyncRequestSchema,
|
||||
ChatSendResponse,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
@@ -58,15 +56,6 @@ export class ChatRemoteDataSource {
|
||||
return Result.wrap(() => this.api.getPreviews(options));
|
||||
}
|
||||
|
||||
async syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.syncGuestHistory(ChatSyncRequestSchema.parse(request), options),
|
||||
);
|
||||
}
|
||||
|
||||
async getHistory(
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatMessage,
|
||||
ChatSendResponse,
|
||||
UnlockHistoryResponse,
|
||||
@@ -63,13 +62,6 @@ export class ChatRepository implements IChatRepository {
|
||||
return this.remote.getPreviews(options);
|
||||
}
|
||||
|
||||
async syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>> {
|
||||
return this.remote.syncGuestHistory(request, options);
|
||||
}
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatImageData,
|
||||
ChatLockDetailData,
|
||||
ChatLockType,
|
||||
@@ -79,12 +78,6 @@ export interface IChatRepository {
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatPreviewsResponse>>;
|
||||
|
||||
/** 把一个角色的游客历史同步到正式账号。 */
|
||||
syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ChatPreviewsResponseSchema,
|
||||
ChatSyncRequestSchema,
|
||||
SendMessageRequestSchema,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
@@ -26,23 +25,12 @@ describe("multi-role chat schemas", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("parses nullable previews and immutable sync messages", () => {
|
||||
it("parses nullable immutable previews", () => {
|
||||
const previews = ChatPreviewsResponseSchema.parse({
|
||||
items: [{ characterId: "elio", message: null }],
|
||||
});
|
||||
const sync = ChatSyncRequestSchema.parse({
|
||||
characterId: "elio",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Welcome back",
|
||||
timestamp: "2026-07-20T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(previews.items[0]?.message).toBeNull();
|
||||
expect(Object.isFrozen(previews.items)).toBe(true);
|
||||
expect(Object.isFrozen(sync.messages)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ export * from "./chat_media";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_payloads";
|
||||
export * from "./request/send_message_request";
|
||||
export * from "./request/chat_sync_request";
|
||||
export * from "./request/unlock_history_request";
|
||||
export * from "./request/unlock_private_request";
|
||||
export * from "./response/chat_history_response";
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ChatSyncMessageSchema = z
|
||||
.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
timestamp: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const ChatSyncRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
messages: z.array(ChatSyncMessageSchema).min(1).readonly(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ChatSyncRequestInput = z.input<typeof ChatSyncRequestSchema>;
|
||||
export type ChatSyncRequest = z.output<typeof ChatSyncRequestSchema>;
|
||||
@@ -3,6 +3,5 @@
|
||||
*/
|
||||
|
||||
export * from "./send_message_request";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./unlock_private_request";
|
||||
export * from "./unlock_history_request";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ChatSyncRequestSchema,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
UnlockPrivateRequestSchema,
|
||||
@@ -74,30 +73,16 @@ describe("multi-character API contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads previews and syncs guest history", async () => {
|
||||
it("loads chat previews", async () => {
|
||||
const api = new ChatApi();
|
||||
httpClientMock
|
||||
.mockResolvedValueOnce({ success: true, data: { items: [] } })
|
||||
.mockResolvedValueOnce({ success: true, data: {} });
|
||||
httpClientMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { items: [] },
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/previews", {});
|
||||
});
|
||||
|
||||
it("forwards request cancellation to the HTTP client", async () => {
|
||||
|
||||
@@ -26,6 +26,5 @@
|
||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||
"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" }
|
||||
"chatPreviews": { "method": "get", "path": "/api/chat/previews" }
|
||||
}
|
||||
|
||||
@@ -104,6 +104,4 @@ export class ApiPath {
|
||||
|
||||
static readonly chatPreviews = apiContract.chatPreviews.path;
|
||||
|
||||
static readonly chatSync = apiContract.chatSync.path;
|
||||
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ChatPreviewsResponseSchema,
|
||||
ChatSendResponse,
|
||||
ChatSendResponseSchema,
|
||||
ChatSyncRequest,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
@@ -67,17 +66,6 @@ export class ChatApi {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁单条历史付费 / 私密消息
|
||||
*/
|
||||
|
||||
@@ -259,22 +259,6 @@ export class NavigationStorage {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
}
|
||||
|
||||
static async saveGuestChatOwnerKey(ownerKey: string): Promise<void> {
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.guestChatOwnerKey,
|
||||
ownerKey,
|
||||
z.string().min(1),
|
||||
);
|
||||
}
|
||||
|
||||
static async getGuestChatOwnerKey(): Promise<string | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.guestChatOwnerKey,
|
||||
z.string().min(1),
|
||||
);
|
||||
return Result.isOk(result) ? result.data : null;
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
value: PendingChatUnlock | null,
|
||||
): PendingChatUnlock | null {
|
||||
|
||||
@@ -28,7 +28,6 @@ export const StorageKeys = {
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
pendingChatPromotion: "pending_chat_promotion",
|
||||
guestChatOwnerKey: "guest_chat_owner_key",
|
||||
|
||||
// pwa / app info
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDeviceId: vi.fn(),
|
||||
getLocalMessages: vi.fn(),
|
||||
syncGuestHistory: vi.fn(),
|
||||
clearLocalMessages: vi.fn(),
|
||||
getGuestChatOwnerKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/data/storage/auth", () => ({
|
||||
AuthStorage: {
|
||||
getInstance: () => ({ getDeviceId: mocks.getDeviceId }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||
loadChatRepository: async () => ({
|
||||
getLocalMessages: mocks.getLocalMessages,
|
||||
syncGuestHistory: mocks.syncGuestHistory,
|
||||
clearLocalMessages: mocks.clearLocalMessages,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/data/storage/navigation", () => ({
|
||||
NavigationStorage: {
|
||||
getGuestChatOwnerKey: mocks.getGuestChatOwnerKey,
|
||||
},
|
||||
}));
|
||||
|
||||
import { syncGuestHistoriesToUser } from "../guest-history-sync";
|
||||
|
||||
describe("syncGuestHistoriesToUser", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getDeviceId.mockResolvedValue(Result.ok("device-1"));
|
||||
mocks.getGuestChatOwnerKey.mockResolvedValue(null);
|
||||
mocks.clearLocalMessages.mockResolvedValue(Result.ok(undefined));
|
||||
});
|
||||
|
||||
it("groups guest history by character and clears only successful caches", async () => {
|
||||
mocks.getLocalMessages.mockImplementation(async (identity: string) =>
|
||||
Result.ok([
|
||||
{
|
||||
id: `${identity}:message`,
|
||||
role: "user",
|
||||
type: "text",
|
||||
content: identity,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
mocks.syncGuestHistory.mockImplementation(
|
||||
async (request: { characterId: string }) =>
|
||||
request.characterId === "elio"
|
||||
? Result.ok(undefined)
|
||||
: Result.err(new Error("offline")),
|
||||
);
|
||||
|
||||
await syncGuestHistoriesToUser(["elio", "maya-tan"]);
|
||||
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ characterId: "elio" }),
|
||||
);
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ characterId: "maya-tan" }),
|
||||
);
|
||||
expect(mocks.clearLocalMessages).toHaveBeenCalledOnce();
|
||||
expect(mocks.clearLocalMessages).toHaveBeenCalledWith(
|
||||
"device:device-1::character:elio",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips characters without guest messages", async () => {
|
||||
mocks.getLocalMessages.mockResolvedValue(Result.ok([]));
|
||||
|
||||
await syncGuestHistoriesToUser(["elio"]);
|
||||
|
||||
expect(mocks.syncGuestHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { ChatSyncRequestSchema } from "@/data/schemas/chat";
|
||||
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
const log = new Logger("StoresChatGuestHistorySync");
|
||||
|
||||
export async function syncGuestHistoriesToUser(
|
||||
characterIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const deviceResult = await AuthStorage.getInstance().getDeviceId();
|
||||
const savedOwnerKey = await NavigationStorage.getGuestChatOwnerKey();
|
||||
const ownerKey =
|
||||
savedOwnerKey ??
|
||||
(Result.isOk(deviceResult) && deviceResult.data
|
||||
? `device:${deviceResult.data}`
|
||||
: null);
|
||||
if (!ownerKey) return;
|
||||
|
||||
const repository = await loadChatRepository();
|
||||
await Promise.allSettled(
|
||||
[...new Set(characterIds)].map(async (characterId) => {
|
||||
const cacheIdentity = buildChatConversationKey(ownerKey, characterId);
|
||||
const messagesResult = await repository.getLocalMessages(cacheIdentity);
|
||||
if (Result.isErr(messagesResult) || messagesResult.data.length === 0) return;
|
||||
|
||||
const messages = messagesResult.data.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
return [{
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: normalizeTimestamp(message.createdAt),
|
||||
}];
|
||||
});
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const request = ChatSyncRequestSchema.parse({ characterId, messages });
|
||||
const syncResult = await repository.syncGuestHistory(request);
|
||||
if (Result.isErr(syncResult)) {
|
||||
log.warn("[chat-sync] guest history sync failed", {
|
||||
characterId,
|
||||
error: syncResult.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clearResult = await repository.clearLocalMessages(cacheIdentity);
|
||||
if (Result.isErr(clearResult)) {
|
||||
log.warn("[chat-sync] guest history cleanup failed", {
|
||||
characterId,
|
||||
error: clearResult.error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime())
|
||||
? new Date().toISOString()
|
||||
: parsed.toISOString();
|
||||
}
|
||||
@@ -16,11 +16,6 @@ import {
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { syncGuestHistoriesToUser } from "@/stores/chat/guest-history-sync";
|
||||
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export function ChatAuthSync() {
|
||||
const authState = useAuthSelector(
|
||||
@@ -32,7 +27,6 @@ export function ChatAuthSync() {
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const characterCatalog = useCharacterCatalog();
|
||||
const prevSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,27 +42,14 @@ export function ChatAuthSync() {
|
||||
}
|
||||
|
||||
if (authState.loginStatus === "guest") {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const ownerResult = await resolveChatCacheOwnerKey();
|
||||
if (Result.isOk(ownerResult)) {
|
||||
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
|
||||
}
|
||||
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
chatDispatch({ type: "ChatGuestLogin" });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||
if (!cancelled && tokenR.success && tokenR.data) {
|
||||
await syncGuestHistoriesToUser(
|
||||
characterCatalog.characters.map((character) => character.id),
|
||||
);
|
||||
if (cancelled) return;
|
||||
chatDispatch({
|
||||
type: "ChatUserLogin",
|
||||
token: tokenR.data,
|
||||
@@ -83,7 +64,6 @@ export function ChatAuthSync() {
|
||||
authState.hasInitialized,
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
characterCatalog.characters,
|
||||
chatDispatch,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user