feat(push): 添加多厂商推送集成支持

- 实现了华为 HMS 推送服务集成
- 实现了小米推送服务集成
- 实现了 OPPO 推送服务集成
- 实现了 vivo 推送服务集成
- 实现了荣耀推送服务集成
- 实现了 FCM 推送服务集成
- 添加了统一的厂商推送接口和检测机制
- 添加了推送配置 API 和存储管理
- 添加了推送令牌管理和设备注册功能
- 添加了模拟器环境的推送测试用例
这个提交包含在:
XuqmGroup 2026-05-05 17:54:59 +08:00
父节点 e1925414c9
当前提交 dcbd833e64
共有 7 个文件被更改,包括 361 次插入11 次删除

查看文件

@ -16,6 +16,7 @@ declare module 'vue' {
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader'] ElHeader: typeof import('element-plus/es')['ElHeader']

查看文件

@ -131,6 +131,64 @@ export interface SensitiveWordPage {
totalPages: number totalPages: number
} }
export interface PushDeviceInfo {
id: string
vendor: string
tokenPreview: string
platform?: string
deviceId: string
brand?: string
model?: string
osVersion?: string
appVersion?: string
receivePush: boolean
lastLoginAt: string
updatedAt: string
}
export interface PushDiagnostics {
tokenType: 'PUSH' | 'IM' | 'UNKNOWN'
appId?: string
userId?: string
online: boolean
lastSeenAt: number
canSendOfflineMessage: boolean
deliverableDevice?: PushDeviceInfo | null
deliverableDevices: PushDeviceInfo[]
devices: PushDeviceInfo[]
}
export interface PushDeviceLog {
id: string
appId: string
userId: string
vendor: string
tokenPreview: string
platform?: string
deviceId: string
brand?: string
model?: string
osVersion?: string
appVersion?: string
receivePush: boolean
eventType: 'REGISTER' | 'UNREGISTER' | 'RECEIVE_PUSH_UPDATE'
createdAt: string
}
export interface PushDeviceLogPage {
content: PushDeviceLog[]
total: number
totalPages: number
}
export interface PushTestResult {
appId: string
userId: string
sent: boolean
targetCount: number
targets: PushDeviceInfo[]
}
export const opsApi = { export const opsApi = {
listTenants: (keyword = '', page = 0, size = 20) => listTenants: (keyword = '', page = 0, size = 20) =>
client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }), client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }),
@ -189,4 +247,13 @@ export const opsApi = {
deleteSensitiveWord: (id: string) => deleteSensitiveWord: (id: string) =>
client.delete(`/ops/risk/sensitive-words/${id}`), client.delete(`/ops/risk/sensitive-words/${id}`),
searchPushByToken: (token: string, appId = '') =>
client.get<{ data: PushDiagnostics }>('/ops/push/search', { params: { token, appId } }),
listPushDeviceLogs: (appId: string, userId: string, page = 0, size = 20) =>
client.get<{ data: PushDeviceLogPage }>('/ops/push/device-logs', { params: { appId, userId, page, size } }),
sendPushTestOffline: (payload: { appId: string; userId: string; title: string; body: string; payload?: string }) =>
client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload),
} }

查看文件

@ -16,6 +16,7 @@ const router = createRouter({
{ path: 'service-requests', component: () => import('@/views/services/ServiceRequestsView.vue') }, { path: 'service-requests', component: () => import('@/views/services/ServiceRequestsView.vue') },
{ path: 'apps', component: () => import('@/views/apps/AppListView.vue') }, { path: 'apps', component: () => import('@/views/apps/AppListView.vue') },
{ path: 'apps/:id', component: () => import('@/views/apps/AppDetailView.vue') }, { path: 'apps/:id', component: () => import('@/views/apps/AppDetailView.vue') },
{ path: 'push', component: () => import('@/views/push/PushDiagnosticsView.vue') },
{ path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') }, { path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') },
{ path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') }, { path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') },
], ],

查看文件

@ -66,7 +66,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning } from '@element-plus/icons-vue' import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const isMobile = ref(false) const isMobile = ref(false)
@ -77,6 +77,7 @@ const navItems = computed(() => [
{ path: '/statistics', label: '服务运营总览', icon: TrendCharts }, { path: '/statistics', label: '服务运营总览', icon: TrendCharts },
{ path: '/service-requests', label: '服务开通审核', icon: Bell }, { path: '/service-requests', label: '服务开通审核', icon: Bell },
{ path: '/apps', label: '应用管理', icon: Grid }, { path: '/apps', label: '应用管理', icon: Grid },
{ path: '/push', label: 'Push 诊断', icon: Promotion },
{ path: '/operation-logs', label: '操作日志', icon: Document }, { path: '/operation-logs', label: '操作日志', icon: Document },
{ path: '/risk-control', label: '风控配置', icon: Warning }, { path: '/risk-control', label: '风控配置', icon: Warning },
]) ])

查看文件

@ -0,0 +1,256 @@
<template>
<div class="push-page">
<el-card class="query-card">
<template #header>
<div class="header-row">
<span>Push 诊断</span>
<div class="header-actions">
<el-tag v-if="diagnostics" :type="diagnostics.online ? 'success' : 'info'">
{{ diagnostics.online ? '用户在线' : '用户离线' }}
</el-tag>
<el-button
v-if="diagnostics?.appId && diagnostics?.userId"
type="primary"
:disabled="!diagnostics.deliverableDevices?.length"
:loading="testLoading"
@click="sendTestOffline"
>
发送测试离线消息
</el-button>
</div>
</div>
</template>
<el-form :model="form" label-width="96px" class="query-form" @submit.prevent="search">
<el-form-item label="Token">
<el-input
v-model="form.token"
type="textarea"
:rows="3"
placeholder="输入 IM token 或厂商 push token"
clearable
/>
</el-form-item>
<el-form-item label="AppId">
<el-input v-model="form.appId" placeholder="可选;push token 无法解析 AppId 时使用" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<template v-if="diagnostics">
<el-row :gutter="16" class="summary-row">
<el-col :xs="24" :md="8">
<el-card>
<el-statistic title="Token 类型" :value="diagnostics.tokenType" />
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card>
<el-statistic title="用户" :value="diagnostics.userId || '-'" />
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card>
<el-statistic title="离线消息" :value="diagnostics.canSendOfflineMessage ? '可送达' : '不可送达'" />
</el-card>
</el-col>
</el-row>
<el-card class="section-card">
<template #header>当前离线可送达设备</template>
<el-empty v-if="!diagnostics.deliverableDevices?.length" description="暂无可送达设备" />
<el-table v-else :data="diagnostics.deliverableDevices" border stripe>
<el-table-column prop="vendor" label="厂商" width="100" />
<el-table-column label="设备" min-width="180">
<template #default="{ row }">{{ deviceName(row) }}</template>
</el-table-column>
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
<el-table-column prop="osVersion" label="系统" width="140" />
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
<el-table-column label="最近登录" width="180">
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="section-card">
<template #header>当前绑定设备</template>
<el-table :data="diagnostics.devices" border stripe>
<el-table-column prop="vendor" label="厂商" width="100" />
<el-table-column label="设备" min-width="180">
<template #default="{ row }">{{ deviceName(row) }}</template>
</el-table-column>
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
<el-table-column prop="osVersion" label="系统" width="140" />
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
<el-table-column label="接收 Push" width="110">
<template #default="{ row }">
<el-tag :type="row.receivePush ? 'success' : 'info'">{{ row.receivePush ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近登录" width="180">
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="section-card">
<template #header>
<div class="header-row">
<span>历史登录设备日志</span>
<el-button :disabled="!diagnostics.appId || !diagnostics.userId" :loading="logsLoading" @click="loadLogs">
刷新
</el-button>
</div>
</template>
<el-table :data="logs" border stripe>
<el-table-column prop="eventType" label="事件" width="150" />
<el-table-column prop="vendor" label="厂商" width="100" />
<el-table-column label="设备" min-width="180">
<template #default="{ row }">{{ deviceName(row) }}</template>
</el-table-column>
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
<el-table-column label="接收 Push" width="110">
<template #default="{ row }">
<el-tag :type="row.receivePush ? 'success' : 'info'">{{ row.receivePush ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-pagination
class="pager"
layout="prev, pager, next, total"
:total="logTotal"
:page-size="logSize"
:current-page="logPage + 1"
@current-change="changeLogPage"
/>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { opsApi, type PushDeviceInfo, type PushDeviceLog, type PushDiagnostics } from '@/api/ops'
const form = reactive({ token: '', appId: '' })
const loading = ref(false)
const logsLoading = ref(false)
const testLoading = ref(false)
const diagnostics = ref<PushDiagnostics | null>(null)
const logs = ref<PushDeviceLog[]>([])
const logTotal = ref(0)
const logPage = ref(0)
const logSize = 20
async function search() {
if (!form.token.trim()) {
ElMessage.warning('请输入 token')
return
}
loading.value = true
try {
const res = await opsApi.searchPushByToken(form.token.trim(), form.appId.trim())
diagnostics.value = res.data.data
logs.value = []
logTotal.value = 0
logPage.value = 0
if (diagnostics.value.appId && diagnostics.value.userId) {
await loadLogs()
}
} finally {
loading.value = false
}
}
async function loadLogs() {
if (!diagnostics.value?.appId || !diagnostics.value?.userId) return
logsLoading.value = true
try {
const res = await opsApi.listPushDeviceLogs(
diagnostics.value.appId,
diagnostics.value.userId,
logPage.value,
logSize,
)
logs.value = res.data.data.content
logTotal.value = res.data.data.total
} finally {
logsLoading.value = false
}
}
function changeLogPage(page: number) {
logPage.value = page - 1
loadLogs()
}
async function sendTestOffline() {
if (!diagnostics.value?.appId || !diagnostics.value?.userId) return
testLoading.value = true
try {
const res = await opsApi.sendPushTestOffline({
appId: diagnostics.value.appId,
userId: diagnostics.value.userId,
title: 'XuqmGroup Push 测试',
body: `离线推送测试 ${new Date().toLocaleString('zh-CN')}`,
payload: JSON.stringify({ type: 'PUSH_TEST', ts: Date.now() }),
})
ElMessage.success(`已发送到 ${res.data.data.targetCount} 个设备`)
} finally {
testLoading.value = false
}
}
function deviceName(device: PushDeviceInfo | PushDeviceLog) {
return [device.brand, device.model].filter(Boolean).join(' ') || '-'
}
function fmt(value?: string | number) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN')
}
</script>
<style scoped>
.push-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.query-card,
.section-card {
border-radius: 8px;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.query-form {
max-width: 920px;
}
.summary-row {
row-gap: 16px;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
}
</style>

查看文件

@ -116,7 +116,7 @@ export const appApi = {
appId: string, appId: string,
platform: string, platform: string,
serviceType: string, serviceType: string,
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig>, config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig> | Record<string, unknown>,
) => ) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, { client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
params: { platform, serviceType }, params: { platform, serviceType },

查看文件

@ -50,8 +50,7 @@
<el-input <el-input
v-else v-else
v-model="pushConfig[vendor.key][field.key]" v-model="pushConfig[vendor.key][field.key]"
:type="field.type === 'password' ? 'password' : 'text'" type="text"
:show-password="field.type === 'password'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
/> />
</el-form-item> </el-form-item>
@ -78,7 +77,7 @@ type VendorKey = keyof PushServiceConfig
type FieldDef = { type FieldDef = {
key: string key: string
label: string label: string
type?: 'password' | 'textarea' | 'switch' type?: 'textarea' | 'switch'
placeholder?: string placeholder?: string
rows?: number rows?: number
} }
@ -117,7 +116,7 @@ const vendorDefs: VendorDef[] = [
hint: '填写 AppId / AppSecret,供服务端推送使用。', hint: '填写 AppId / AppSecret,供服务端推送使用。',
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' }, { key: 'appSecret', label: 'AppSecret' },
], ],
}, },
{ {
@ -127,7 +126,7 @@ const vendorDefs: VendorDef[] = [
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' }, { key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' }, { key: 'appSecret', label: 'AppSecret' },
], ],
}, },
{ {
@ -137,7 +136,7 @@ const vendorDefs: VendorDef[] = [
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' }, { key: 'appKey', label: 'AppKey' },
{ key: 'masterSecret', label: 'MasterSecret', type: 'password' }, { key: 'masterSecret', label: 'MasterSecret' },
], ],
}, },
{ {
@ -147,7 +146,7 @@ const vendorDefs: VendorDef[] = [
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' }, { key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' }, { key: 'appSecret', label: 'AppSecret' },
], ],
}, },
{ {
@ -157,7 +156,7 @@ const vendorDefs: VendorDef[] = [
fields: [ fields: [
{ key: 'appId', label: 'AppId' }, { key: 'appId', label: 'AppId' },
{ key: 'clientId', label: 'ClientId' }, { key: 'clientId', label: 'ClientId' },
{ key: 'clientSecret', label: 'ClientSecret', type: 'password' }, { key: 'clientSecret', label: 'ClientSecret' },
], ],
}, },
{ {
@ -212,7 +211,7 @@ async function saveConfig() {
if (!app.value) return if (!app.value) return
saving.value = true saving.value = true
try { try {
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', pushConfig) await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
ElMessage.success('推送配置已保存') ElMessage.success('推送配置已保存')
await loadData() await loadData()
} catch { } catch {
@ -222,6 +221,31 @@ async function saveConfig() {
} }
} }
function toPushConfigRequest(): Record<string, string | boolean> {
return {
huaweiAppId: pushConfig.huawei.appId ?? '',
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
xiaomiAppId: pushConfig.xiaomi.appId ?? '',
xiaomiAppKey: pushConfig.xiaomi.appKey ?? '',
xiaomiAppSecret: pushConfig.xiaomi.appSecret ?? '',
oppoAppId: pushConfig.oppo.appId ?? '',
oppoAppKey: pushConfig.oppo.appKey ?? '',
oppoMasterSecret: pushConfig.oppo.masterSecret ?? '',
vivoAppId: pushConfig.vivo.appId ?? '',
vivoAppKey: pushConfig.vivo.appKey ?? '',
vivoAppSecret: pushConfig.vivo.appSecret ?? '',
honorAppId: pushConfig.honor.appId ?? '',
honorClientId: pushConfig.honor.clientId ?? '',
honorClientSecret: pushConfig.honor.clientSecret ?? '',
apnsTeamId: pushConfig.apns.teamId ?? '',
apnsKeyId: pushConfig.apns.keyId ?? '',
apnsBundleId: pushConfig.apns.bundleId ?? '',
apnsKeyPath: pushConfig.apns.keyPath ?? '',
apnsSandbox: pushConfig.apns.sandbox ?? false,
fcmServiceAccountJson: pushConfig.fcm.serviceAccountJson ?? '',
}
}
async function onTogglePushService(enable: boolean) { async function onTogglePushService(enable: boolean) {
if (enable) { if (enable) {
ElMessage.info('请通过服务开通流程启用推送服务') ElMessage.info('请通过服务开通流程启用推送服务')