From 618c3f440971ef4fae9e34e564ea1ec51798b015 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Mon, 2 Mar 2026 17:15:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20WebSocket=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20+=20Contact=20Store=20+=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/websocket.js: 单例 WS 连接管理(心跳/重连/事件分发) - store/websocket.js: Pinia Store 管理连接状态 - store/contact.js: 联系人 Store(好友/申请/分组/黑名单/在线状态) - api/contact.js: 联系人 REST API 封装(17 个接口) - api/user.js: 用户搜索 API Made-with: Cursor --- frontend/src/api/contact.js | 116 +++++++++++++ frontend/src/api/user.js | 16 ++ frontend/src/services/websocket.js | 254 ++++++++++++++++++++++++++--- frontend/src/store/contact.js | 195 ++++++++++++++++++++++ frontend/src/store/websocket.js | 69 ++++++++ 5 files changed, 630 insertions(+), 20 deletions(-) create mode 100644 frontend/src/api/contact.js create mode 100644 frontend/src/api/user.js create mode 100644 frontend/src/store/contact.js create mode 100644 frontend/src/store/websocket.js diff --git a/frontend/src/api/contact.js b/frontend/src/api/contact.js new file mode 100644 index 0000000..d8c350c --- /dev/null +++ b/frontend/src/api/contact.js @@ -0,0 +1,116 @@ +/** + * 联系人模块 API + * + * 对应后端路由:/api/v1/contacts/* + * 接口文档见:docs/api/frontend/contact.md + */ + +import { get, post, put, del } from '@/utils/request' + +/** 获取好友列表 */ +const getFriendList = (groupId) => { + const params = groupId ? { group_id: groupId } : {} + return get('/api/v1/contacts', params) +} + +/** 发送好友申请 */ +const sendFriendRequest = (targetId, message = '') => { + return post('/api/v1/contacts/request', { target_id: targetId, message }) +} + +/** 接受好友申请 */ +const acceptRequest = (requestId) => { + return post('/api/v1/contacts/accept', { request_id: requestId }) +} + +/** 拒绝好友申请 */ +const rejectRequest = (requestId) => { + return post('/api/v1/contacts/reject', { request_id: requestId }) +} + +/** 获取待处理的好友申请 */ +const getPendingRequests = () => { + return get('/api/v1/contacts/requests') +} + +/** 删除好友 */ +const deleteFriend = (friendId) => { + return del(`/api/v1/contacts/${friendId}`) +} + +/** 更新好友备注 */ +const updateRemark = (friendId, remark) => { + return put(`/api/v1/contacts/${friendId}/remark`, { remark }) +} + +/** 拉黑用户 */ +const blockUser = (targetId) => { + return post('/api/v1/contacts/block', { target_id: targetId }) +} + +/** 取消拉黑 */ +const unblockUser = (userId) => { + return del(`/api/v1/contacts/block/${userId}`) +} + +/** 获取黑名单 */ +const getBlockList = () => { + return get('/api/v1/contacts/block') +} + +/** 获取分组列表 */ +const getGroups = () => { + return get('/api/v1/contacts/groups') +} + +/** 创建分组 */ +const createGroup = (name) => { + return post('/api/v1/contacts/groups', { name }) +} + +/** 修改分组 */ +const updateGroup = (groupId, name, sortOrder) => { + const data = { name } + if (sortOrder !== undefined) data.sort_order = sortOrder + return put(`/api/v1/contacts/groups/${groupId}`, data) +} + +/** 删除分组 */ +const deleteGroup = (groupId) => { + return del(`/api/v1/contacts/groups/${groupId}`) +} + +/** 移动好友到分组 */ +const moveToGroup = (friendId, groupId) => { + return put(`/api/v1/contacts/${friendId}/group`, { group_id: groupId }) +} + +/** 搜索用户 */ +const searchUsers = (keyword, page = 1, pageSize = 20) => { + return get('/api/v1/users/search', { keyword, page, page_size: pageSize }) +} + +/** 好友推荐 */ +const getRecommendFriends = () => { + return get('/api/v1/contacts/recommend') +} + +export default { + getFriendList, + sendFriendRequest, + acceptRequest, + rejectRequest, + getPendingRequests, + deleteFriend, + updateRemark, + blockUser, + unblockUser, + getBlockList, + getGroups, + createGroup, + updateGroup, + deleteGroup, + moveToGroup, + searchUsers, + getRecommendFriends +} diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js new file mode 100644 index 0000000..d6b5566 --- /dev/null +++ b/frontend/src/api/user.js @@ -0,0 +1,16 @@ +/** + * 用户搜索 API + * + * 对应后端路由:/api/v1/users/* + */ + +import { get } from '@/utils/request' + +/** 搜索用户(按用户名/昵称模糊匹配) */ +const searchUsers = (keyword, page = 1, pageSize = 20) => { + return get('/api/v1/users/search', { keyword, page, page_size: pageSize }) +} + +export default { + searchUsers +} diff --git a/frontend/src/services/websocket.js b/frontend/src/services/websocket.js index 4c9a620..e2300d1 100644 --- a/frontend/src/services/websocket.js +++ b/frontend/src/services/websocket.js @@ -1,27 +1,241 @@ /** - * WebSocket 服务模块(占位) + * WebSocket 连接管理服务 * - * 后续阶段实现 IM 即时通讯和会议信令时使用。 - * 职责: - * - 建立和管理 WebSocket 连接 - * - 心跳保活机制 - * - 断线自动重连 - * - 消息收发与事件分发 + * 单例模式,管理与后端的 WebSocket 长连接,提供: + * - JWT Token 认证连接 + * - 心跳保活(30s 间隔) + * - 断线自动重连(指数退避 1s→2s→4s→8s→30s max) + * - 事件分发:on(event, callback) / off(event, callback) * - * 对应架构设计见:docs/architecture/system-architecture.md 第 4.1 节 - * 日志规范见:docs/architecture/system-architecture.md 第 8.7 节 + * 对应后端:GET /ws?token=xxx */ -// TODO: Phase 2 - IM 模块开发时实现 -// 预期功能: -// - connect(token) — 建立 WebSocket 连接,附带认证 Token -// - disconnect() — 主动断开连接 -// - send(event, data) — 发送消息 -// - on(event, callback) — 监听事件 -// - off(event, callback) — 移除监听 -// - 自动心跳(30s 间隔) -// - 断线重连(指数退避,最大 5 次) +import { BASE_URL } from '@/utils/request' +import { getToken } from '@/utils/storage' -export default { - // 占位导出,后续实现时替换 +const WS_BASE = BASE_URL.replace(/^http/, 'ws') +const HEARTBEAT_INTERVAL = 30000 +const RECONNECT_BASE_DELAY = 1000 +const RECONNECT_MAX_DELAY = 30000 +const MAX_RECONNECT_ATTEMPTS = Infinity + +class WebSocketService { + constructor() { + this.ws = null + this.listeners = new Map() + this.heartbeatTimer = null + this.reconnectTimer = null + this.reconnectAttempts = 0 + this.isManualClose = false + this.seq = 0 + } + + /** + * 建立 WebSocket 连接 + * @param {string} [token] - JWT Token,不传则从本地存储获取 + */ + connect(token) { + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return + } + + const accessToken = token || getToken() + if (!accessToken) { + console.warn('[WS] 无 Token,无法连接') + return + } + + this.isManualClose = false + const url = `${WS_BASE}/ws?token=${accessToken}` + + try { + // #ifdef H5 + this.ws = new WebSocket(url) + // #endif + + // #ifndef H5 + this.ws = uni.connectSocket({ + url, + complete: () => {} + }) + // #endif + + this._bindEvents() + } catch (err) { + console.error('[WS] 连接创建失败', err) + this._scheduleReconnect() + } + } + + /** 主动断开连接 */ + disconnect() { + this.isManualClose = true + this._clearTimers() + if (this.ws) { + // #ifdef H5 + this.ws.close() + // #endif + // #ifndef H5 + uni.closeSocket() + // #endif + this.ws = null + } + } + + /** + * 发送消息 + * @param {string} event - 事件类型 + * @param {Object} [data] - 业务数据 + * @returns {number} 消息序号 + */ + send(event, data = {}) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.warn('[WS] 连接未就绪,无法发送') + return -1 + } + + this.seq++ + const msg = JSON.stringify({ + event, + seq: this.seq, + data, + time: new Date().toISOString() + }) + + // #ifdef H5 + this.ws.send(msg) + // #endif + // #ifndef H5 + uni.sendSocketMessage({ data: msg }) + // #endif + + return this.seq + } + + /** + * 注册事件监听 + * @param {string} event - 事件类型 + * @param {Function} callback - 回调函数 + */ + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event).add(callback) + } + + /** + * 移除事件监听 + * @param {string} event - 事件类型 + * @param {Function} callback - 回调函数 + */ + off(event, callback) { + const cbs = this.listeners.get(event) + if (cbs) { + cbs.delete(callback) + } + } + + /** 绑定 WebSocket 事件处理 */ + _bindEvents() { + // #ifdef H5 + this.ws.onopen = () => this._onOpen() + this.ws.onclose = (e) => this._onClose(e) + this.ws.onerror = (e) => this._onError(e) + this.ws.onmessage = (e) => this._onMessage(e.data) + // #endif + + // #ifndef H5 + uni.onSocketOpen(() => this._onOpen()) + uni.onSocketClose((e) => this._onClose(e)) + uni.onSocketError((e) => this._onError(e)) + uni.onSocketMessage((e) => this._onMessage(e.data)) + // #endif + } + + _onOpen() { + console.log('[WS] 连接成功') + this.reconnectAttempts = 0 + this._startHeartbeat() + this._emit('_connected') + } + + _onClose(e) { + console.log('[WS] 连接关闭', e) + this._clearTimers() + this._emit('_disconnected') + + if (!this.isManualClose) { + this._scheduleReconnect() + } + } + + _onError(e) { + console.error('[WS] 连接错误', e) + } + + _onMessage(data) { + try { + const msg = JSON.parse(data) + this._emit(msg.event, msg) + } catch (e) { + console.warn('[WS] 消息解析失败', data) + } + } + + /** 分发事件给注册的监听器 */ + _emit(event, data) { + const cbs = this.listeners.get(event) + if (cbs) { + cbs.forEach(cb => { + try { + cb(data) + } catch (e) { + console.error(`[WS] 事件处理器异常: ${event}`, e) + } + }) + } + } + + /** 启动心跳 */ + _startHeartbeat() { + this._clearTimers() + this.heartbeatTimer = setInterval(() => { + this.send('heartbeat') + }, HEARTBEAT_INTERVAL) + } + + /** 调度重连(指数退避) */ + _scheduleReconnect() { + if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.error('[WS] 超过最大重连次数') + return + } + + const delay = Math.min( + RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts), + RECONNECT_MAX_DELAY + ) + + console.log(`[WS] ${delay}ms 后重连 (第 ${this.reconnectAttempts + 1} 次)`) + this.reconnectTimer = setTimeout(() => { + this.reconnectAttempts++ + this.connect() + }, delay) + } + + _clearTimers() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } } + +/** 全局单例 */ +const wsService = new WebSocketService() +export default wsService diff --git a/frontend/src/store/contact.js b/frontend/src/store/contact.js new file mode 100644 index 0000000..7286917 --- /dev/null +++ b/frontend/src/store/contact.js @@ -0,0 +1,195 @@ +/** + * 联系人状态 Store + * + * 管理好友列表、好友申请、分组、黑名单和在线状态,提供: + * - 好友列表增删改查 + * - 好友申请发送/接受/拒绝 + * - 好友分组管理 + * - 在线状态跟踪(结合 WebSocket 推送) + * + * 对应后端 API:/api/v1/contacts/* + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import contactApi from '@/api/contact' +import wsService from '@/services/websocket' + +export const useContactStore = defineStore('contact', () => { + /** 好友列表 */ + const friendList = ref([]) + + /** 待处理的好友申请列表 */ + const pendingRequests = ref([]) + + /** 好友分组列表 */ + const groups = ref([]) + + /** 黑名单列表 */ + const blockList = ref([]) + + /** 在线状态映射 userID -> boolean */ + const onlineMap = ref({}) + + /** 未读好友申请数 */ + const pendingCount = computed(() => pendingRequests.value.length) + + // ==================== Actions ==================== + + /** 获取好友列表 */ + const fetchFriends = async (groupId) => { + const res = await contactApi.getFriendList(groupId) + friendList.value = res.data || [] + return friendList.value + } + + /** 获取待处理的好友申请 */ + const fetchPendingRequests = async () => { + const res = await contactApi.getPendingRequests() + pendingRequests.value = res.data || [] + return pendingRequests.value + } + + /** 发送好友申请 */ + const sendRequest = async (targetId, message) => { + await contactApi.sendFriendRequest(targetId, message) + } + + /** 接受好友申请 */ + const acceptRequest = async (requestId) => { + await contactApi.acceptRequest(requestId) + pendingRequests.value = pendingRequests.value.filter(r => r.id !== requestId) + await fetchFriends() + } + + /** 拒绝好友申请 */ + const rejectRequest = async (requestId) => { + await contactApi.rejectRequest(requestId) + pendingRequests.value = pendingRequests.value.filter(r => r.id !== requestId) + } + + /** 删除好友 */ + const deleteFriend = async (friendId) => { + await contactApi.deleteFriend(friendId) + friendList.value = friendList.value.filter(f => f.user_id !== friendId) + } + + /** 更新好友备注 */ + const updateRemark = async (friendId, remark) => { + await contactApi.updateRemark(friendId, remark) + const friend = friendList.value.find(f => f.user_id === friendId) + if (friend) friend.remark = remark + } + + /** 拉黑用户 */ + const blockUser = async (targetId) => { + await contactApi.blockUser(targetId) + friendList.value = friendList.value.filter(f => f.user_id !== targetId) + } + + /** 取消拉黑 */ + const unblockUser = async (userId) => { + await contactApi.unblockUser(userId) + blockList.value = blockList.value.filter(b => b.user_id !== userId) + } + + /** 获取黑名单列表 */ + const fetchBlockList = async () => { + const res = await contactApi.getBlockList() + blockList.value = res.data || [] + return blockList.value + } + + /** 获取分组列表 */ + const fetchGroups = async () => { + const res = await contactApi.getGroups() + groups.value = res.data || [] + return groups.value + } + + /** 创建分组 */ + const createGroup = async (name) => { + const res = await contactApi.createGroup(name) + groups.value.push(res.data) + return res.data + } + + /** 更新分组 */ + const updateGroup = async (groupId, name, sortOrder) => { + await contactApi.updateGroup(groupId, name, sortOrder) + const group = groups.value.find(g => g.id === groupId) + if (group) { + group.name = name + if (sortOrder !== undefined) group.sort_order = sortOrder + } + } + + /** 删除分组 */ + const deleteGroup = async (groupId) => { + await contactApi.deleteGroup(groupId) + groups.value = groups.value.filter(g => g.id !== groupId) + } + + /** 移动好友到分组 */ + const moveToGroup = async (friendId, groupId) => { + await contactApi.moveToGroup(friendId, groupId) + const friend = friendList.value.find(f => f.user_id === friendId) + if (friend) friend.group_id = groupId + } + + /** 更新单个用户的在线状态 */ + const setOnline = (userId, online) => { + onlineMap.value[userId] = online + const friend = friendList.value.find(f => f.user_id === userId) + if (friend) friend.is_online = online + } + + /** 初始化 WebSocket 事件监听 */ + const initWsListeners = () => { + wsService.on('notify.friend.request', () => { + fetchPendingRequests() + }) + + wsService.on('contact.request.accepted', () => { + fetchFriends() + }) + + wsService.on('user.status.online', (msg) => { + if (msg && msg.data) { + setOnline(msg.data.user_id, true) + } + }) + + wsService.on('user.status.offline', (msg) => { + if (msg && msg.data) { + setOnline(msg.data.user_id, false) + } + }) + } + + return { + friendList, + pendingRequests, + groups, + blockList, + onlineMap, + pendingCount, + fetchFriends, + fetchPendingRequests, + sendRequest, + acceptRequest, + rejectRequest, + deleteFriend, + updateRemark, + blockUser, + unblockUser, + fetchBlockList, + fetchGroups, + createGroup, + updateGroup, + deleteGroup, + moveToGroup, + setOnline, + initWsListeners + } +}) diff --git a/frontend/src/store/websocket.js b/frontend/src/store/websocket.js new file mode 100644 index 0000000..6d6f0d1 --- /dev/null +++ b/frontend/src/store/websocket.js @@ -0,0 +1,69 @@ +/** + * WebSocket 连接状态 Store + * + * 管理 WebSocket 连接生命周期,提供: + * - 连接/断开操作 + * - 连接状态跟踪(connected/disconnected) + * - 与 user store 联动(登录时自动连接,登出时断开) + * + * 对应服务层:services/websocket.js + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import wsService from '@/services/websocket' + +export const useWebSocketStore = defineStore('websocket', () => { + /** 连接状态 */ + const connected = ref(false) + + /** 是否已连接 */ + const isConnected = computed(() => connected.value) + + /** + * 初始化 WebSocket 连接 + * @param {string} [token] - JWT Token + */ + const connect = (token) => { + wsService.on('_connected', () => { + connected.value = true + }) + wsService.on('_disconnected', () => { + connected.value = false + }) + wsService.connect(token) + } + + /** 断开 WebSocket 连接 */ + const disconnect = () => { + wsService.disconnect() + connected.value = false + } + + /** + * 注册事件监听(代理 wsService) + * @param {string} event - 事件类型 + * @param {Function} callback - 回调函数 + */ + const on = (event, callback) => { + wsService.on(event, callback) + } + + /** + * 移除事件监听(代理 wsService) + * @param {string} event - 事件类型 + * @param {Function} callback - 回调函数 + */ + const off = (event, callback) => { + wsService.off(event, callback) + } + + return { + connected, + isConnected, + connect, + disconnect, + on, + off + } +})