feat(sdk): 升级HaveADrink SDK并集成邀请系统

- 将HaveADrink依赖从1.0.8升级至1.0.12版本
- 集成邀请码功能,新增common/invite.js处理邀请链路
- 实现发送好友请求返回邀请码的新流程
- 添加删除动态功能,新增DeleteFeed接口
- 集成UniAppWebSocket适配器,重构WebSocket连接管理
- 优化好友请求支持关键词搜索功能
- 在FeedCard组件中添加删除按钮和分享按钮
- 更新SDK类型定义文件以匹配新接口规范
This commit is contained in:
cg
2026-08-04 23:32:45 +08:00
parent f988c8d868
commit 57eef74eb1
19 changed files with 1461 additions and 223 deletions
+16 -8
View File
@@ -2,11 +2,12 @@
* 基于 HaveADrink SDK 封装后端接口
*/
import HaveADrink from 'HaveADrink'
import UniAppWebSocket from './uni-websocket'
// ==========================================
// 后端服务地址(切换环境只需改这里)
// ==========================================
const API_HOST = 'https://dev.wash-painting.cn'
export const API_HOST = 'https://dev.wash-painting.cn'
// ==========================================
// HTTP 请求函数(供 SDK 内部调用)
@@ -113,6 +114,7 @@ const client = new HaveADrink({
host: API_HOST,
http_request: httpRequest,
upload: uploadRequest,
websocket: UniAppWebSocket,
token: uni.getStorageSync('auth_token') || ''
})
@@ -186,6 +188,12 @@ export async function UnlikeFeed({ feedId }) {
return { data: res }
}
/** 删除动态(仅本人可删,后端会校验归属) */
export async function DeleteFeed({ feedId }) {
const res = await client.DeleteFeed({ id: feedId })
return { data: res }
}
/** 获取动态评论 */
export async function GetFeedComments({ feedId }) {
const res = await client.GetFeedComments({ id: feedId })
@@ -210,19 +218,19 @@ export async function GetFriends({ keyword = '' } = {}) {
return { data: res }
}
/** 获取好友请求 */
export async function GetFriendRequests() {
const res = await client.GetFriendRequests({})
/** 获取好友请求(支持关键词搜索) */
export async function GetFriendRequests({ keyword = '' } = {}) {
const res = await client.GetFriendRequests({ keyword })
return { data: res }
}
/** 发送好友请求 */
export async function SendFriendRequest({ userId, message = '' }) {
const res = await client.SendFriendRequest({ userId, message })
/** 发送好友请求(创建邀请,SDK v1.0.12:无需 userId,响应返回 inviteCode 邀请码) */
export async function SendFriendRequest({ message = '' } = {}) {
const res = await client.SendFriendRequest({ message })
return { data: res }
}
/** 接受好友请求 */
/** 接受好友请求(id 为好友请求ID,即邀请码 inviteCode */
export async function AcceptFriendRequest({ requestId }) {
const res = await client.AcceptFriendRequest({ id: requestId })
return { data: res }
+86
View File
@@ -0,0 +1,86 @@
/* 碰盏日记 - 邀请链路处理(邀请码模式,SDK v1.0.12
*
* 后端规则:每次邀请都生成新的邀请码(一次性,不复用)
*
* 新流程:
* 1. 邀请人调用 SendFriendRequest({ message }) 创建邀请 → 后端返回新的 inviteCode(好友请求ID
* 2. 分享链接携带 inviteCode、inviterNick;每次分享后重新生成新码供下次使用
* 3. 被邀请人打开小程序 → index.vue / friends.vue onLoad 收到 inviteCode → savePendingInvite 暂存
* 4. 被邀请人登录后调用 AcceptFriendRequest({ id: inviteCode }) 接受邀请,双方成为酒友
*/
import { SendFriendRequest, AcceptFriendRequest } from './api'
const INVITE_KEY = 'pending_invite' // 我收到的邀请(待接受)
// 内存记录最近一次自己生成的邀请码(仅用于过滤自己点开自己的分享)
let myLastCode = ''
// 清理旧版本遗留的永久缓存键(旧方案曾永久缓存邀请码,新方案每次生成新码)
try { uni.removeStorageSync('my_invite_code') } catch (e) { /* ignore */ }
/**
* 生成新的邀请码(用于分享)
* 后端每次邀请都生成新码,故不做持久化缓存,每次调用都创建新邀请
* @returns {Promise<string>} 邀请码,未登录或失败返回空字符串
*/
export async function getMyInviteCode() {
if (uni.getStorageSync('is_logged_in') !== 'true') return ''
try {
const res = await SendFriendRequest({ message: '邀请你成为酒友,一起记录饮酒生活 🍻' })
const code = res.data && res.data.inviteCode
if (code !== undefined && code !== null && code !== '') {
myLastCode = String(code)
return myLastCode
}
} catch (e) {
console.warn('[invite] 创建邀请码失败', e)
}
return ''
}
/**
* 暂存收到的邀请(由分享落地页 onLoad 调用)
* @param {string|number} inviteCode - 邀请码(好友请求ID
* @param {string} inviterNick - 邀请人昵称(用于提示文案)
*/
export function savePendingInvite(inviteCode, inviterNick) {
if (!inviteCode) return
try {
// 自己分享给自己的邀请码,不处理(同一会话内自己点开自己的分享卡片)
if (myLastCode && String(myLastCode) === String(inviteCode)) return
uni.setStorageSync(INVITE_KEY, JSON.stringify({
inviteCode: String(inviteCode),
// uni-app onLoad options 已自动解码,此处直接存储
inviterNick: inviterNick || ''
}))
} catch (e) { /* ignore */ }
}
/**
* 接受暂存的邀请(已登录时调用)
* 用邀请码调用 AcceptFriendRequest,成功后清除暂存
* @returns {Promise<boolean>} 是否成功接受
*/
export async function handlePendingInvite() {
// 未登录时不处理,等登录完成后再调用
if (uni.getStorageSync('is_logged_in') !== 'true') return false
let invite = null
try {
invite = JSON.parse(uni.getStorageSync(INVITE_KEY) || '')
} catch (e) { /* ignore */ }
if (!invite || !invite.inviteCode) return false
try {
await AcceptFriendRequest({ requestId: invite.inviteCode })
uni.removeStorageSync(INVITE_KEY)
const nick = invite.inviterNick || '邀请人'
uni.showToast({ title: `已接受邀请,和「${nick}」成为酒友 🍻`, icon: 'none', duration: 2500 })
return true
} catch (e) {
// 后端可能返回"已是好友/邀请已失效"等业务失败,同样清除暂存避免重复提示
uni.removeStorageSync(INVITE_KEY)
console.warn('[invite] 接受邀请失败', e)
return false
}
}
+156
View File
@@ -0,0 +1,156 @@
/* 碰盏日记 - UniAppWebSocket 适配器
* 将 uni-app 的 uni.connectSocket 适配为 HaveADrink SDK WebsocketInterface
* (标准 WebSocket 风格:constructor(host)、onopen/onmessage/onclose/onerror、send、close
* 用法:在 SDK 初始化时传入 websocket: UniAppWebSocket
*/
export default class UniAppWebSocket {
/**
* @param {string} host - 完整 ws/wss 连接地址(SDK 已处理 https→wss 协议转换)
*/
constructor(host) {
this.url = host
// 可赋值的事件回调(SDK ChatWebSocketConn.connect() 中直接赋值)
this.onopen = null
this.onmessage = null
this.onclose = null
this.onerror = null
// 连接状态: connecting | open | closed
this.readyState = 0
this._sendQueue = []
this._closedByUser = false
this._connect()
}
_connect() {
// 微信小程序下直接用 wx.connectSocket
// uni 的 API 运行时(uni.api.esm.js)会向 connectSocket 注入 success/fail/complete 回调,
// 而微信规则是“传入回调后不再返回 SocketTask”,导致 uni.connectSocket 永远拿不到 task。
// wx.connectSocket 不传回调时正常返回 SocketTask,避免降级到全局 socket API
// (全局 API 重连时会重复注册监听器,引发重连风暴)
console.log('[UniAppWebSocket] 发起连接:', this.url)
const rawConnect = (typeof wx !== 'undefined' && typeof wx.connectSocket === 'function')
? wx.connectSocket.bind(wx)
: uni.connectSocket
const task = rawConnect({ url: this.url })
// 部分平台 uni.connectSocket 无返回值,降级为全局 API 监听
if (task && typeof task.onOpen === 'function') {
this.socketTask = task
task.onOpen((res) => {
console.log('[UniAppWebSocket] 连接已打开(open)')
this.readyState = 1
this._flush()
if (typeof this.onopen === 'function') {
this.onopen({ type: 'open' })
}
})
task.onMessage((res) => {
if (typeof this.onmessage === 'function') {
// 与浏览器 MessageEvent 对齐:消息内容放在 event.data
this.onmessage({ type: 'message', data: res.data })
}
})
task.onClose((event) => {
// 诊断:打印关闭码/原因,用于判断是服务端拒连还是网络问题
// 常见 code1006 异常断开、1000 正常关闭;reason 由服务端给出
console.warn('[UniAppWebSocket] 连接被关闭(close) code:', event && event.code,
'reason:', event && event.reason)
this.readyState = 3
if (!this._closedByUser && typeof this.onclose === 'function') {
this.onclose(Object.assign({ type: 'close' }, event || {}))
}
})
task.onError((err) => {
// 诊断:微信会在 errMsg 中给出具体原因(域名不在合法列表、TLS 失败等)
console.warn('[UniAppWebSocket] 连接错误(error):', err && (err.errMsg || err.message || JSON.stringify(err)))
if (typeof this.onerror === 'function') {
this.onerror(Object.assign({ type: 'error' }, err || {}))
}
})
} else {
console.warn('[UniAppWebSocket] 未获得 SocketTask,降级到全局 socket API')
// 兜底:旧版小程序全局 socket API
uni.onSocketOpen(() => {
this.readyState = 1
this._flush()
if (typeof this.onopen === 'function') {
this.onopen({ type: 'open' })
}
})
uni.onSocketMessage((res) => {
if (typeof this.onmessage === 'function') {
this.onmessage({ type: 'message', data: res.data })
}
})
uni.onSocketClose((event) => {
this.readyState = 3
if (!this._closedByUser && typeof this.onclose === 'function') {
this.onclose(Object.assign({ type: 'close' }, event || {}))
}
})
uni.onSocketError((err) => {
if (typeof this.onerror === 'function') {
this.onerror(Object.assign({ type: 'error' }, err || {}))
}
})
}
}
/**
* 发送数据(与浏览器 WebSocket.send 签名一致)
* @param {string} data
*/
send(data) {
if (this.readyState !== 1) {
// 连接未就绪时暂存,连接建立后补发
this._sendQueue.push(data)
return
}
if (this.socketTask) {
this.socketTask.send({
data,
fail: (err) => {
console.warn('[UniAppWebSocket] 发送失败', err)
}
})
} else {
// 兜底:旧版小程序全局 socket API
uni.sendSocketMessage({
data,
fail: (err) => {
console.warn('[UniAppWebSocket] 发送失败', err)
}
})
}
}
/** 主动关闭连接(主动关闭不再触发 onclose,由 SDK 自行控制重连) */
close() {
this._closedByUser = true
this.readyState = 3
if (this.socketTask) {
this.socketTask.close({
complete: () => {}
})
this.socketTask = null
} else {
uni.closeSocket({
complete: () => {}
})
}
}
/** 连接建立后补发暂存消息 */
_flush() {
if (!this._sendQueue.length) return
const queue = [...this._sendQueue]
this._sendQueue = []
queue.forEach((data) => this.send(data))
}
}
+115 -145
View File
@@ -1,10 +1,34 @@
/* 碰盏日记 - WebSocket 管理器
* 单例模式,负责私信实时通信
* 协议格式与 HaveADrink SDK ChatWebSocket 一致:{ mesgType, data }
* 支持心跳、自动重连、消息队列
* 基于 HaveADrink SDK ChatWebSocketChatWebSocketConn)实现:
* - 底层 socket 通过 UniAppWebSocket 适配器注入(common/uni-websocket.js
* - 连接地址、token query、指数退避重连均由 SDK 管理
* - 消息协议:{ mesgType, data }
* 对外保持 on/off/send/connect/disconnect/userId 接口,页面层无感知
*/
const WS_HOST = 'wss://dev.wash-painting.cn/api/have_a_drink/v1/chat/chat'
import client, { API_HOST } from './api'
// SDK 具名导出的连接类(绕开 client.ChatWebSocket 方法内部使用的浏览器 API)
import { ChatWebSocketConn } from 'HaveADrink'
/**
* 自行解析 host 并构造 ChatWebSocketConn
* SDK 的 client.ChatWebSocket() 内部使用 new URL() / new URLSearchParams()
* 微信小程序沙箱中裸标识符 URL 解析到不可构造的空壳,全局 polyfill 无法生效,
* 因此这里手动完成同样的逻辑:https -> wssquery 键值对拼接
* @param {object} input - { token }
* @returns {ChatWebSocketConn}
*/
function createChatConn(input) {
const m = String(API_HOST).match(/^(https?|wss?):\/\/([^/?#\s]+)/i)
const scheme = m && m[1].toLowerCase() === 'https' ? 'wss' : 'ws'
const host = `${scheme}://${m ? m[2] : API_HOST}`
const query = Object.keys(input || {})
.filter((k) => input[k] !== '' && input[k] !== null && input[k] !== undefined)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(input[k])}`)
.join('&')
return new ChatWebSocketConn(host, query)
}
// 客户端消息类型(与 SDK ClientMesgType 枚举值一致)
const ClientMesgType = {
@@ -14,42 +38,26 @@ const ClientMesgType = {
Ping: 'ping'
}
// 服务端消息类型(与 SDK ServerMesgType 枚举值一致)
const ServerMesgType = {
Chat: 'chat',
Typing: 'typing',
Read: 'read',
Ack: 'ack',
Pong: 'pong',
Connected: 'connected',
Kicked: 'kicked'
}
class WebSocketManager {
constructor() {
this.socketTask = null
this.conn = null // SDK ChatWebSocketConn 实例
this.isConnected = false
this.isConnecting = false
this.listeners = {} // 事件订阅 { event: [callbacks] }
this.messageQueue = [] // 断线期间暂存消息
this.messageQueue = [] // 断线期间暂存消息 { mesgType, data }
this.heartbeatTimer = null
this.heartbeatTimeout = null
this.reconnectTimer = null
this.reconnectAttempts = 0
this.maxReconnectDelay = 30000
this.heartbeatInterval = 30000 // 30s 发一次 ping
this.heartbeatWait = 60000 // 60s 无 pong 则重连
this.manualClose = false
this.token = ''
this.userId = '' // 连接成功后服务端返回的用户ID
this.userId = '' // 服务端 connected 事件返回的用户ID
}
/**
* 建立 WebSocket 连接
* 建立 WebSocket 连接(走 SDK ChatWebSocket
* @param {string} token - 用户认证 token
*/
connect(token) {
if (this.isConnected || this.isConnecting) return
if (this.conn || this.isConnecting) return
this.token = token || uni.getStorageSync('auth_token') || ''
if (!this.token) {
console.warn('[WS] 无 token,跳过连接')
@@ -57,65 +65,80 @@ class WebSocketManager {
}
this.manualClose = false
this.isConnecting = true
console.log('[WS] 正在连接(SDK ChatWebSocket...')
const url = WS_HOST
console.log('[WS] 正在连接...')
// SDK 内部:new websocket(`${host}/api/have_a_drink/v1/chat/chat?token=xxx`)
// websocket 即注入的 UniAppWebSocket 适配器,重连由 SDK 指数退避管理
// 注意:不用 client.ChatWebSocket()(其内部 new URL 在小程序沙箱不可用),
// 直接用 SDK 导出的 ChatWebSocketConn 构造,等价实现
const conn = createChatConn({ token: this.token })
this.socketTask = uni.connectSocket({
url,
header: {
'Authorization': `Bearer ${this.token}`
},
complete: () => {}
})
this.socketTask.onOpen(() => {
conn.onopen = () => {
console.log('[WS] 连接成功')
this.isConnected = true
this.isConnecting = false
this.reconnectAttempts = 0
this._startHeartbeat()
this._flushQueue()
this._emit('connect', {})
})
}
this.socketTask.onMessage((res) => {
try {
const msg = JSON.parse(res.data)
this._handleMessage(msg)
} catch (e) {
console.warn('[WS] 消息解析失败', res.data)
}
})
this.socketTask.onClose(() => {
conn.onclose = () => {
console.log('[WS] 连接关闭')
this.isConnected = false
this.isConnecting = false
this._stopHeartbeat()
this._emit('disconnect', {})
if (!this.manualClose) {
this._scheduleReconnect()
}
})
// 非主动关闭时,SDK 已自动安排指数退避重连,无需手动处理
}
this.socketTask.onError((err) => {
conn.onerror = (err) => {
console.warn('[WS] 连接错误', err)
this.isConnected = false
this.isConnecting = false
})
}
// ===== 服务端协议消息(SDK 已解析 { mesgType, data } =====
conn.onServerChatData = (data) => {
this._emit('message', data)
}
conn.onServerTypingData = (data) => {
this._emit('typing', data)
}
conn.onServerReadData = (data) => {
this._emit('read', data)
}
conn.onServerAckData = (data) => {
this._emit('ack', data)
}
conn.onServerConnectedData = (data) => {
// 服务端验证 token 成功后推送,携带 userId
this.userId = data && data.userId ? data.userId : ''
console.log('[WS] 服务端确认连接, userId:', this.userId)
this._emit('connected', data)
}
conn.onServerKickedData = (data) => {
console.warn('[WS] 被踢下线')
this._emit('kicked', data)
this.disconnect()
}
this.conn = conn
}
/** 主动断开连接 */
/** 主动断开连接SDK close() 会关闭重连标志并断开 socket) */
disconnect() {
this.manualClose = true
this._stopHeartbeat()
this._clearReconnect()
if (this.socketTask) {
this.socketTask.close()
this.socketTask = null
if (this.conn) {
this.conn.close()
this.conn = null
}
this.isConnected = false
this.isConnecting = false
}
/**
@@ -124,17 +147,16 @@ class WebSocketManager {
* @param {object} data - 消息数据
*/
send(mesgType, data = {}) {
const payload = JSON.stringify({ mesgType, data })
if (this.isConnected && this.socketTask) {
this.socketTask.send({ data: payload })
if (this.isConnected && this.conn) {
this._write(mesgType, data)
} else {
// 断线暂存队列(ping 不暂存)
if (mesgType !== ClientMesgType.Ping) {
this.messageQueue.push(payload)
this.messageQueue.push({ mesgType, data })
}
// 尝试重连
if (!this.isConnecting && !this.manualClose) {
this._scheduleReconnect()
// 未连接时触发(重)连接
if (!this.conn && !this.manualClose) {
this.connect(this.token)
}
}
}
@@ -167,38 +189,31 @@ class WebSocketManager {
// ========== 内部方法 ==========
/** 处理收到的消息(SDK ServerMesgType 协议) */
_handleMessage(msg) {
const { mesgType, data } = msg
switch (mesgType) {
case ServerMesgType.Chat:
this._emit('message', data)
break
case ServerMesgType.Typing:
this._emit('typing', data)
break
case ServerMesgType.Read:
this._emit('read', data)
break
case ServerMesgType.Ack:
this._emit('ack', data)
break
case ServerMesgType.Pong:
this._onPong()
break
case ServerMesgType.Connected:
// 服务端验证 token 成功后推送,携带 userId
this.userId = data && data.userId ? data.userId : ''
console.log('[WS] 服务端确认连接, userId:', this.userId)
this._emit('connected', data)
break
case ServerMesgType.Kicked:
console.warn('[WS] 被踢下线')
this._emit('kicked', data)
this.disconnect()
break
default:
console.log('[WS] 未知消息类型', mesgType)
/** 通过 SDK writeClient* 方法写入消息 */
_write(mesgType, data) {
try {
switch (mesgType) {
case ClientMesgType.Chat:
this.conn.writeClientChatData(data)
break
case ClientMesgType.Typing:
this.conn.writeClientTypingData(data)
break
case ClientMesgType.Read:
this.conn.writeClientReadData(data)
break
case ClientMesgType.Ping:
this.conn.writeClientPingData(data)
break
default:
console.warn('[WS] 未知发送类型', mesgType)
}
} catch (e) {
console.warn('[WS] 发送异常', e)
// 发送失败时暂存,待重连后补发
if (mesgType !== ClientMesgType.Ping) {
this.messageQueue.push({ mesgType, data })
}
}
}
@@ -212,61 +227,20 @@ class WebSocketManager {
}
}
/** 启动心跳 */
/** 启动心跳(保活,防运营商/小程序后台掐连接) */
_startHeartbeat() {
this._stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
this.send(ClientMesgType.Ping, {})
// 设置超时检测
this.heartbeatTimeout = setTimeout(() => {
console.warn('[WS] 心跳超时,触发重连')
if (this.socketTask) {
this.socketTask.close()
}
}, this.heartbeatWait)
}, this.heartbeatInterval)
}
/** 收到 pong,清除超时 */
_onPong() {
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = null
}
}
/** 停止心跳 */
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = null
}
}
/** 指数退避重连 */
_scheduleReconnect() {
if (this.manualClose || this.reconnectTimer) return
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
this.reconnectAttempts++
console.log(`[WS] ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.isConnecting = false
this.connect(this.token)
}, delay)
}
/** 清除重连计时器 */
_clearReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.reconnectAttempts = 0
}
/** 重连成功后重发队列消息 */
@@ -275,11 +249,7 @@ class WebSocketManager {
console.log(`[WS] 重发 ${this.messageQueue.length} 条暂存消息`)
const queue = [...this.messageQueue]
this.messageQueue = []
queue.forEach(payload => {
if (this.socketTask && this.isConnected) {
this.socketTask.send({ data: payload })
}
})
queue.forEach(({ mesgType, data }) => this._write(mesgType, data))
}
}