feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
7d63672a7a
当前提交
9e11a63144
276
README.md
276
README.md
@ -1,256 +1,40 @@
|
||||
# XuqmGroup React Native SDK 文档
|
||||
# XuqmGroup React Native SDK
|
||||
|
||||
> TypeScript · React Native 0.76+ · 发布至 Nexus npm
|
||||
> `rn-sdk` 为内部基础包,业务方正常接入时使用 `rn-common` 和各业务模块即可。
|
||||
`rn-sdk` 的稳定入口是 `src/index.ts`,统一登录/登出层在 `src/sdk.ts`。
|
||||
旧的 `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
|
||||
# 基础能力
|
||||
npm install @xuqm/rn-common
|
||||
# 或
|
||||
yarn add @xuqm/rn-common
|
||||
yarn add @xuqm/rn-sdk
|
||||
yarn add @react-native-async-storage/async-storage
|
||||
|
||||
# 单独接入某个业务模块时,`rn-common` 会作为依赖自动安装
|
||||
npm install @xuqm/rn-im
|
||||
npm install @xuqm/rn-push
|
||||
npm install @xuqm/rn-update
|
||||
|
||||
# 依赖安装(peerDependencies)
|
||||
npm install @react-native-async-storage/async-storage
|
||||
# 如需按模块拆分接入,也可以直接安装
|
||||
yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
## 入口
|
||||
|
||||
```
|
||||
XuqmGroup-RNSDK/src/
|
||||
├── core/
|
||||
│ ├── http.ts # fetch HTTP 客户端,AsyncStorage Token
|
||||
│ └── sdk.ts # XuqmSDK 初始化入口
|
||||
├── im/
|
||||
│ └── imClient.ts # WebSocket IM 客户端,指数退避重连
|
||||
├── push/
|
||||
│ └── pushSDK.ts # 推送 Token 注册
|
||||
├── update/
|
||||
│ └── updateSDK.ts # 检查更新 / 下载 Bundle
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
- `XuqmSDK.initialize({ appKey, debug })`
|
||||
- `XuqmSDK.login({ userId, userSig, profile, expiresAt, refreshUserSig })`
|
||||
- `XuqmSDK.logout()`
|
||||
- `ImSDK`
|
||||
- `PushSDK`
|
||||
- `UpdateSDK`
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 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` 字段维护。
|
||||
详细用法见 [docs/rn-sdk/README.md](../docs/rn-sdk/README.md)。
|
||||
|
||||
@ -10,7 +10,10 @@
|
||||
"publishConfig": {
|
||||
"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": {
|
||||
"@xuqm/rn-common": ">=0.2.0",
|
||||
"@xuqm/rn-sdk": ">=0.2.0"
|
||||
|
||||
@ -16,6 +16,7 @@ import type {
|
||||
UserProfile,
|
||||
} from './types'
|
||||
import { uploadFile } from './upload'
|
||||
import { buildDbName, sortConversations as sortConversationsByRuntime } from './runtime'
|
||||
|
||||
let client: ImClient | 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 {
|
||||
return `${chatType}:${targetId}`
|
||||
}
|
||||
|
||||
function emitConversationMemory(): void {
|
||||
const snapshot = sortConversations(conversationMemory)
|
||||
const snapshot = sortConversationsByRuntime(conversationMemory)
|
||||
conversationMemory = 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> {
|
||||
const config = getConfig()
|
||||
const device = await getDeviceInfo()
|
||||
client?.disconnect()
|
||||
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
|
||||
method: 'POST',
|
||||
skipAuth: true,
|
||||
@ -314,9 +309,7 @@ export const ImSDK = {
|
||||
_currentUserId = userId
|
||||
setCommonUserId(userId)
|
||||
|
||||
if (dbName !== undefined || ImDatabase.isInitialized()) {
|
||||
ImDatabase.init(dbName ?? 'xuqm_im')
|
||||
}
|
||||
ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
|
||||
|
||||
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
||||
client.addListener({
|
||||
@ -333,13 +326,12 @@ export const ImSDK = {
|
||||
*/
|
||||
async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> {
|
||||
const config = getConfig()
|
||||
client?.disconnect()
|
||||
await _saveToken(token)
|
||||
_currentUserId = userId
|
||||
setCommonUserId(userId)
|
||||
|
||||
if (dbName !== undefined || ImDatabase.isInitialized()) {
|
||||
ImDatabase.init(dbName ?? 'xuqm_im')
|
||||
}
|
||||
ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
|
||||
|
||||
client = new ImClient(config.imWsUrl, token, config.appId)
|
||||
client.addListener({
|
||||
@ -350,6 +342,10 @@ export const ImSDK = {
|
||||
void client.connect()
|
||||
},
|
||||
|
||||
async loginWithUserSig(userId: string, userSig: string, dbName?: string): Promise<void> {
|
||||
return ImSDK.loginWithToken(userId, userSig, dbName)
|
||||
},
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
const config = getConfig()
|
||||
const token = await _getToken()
|
||||
@ -1044,7 +1040,7 @@ export const ImSDK = {
|
||||
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
|
||||
if (!ImDatabase.isInitialized()) {
|
||||
conversationListeners.add(callback)
|
||||
callback(sortConversations(conversationMemory))
|
||||
callback(sortConversationsByRuntime(conversationMemory))
|
||||
return () => {
|
||||
conversationListeners.delete(callback)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel'
|
||||
import type { ImMessage } from '../types'
|
||||
|
||||
let _db: Database | null = null
|
||||
let _dbName: string | null = null
|
||||
const draftStore = new Map<string, string>()
|
||||
|
||||
function getDb(): Database {
|
||||
@ -36,7 +37,7 @@ export interface MessageSearchParams {
|
||||
|
||||
export const ImDatabase = {
|
||||
init(dbName = 'xuqm_im') {
|
||||
if (_db) return
|
||||
if (_db && _dbName === dbName) return
|
||||
const adapter = new SQLiteAdapter({
|
||||
schema: imDbSchema,
|
||||
dbName,
|
||||
@ -47,6 +48,7 @@ export const ImDatabase = {
|
||||
adapter,
|
||||
modelClasses: [ConversationModel, MessageModel],
|
||||
})
|
||||
_dbName = dbName
|
||||
},
|
||||
|
||||
async saveMessage(msg: ImMessage, currentUserId: string): Promise<void> {
|
||||
|
||||
28
packages/im/src/runtime.ts
普通文件
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 token = await _getToken()
|
||||
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()
|
||||
|
||||
@ -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'],
|
||||
)
|
||||
})
|
||||
16
packages/im/tsconfig.test.json
普通文件
16
packages/im/tsconfig.test.json
普通文件
@ -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'
|
||||
|
||||
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 = {
|
||||
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.
|
||||
* 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
|
||||
*/
|
||||
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
|
||||
currentUserId = userId
|
||||
pendingToken = { token, vendor }
|
||||
const config = getConfig()
|
||||
const device = await getDeviceInfo()
|
||||
await apiRequest('/api/push/register', {
|
||||
@ -38,5 +79,13 @@ export const PushSDK = {
|
||||
method: 'DELETE',
|
||||
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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
136
src/im/imSDK.ts
136
src/im/imSDK.ts
@ -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
|
||||
}
|
||||
28
src/index.ts
28
src/index.ts
@ -1,9 +1,27 @@
|
||||
// @xuqm/rn-sdk — hidden core package.
|
||||
// App code should import @xuqm/rn-common directly; this package is installed
|
||||
// transitively by @xuqm/rn-im / @xuqm/rn-push / @xuqm/rn-update.
|
||||
|
||||
export { XuqmSDK } from '@xuqm/rn-common'
|
||||
export { XuqmSDK } from './sdk'
|
||||
export type { UnifiedLoginOptions, UnifiedLoginRefreshResult } from './sdk'
|
||||
export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common'
|
||||
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common'
|
||||
export { ScaledImage } 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
普通文件
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
普通文件
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
普通文件
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 }) {}
|
||||
}
|
||||
4
src/types/async-storage.d.ts
vendored
4
src/types/async-storage.d.ts
vendored
@ -1,9 +1,11 @@
|
||||
declare module '@react-native-async-storage/async-storage' {
|
||||
const AsyncStorage: {
|
||||
export interface AsyncStorageStatic {
|
||||
getItem(key: string): Promise<string | null>
|
||||
setItem(key: string, value: string): Promise<void>
|
||||
removeItem(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
const AsyncStorage: AsyncStorageStatic
|
||||
export default AsyncStorage
|
||||
}
|
||||
|
||||
57
src/types/watermelondb.d.ts
vendored
57
src/types/watermelondb.d.ts
vendored
@ -1,61 +1,30 @@
|
||||
declare module '@nozbe/watermelondb' {
|
||||
export class Model {
|
||||
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>
|
||||
}
|
||||
|
||||
export class Database {
|
||||
constructor(options: unknown)
|
||||
write<T>(work: () => Promise<T> | T): Promise<T>
|
||||
get<T extends Model>(table: string): Collection<T>
|
||||
constructor(config: { adapter: unknown; modelClasses: Array<new (...args: unknown[]) => unknown> })
|
||||
write<T>(action: () => Promise<T>): Promise<T>
|
||||
get<T>(tableName: string): any
|
||||
}
|
||||
|
||||
export interface Collection<T extends Model> {
|
||||
query(...conditions: unknown[]): Query<T>
|
||||
create(builder: (record: T) => void | Promise<void>): Promise<T>
|
||||
}
|
||||
export const Q: any
|
||||
|
||||
export interface Query<T extends Model> {
|
||||
fetch(): Promise<T[]>
|
||||
extend(...conditions: unknown[]): Query<T>
|
||||
observe(): Observable<T[]>
|
||||
}
|
||||
export function appSchema(input: unknown): any
|
||||
export function tableSchema(input: unknown): any
|
||||
}
|
||||
|
||||
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/decorators' {
|
||||
export const field: any
|
||||
export const date: any
|
||||
}
|
||||
|
||||
declare module '@nozbe/watermelondb/adapters/sqlite' {
|
||||
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",
|
||||
"baseUrl": ".",
|
||||
"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-im": ["packages/im/src"],
|
||||
"@xuqm/rn-push": ["packages/push/src"],
|
||||
"@xuqm/rn-update": ["packages/update/src"]
|
||||
},
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react-native",
|
||||
"lib": ["ES2020"],
|
||||
"declaration": true,
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户