feat(push): 添加推送SDK和消息服务实现

- 实现了 Android 推送 SDK,支持华为、小米、Oppo、Vivo、荣耀、FCM 等厂商推送
- 添加了推送配置管理和设备注册功能
- 实现了推送令牌管理和用户绑定功能
- 添加了消息发送、撤回、编辑等核心消息服务功能
- 实现了单聊和群聊消息历史记录管理
- 添加了消息读取回执和群组消息状态同步
- 实现了消息过滤、黑名单和权限控制
- 添加了离线消息推送和消息预览功能
- 实现了消息 Webhook 回调机制
这个提交包含在:
XuqmGroup 2026-05-05 22:16:11 +08:00
父节点 dcbd833e64
当前提交 edfb3bac2e
共有 2 个文件被更改,包括 237 次插入2 次删除

查看文件

@ -79,6 +79,26 @@ export interface PushServiceConfig {
honor?: PushVendorConfig
apns?: PushVendorConfig
fcm?: PushVendorConfig
channels?: PushNotificationChannelConfig[]
routing?: Record<string, PushNotificationRouteConfig>
}
export interface PushNotificationChannelConfig {
key: string
channelId: string
version: number
name: string
description?: string
importance: 'MIN' | 'LOW' | 'DEFAULT' | 'HIGH' | 'MAX'
sound: boolean
vibration: boolean
badge: boolean
}
export interface PushNotificationRouteConfig {
channel: string
category: string
priority: 'LOW' | 'DEFAULT' | 'HIGH'
}
export const appApi = {

查看文件

@ -30,6 +30,81 @@
<el-card>
<template #header>厂商配置</template>
<el-divider content-position="left">通知通道</el-divider>
<el-table :data="pushConfig.channels" border style="margin-bottom:16px">
<el-table-column label="业务键" min-width="130">
<template #default="{ row }">
<el-input v-model="row.key" />
</template>
</el-table-column>
<el-table-column label="Channel ID" min-width="180">
<template #default="{ row }">
<el-input v-model="row.channelId" />
</template>
</el-table-column>
<el-table-column label="版本" width="82">
<template #default="{ row }">
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:72px" />
</template>
</el-table-column>
<el-table-column label="名称" min-width="130">
<template #default="{ row }">
<el-input v-model="row.name" />
</template>
</el-table-column>
<el-table-column label="重要性" width="130">
<template #default="{ row }">
<el-select v-model="row.importance">
<el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="声音" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.sound" /></template>
</el-table-column>
<el-table-column label="振动" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.vibration" /></template>
</el-table-column>
<el-table-column label="角标" width="80" align="center">
<template #default="{ row }"><el-switch v-model="row.badge" /></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button link type="danger" @click="removeChannel($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button style="margin-bottom:20px" @click="addChannel">新增通道</el-button>
<el-divider content-position="left">业务分类路由</el-divider>
<el-table :data="routeRows" border style="margin-bottom:20px">
<el-table-column label="业务分类" width="160">
<template #default="{ row }">{{ row.type }}</template>
</el-table-column>
<el-table-column label="通道" min-width="180">
<template #default="{ row }">
<el-select v-model="row.route.channel">
<el-option v-for="channel in pushConfig.channels" :key="channel.key" :label="channel.name || channel.key" :value="channel.key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="Category" min-width="160">
<template #default="{ row }">
<el-input v-model="row.route.category" />
</template>
</el-table-column>
<el-table-column label="优先级" width="130">
<template #default="{ row }">
<el-select v-model="row.route.priority">
<el-option label="低" value="LOW" />
<el-option label="默认" value="DEFAULT" />
<el-option label="高" value="HIGH" />
</el-select>
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">厂商凭据</el-divider>
<div class="vendor-grid">
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
<template #header>{{ vendor.label }}</template>
@ -71,7 +146,14 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { appApi, type App, type FeatureService, type PushServiceConfig } from '@/api/app'
import {
appApi,
type App,
type FeatureService,
type PushNotificationChannelConfig,
type PushNotificationRouteConfig,
type PushServiceConfig,
} from '@/api/app'
type VendorKey = keyof PushServiceConfig
type FieldDef = {
@ -107,7 +189,29 @@ const pushConfig = reactive<Required<PushServiceConfig>>({
honor: { appId: '', clientId: '', clientSecret: '' },
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
fcm: { serviceAccountJson: '' },
channels: defaultChannels(),
routing: defaultRouting(),
} as Required<PushServiceConfig>)
const originalChannels = ref<PushNotificationChannelConfig[]>([])
const importanceOptions = [
{ label: '最小', value: 'MIN' },
{ label: '低', value: 'LOW' },
{ label: '默认', value: 'DEFAULT' },
{ label: '高', value: 'HIGH' },
{ label: '最高', value: 'MAX' },
] as const
const routeTypes = [
{ type: 'IM_MESSAGE', channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
{ type: 'FRIEND_REQUEST', channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
{ type: 'SYSTEM_NOTICE', channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
] as const
const routeRows = computed(() => routeTypes.map(item => ({
type: item.type,
route: pushConfig.routing[item.type] ?? ensureRoute(item.type, item.channel, item.category, item.priority),
})))
const vendorDefs: VendorDef[] = [
{
@ -196,6 +300,9 @@ function applyConfig(raw?: string | null) {
pushConfig.honor = { ...pushConfig.honor, ...parsed.honor }
pushConfig.apns = { ...pushConfig.apns, ...parsed.apns }
pushConfig.fcm = { ...pushConfig.fcm, ...parsed.fcm }
pushConfig.channels = normalizeChannels(parsed.channels)
pushConfig.routing = normalizeRouting(parsed.routing)
originalChannels.value = pushConfig.channels.map(channel => ({ ...channel }))
}
function parseConfig(raw?: string | null): PushServiceConfig {
@ -211,6 +318,7 @@ async function saveConfig() {
if (!app.value) return
saving.value = true
try {
bumpChangedChannelVersions()
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
ElMessage.success('推送配置已保存')
await loadData()
@ -221,7 +329,7 @@ async function saveConfig() {
}
}
function toPushConfigRequest(): Record<string, string | boolean> {
function toPushConfigRequest(): Record<string, unknown> {
return {
huaweiAppId: pushConfig.huawei.appId ?? '',
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
@ -243,9 +351,116 @@ function toPushConfigRequest(): Record<string, string | boolean> {
apnsKeyPath: pushConfig.apns.keyPath ?? '',
apnsSandbox: pushConfig.apns.sandbox ?? false,
fcmServiceAccountJson: pushConfig.fcm.serviceAccountJson ?? '',
channels: pushConfig.channels,
routing: pushConfig.routing,
}
}
function defaultChannels(): PushNotificationChannelConfig[] {
return [
{
key: 'im_message',
channelId: 'xuqm_im_message',
version: 1,
name: '聊天消息',
description: '单聊、群聊和好友消息',
importance: 'HIGH',
sound: true,
vibration: true,
badge: true,
},
{
key: 'system_notice',
channelId: 'xuqm_system_notice',
version: 1,
name: '系统通知',
description: '系统通知和业务提醒',
importance: 'DEFAULT',
sound: true,
vibration: true,
badge: true,
},
]
}
function defaultRouting(): Record<string, PushNotificationRouteConfig> {
return {
IM_MESSAGE: { channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
FRIEND_REQUEST: { channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
SYSTEM_NOTICE: { channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
}
}
function normalizeChannels(channels?: PushNotificationChannelConfig[]): PushNotificationChannelConfig[] {
const source = Array.isArray(channels) && channels.length > 0 ? channels : defaultChannels()
return source.map((channel, index) => ({
key: channel.key || `channel_${index + 1}`,
channelId: channel.channelId || `xuqm_channel_${index + 1}`,
version: Math.max(Number(channel.version || 1), 1),
name: channel.name || channel.key || `通知通道 ${index + 1}`,
description: channel.description ?? '',
importance: channel.importance || 'DEFAULT',
sound: channel.sound ?? true,
vibration: channel.vibration ?? true,
badge: channel.badge ?? true,
}))
}
function normalizeRouting(routing?: Record<string, PushNotificationRouteConfig>): Record<string, PushNotificationRouteConfig> {
return { ...defaultRouting(), ...(routing ?? {}) }
}
function ensureRoute(
type: string,
channel: string,
category: string,
priority: PushNotificationRouteConfig['priority'],
): PushNotificationRouteConfig {
const route = { channel, category, priority }
pushConfig.routing[type] = route
return route
}
function addChannel() {
const next = pushConfig.channels.length + 1
pushConfig.channels.push({
key: `custom_${next}`,
channelId: `xuqm_custom_${next}`,
version: 1,
name: `自定义通道 ${next}`,
description: '',
importance: 'DEFAULT',
sound: true,
vibration: true,
badge: true,
})
}
function removeChannel(index: number) {
const [removed] = pushConfig.channels.splice(index, 1)
if (!removed) return
Object.values(pushConfig.routing).forEach(route => {
if (route.channel === removed.key) {
route.channel = pushConfig.channels[0]?.key ?? ''
}
})
}
function bumpChangedChannelVersions() {
const originalByKey = new Map(originalChannels.value.map(channel => [channel.key, channel]))
pushConfig.channels.forEach(channel => {
const original = originalByKey.get(channel.key)
if (!original) return
const immutableChanged = original.importance !== channel.importance
|| original.sound !== channel.sound
|| original.vibration !== channel.vibration
|| original.badge !== channel.badge
if (immutableChanged && channel.version <= original.version) {
channel.version = original.version + 1
}
})
}
async function onTogglePushService(enable: boolean) {
if (enable) {
ElMessage.info('请通过服务开通流程启用推送服务')