refactor(chat): remove private unlock machine flow
This commit is contained in:
@@ -39,7 +39,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
unlockingPrivateMessageId: null,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createActor, fromCallback, fromPromise, waitFor } from "xstate";
|
||||
|
||||
import {
|
||||
ChatSendResponse,
|
||||
UnlockPrivateResponse,
|
||||
type UiMessage,
|
||||
} from "@/data/dto/chat";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
@@ -29,11 +28,6 @@ interface SendMessageHttpOutput {
|
||||
reply: UiMessage | null;
|
||||
}
|
||||
|
||||
interface UnlockPrivateOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
function makeChatSendResponse(): ChatSendResponse {
|
||||
return ChatSendResponse.from({
|
||||
reply: "",
|
||||
@@ -56,7 +50,6 @@ function makeChatSendResponse(): ChatSendResponse {
|
||||
function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
unlockedContent?: string;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -97,21 +90,6 @@ function createTestChatMachine(
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockPrivateMessage: fromPromise<
|
||||
UnlockPrivateOutput,
|
||||
{ messageId: string }
|
||||
>(async ({ input }) => ({
|
||||
messageId: input.messageId,
|
||||
response: UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
content: options.unlockedContent ?? "unlocked",
|
||||
showUpgrade: false,
|
||||
paywallTriggered: false,
|
||||
privateFreeLimit: 0,
|
||||
privateUsedToday: 0,
|
||||
reason: "ok",
|
||||
}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -167,49 +145,6 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("unlocks a locked private message and clears unlocking state", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-25",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
isPrivate: true,
|
||||
lockedPrivate: true,
|
||||
privateMessageHint: "A private message is waiting.",
|
||||
},
|
||||
],
|
||||
unlockedContent: "Here is the unlocked private message.",
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" });
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) =>
|
||||
snapshot.context.messages[0]?.content ===
|
||||
"Here is the unlocked private message.",
|
||||
);
|
||||
|
||||
const [message] = actor.getSnapshot().context.messages;
|
||||
expect(message.content).toBe("Here is the unlocked private message.");
|
||||
expect(message.locked).toBe(false);
|
||||
expect(message.lockedPrivate).toBe(false);
|
||||
expect(message.privateMessageHint).toBeNull();
|
||||
expect(actor.getSnapshot().context.unlockingPrivateMessageId).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
|
||||
@@ -278,21 +213,6 @@ describe("chatMachine transitions", () => {
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockPrivateMessage: fromPromise<
|
||||
UnlockPrivateOutput,
|
||||
{ messageId: string }
|
||||
>(async ({ input }) => ({
|
||||
messageId: input.messageId,
|
||||
response: UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
content: "unlocked",
|
||||
showUpgrade: false,
|
||||
paywallTriggered: false,
|
||||
privateFreeLimit: 0,
|
||||
privateUsedToday: 0,
|
||||
reason: "ok",
|
||||
}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
|
||||
@@ -25,7 +25,6 @@ interface ChatState {
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
upgradeHint: MachineContext["upgradeHint"];
|
||||
upgradeDetail: MachineContext["upgradeDetail"];
|
||||
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -52,7 +51,6 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
upgradeHint: state.context.upgradeHint,
|
||||
upgradeDetail: state.context.upgradeDetail,
|
||||
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||
isLoadingMore: state.context.isLoadingMore,
|
||||
hasMore: state.context.hasMore,
|
||||
historyOffset: state.context.historyOffset,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* chat 机器不感知鉴权。
|
||||
*
|
||||
* 鉴权生命周期事件(本轮新增):
|
||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏,不连 WS
|
||||
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏,不连 WS
|
||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
||||
*
|
||||
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||
@@ -23,7 +23,6 @@ export type ChatEvent =
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| { type: "ChatQueuedSendStarted" }
|
||||
| {
|
||||
|
||||
@@ -73,41 +73,6 @@ export const loadMoreHistoryActor = fromPromise<
|
||||
};
|
||||
});
|
||||
|
||||
export const unlockPrivateMessageActor = fromPromise<
|
||||
{
|
||||
messageId: string;
|
||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||
},
|
||||
{ messageId: string }
|
||||
>(async ({ input }) => {
|
||||
const result = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||
if (Result.isErr(result)) {
|
||||
log.error("[chat-machine] unlockPrivateMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
error: result.error,
|
||||
});
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.data.unlocked && result.data.content != null) {
|
||||
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||
input.messageId,
|
||||
result.data.content,
|
||||
);
|
||||
if (Result.isErr(localResult)) {
|
||||
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
|
||||
messageId: input.messageId,
|
||||
error: localResult.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: input.messageId,
|
||||
response: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||
({ sendBack, receive }) => {
|
||||
return createMessageQueueActor(sendBack, receive);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Chat 状态机(XState v5)
|
||||
*
|
||||
* 鉴权解耦(事件驱动):
|
||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||
* chat 机器不感知鉴权 —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||
* ChatAuthSync 派发登录态生命周期事件:
|
||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||
* - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS)
|
||||
* - `ChatGuestLogin` → 游客会话
|
||||
* - `ChatUserLogin { token }` → 其他登录用户会话
|
||||
* - `ChatLogout` → 正式登录用户登出
|
||||
*
|
||||
* 登录态流转约束:
|
||||
@@ -15,8 +15,8 @@
|
||||
*
|
||||
* 状态结构(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||||
* - `userSession`(parent):其他登录用户会话 —— 不 invoke WS
|
||||
* - `guestSession`(parent):游客会话
|
||||
* - `userSession`(parent):其他登录用户会话
|
||||
*
|
||||
* init 任务:
|
||||
* - guestSession.initializing:loadHistory
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
sendMessageHttpActor,
|
||||
loadMoreHistoryActor,
|
||||
httpMessageQueueActor,
|
||||
unlockPrivateMessageActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -73,7 +72,6 @@ export const chatMachine = setup({
|
||||
sendMessageHttp: sendMessageHttpActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||
},
|
||||
actions: {
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
@@ -195,7 +193,7 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendSocketErrorMessage: assign(({ context }) => {
|
||||
appendQueuedSendErrorMessage: assign(({ context }) => {
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
@@ -213,69 +211,6 @@ export const chatMachine = setup({
|
||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
}),
|
||||
|
||||
setUnlockingPrivateMessage: assign(({ event }) => {
|
||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||
return {
|
||||
unlockingPrivateMessageId: event.messageId,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
||||
if (!("output" in event)) return {};
|
||||
const output = event.output as unknown as {
|
||||
messageId: string;
|
||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||
};
|
||||
const { messageId, response } = output;
|
||||
|
||||
if (response.unlocked && response.content != null) {
|
||||
return {
|
||||
messages: context.messages.map((message) =>
|
||||
message.id === messageId
|
||||
? {
|
||||
...message,
|
||||
content: response.content ?? message.content,
|
||||
locked: false,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
isPrivate: message.isPrivate ?? true,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
unlockingPrivateMessageId: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.showUpgrade) {
|
||||
return {
|
||||
unlockingPrivateMessageId: null,
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason: "private_message",
|
||||
upgradeHint: null,
|
||||
upgradeDetail: {
|
||||
usedToday: response.privateUsedToday,
|
||||
limit: response.privateFreeLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
unlockingPrivateMessageId: null,
|
||||
};
|
||||
}),
|
||||
|
||||
clearUnlockingPrivateMessage: assign({
|
||||
unlockingPrivateMessageId: null,
|
||||
}),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "chat",
|
||||
@@ -312,7 +247,7 @@ export const chatMachine = setup({
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
actions: "appendQueuedSendErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
@@ -354,11 +289,7 @@ export const chatMachine = setup({
|
||||
actions: "appendGuestUserImage",
|
||||
target: "sending",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||
// 游客不支持翻页历史,发送消息统一走 HTTP 队列。
|
||||
},
|
||||
},
|
||||
sending: {
|
||||
@@ -379,23 +310,6 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -421,7 +335,7 @@ export const chatMachine = setup({
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
actions: "appendQueuedSendErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
@@ -460,10 +374,6 @@ export const chatMachine = setup({
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
},
|
||||
},
|
||||
sendingViaHttp: {
|
||||
@@ -503,23 +413,6 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ChatState {
|
||||
isReplyingAI: boolean;
|
||||
pendingReplyCount: number;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: "daily_limit" | "private_message" | "image" | null;
|
||||
upgradeReason: "daily_limit" | "image" | null;
|
||||
upgradeHint: string | null;
|
||||
upgradeDetail:
|
||||
| {
|
||||
@@ -15,7 +15,6 @@ export interface ChatState {
|
||||
limit?: number;
|
||||
}
|
||||
| null;
|
||||
unlockingPrivateMessageId: string | null;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -34,7 +33,6 @@ export const initialState: ChatState = {
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
unlockingPrivateMessageId: null,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
|
||||
Reference in New Issue
Block a user