feat(chat): implement WebSocket chat client with useChatSocket hook
Replace the stubbed chat API with a real WebSocket hook that connects to /ws/chat and supports DM creation, messaging, session join/leave, and history retrieval. Keep REST stub functions for backward compatibility with existing pages that require listChatSessions/getChatSessionById imports.
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
import { deny } from "@/lib/decision"
|
||||
import type { ApiDecision } from "@/lib/errors"
|
||||
import type { ChatMessage, ChatSession } from "@/lib/types"
|
||||
|
||||
export type ListChatSessionsOptions = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type ListChatMessagesOptions = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const unavailable = "聊天接口暂未开放"
|
||||
|
||||
export async function listChatSessions(_options?: ListChatSessionsOptions): Promise<ChatSession[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getChatSessionById(_sessionId: string): Promise<ChatSession | undefined> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function listChatMessages(
|
||||
_sessionId: string,
|
||||
_options?: ListChatMessagesOptions,
|
||||
): Promise<ChatMessage[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
export async function sendTextMessage(_sessionId: string, _content: string): Promise<ApiDecision> {
|
||||
return deny(404, unavailable)
|
||||
}
|
||||
|
||||
export async function sendImageMessage(
|
||||
_sessionId: string,
|
||||
_imageUrl: string,
|
||||
): Promise<ApiDecision> {
|
||||
return deny(404, unavailable)
|
||||
}
|
||||
@@ -12,6 +12,29 @@ function getCookieValue(name: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
let csrfReady: Promise<void> | null = null
|
||||
|
||||
async function prepareCsrf() {
|
||||
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||
|
||||
csrfReady ??= fetch("/healthz", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
csrfReady = null
|
||||
})
|
||||
await csrfReady
|
||||
|
||||
if (!getCookieValue("__Host-XSRF-TOKEN")) {
|
||||
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
|
||||
const text = await res.text()
|
||||
if (!text) return { json: null, text: "" }
|
||||
@@ -70,6 +93,8 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
||||
formData.set("type", type)
|
||||
formData.set("file", file)
|
||||
|
||||
await prepareCsrf()
|
||||
|
||||
const headers = new Headers()
|
||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||
@@ -78,6 +103,7 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
const { json, text } = await readJsonBody(res)
|
||||
|
||||
+31
-4
@@ -60,6 +60,34 @@ function getCookieValue(name: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
let csrfReady: Promise<void> | null = null
|
||||
|
||||
function needsCsrf(method: string) {
|
||||
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"
|
||||
}
|
||||
|
||||
async function prepareCsrf(path: string) {
|
||||
if (typeof document === "undefined") return
|
||||
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||
|
||||
csrfReady ??= fetch("/healthz", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
csrfReady = null
|
||||
})
|
||||
await csrfReady
|
||||
|
||||
if (!getCookieValue("__Host-XSRF-TOKEN") && path !== "/healthz") {
|
||||
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const v = value as { code?: unknown; message?: unknown }
|
||||
@@ -91,10 +119,8 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
||||
}
|
||||
|
||||
const method = (rest.method ?? "GET").toUpperCase()
|
||||
if (
|
||||
(method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") &&
|
||||
!headers.has("XSRF-TOKEN")
|
||||
) {
|
||||
if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) {
|
||||
await prepareCsrf(path)
|
||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||
}
|
||||
@@ -103,6 +129,7 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
||||
...rest,
|
||||
headers,
|
||||
body,
|
||||
credentials: rest.credentials ?? "include",
|
||||
})
|
||||
|
||||
const { json: data, text } = await readJsonBody(res)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { login, logout, register, resetPassword } from "./auth"
|
||||
export { sendForgotPasswordCode } from "./auth-extra"
|
||||
export { getChatSessionById, listChatMessages, listChatSessions } from "./chat"
|
||||
export { requestWithAuth } from "./client"
|
||||
export { addComment, listCommentsByPost, toggleCommentLike } from "./comments"
|
||||
export { getDisputeByOrderId, listDisputes } from "./disputes"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
export type ChatSocketMessage = {
|
||||
id: string
|
||||
sessionId: string
|
||||
senderId: string
|
||||
type: "text" | "image" | "system"
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type WsEnvelope = {
|
||||
type: string
|
||||
sessionId?: number
|
||||
senderId?: number
|
||||
content?: string
|
||||
msgType?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type ChatSocketState = {
|
||||
connected: boolean
|
||||
sessionId: string | null
|
||||
messages: ChatSocketMessage[]
|
||||
error: string | null
|
||||
createDM: (targetUserId: string) => void
|
||||
joinSession: (nextSessionId: string) => void
|
||||
leaveSession: (nextSessionId: string) => void
|
||||
sendTextMessage: (content: string) => void
|
||||
sendImageMessage: (imageUrl: string) => void
|
||||
requestHistory: (nextSessionId: string) => void
|
||||
}
|
||||
|
||||
function getChatSocketUrl() {
|
||||
const configured = process.env.NEXT_PUBLIC_BACKEND_URL
|
||||
const origin =
|
||||
configured ||
|
||||
(process.env.NODE_ENV === "development" ? "http://localhost:18080" : window.location.origin)
|
||||
const url = new URL(origin)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.pathname = "/ws/chat"
|
||||
url.search = ""
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function unixToIso(value: unknown) {
|
||||
if (typeof value !== "number" || value <= 0) return new Date().toISOString()
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
|
||||
function normalizeMessage(value: unknown): ChatSocketMessage | null {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const item = value as {
|
||||
id?: unknown
|
||||
sessionId?: unknown
|
||||
senderId?: unknown
|
||||
type?: unknown
|
||||
content?: unknown
|
||||
createdAt?: unknown
|
||||
}
|
||||
if (typeof item.content !== "string") return null
|
||||
|
||||
const type = item.type === "image" || item.type === "system" ? item.type : "text"
|
||||
return {
|
||||
id: String(item.id ?? `${item.sessionId ?? ""}-${item.senderId ?? ""}-${item.createdAt ?? ""}`),
|
||||
sessionId: String(item.sessionId ?? ""),
|
||||
senderId: String(item.senderId ?? ""),
|
||||
type,
|
||||
content: item.content,
|
||||
createdAt: unixToIso(item.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
function liveMessage(msg: WsEnvelope): ChatSocketMessage | null {
|
||||
if (msg.type !== "message" || !msg.content || msg.sessionId === undefined) return null
|
||||
const data = typeof msg.data === "object" && msg.data !== null ? msg.data : {}
|
||||
const messageId = "messageId" in data ? (data as { messageId?: unknown }).messageId : undefined
|
||||
const type = msg.msgType === "image" ? "image" : "text"
|
||||
|
||||
return {
|
||||
id: String(messageId ?? `${msg.sessionId}-${msg.senderId ?? ""}-${Date.now()}`),
|
||||
sessionId: String(msg.sessionId),
|
||||
senderId: String(msg.senderId ?? ""),
|
||||
type,
|
||||
content: msg.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function jsonWithInt64(type: string, fields: Record<string, string | undefined>) {
|
||||
const parts = [`"type":${JSON.stringify(type)}`]
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!value) continue
|
||||
parts.push(`"${key}":${value}`)
|
||||
}
|
||||
return `{${parts.join(",")}}`
|
||||
}
|
||||
|
||||
function jsonMessage(sessionId: string, content: string, msgType: "text" | "image") {
|
||||
const payload = JSON.stringify({ type: "message", content, msgType })
|
||||
return payload.replace(/^\{/, `{"sessionId":${sessionId},`)
|
||||
}
|
||||
|
||||
export function useChatSocket(): ChatSocketState {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ChatSocketMessage[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const userId = useAuthStore((state) => state.user?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
|
||||
const ws = new WebSocket(getChatSocketUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data) as WsEnvelope
|
||||
|
||||
if (msg.type === "dm_created" || msg.type === "group_created" || msg.type === "joined") {
|
||||
if (msg.sessionId !== undefined) setSessionId(String(msg.sessionId))
|
||||
if (msg.sessionId !== undefined)
|
||||
ws.send(jsonWithInt64("history", { sessionId: String(msg.sessionId) }))
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "history") {
|
||||
const nextMessages = Array.isArray(msg.data)
|
||||
? msg.data
|
||||
.map(normalizeMessage)
|
||||
.filter((item): item is ChatSocketMessage => item !== null)
|
||||
: []
|
||||
setMessages(nextMessages)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
setError(msg.content ?? "聊天服务错误")
|
||||
return
|
||||
}
|
||||
|
||||
const nextMessage = liveMessage(msg)
|
||||
if (nextMessage) setMessages((prev) => [...prev, nextMessage])
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setConnected(false)
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setError("聊天连接失败")
|
||||
ws.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
ws.close()
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const sendRaw = useCallback((payload: string) => {
|
||||
const ws = wsRef.current
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(payload)
|
||||
}, [])
|
||||
|
||||
const createDM = useCallback(
|
||||
(targetUserId: string) => sendRaw(jsonWithInt64("create_dm", { targetId: targetUserId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const joinSession = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("join", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const leaveSession = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("leave", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const requestHistory = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("history", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const sendTextMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!sessionId) return
|
||||
sendRaw(jsonMessage(sessionId, content, "text"))
|
||||
},
|
||||
[sendRaw, sessionId],
|
||||
)
|
||||
|
||||
const sendImageMessage = useCallback(
|
||||
(imageUrl: string) => {
|
||||
if (!sessionId) return
|
||||
sendRaw(jsonMessage(sessionId, imageUrl, "image"))
|
||||
},
|
||||
[sendRaw, sessionId],
|
||||
)
|
||||
|
||||
return {
|
||||
connected,
|
||||
sessionId,
|
||||
messages,
|
||||
error,
|
||||
createDM,
|
||||
joinSession,
|
||||
leaveSession,
|
||||
sendTextMessage,
|
||||
sendImageMessage,
|
||||
requestHistory,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user