feat(sdk): 更新 SDK 设计文档和 API 重构

- 添加 expiresAt 和 refreshUserSig 参数支持自动续签
- 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化
- 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发
- 重构 RN SDK 文档结构,简化安装和使用方式
- 更新统一登录流程,支持 profile 信息传递
- 添加 IM 数据库自动隔离功能
- 修复 Android 群消息聚合问题
- 补充自动化测试验证和错误处理机制
这个提交包含在:
XuqmGroup 2026-05-01 21:27:39 +08:00
父节点 7d63672a7a
当前提交 9e11a63144
共有 24 个文件被更改,包括 424 次插入967 次删除

276
README.md
查看文件

@ -1,256 +1,40 @@
# XuqmGroup React Native SDK 文档 # XuqmGroup React Native SDK
> TypeScript · React Native 0.76+ · 发布至 Nexus npm `rn-sdk` 的稳定入口是 `src/index.ts`,统一登录/登出层在 `src/sdk.ts`
> `rn-sdk` 为内部基础包,业务方正常接入时使用 `rn-common` 和各业务模块即可。 旧的 `src/core`、`src/im`、`src/push`、`src/update` 目录已清理,避免继续引用废弃实现。
## 当前结构
```text
XuqmGroup-RNSDK/
├── src/
│ ├── index.ts # 对外聚合入口
│ └── sdk.ts # 统一登录 / 登出封装
├── packages/
│ ├── common/ # 初始化、网络、设备、Token、基础组件
│ ├── im/ # IM、会话、历史、群组、关系链
│ ├── push/ # 推送设备注册
│ └── update/ # App 更新 / RN 热更新
└── README.md
```
## 安装 ## 安装
`rn-common` 可以独立使用;`rn-im` / `rn-push` / `rn-update` 会自动带上 `rn-common``rn-sdk`;`rn-sdk` 是内部基础包,不建议业务方直接引用。
```bash ```bash
# 基础能力 yarn add @xuqm/rn-sdk
npm install @xuqm/rn-common yarn add @react-native-async-storage/async-storage
# 或
yarn add @xuqm/rn-common
# 单独接入某个业务模块时,`rn-common` 会作为依赖自动安装 # 如需按模块拆分接入,也可以直接安装
npm install @xuqm/rn-im yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update
npm install @xuqm/rn-push
npm install @xuqm/rn-update
# 依赖安装peerDependencies
npm install @react-native-async-storage/async-storage
``` ```
## 目录结构 ## 入口
``` - `XuqmSDK.initialize({ appKey, debug })`
XuqmGroup-RNSDK/src/ - `XuqmSDK.login({ userId, userSig, profile, expiresAt, refreshUserSig })`
├── core/ - `XuqmSDK.logout()`
│ ├── http.ts # fetch HTTP 客户端,AsyncStorage Token - `ImSDK`
│ └── sdk.ts # XuqmSDK 初始化入口 - `PushSDK`
├── im/ - `UpdateSDK`
│ └── imClient.ts # WebSocket IM 客户端,指数退避重连
├── push/
│ └── pushSDK.ts # 推送 Token 注册
├── update/
│ └── updateSDK.ts # 检查更新 / 下载 Bundle
└── index.ts # 统一导出
```
--- 详细用法见 [docs/rn-sdk/README.md](../docs/rn-sdk/README.md)。
## 快速开始
### 1. 初始化App 入口)
```typescript
import { XuqmSDK } from '@xuqm/rn-common'
await XuqmSDK.init({
appKey: 'ak_your_app_key',
debug: __DEV__,
})
```
### 2. 用户登录后设置 Token
```typescript
await XuqmSDK.setToken('eyJ...')
// 登出时
await XuqmSDK.setToken(null)
```
Token 持久化存储于 `AsyncStorage`,App 重启后自动恢复。
---
## HTTP 客户端
```typescript
import { apiRequest } from '@xuqm/rn-common'
// 自动附加 Bearer Token
const data = await apiRequest<UserInfo>('/api/user/profile')
const result = await apiRequest<LoginResp>('/api/auth/login', {
method: 'POST',
body: { account, password },
})
```
---
## IM 模块
### ImClient
```typescript
import { ImClient, MsgType, ChatType } from '@xuqm/rn-im'
const im = new ImClient()
im.on('connected', () => console.log('IM 已连接'))
im.on('disconnected', (code, reason) => console.log('断开', code))
im.on('message', (msg) => {
console.log('新消息', msg.content)
// msg: ImMessage
})
im.on('revoke', ({ msgId, operatorId }) => {
console.log('消息被撤回', msgId)
})
im.on('error', (err) => console.error(err))
// 连接
im.connect()
// 发送消息
im.send({
toId: 'user_002',
chatType: ChatType.SINGLE,
msgType: MsgType.TEXT,
content: 'Hello from RN!',
})
// 撤回
im.revoke('msg-uuid')
// 组件卸载时断开
useEffect(() => {
im.connect()
return () => im.disconnect()
}, [])
```
### React Hook 封装示例
```typescript
import { useEffect, useState, useRef } from 'react'
import { ImClient, ImMessage } from '@xuqm/rn-im'
export function useIm() {
const [messages, setMessages] = useState<ImMessage[]>([])
const [connected, setConnected] = useState(false)
const imRef = useRef<ImClient | null>(null)
useEffect(() => {
const im = new ImClient()
imRef.current = im
im.on('connected', () => setConnected(true))
im.on('disconnected', () => setConnected(false))
im.on('message', (msg) => setMessages(prev => [...prev, msg]))
im.connect()
return () => im.disconnect()
}, [])
const send = (params: Parameters<ImClient['send']>[0]) =>
imRef.current?.send(params)
return { messages, connected, send }
}
```
### ImMessage 结构
```typescript
interface ImMessage {
id: string
fromId: string
toId: string
chatType: 'SINGLE' | 'GROUP'
msgType: MsgType
content: string
extra?: string
revoked: boolean
createdAt: string
}
```
### 消息类型MsgType
```typescript
enum MsgType {
TEXT = 'TEXT',
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
AUDIO = 'AUDIO',
FILE = 'FILE',
CUSTOM = 'CUSTOM',
LOCATION = 'LOCATION',
NOTIFY = 'NOTIFY',
RICH_TEXT = 'RICH_TEXT',
CALL_AUDIO = 'CALL_AUDIO',
CALL_VIDEO = 'CALL_VIDEO',
FORWARD = 'FORWARD',
}
```
### 自动重连
断线后指数退避重连3s → 6s → 12s → ... → 最大 30s。调用 `disconnect()` 后停止。
---
## 推送模块
```typescript
import { PushSDK } from '@xuqm/rn-push'
// 在获取到设备推送 Token 后调用(如小米、华为推送回调)
await PushSDK.registerToken({
appId: 'ak_xxx',
userId: 'user_001',
vendor: 'XIAOMI', // HUAWEI / XIAOMI / OPPO / VIVO / HONOR / APNS
token: 'device_push_token',
})
```
---
## 版本管理模块
### 检查 App 更新
```typescript
import { UpdateSDK } from '@xuqm/rn-update'
const result = await UpdateSDK.checkAppUpdate({
appId: 'ak_xxx',
platform: 'android', // 'android' | 'ios'
currentVersionCode: 10,
})
if (result.needsUpdate) {
console.log(result.versionName, result.downloadUrl, result.forceUpdate)
}
```
### 检查 RN Bundle 热更新
```typescript
const rnResult = await UpdateSDK.checkRnUpdate({
appId: 'ak_xxx',
moduleId: 'main',
platform: 'android',
currentVersion: '1.0.0',
})
if (rnResult.needsUpdate && rnResult.info) {
const { downloadUrl, md5 } = rnResult.info
// 下载 bundle,校验 md5,调用原生模块热加载
}
```
---
## 发版
```bash
# 确认 .npmrc 指向 https://nexus.xuqinmin.com/repository/npm-hosted/
# 登录 Nexus npm首次
npm login --registry=https://nexus.xuqinmin.com/repository/npm-hosted/
# 发版
npm publish
```
版本号在 `package.json``version` 字段维护。

查看文件

@ -10,7 +10,10 @@
"publishConfig": { "publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
}, },
"scripts": { "typecheck": "tsc --noEmit" }, "scripts": {
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node --test dist-test/tests/runtime.test.js"
},
"dependencies": { "dependencies": {
"@xuqm/rn-common": ">=0.2.0", "@xuqm/rn-common": ">=0.2.0",
"@xuqm/rn-sdk": ">=0.2.0" "@xuqm/rn-sdk": ">=0.2.0"

查看文件

@ -16,6 +16,7 @@ import type {
UserProfile, UserProfile,
} from './types' } from './types'
import { uploadFile } from './upload' import { uploadFile } from './upload'
import { buildDbName, sortConversations as sortConversationsByRuntime } from './runtime'
let client: ImClient | null = null let client: ImClient | null = null
let _currentUserId: string | null = null let _currentUserId: string | null = null
@ -115,19 +116,12 @@ function normalizeConversation(item: {
} }
} }
function sortConversations(conversations: ConversationData[]): ConversationData[] {
return [...conversations].sort((a, b) => {
if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned)
return b.lastMsgTime - a.lastMsgTime
})
}
function conversationKey(targetId: string, chatType: ChatType): string { function conversationKey(targetId: string, chatType: ChatType): string {
return `${chatType}:${targetId}` return `${chatType}:${targetId}`
} }
function emitConversationMemory(): void { function emitConversationMemory(): void {
const snapshot = sortConversations(conversationMemory) const snapshot = sortConversationsByRuntime(conversationMemory)
conversationMemory = snapshot conversationMemory = snapshot
conversationListeners.forEach(listener => listener(snapshot)) conversationListeners.forEach(listener => listener(snapshot))
} }
@ -295,6 +289,7 @@ export const ImSDK = {
async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise<void> { async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise<void> {
const config = getConfig() const config = getConfig()
const device = await getDeviceInfo() const device = await getDeviceInfo()
client?.disconnect()
const res = await apiRequest<{ token: string }>('/api/im/auth/login', { const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
method: 'POST', method: 'POST',
skipAuth: true, skipAuth: true,
@ -314,9 +309,7 @@ export const ImSDK = {
_currentUserId = userId _currentUserId = userId
setCommonUserId(userId) setCommonUserId(userId)
if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
ImDatabase.init(dbName ?? 'xuqm_im')
}
client = new ImClient(config.imWsUrl, res.token, config.appId) client = new ImClient(config.imWsUrl, res.token, config.appId)
client.addListener({ client.addListener({
@ -333,13 +326,12 @@ export const ImSDK = {
*/ */
async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> { async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> {
const config = getConfig() const config = getConfig()
client?.disconnect()
await _saveToken(token) await _saveToken(token)
_currentUserId = userId _currentUserId = userId
setCommonUserId(userId) setCommonUserId(userId)
if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
ImDatabase.init(dbName ?? 'xuqm_im')
}
client = new ImClient(config.imWsUrl, token, config.appId) client = new ImClient(config.imWsUrl, token, config.appId)
client.addListener({ client.addListener({
@ -350,6 +342,10 @@ export const ImSDK = {
void client.connect() void client.connect()
}, },
async loginWithUserSig(userId: string, userSig: string, dbName?: string): Promise<void> {
return ImSDK.loginWithToken(userId, userSig, dbName)
},
async reconnect(): Promise<void> { async reconnect(): Promise<void> {
const config = getConfig() const config = getConfig()
const token = await _getToken() const token = await _getToken()
@ -1044,7 +1040,7 @@ export const ImSDK = {
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
if (!ImDatabase.isInitialized()) { if (!ImDatabase.isInitialized()) {
conversationListeners.add(callback) conversationListeners.add(callback)
callback(sortConversations(conversationMemory)) callback(sortConversationsByRuntime(conversationMemory))
return () => { return () => {
conversationListeners.delete(callback) conversationListeners.delete(callback)
} }

查看文件

@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel'
import type { ImMessage } from '../types' import type { ImMessage } from '../types'
let _db: Database | null = null let _db: Database | null = null
let _dbName: string | null = null
const draftStore = new Map<string, string>() const draftStore = new Map<string, string>()
function getDb(): Database { function getDb(): Database {
@ -36,7 +37,7 @@ export interface MessageSearchParams {
export const ImDatabase = { export const ImDatabase = {
init(dbName = 'xuqm_im') { init(dbName = 'xuqm_im') {
if (_db) return if (_db && _dbName === dbName) return
const adapter = new SQLiteAdapter({ const adapter = new SQLiteAdapter({
schema: imDbSchema, schema: imDbSchema,
dbName, dbName,
@ -47,6 +48,7 @@ export const ImDatabase = {
adapter, adapter,
modelClasses: [ConversationModel, MessageModel], modelClasses: [ConversationModel, MessageModel],
}) })
_dbName = dbName
}, },
async saveMessage(msg: ImMessage, currentUserId: string): Promise<void> { async saveMessage(msg: ImMessage, currentUserId: string): Promise<void> {

28
packages/im/src/runtime.ts 普通文件
查看文件

@ -0,0 +1,28 @@
import type { ConversationData, ImMessage } from './types'
export function buildDbName(appKey: string, userId: string): string {
return `xuqm_im_${appKey}_${userId}`
}
export function sortMessages(messages: ImMessage[]): ImMessage[] {
const deduped = new Map<string, ImMessage>()
messages.forEach((message) => {
const existing = deduped.get(message.id)
if (!existing || message.createdAt >= existing.createdAt) {
deduped.set(message.id, message)
}
})
return [...deduped.values()]
.sort((a, b) => {
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt
return b.id.localeCompare(a.id)
})
}
export function sortConversations(conversations: ConversationData[]): ConversationData[] {
return [...conversations].sort((a, b) => {
if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned)
if (b.lastMsgTime !== a.lastMsgTime) return b.lastMsgTime - a.lastMsgTime
return `${a.chatType}:${a.targetId}`.localeCompare(`${b.chatType}:${b.targetId}`)
})
}

查看文件

@ -24,7 +24,7 @@ export async function uploadFile(
const config = getConfig() const config = getConfig()
const token = await _getToken() const token = await _getToken()
if (!token) { if (!token) {
throw new Error('[uploadFile] No active session — call ImSDK.login() first.') throw new Error('[uploadFile] No active session — call ImSDK.loginWithToken() first.')
} }
const form = new FormData() const form = new FormData()

查看文件

@ -0,0 +1,34 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildDbName, sortConversations, sortMessages } from '../src/runtime'
test('buildDbName derives a stable per-user database name', () => {
assert.equal(buildDbName('ak_demo_chat', 'demo_alice'), 'xuqm_im_ak_demo_chat_demo_alice')
})
test('sortConversations keeps pinned conversations first and then sorts by time', () => {
const conversations = sortConversations([
{ targetId: 'c', chatType: 'SINGLE', lastMsgTime: 100, unreadCount: 0, isMuted: false, isPinned: false },
{ targetId: 'a', chatType: 'SINGLE', lastMsgTime: 80, unreadCount: 0, isMuted: false, isPinned: true },
{ targetId: 'b', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false },
{ targetId: 'd', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false },
])
assert.deepEqual(
conversations.map((item) => `${item.chatType}:${item.targetId}`),
['SINGLE:a', 'GROUP:b', 'GROUP:d', 'SINGLE:c'],
)
})
test('sortMessages deduplicates by message id and sorts newest first', () => {
const messages = sortMessages([
{ id: 'm2', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'old', status: 'SENT', createdAt: 100 },
{ id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'latest', status: 'SENT', createdAt: 200 },
{ id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'duplicate', status: 'READ', createdAt: 180 },
])
assert.deepEqual(
messages.map((item) => `${item.id}:${item.createdAt}:${item.content}`),
['m1:200:latest', 'm2:100:old'],
)
})

查看文件

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"rootDir": ".",
"outDir": "dist-test",
"noEmit": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/runtime.ts", "src/types.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-test"]
}

查看文件

@ -1,9 +1,48 @@
import { apiRequest, getConfig, getDeviceInfo } from '@xuqm/rn-common' import { apiRequest, getConfig, getDeviceInfo, getUserId as getCommonUserId } from '@xuqm/rn-common'
import type { PushVendor } from '@xuqm/rn-common' import type { PushVendor } from '@xuqm/rn-common'
export type { PushVendor } export type { PushVendor }
type PendingDeviceToken = {
token: string
vendor?: PushVendor
}
let currentUserId: string | null = null
let pendingToken: PendingDeviceToken | null = null
async function registerPendingToken(): Promise<void> {
const userId = currentUserId ?? getCommonUserId()
if (!userId || !pendingToken) return
const device = await getDeviceInfo()
const config = getConfig()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appId: config.appId,
userId,
vendor: pendingToken.vendor ?? device.pushVendor,
token: pendingToken.token,
platform: device.platform,
deviceId: device.deviceId,
brand: device.brand,
model: device.model,
osVersion: device.osVersion,
},
})
}
export const PushSDK = { export const PushSDK = {
async initialize(userId?: string): Promise<void> {
currentUserId = userId ?? getCommonUserId()
await registerPendingToken()
},
async setDeviceToken(token: string, vendor?: PushVendor): Promise<void> {
pendingToken = { token, vendor }
await registerPendingToken()
},
/** /**
* Register a push device token for the given user. * Register a push device token for the given user.
* If vendor is omitted, it is auto-detected from the device brand. * If vendor is omitted, it is auto-detected from the device brand.
@ -13,6 +52,8 @@ export const PushSDK = {
* @param vendor - Optional; auto-detected when not provided * @param vendor - Optional; auto-detected when not provided
*/ */
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> { async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
currentUserId = userId
pendingToken = { token, vendor }
const config = getConfig() const config = getConfig()
const device = await getDeviceInfo() const device = await getDeviceInfo()
await apiRequest('/api/push/register', { await apiRequest('/api/push/register', {
@ -38,5 +79,13 @@ export const PushSDK = {
method: 'DELETE', method: 'DELETE',
params: { appId: config.appId, userId, deviceId }, params: { appId: config.appId, userId, deviceId },
}) })
if (currentUserId === userId) currentUserId = null
if (pendingToken && currentUserId === null) pendingToken = null
},
async logout(userId?: string): Promise<void> {
const targetUserId = userId ?? currentUserId ?? getCommonUserId()
if (!targetUserId) return
await PushSDK.unregisterToken(targetUserId)
}, },
} }

查看文件

@ -1,16 +0,0 @@
export interface XuqmSDKConfig {
appKey: string
appSecret: string
debug?: boolean
}
let _config: XuqmSDKConfig | null = null
export function initSDK(config: XuqmSDKConfig) {
_config = config
}
export function getConfig(): XuqmSDKConfig {
if (!_config) throw new Error('XuqmSDK not initialized. Call initSDK() first.')
return _config
}

查看文件

@ -1,49 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { getConfig } from './config'
const TOKEN_KEY = '@xuqm_sdk_token'
const DEFAULT_API_URL = 'https://dev.xuqinmin.com'
export async function getToken(): Promise<string | null> {
return AsyncStorage.getItem(TOKEN_KEY)
}
export async function saveToken(token: string): Promise<void> {
return AsyncStorage.setItem(TOKEN_KEY, token)
}
export async function clearToken(): Promise<void> {
return AsyncStorage.removeItem(TOKEN_KEY)
}
export async function apiRequest<T>(
path: string,
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
): Promise<T> {
const config = getConfig()
const token = await getToken()
let url = DEFAULT_API_URL.replace(/\/$/, '') + path
if (options.params) {
const qs = new URLSearchParams(options.params).toString()
url += (url.includes('?') ? '&' : '?') + qs
}
const res = await fetch(url, {
method: options.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(err.message ?? `HTTP ${res.status}`)
}
const json = await res.json()
return json.data ?? json
}

查看文件

@ -1,26 +0,0 @@
import { getConfig, initSDK, type XuqmSDKConfig } from './config'
import { clearToken, getToken, saveToken } from './http'
export const XuqmSDK = {
init(config: XuqmSDKConfig) {
initSDK(config)
},
getConfig,
async getToken() {
return getToken()
},
async setToken(token: string | null) {
if (token) {
await saveToken(token)
return
}
await clearToken()
},
async clearToken() {
await clearToken()
},
}

查看文件

@ -1,209 +0,0 @@
import type { ImEventListener, ImMessage, SendMessageParams } from './types'
interface StompFrame {
command: string
headers: Record<string, string>
body: string
}
export class ImClient {
private ws: WebSocket | null = null
private listeners: ImEventListener[] = []
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 3000
private shouldReconnect = true
private readonly subscriptionId = 'sub-user-queue'
private groupSubscriptions = new Set<string>()
constructor(
private readonly wsUrl: string,
private readonly token: string,
private readonly appId: string,
) {}
connect() {
this.shouldReconnect = true
this.openSocket()
}
sendMessage(
toId: string,
chatType: SendMessageParams['chatType'],
msgType: SendMessageParams['msgType'],
content: string,
mentionedUserIds?: string,
) {
this.send({
toId,
chatType,
msgType,
content,
mentionedUserIds,
})
}
send(params: SendMessageParams) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected')
}
this.sendFrame(
'SEND',
{
destination: '/app/chat.send',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
toId: params.toId,
chatType: params.chatType,
msgType: params.msgType,
content: params.content,
mentionedUserIds: params.mentionedUserIds ?? '',
}),
)
}
revoke(messageId: string) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected')
}
this.sendFrame(
'SEND',
{
destination: '/app/chat.revoke',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
messageId,
}),
)
}
subscribeGroup(groupId: string) {
this.groupSubscriptions.add(groupId)
if (this.ws?.readyState === WebSocket.OPEN) {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
}
}
addListener(listener: ImEventListener) {
this.listeners.push(listener)
}
removeListener(listener: ImEventListener) {
this.listeners = this.listeners.filter(item => item !== listener)
}
disconnect() {
this.shouldReconnect = false
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendFrame('DISCONNECT')
}
this.ws?.close(1000, 'User disconnect')
this.ws = null
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
private openSocket() {
this.ws = new WebSocket(this.wsUrl)
this.ws.onopen = () => {
this.sendFrame('CONNECT', {
'accept-version': '1.2',
Authorization: `Bearer ${this.token}`,
'heart-beat': '10000,10000',
})
}
this.ws.onmessage = event => {
try {
const frames = this.parseFrames(String(event.data))
frames.forEach(frame => this.handleFrame(frame))
} catch {
this.listeners.forEach(listener => listener.onError?.('Parse error'))
}
}
this.ws.onclose = event => {
this.listeners.forEach(listener => listener.onDisconnected?.(event.reason))
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
this.openSocket()
}, this.reconnectDelay)
}
}
this.ws.onerror = () => {
this.listeners.forEach(listener => listener.onError?.('WebSocket error'))
}
}
private handleFrame(frame: StompFrame) {
if (frame.command === 'CONNECTED') {
this.reconnectDelay = 3000
this.subscribe('/user/queue/messages', this.subscriptionId)
this.groupSubscriptions.forEach(groupId => {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
})
this.listeners.forEach(listener => listener.onConnected?.())
return
}
if (frame.command === 'MESSAGE') {
const message: ImMessage = JSON.parse(frame.body)
if (message.chatType === 'GROUP') {
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
return
}
this.listeners.forEach(listener => listener.onMessage?.(message))
return
}
if (frame.command === 'ERROR') {
this.listeners.forEach(listener => listener.onError?.(frame.body || 'WebSocket error'))
}
}
private subscribe(destination: string, id: string) {
this.sendFrame('SUBSCRIBE', { destination, id })
}
private sendFrame(command: string, headers: Record<string, string> = {}, body = '') {
if (!this.ws) return
const headerLines = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join('\n')
const frame = `${command}\n${headerLines}\n\n${body}\u0000`
this.ws.send(frame)
}
private parseFrames(raw: string): StompFrame[] {
return raw
.split('\u0000')
.map(frame => frame.replace(/^\n+/, '').trim())
.filter(Boolean)
.map(frame => {
const separatorIndex = frame.indexOf('\n\n')
const headerBlock = separatorIndex >= 0 ? frame.slice(0, separatorIndex) : frame
const body = separatorIndex >= 0 ? frame.slice(separatorIndex + 2) : ''
const [command, ...headerLines] = headerBlock.split('\n').filter(Boolean)
const headers = Object.fromEntries(
headerLines
.filter(line => line.includes(':'))
.map(line => {
const index = line.indexOf(':')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
return { command, headers, body }
})
}
}

查看文件

@ -1,136 +0,0 @@
import { getConfig } from '../core/config'
import { apiRequest, getToken, saveToken } from '../core/http'
import { ImClient } from './imClient'
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
let client: ImClient | null = null
const DEFAULT_IM_WS_URL = 'wss://dev.xuqinmin.com/ws/im'
export const ImSDK = {
async login(userId: string, nickname?: string, avatar?: string): Promise<void> {
const config = getConfig()
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
method: 'POST',
params: {
appId: config.appKey,
userId,
...(nickname ? { nickname } : {}),
...(avatar ? { avatar } : {}),
},
})
await saveToken(res.token)
client = new ImClient(DEFAULT_IM_WS_URL, res.token, config.appKey)
client.connect()
},
async reconnect(): Promise<void> {
const config = getConfig()
const token = await getToken()
if (!token) throw new Error('ImSDK: token not found')
client = new ImClient(DEFAULT_IM_WS_URL, token, config.appKey)
client.connect()
},
async fetchHistory(toId: string, page = 0, size = 20): Promise<ImMessage[]> {
const config = getConfig()
const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(`/api/im/messages/history/${encodeURIComponent(toId)}`, {
params: {
appId: config.appKey,
page: String(page),
size: String(size),
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async sendMessage(
toId: string,
chatType: ChatType,
msgType: MsgType,
content: string,
mentionedUserIds?: string,
): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>('/api/im/messages/send', {
method: 'POST',
params: { appId: config.appKey },
body: {
toId,
chatType,
msgType,
content,
mentionedUserIds: mentionedUserIds ?? '',
},
})
},
async revokeMessage(messageId: string): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, {
method: 'POST',
params: { appId: config.appKey },
})
},
async editMessage(messageId: string, content: string): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}`, {
method: 'PUT',
params: { appId: config.appKey },
body: { content },
})
},
addListener(listener: ImEventListener) {
client?.addListener(listener)
},
removeListener(listener: ImEventListener) {
client?.removeListener(listener)
},
disconnect() {
client?.disconnect()
client = null
},
subscribeGroup(groupId: string) {
client?.subscribeGroup(groupId)
},
isConnected() {
return client?.isConnected() ?? false
},
async createGroup(name: string, memberIds: string[]): Promise<ImGroup> {
const config = getConfig()
return apiRequest<ImGroup>('/api/im/groups', {
method: 'POST',
params: { appId: config.appKey },
body: { name, memberIds },
})
},
async listGroups(): Promise<ImGroup[]> {
const config = getConfig()
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups', {
params: { appId: config.appKey },
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise<ImMessage[]> {
const config = getConfig()
const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(
`/api/im/messages/history/${encodeURIComponent(groupId)}`,
{
params: {
appId: config.appKey,
page: String(page),
size: String(size),
},
},
)
return Array.isArray(res) ? res : (res.content ?? [])
},
}

查看文件

@ -1,58 +0,0 @@
export type ChatType = 'SINGLE' | 'GROUP'
export type MsgType =
| 'TEXT'
| 'IMAGE'
| 'VIDEO'
| 'AUDIO'
| 'FILE'
| 'CUSTOM'
| 'LOCATION'
| 'NOTIFY'
| 'RICH_TEXT'
| 'CALL_AUDIO'
| 'CALL_VIDEO'
| 'REVOKED'
| 'FORWARD'
export type MsgStatus = 'SENT' | 'DELIVERED' | 'READ' | 'REVOKED'
export interface ImMessage {
id: string
appId: string
fromUserId: string
toId: string
chatType: ChatType
msgType: MsgType
content: string
status: MsgStatus
mentionedUserIds?: string
createdAt: string
editedAt?: string | null
}
export interface ImEventListener {
onConnected?: () => void
onDisconnected?: (reason?: string) => void
onMessage?: (msg: ImMessage) => void
onGroupMessage?: (msg: ImMessage) => void
onError?: (error: string) => void
}
export interface SendMessageParams {
toId: string
chatType: ChatType
msgType: MsgType
content: string
mentionedUserIds?: string
}
export interface ImGroup {
id: string
appId: string
name: string
creatorId: string
memberIds: string
adminIds: string
createdAt: string
}

查看文件

@ -1,9 +1,27 @@
// @xuqm/rn-sdk — hidden core package. export { XuqmSDK } from './sdk'
// App code should import @xuqm/rn-common directly; this package is installed export type { UnifiedLoginOptions, UnifiedLoginRefreshResult } from './sdk'
// transitively by @xuqm/rn-im / @xuqm/rn-push / @xuqm/rn-update.
export { XuqmSDK } from '@xuqm/rn-common'
export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common' export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common'
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common' export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common'
export { ScaledImage } from '@xuqm/rn-common' export { ScaledImage } from '@xuqm/rn-common'
export { apiRequest } from '@xuqm/rn-common' export { apiRequest } from '@xuqm/rn-common'
export { ImSDK, ImClient, ImDatabase, uploadFile } from '@xuqm/rn-im'
export type {
ImMessage,
ImGroup,
ChatType,
MsgType,
MsgStatus,
ImEventListener,
SendMessageParams,
ConversationData,
HistoryQuery,
PageResult,
FriendRequest,
GroupJoinRequest,
BlacklistEntry,
UserProfile,
} from '@xuqm/rn-im'
export { PushSDK } from '@xuqm/rn-push'
export type { PushVendor } from '@xuqm/rn-push'
export { UpdateSDK } from '@xuqm/rn-update'
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'

查看文件

@ -1,28 +0,0 @@
import { Platform } from 'react-native'
import { apiRequest } from '../core/http'
import { getConfig } from '../core/config'
export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM'
export const PushSDK = {
async registerToken(userId: string, vendor: PushVendor, token: string): Promise<void> {
const config = getConfig()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appId: config.appKey,
userId,
vendor,
token,
},
})
},
async unregisterToken(userId: string): Promise<void> {
const config = getConfig()
await apiRequest('/api/push/unregister', {
method: 'DELETE',
params: { appId: config.appKey, userId },
})
},
}

117
src/sdk.ts 普通文件
查看文件

@ -0,0 +1,117 @@
import { _clearToken, setUserId as setCommonUserId, getUserId as getCommonUserId, XuqmSDK as CommonXuqmSDK } from '@xuqm/rn-common'
import { ImSDK } from '@xuqm/rn-im'
import { PushSDK } from '@xuqm/rn-push'
import type { UserProfile } from '@xuqm/rn-im'
export interface UnifiedLoginRefreshResult {
userSig: string
expiresAt?: number
}
export interface UnifiedLoginOptions {
userId: string
userSig: string
profile?: Pick<UserProfile, 'nickname' | 'avatar' | 'gender'>
expiresAt?: number
refreshUserSig?: () => Promise<UnifiedLoginRefreshResult>
}
type SessionState = UnifiedLoginOptions
const REFRESH_GRACE_MS = 5 * 60 * 1000
const REFRESH_RETRY_MS = 30 * 1000
let currentSession: SessionState | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let refreshInFlight = false
function clearRefreshTimer(): void {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
async function applyLoginSession(session: SessionState): Promise<void> {
clearRefreshTimer()
setCommonUserId(session.userId)
try {
await ImSDK.loginWithUserSig(session.userId, session.userSig)
} catch (error) {
setCommonUserId(null)
currentSession = null
throw error
}
currentSession = session
try {
await PushSDK.initialize(session.userId)
} catch (error) {
void error
}
scheduleRefresh()
}
async function refreshSession(): Promise<void> {
const session = currentSession
if (refreshInFlight || !session?.refreshUserSig) return
refreshInFlight = true
try {
const refreshed = await session.refreshUserSig()
if (currentSession !== session) return
await applyLoginSession({
...session,
userSig: refreshed.userSig,
expiresAt: refreshed.expiresAt ?? session.expiresAt,
})
} catch (error) {
clearRefreshTimer()
refreshTimer = setTimeout(() => {
void refreshSession()
}, REFRESH_RETRY_MS)
if (CommonXuqmSDK.getUserId() && process.env.NODE_ENV !== 'production') {
console.warn('[XuqmSDK] userSig refresh failed, will retry', error)
}
} finally {
refreshInFlight = false
}
}
function scheduleRefresh(): void {
clearRefreshTimer()
const session = currentSession
if (!session?.refreshUserSig || !session.expiresAt) return
const delayMs = Math.max(session.expiresAt - Date.now() - REFRESH_GRACE_MS, 0)
refreshTimer = setTimeout(() => {
void refreshSession()
}, delayMs)
}
async function login(options: UnifiedLoginOptions): Promise<void> {
if (currentSession) {
await logout()
}
await applyLoginSession(options)
}
async function logout(): Promise<void> {
clearRefreshTimer()
const userId = currentSession?.userId ?? getCommonUserId()
currentSession = null
refreshInFlight = false
setCommonUserId(null)
if (userId) {
try {
await PushSDK.logout(userId)
} catch (error) {
void error
}
}
ImSDK.disconnect()
await _clearToken()
}
export const XuqmSDK = {
...CommonXuqmSDK,
login,
logout,
}

19
src/shims/async-storage.ts 普通文件
查看文件

@ -0,0 +1,19 @@
const AsyncStorage = {
async getItem(_key: string): Promise<string | null> {
return null
},
async setItem(_key: string, _value: string): Promise<void> {
return
},
async removeItem(_key: string): Promise<void> {
return
},
async clear(): Promise<void> {
return
},
}
export default AsyncStorage

67
src/shims/watermelondb.ts 普通文件
查看文件

@ -0,0 +1,67 @@
export class Model {
static table = ''
async update(updater: (record: this) => void | Promise<void>): Promise<this> {
await updater(this)
return this
}
async destroyPermanently(): Promise<void> {
return
}
}
export class Database {
constructor(_config: { adapter: unknown; modelClasses: Array<new (...args: unknown[]) => unknown> }) {}
async write<T>(action: () => Promise<T>): Promise<T> {
return action()
}
get<T>(_tableName: string): any {
return {
query: (..._queryParts: unknown[]) => ({
fetch: async () => [] as T[],
extend: () => ({
fetch: async () => [] as T[],
observe: () => ({ subscribe: () => undefined }),
}),
observe: () => ({ subscribe: () => undefined }),
}),
create: async (_builder: (record: T) => void | Promise<void>) => ({} as T),
}
}
}
export const Q: any = {
where: () => undefined,
take: () => undefined,
skip: () => undefined,
sortBy: () => undefined,
oneOf: () => undefined,
like: () => undefined,
gte: () => undefined,
lte: () => undefined,
sanitizeLikeString: (value: string) => value,
desc: undefined,
}
export function appSchema(input: unknown): any {
return input
}
export function tableSchema(input: unknown): any {
return input
}
export function field(_name: string): any {
return () => undefined
}
export function date(_name: string): any {
return () => undefined
}
export default class SQLiteAdapter {
constructor(_config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void }) {}
}

查看文件

@ -1,9 +1,11 @@
declare module '@react-native-async-storage/async-storage' { declare module '@react-native-async-storage/async-storage' {
const AsyncStorage: { export interface AsyncStorageStatic {
getItem(key: string): Promise<string | null> getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void> setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void> removeItem(key: string): Promise<void>
clear(): Promise<void>
} }
const AsyncStorage: AsyncStorageStatic
export default AsyncStorage export default AsyncStorage
} }

查看文件

@ -1,61 +1,30 @@
declare module '@nozbe/watermelondb' { declare module '@nozbe/watermelondb' {
export class Model { export class Model {
static table: string static table: string
update(mutator: (record: this) => void | Promise<void>): Promise<this> constructor(...args: unknown[])
update(updater: (record: this) => void | Promise<void>): Promise<this>
destroyPermanently(): Promise<void> destroyPermanently(): Promise<void>
} }
export class Database { export class Database {
constructor(options: unknown) constructor(config: { adapter: unknown; modelClasses: Array<new (...args: unknown[]) => unknown> })
write<T>(work: () => Promise<T> | T): Promise<T> write<T>(action: () => Promise<T>): Promise<T>
get<T extends Model>(table: string): Collection<T> get<T>(tableName: string): any
} }
export interface Collection<T extends Model> { export const Q: any
query(...conditions: unknown[]): Query<T>
create(builder: (record: T) => void | Promise<void>): Promise<T> export function appSchema(input: unknown): any
export function tableSchema(input: unknown): any
} }
export interface Query<T extends Model> { declare module '@nozbe/watermelondb/decorators' {
fetch(): Promise<T[]> export const field: any
extend(...conditions: unknown[]): Query<T> export const date: any
observe(): Observable<T[]>
}
export interface Observable<T> {
subscribe(callback: (value: T) => void): Subscription
}
export interface Subscription {
unsubscribe(): void
}
export const Q: {
and(...conditions: unknown[]): unknown
or(...conditions: unknown[]): unknown
where(column: string, value: unknown): unknown
sortBy(column: string, order?: unknown): unknown
take(count: number): unknown
skip(count: number): unknown
oneOf(values: unknown[]): unknown
like(pattern: string): unknown
gte(value: unknown): unknown
lte(value: unknown): unknown
sanitizeLikeString(value: string): string
desc: unknown
}
export function appSchema(schema: unknown): unknown
export function tableSchema(schema: unknown): unknown
} }
declare module '@nozbe/watermelondb/adapters/sqlite' { declare module '@nozbe/watermelondb/adapters/sqlite' {
export default class SQLiteAdapter { export default class SQLiteAdapter {
constructor(options: unknown) constructor(config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void })
} }
} }
declare module '@nozbe/watermelondb/decorators' {
export function field(columnName: string): any
export function date(columnName: string): any
}

查看文件

@ -1,130 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { getConfig } from '../core/config'
import { apiRequest } from '../core/http'
export interface AppUpdateInfo {
needsUpdate: boolean
versionName?: string
versionCode?: number
downloadUrl?: string
changeLog?: string
forceUpdate?: boolean
appStoreUrl?: string
marketUrl?: string
}
export interface RnUpdateInfo {
needsUpdate: boolean
latestVersion: string
downloadUrl: string
md5: string
minCommonVersion: string
note: string
packageName?: string
packageMatched?: boolean
}
export interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
function getBundleCacheKey(moduleId: string) {
return `@xuqm_sdk_rn_bundle:${moduleId}`
}
function normalizeDownloadUrl(rawUrl?: string) {
if (!rawUrl) return rawUrl
// Compatible with legacy server config where update base URL already includes /api/v1/updates.
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
return rawUrl.replace('/api/v1/updates/api/v1/rn/files/', '/api/v1/rn/files/')
}
if (rawUrl.includes('/files/apk/')) {
try {
const url = new URL(rawUrl)
if (url.pathname.startsWith('/files/apk/')) {
return `${url.origin}/api/v1/updates${url.pathname}${url.search}`
}
} catch {
return rawUrl
}
}
return rawUrl
}
export const UpdateSDK = {
async checkAppUpdate(currentVersionCode: number): Promise<AppUpdateInfo> {
const config = getConfig()
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
params: {
appId: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(currentVersionCode),
},
})
return {
...result,
downloadUrl: normalizeDownloadUrl(result.downloadUrl),
}
},
async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
async checkRnUpdate(moduleId: string, currentVersion: string, packageName?: string): Promise<RnUpdateInfo> {
const config = getConfig()
const result = await apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
params: {
appId: config.appKey,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion,
...(packageName ? { packageName } : {}),
},
})
if (packageName && result.packageMatched === false) {
return {
...result,
needsUpdate: false,
}
}
return {
...result,
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
}
},
async downloadRnBundle(downloadUrl: string): Promise<string> {
const response = await fetch(downloadUrl)
if (!response.ok) {
throw new Error(`Failed to download bundle: ${response.status}`)
}
return response.text()
},
async cacheRnBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
const payload: CachedRnBundle = {
moduleId,
version,
md5,
source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(getBundleCacheKey(moduleId), JSON.stringify(payload))
return payload
},
async getCachedRnBundle(moduleId: string): Promise<CachedRnBundle | null> {
const raw = await AsyncStorage.getItem(getBundleCacheKey(moduleId))
return raw ? JSON.parse(raw) as CachedRnBundle : null
},
}

查看文件

@ -5,12 +5,17 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"],
"@nozbe/watermelondb": ["src/shims/watermelondb.ts"],
"@nozbe/watermelondb/decorators": ["src/shims/watermelondb.ts"],
"@nozbe/watermelondb/adapters/sqlite": ["src/shims/watermelondb.ts"],
"@xuqm/rn-common": ["packages/common/src"], "@xuqm/rn-common": ["packages/common/src"],
"@xuqm/rn-im": ["packages/im/src"], "@xuqm/rn-im": ["packages/im/src"],
"@xuqm/rn-push": ["packages/push/src"], "@xuqm/rn-push": ["packages/push/src"],
"@xuqm/rn-update": ["packages/update/src"] "@xuqm/rn-update": ["packages/update/src"]
}, },
"strict": true, "strict": true,
"experimentalDecorators": true,
"jsx": "react-native", "jsx": "react-native",
"lib": ["ES2020"], "lib": ["ES2020"],
"declaration": true, "declaration": true,