refactor(chat): remove guest history synchronization

This commit is contained in:
2026-07-20 14:02:39 +08:00
parent 1f7ab2be04
commit 4d1c85727a
17 changed files with 10 additions and 300 deletions
@@ -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 { import {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatSyncRequestSchema,
ChatSendResponse, ChatSendResponse,
SendMessageRequestSchema, SendMessageRequestSchema,
UnlockHistoryRequestSchema, UnlockHistoryRequestSchema,
@@ -58,15 +56,6 @@ export class ChatRemoteDataSource {
return Result.wrap(() => this.api.getPreviews(options)); 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( async getHistory(
characterId: string, characterId: string,
limit = 50, limit = 50,
-8
View File
@@ -10,7 +10,6 @@ import type {
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatMessage, ChatMessage,
ChatSendResponse, ChatSendResponse,
UnlockHistoryResponse, UnlockHistoryResponse,
@@ -63,13 +62,6 @@ export class ChatRepository implements IChatRepository {
return this.remote.getPreviews(options); return this.remote.getPreviews(options);
} }
async syncGuestHistory(
request: ChatSyncRequest,
options?: ChatRequestOptions,
): Promise<Result<void>> {
return this.remote.syncGuestHistory(request, options);
}
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
async unlockPrivateMessage( async unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
@@ -7,7 +7,6 @@
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatImageData, ChatImageData,
ChatLockDetailData, ChatLockDetailData,
ChatLockType, ChatLockType,
@@ -79,12 +78,6 @@ export interface IChatRepository {
options?: ChatRequestOptions, options?: ChatRequestOptions,
): Promise<Result<ChatPreviewsResponse>>; ): Promise<Result<ChatPreviewsResponse>>;
/** 把一个角色的游客历史同步到正式账号。 */
syncGuestHistory(
request: ChatSyncRequest,
options?: ChatRequestOptions,
): Promise<Result<void>>;
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
unlockPrivateMessage( unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
ChatPreviewsResponseSchema, ChatPreviewsResponseSchema,
ChatSyncRequestSchema,
SendMessageRequestSchema, SendMessageRequestSchema,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
@@ -26,23 +25,12 @@ describe("multi-role chat schemas", () => {
).toThrow(); ).toThrow();
}); });
it("parses nullable previews and immutable sync messages", () => { it("parses nullable immutable previews", () => {
const previews = ChatPreviewsResponseSchema.parse({ const previews = ChatPreviewsResponseSchema.parse({
items: [{ characterId: "elio", message: null }], 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(previews.items[0]?.message).toBeNull();
expect(Object.isFrozen(previews.items)).toBe(true); expect(Object.isFrozen(previews.items)).toBe(true);
expect(Object.isFrozen(sync.messages)).toBe(true);
}); });
}); });
-1
View File
@@ -7,7 +7,6 @@ export * from "./chat_media";
export * from "./chat_message"; export * from "./chat_message";
export * from "./chat_payloads"; export * from "./chat_payloads";
export * from "./request/send_message_request"; export * from "./request/send_message_request";
export * from "./request/chat_sync_request";
export * from "./request/unlock_history_request"; export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request"; export * from "./request/unlock_private_request";
export * from "./response/chat_history_response"; 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>;
-1
View File
@@ -3,6 +3,5 @@
*/ */
export * from "./send_message_request"; export * from "./send_message_request";
export * from "./chat_sync_request";
export * from "./unlock_private_request"; export * from "./unlock_private_request";
export * from "./unlock_history_request"; export * from "./unlock_history_request";
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
ChatSyncRequestSchema,
SendMessageRequestSchema, SendMessageRequestSchema,
UnlockHistoryRequestSchema, UnlockHistoryRequestSchema,
UnlockPrivateRequestSchema, 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(); const api = new ChatApi();
httpClientMock httpClientMock.mockResolvedValueOnce({
.mockResolvedValueOnce({ success: true, data: { items: [] } }) success: true,
.mockResolvedValueOnce({ success: true, data: {} }); data: { items: [] },
});
await api.getPreviews(); 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).toHaveBeenCalledWith("/api/chat/previews", {});
expect(httpClientMock).toHaveBeenNthCalledWith(2, "/api/chat/sync", {
method: "POST",
body: request,
});
}); });
it("forwards request cancellation to the HTTP client", async () => { it("forwards request cancellation to the HTTP client", async () => {
+1 -2
View File
@@ -26,6 +26,5 @@
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" }, "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" }, "characters": { "method": "get", "path": "/api/characters" },
"chatPreviews": { "method": "get", "path": "/api/chat/previews" }, "chatPreviews": { "method": "get", "path": "/api/chat/previews" }
"chatSync": { "method": "post", "path": "/api/chat/sync" }
} }
-2
View File
@@ -104,6 +104,4 @@ export class ApiPath {
static readonly chatPreviews = apiContract.chatPreviews.path; static readonly chatPreviews = apiContract.chatPreviews.path;
static readonly chatSync = apiContract.chatSync.path;
} }
-12
View File
@@ -11,7 +11,6 @@ import {
ChatPreviewsResponseSchema, ChatPreviewsResponseSchema,
ChatSendResponse, ChatSendResponse,
ChatSendResponseSchema, ChatSendResponseSchema,
ChatSyncRequest,
SendMessageRequest, SendMessageRequest,
UnlockHistoryRequest, UnlockHistoryRequest,
UnlockHistoryResponse, UnlockHistoryResponse,
@@ -67,17 +66,6 @@ export class ChatApi {
return ChatPreviewsResponseSchema.parse(unwrap(env)); 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); 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( private static parsePendingChatUnlock(
value: PendingChatUnlock | null, value: PendingChatUnlock | null,
): PendingChatUnlock | null { ): PendingChatUnlock | null {
-1
View File
@@ -28,7 +28,6 @@ export const StorageKeys = {
pendingChatImageReturn: "pending_chat_image_return", pendingChatImageReturn: "pending_chat_image_return",
pendingChatUnlock: "pending_chat_unlock", pendingChatUnlock: "pending_chat_unlock",
pendingChatPromotion: "pending_chat_promotion", pendingChatPromotion: "pending_chat_promotion",
guestChatOwnerKey: "guest_chat_owner_key",
// pwa / app info // pwa / app info
pwaDialogShown: "pwa_dialog_shown", 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();
});
});
-65
View File
@@ -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();
}
+2 -22
View File
@@ -16,11 +16,6 @@ import {
useAuthSelector, useAuthSelector,
} from "@/stores/auth/auth-context"; } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-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() { export function ChatAuthSync() {
const authState = useAuthSelector( const authState = useAuthSelector(
@@ -32,7 +27,6 @@ export function ChatAuthSync() {
shallowEqual, shallowEqual,
); );
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const characterCatalog = useCharacterCatalog();
const prevSessionKeyRef = useRef<string | null>(null); const prevSessionKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -48,27 +42,14 @@ export function ChatAuthSync() {
} }
if (authState.loginStatus === "guest") { if (authState.loginStatus === "guest") {
let cancelled = false; chatDispatch({ type: "ChatGuestLogin" });
void (async () => { return;
const ownerResult = await resolveChatCacheOwnerKey();
if (Result.isOk(ownerResult)) {
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
}
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
})();
return () => {
cancelled = true;
};
} }
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken(); const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) { if (!cancelled && tokenR.success && tokenR.data) {
await syncGuestHistoriesToUser(
characterCatalog.characters.map((character) => character.id),
);
if (cancelled) return;
chatDispatch({ chatDispatch({
type: "ChatUserLogin", type: "ChatUserLogin",
token: tokenR.data, token: tokenR.data,
@@ -83,7 +64,6 @@ export function ChatAuthSync() {
authState.hasInitialized, authState.hasInitialized,
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
characterCatalog.characters,
chatDispatch, chatDispatch,
]); ]);