Parcourir la source

增加 Android FCM 推送并发布 0.2.16

LSamien il y a 2 mois
Parent
commit
13c16d08c6

+ 5 - 0
.env.example

@@ -17,6 +17,11 @@ HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 # HERMES_YAOYAO_APNS_KEY_ID=XXXXXXXXXX
 # HERMES_YAOYAO_APNS_KEY_ID=XXXXXXXXXX
 # HERMES_YAOYAO_APNS_TEAM_ID=GMU6W5FKQ6
 # HERMES_YAOYAO_APNS_TEAM_ID=GMU6W5FKQ6
 # HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
 # HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
+# Android 后台通知使用 FCM HTTP v1。服务账号 JSON 必须位于仓库外并建议设为 0600。
+# 任一 FCM 环境变量存在时,系统管理中的 FCM 配置为只读;三项必须完整匹配。
+# HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE=/absolute/path/to/firebase-service-account.json
+# HERMES_YAOYAO_FCM_PROJECT_ID=your-firebase-project-id
+# HERMES_YAOYAO_FCM_PACKAGE_NAME=cn.samien.yaoyao.hermes
 # 固定 Git 发布源。系统更新只接受这里发现的 vX.Y.Z 标签,不接受浏览器指定仓库。
 # 固定 Git 发布源。系统更新只接受这里发现的 vX.Y.Z 标签,不接受浏览器指定仓库。
 # HERMES_YAOYAO_RELEASE_SOURCE=https://git.samien.cn/samien/hermes-yaoyao.git
 # HERMES_YAOYAO_RELEASE_SOURCE=https://git.samien.cn/samien/hermes-yaoyao.git
 # HERMES_YAOYAO_RELEASE_ROOT=/Users/you/.local/share/hermes-yaoyao
 # HERMES_YAOYAO_RELEASE_ROOT=/Users/you/.local/share/hermes-yaoyao

+ 23 - 1
README.md

@@ -49,6 +49,28 @@ Apple,不需要把 8800 暴露到公网;手机访问 8800 仍应优先使用
 宽于 `0600` 只显示安全建议,不会阻止启用。任一 APNs 环境变量存在时仍以环境变量为准,
 宽于 `0600` 只显示安全建议,不会阻止启用。任一 APNs 环境变量存在时仍以环境变量为准,
 Web 界面只读,避免两个配置源互相覆盖。
 Web 界面只读,避免两个配置源互相覆盖。
 
 
+### Android 后台消息推送
+
+Android 使用 FCM HTTP v1 的高优先级 data-only 消息;通知标题、摘要、去重 `eventId`
+和更新用 `collapseId` 由 App 统一处理。FCM 与 APNs 是两个独立 provider:任一方配置或
+发送失败都不会停用另一方,团队订阅、事件去重和后台任务恢复继续共用同一份持久状态。
+
+在 Firebase 项目中注册包名 `cn.samien.yaoyao.hermes`,为专用服务账号授予发送 FCM
+消息所需的最小权限。把服务账号 JSON 保存在仓库外、权限设为 `0600`,然后配置:
+
+```bash
+export HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE=/absolute/path/to/firebase-service-account.json
+export HERMES_YAOYAO_FCM_PROJECT_ID=your-firebase-project-id
+export HERMES_YAOYAO_FCM_PACKAGE_NAME=cn.samien.yaoyao.hermes
+```
+
+也可以在“系统管理 → Android 消息推送”填写 8800 所在机器上的 JSON 绝对路径、Project
+ID 和包名。浏览器只提交路径和公开元数据;8800 本地校验 RSA 凭据,并通过
+`validate_only` 发送探测确认该账号对目标项目具有消息权限,然后把路径和元数据原子保存到
+`$HERMES_YAOYAO_HOME/push/fcm-config.json`。配置文件不会复制 JSON 私钥或设备 FID。
+任一 `HERMES_YAOYAO_FCM_*` 环境变量存在时,Web 界面保持只读。Docker 部署时需把服务
+账号文件只读挂载到容器,并允许 8800 主动访问 Google OAuth 与 FCM HTTP v1 端点。
+
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 
 
@@ -56,7 +78,7 @@ Web 界面只读,避免两个配置源互相覆盖。
 插件安装接口安装。默认源为
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
 
-当前发布版本:**Git `v0.2.15` / 夭夭 Web `0.2.15` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.16` / 夭夭 Web `0.2.16` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
 

+ 6 - 0
docker.env.example

@@ -10,3 +10,9 @@ HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 
 
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 HERMES_YAOYAO_ALLOWED_HOSTS=
 HERMES_YAOYAO_ALLOWED_HOSTS=
+
+# 可选 Android FCM:先把仓库外的服务账号 JSON 以只读方式挂载到 Web 容器,
+# 再在 compose override 中把以下三项透传给 web 服务。不要把 JSON 复制进镜像或仓库。
+# HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE=/run/secrets/firebase-service-account.json
+# HERMES_YAOYAO_FCM_PROJECT_ID=your-firebase-project-id
+# HERMES_YAOYAO_FCM_PACKAGE_NAME=cn.samien.yaoyao.hermes

+ 28 - 5
docs/agent-install.md

@@ -6,8 +6,8 @@
 
 
 | 项目 | 版本 | 校验位置 |
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
 | --- | --- | --- |
-| Git 发布标签 | `v0.2.15` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.2.15` | `release.json` 与 `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.16` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.16` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
@@ -78,12 +78,12 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 ## 1. 获取并校验指定发布版本
 
 
 ```bash
 ```bash
-RELEASE_VERSION=v0.2.15
+RELEASE_VERSION=v0.2.16
 git fetch --tags
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 git checkout "$RELEASE_VERSION"
 
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.2.15"
+test "$(node -p \"require('./package.json').version\")" = "0.2.16"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 npm run release:verify
 ```
 ```
@@ -222,9 +222,32 @@ plutil -lint ~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist
 launchctl load -w ~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist
 launchctl load -w ~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist
 ```
 ```
 
 
+### 推送状态 v2 与 Android FCM
+
+Web `0.2.16` 会在首次写入时把 `$HERMES_YAOYAO_HOME/push/state.json` 从 schema 1
+迁移为 schema 2;已有 APNs 安装、待发送队列、团队订阅、事件去重、游标和聊天恢复
+任务都会保留。旧版 Web 不能直接读取 schema 2,因此升级前必须备份整个 `push/`
+目录;若回滚 Web,也要同步恢复这份升级前备份,不能只切换 `current` 符号链接。
+
+Android FCM 的服务账号 JSON 必须位于发布目录和 Git 仓库之外,权限建议为 `0600`。
+可在 8800“系统管理 → Android 消息推送”填写其绝对路径、Firebase Project ID 和
+固定包名 `cn.samien.yaoyao.hermes`。使用环境变量管理时配置以下三项并重新执行
+`service install`;任一项出现时三项都必须有效,Web 界面只读:
+
+```bash
+HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE=/absolute/path/to/firebase-service-account.json \
+HERMES_YAOYAO_FCM_PROJECT_ID=your-firebase-project-id \
+HERMES_YAOYAO_FCM_PACKAGE_NAME=cn.samien.yaoyao.hermes \
+node bin/hermes-yaoyao.mjs service install
+```
+
+服务账号文件、OAuth Token、FID 和 Android 的 `google-services.json` 都不得进入
+Git、发布目录、命令输出或日志。配置后检查系统管理中的 APNs/FCM 独立状态;一方
+未配置或失败不能使另一方停止。
+
 ## 7. 通过受管服务进行配套升级
 ## 7. 通过受管服务进行配套升级
 
 
-首次按第 6 步安装 Web `0.2.15` 后,可在左下角 Agent 菜单打开“系统更新”。
+首次按第 6 步安装 Web `0.2.16` 后,可在左下角 Agent 菜单打开“系统更新”。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
 标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
 
 

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "hermes-yaoyao",
   "name": "hermes-yaoyao",
-  "version": "0.2.15",
+  "version": "0.2.16",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "hermes-yaoyao",
       "name": "hermes-yaoyao",
-      "version": "0.2.15",
+      "version": "0.2.16",
       "license": "BSL-1.1",
       "license": "BSL-1.1",
       "dependencies": {
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "hermes-yaoyao",
   "name": "hermes-yaoyao",
-  "version": "0.2.15",
+  "version": "0.2.16",
   "private": true,
   "private": true,
   "license": "BSL-1.1",
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,7 +1,7 @@
 {
 {
   "schemaVersion": 1,
   "schemaVersion": 1,
-  "releaseVersion": "0.2.15",
-  "webVersion": "0.2.15",
+  "releaseVersion": "0.2.16",
+  "webVersion": "0.2.16",
   "pluginVersion": "1.7.3",
   "pluginVersion": "1.7.3",
-  "gitTag": "v0.2.15"
+  "gitTag": "v0.2.16"
 }
 }

+ 95 - 0
src/client/api/push.ts

@@ -8,6 +8,14 @@ export interface PushCapabilities {
   previewMode?: string
   previewMode?: string
   events: string[]
   events: string[]
   maxSummaryCharacters: number
   maxSummaryCharacters: number
+  platforms?: {
+    android?: {
+      enabled: boolean
+      provider: 'fcm'
+      packageName: string
+      configurationError?: string
+    }
+  }
 }
 }
 
 
 export type APNsEnvironment = 'development' | 'production'
 export type APNsEnvironment = 'development' | 'production'
@@ -28,6 +36,28 @@ export interface PushSystemConfigInput {
   environments: APNsEnvironment[]
   environments: APNsEnvironment[]
 }
 }
 
 
+export interface FCMPushSystemConfigInput {
+  serviceAccountFile: string
+  projectId: string
+  packageName: string
+}
+
+export interface FCMPushSystemStatus {
+  configured: boolean
+  healthy: boolean
+  registrationCount: number
+  pendingCount: number
+  lastSuccessAt?: number
+  lastError?: string
+  source: PushConfigSource
+  editable: boolean
+  managementAvailable: boolean
+  serviceAccountFile?: string
+  projectId?: string
+  packageName: string
+  warnings: PushConfigWarning[]
+}
+
 export interface PushSystemStatus {
 export interface PushSystemStatus {
   configured: boolean
   configured: boolean
   healthy: boolean
   healthy: boolean
@@ -44,6 +74,7 @@ export interface PushSystemStatus {
   teamId?: string
   teamId?: string
   environments: APNsEnvironment[]
   environments: APNsEnvironment[]
   warnings: PushConfigWarning[]
   warnings: PushConfigWarning[]
+  providers?: { fcm?: FCMPushSystemStatus }
 }
 }
 
 
 function normalizeEnvironment(value: unknown): APNsEnvironment | undefined {
 function normalizeEnvironment(value: unknown): APNsEnvironment | undefined {
@@ -72,6 +103,26 @@ function normalizePushSystemStatus(value: unknown): PushSystemStatus {
       recommendedMode: string(warning.recommendedMode ?? warning.recommended_mode) || undefined,
       recommendedMode: string(warning.recommendedMode ?? warning.recommended_mode) || undefined,
     }]
     }]
   })
   })
+  const providers = record(payload.providers)
+  const rawFCM = record(providers.fcm)
+  const fcmSourceValue = string(rawFCM.source ?? rawFCM.configurationSource ?? rawFCM.configuration_source)
+  const fcmSource: PushConfigSource = fcmSourceValue === 'file' || fcmSourceValue === 'environment'
+    ? fcmSourceValue : 'none'
+  const hasFCMManagementMetadata = ['none', 'file', 'environment'].includes(fcmSourceValue)
+  const fcmManagementAvailable = hasFCMManagementMetadata
+    ? true : bool(rawFCM.managementAvailable ?? rawFCM.management_available, false)
+  const fcmWarnings = values(rawFCM.warnings).flatMap((value): PushConfigWarning[] => {
+    if (typeof value === 'string' && value.trim()) return [{ code: 'warning', message: value.trim() }]
+    const warning = record(value)
+    const message = string(warning.message).trim()
+    if (!message) return []
+    return [{
+      code: string(warning.code, 'warning'),
+      message,
+      actualMode: string(warning.actualMode ?? warning.actual_mode) || undefined,
+      recommendedMode: string(warning.recommendedMode ?? warning.recommended_mode) || undefined,
+    }]
+  })
   return {
   return {
     configured: bool(payload.configured),
     configured: bool(payload.configured),
     healthy: bool(payload.healthy),
     healthy: bool(payload.healthy),
@@ -90,11 +141,33 @@ function normalizePushSystemStatus(value: unknown): PushSystemStatus {
     teamId: string(payload.teamId ?? payload.team_id) || undefined,
     teamId: string(payload.teamId ?? payload.team_id) || undefined,
     environments,
     environments,
     warnings,
     warnings,
+    ...(Object.keys(rawFCM).length ? {
+      providers: {
+        fcm: {
+          configured: bool(rawFCM.configured),
+          healthy: bool(rawFCM.healthy),
+          registrationCount: number(rawFCM.registrationCount ?? rawFCM.registration_count),
+          pendingCount: number(rawFCM.pendingCount ?? rawFCM.pending_count),
+          lastSuccessAt: number(rawFCM.lastSuccessAt ?? rawFCM.last_success_at) || undefined,
+          lastError: string(
+            rawFCM.lastError ?? rawFCM.last_error ?? rawFCM.configurationError ?? rawFCM.configuration_error,
+          ) || undefined,
+          source: fcmSource,
+          editable: fcmManagementAvailable && bool(rawFCM.editable),
+          managementAvailable: fcmManagementAvailable,
+          serviceAccountFile: string(rawFCM.serviceAccountFile ?? rawFCM.service_account_file) || undefined,
+          projectId: string(rawFCM.projectId ?? rawFCM.project_id) || undefined,
+          packageName: string(rawFCM.packageName ?? rawFCM.package_name, 'cn.samien.yaoyao.hermes'),
+          warnings: fcmWarnings,
+        },
+      },
+    } : {}),
   }
   }
 }
 }
 
 
 export async function getPushCapabilities(): Promise<PushCapabilities> {
 export async function getPushCapabilities(): Promise<PushCapabilities> {
   const payload = record(await apiRequest<unknown>('/api/app/push/v1/capabilities'))
   const payload = record(await apiRequest<unknown>('/api/app/push/v1/capabilities'))
+  const android = record(record(payload.platforms).android)
   return {
   return {
     protocolVersion: number(payload.protocolVersion ?? payload.protocol_version),
     protocolVersion: number(payload.protocolVersion ?? payload.protocol_version),
     enabled: bool(payload.enabled),
     enabled: bool(payload.enabled),
@@ -105,6 +178,16 @@ export async function getPushCapabilities(): Promise<PushCapabilities> {
       payload.maxSummaryCharacters ?? payload.maximumSummaryCharacters ?? payload.max_summary_characters,
       payload.maxSummaryCharacters ?? payload.maximumSummaryCharacters ?? payload.max_summary_characters,
       180,
       180,
     ),
     ),
+    ...(Object.keys(android).length ? {
+      platforms: {
+        android: {
+          enabled: bool(android.enabled),
+          provider: 'fcm',
+          packageName: string(android.packageName ?? android.package_name, 'cn.samien.yaoyao.hermes'),
+          configurationError: string(android.configurationError ?? android.configuration_error) || undefined,
+        },
+      },
+    } : {}),
   }
   }
 }
 }
 
 
@@ -143,3 +226,15 @@ export async function savePushSystemConfig(input: PushSystemConfigInput): Promis
     timeoutMs: 45_000,
     timeoutMs: 45_000,
   }))
   }))
 }
 }
+
+export async function saveFCMPushSystemConfig(input: FCMPushSystemConfigInput): Promise<PushSystemStatus> {
+  return normalizePushSystemStatus(await apiRequest<unknown>('/api/app/system/push-config/fcm', {
+    method: 'PUT',
+    body: {
+      serviceAccountFile: input.serviceAccountFile,
+      projectId: input.projectId,
+      packageName: input.packageName,
+    },
+    timeoutMs: 45_000,
+  }))
+}

+ 78 - 0
src/client/components/app/SystemManagementDialog.vue

@@ -4,6 +4,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { createUser, deleteUser, listUsers, setUpstreamCredentials, updateUser, type ManagedUser } from '@/api/admin'
 import { createUser, deleteUser, listUsers, setUpstreamCredentials, updateUser, type ManagedUser } from '@/api/admin'
 import {
 import {
   getPushSystemStatus,
   getPushSystemStatus,
+  saveFCMPushSystemConfig,
   savePushSystemConfig,
   savePushSystemConfig,
   type APNsEnvironment,
   type APNsEnvironment,
   type PushSystemStatus,
   type PushSystemStatus,
@@ -28,8 +29,26 @@ const pushKeyId = ref('')
 const pushTeamId = ref('')
 const pushTeamId = ref('')
 const pushTopic = ref('cn.samien.yaoyao.hermes')
 const pushTopic = ref('cn.samien.yaoyao.hermes')
 const pushEnvironments = ref<APNsEnvironment[]>(['development', 'production'])
 const pushEnvironments = ref<APNsEnvironment[]>(['development', 'production'])
+const fcmServiceAccountFile = ref('')
+const fcmProjectId = ref('')
+const fcmPackageName = ref('cn.samien.yaoyao.hermes')
+const fcmBusy = ref(false)
+const fcmError = ref('')
+const fcmNotice = ref('')
 
 
 const pushManagedByEnvironment = computed(() => pushStatus.value?.source === 'environment')
 const pushManagedByEnvironment = computed(() => pushStatus.value?.source === 'environment')
+const fcmStatus = computed(() => pushStatus.value?.providers?.fcm)
+const fcmManagedByEnvironment = computed(() => fcmStatus.value?.source === 'environment')
+const fcmCanSave = computed(() => Boolean(
+  fcmStatus.value?.managementAvailable && fcmStatus.value.editable && !fcmBusy.value,
+))
+const fcmStatusText = computed(() => {
+  if (!fcmStatus.value) return '当前版本未提供 Android 推送状态'
+  if (!fcmStatus.value.configured && fcmStatus.value.lastError) return `FCM 配置无效:${fcmStatus.value.lastError}`
+  if (!fcmStatus.value.configured) return '尚未配置 FCM 服务账号文件'
+  if (fcmStatus.value.healthy) return 'FCM 推送服务正常'
+  return fcmStatus.value.lastError || 'FCM 推送服务异常'
+})
 const pushCanSave = computed(() => Boolean(
 const pushCanSave = computed(() => Boolean(
   pushStatus.value?.managementAvailable
   pushStatus.value?.managementAvailable
     && pushStatus.value.editable
     && pushStatus.value.editable
@@ -51,6 +70,10 @@ function hydratePushForm(status: PushSystemStatus) {
   pushEnvironments.value = status.environments.length
   pushEnvironments.value = status.environments.length
     ? [...status.environments]
     ? [...status.environments]
     : ['development', 'production']
     : ['development', 'production']
+  const fcm = status.providers?.fcm
+  fcmServiceAccountFile.value = fcm?.serviceAccountFile ?? ''
+  fcmProjectId.value = fcm?.projectId ?? ''
+  fcmPackageName.value = fcm?.packageName || 'cn.samien.yaoyao.hermes'
 }
 }
 
 
 async function refresh(hydratePush = false) {
 async function refresh(hydratePush = false) {
@@ -130,10 +153,43 @@ async function savePush() {
     pushBusy.value = false
     pushBusy.value = false
   }
   }
 }
 }
+async function saveFCM() {
+  if (!fcmStatus.value?.editable || fcmManagedByEnvironment.value || fcmBusy.value) return
+  const serviceAccountFile = fcmServiceAccountFile.value.trim()
+  const projectId = fcmProjectId.value.trim()
+  const packageName = fcmPackageName.value.trim()
+  fcmError.value = ''
+  fcmNotice.value = ''
+  if (!serviceAccountFile.startsWith('/')) {
+    fcmError.value = '请输入 8800 所在机器上的服务账号 JSON 文件绝对路径'
+    return
+  }
+  if (!/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(projectId)) {
+    fcmError.value = '请输入有效的 Firebase Project ID'
+    return
+  }
+  if (packageName !== 'cn.samien.yaoyao.hermes') {
+    fcmError.value = 'Android 包名必须是 cn.samien.yaoyao.hermes'
+    return
+  }
+  fcmBusy.value = true
+  try {
+    const next = await saveFCMPushSystemConfig({ serviceAccountFile, projectId, packageName })
+    pushStatus.value = next
+    hydratePushForm(next)
+    fcmNotice.value = 'Firebase 权限与项目验证通过,FCM 已启用'
+  } catch (cause) {
+    fcmError.value = cause instanceof Error ? cause.message : '无法验证并启用 FCM'
+  } finally {
+    fcmBusy.value = false
+  }
+}
 watch(() => props.open, open => {
 watch(() => props.open, open => {
   if (!open) return
   if (!open) return
   pushError.value = ''
   pushError.value = ''
   pushNotice.value = ''
   pushNotice.value = ''
+  fcmError.value = ''
+  fcmNotice.value = ''
   void refresh(true).catch(cause => { error.value = cause instanceof Error ? cause.message : '读取用户失败' })
   void refresh(true).catch(cause => { error.value = cause instanceof Error ? cause.message : '读取用户失败' })
 })
 })
 </script>
 </script>
@@ -174,6 +230,28 @@ watch(() => props.open, open => {
       </template>
       </template>
       <p v-else-if="pushStatus" class="push-managed-note">当前服务版本只提供推送状态,不能在 Web 中修改配置。</p>
       <p v-else-if="pushStatus" class="push-managed-note">当前服务版本只提供推送状态,不能在 Web 中修改配置。</p>
     </div>
     </div>
+    <div class="block push-block">
+      <h3>Android 消息推送</h3>
+      <p class="push-health" :class="{ ok: fcmStatus?.configured && fcmStatus?.healthy }">{{ fcmStatusText }}</p>
+      <div v-if="fcmStatus" class="push-status"><span><small>Firebase Project</small><b>{{ fcmStatus.projectId || '—' }}</b></span><span><small>注册设备</small><b>{{ fcmStatus.registrationCount }}</b></span><span><small>待发送</small><b>{{ fcmStatus.pendingCount }}</b></span></div>
+
+      <template v-if="fcmStatus?.managementAvailable">
+        <p v-if="fcmManagedByEnvironment" class="push-managed-note">当前 FCM 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
+        <p v-else-if="!fcmStatus.editable" class="push-managed-note">当前 FCM 配置为只读,请在 8800 服务端修改后重启服务。</p>
+        <form class="push-config-form" @submit.prevent="saveFCM">
+          <label class="wide"><span>服务账号 JSON 本地路径</span><input v-model="fcmServiceAccountFile" name="fcm-service-account-file" :readonly="!fcmStatus.editable || fcmBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/firebase-service-account.json" /><small>只填写 8800 所在机器上的绝对路径;JSON 内容和私钥不会经过浏览器,也不会写入夭夭配置。</small></label>
+          <label><span>Firebase Project ID</span><input v-model="fcmProjectId" name="fcm-project-id" :readonly="!fcmStatus.editable || fcmBusy" autocomplete="off" spellcheck="false" /></label>
+          <label><span>Android 包名</span><input v-model="fcmPackageName" name="fcm-package-name" :readonly="!fcmStatus.editable || fcmBusy" autocomplete="off" spellcheck="false" /></label>
+          <div v-if="fcmStatus.warnings.length" class="wide push-warnings" aria-live="polite">
+            <p v-for="warning in fcmStatus.warnings" :key="`${warning.code}:${warning.message}`" class="push-warning" role="status"><AppIcon name="alert" :size="14" />{{ warning.message }}</p>
+          </div>
+          <p v-if="fcmError" class="wide push-error" role="alert">{{ fcmError }}</p>
+          <p v-else-if="fcmNotice" class="wide push-notice" role="status">{{ fcmNotice }}</p>
+          <footer v-if="fcmStatus.editable" class="wide"><button class="solid-button" :disabled="!fcmCanSave">{{ fcmBusy ? '验证中…' : '验证并启用' }}</button></footer>
+        </form>
+      </template>
+      <p v-else-if="fcmStatus" class="push-managed-note">当前服务版本只提供 FCM 状态,不能在 Web 中修改配置。</p>
+    </div>
   </section></div></Teleport>
   </section></div></Teleport>
 </template>
 </template>
 
 

+ 20 - 1
src/server/app.ts

@@ -29,6 +29,10 @@ import {
   APNsConfigurationManager,
   APNsConfigurationManager,
   type APNsConfigurationSnapshot,
   type APNsConfigurationSnapshot,
 } from './apnsConfiguration.js'
 } from './apnsConfiguration.js'
+import {
+  FCMConfigurationManager,
+  type FCMConfigurationSnapshot,
+} from './fcmConfiguration.js'
 import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
 import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
 import {
 import {
   ChatPushJobManager,
   ChatPushJobManager,
@@ -51,6 +55,7 @@ export interface ApplicationOptions {
   profileIdentities?: UpstreamProfileIdentityService
   profileIdentities?: UpstreamProfileIdentityService
   push?: PushCoordinator
   push?: PushCoordinator
   apnsConfiguration?: APNsConfigurationManager
   apnsConfiguration?: APNsConfigurationManager
+  fcmConfiguration?: FCMConfigurationManager
 }
 }
 
 
 export interface ApplicationRuntime {
 export interface ApplicationRuntime {
@@ -68,6 +73,7 @@ export interface ApplicationRuntime {
   profileIdentities: UpstreamProfileIdentityService
   profileIdentities: UpstreamProfileIdentityService
   push: PushCoordinator
   push: PushCoordinator
   apnsConfiguration: APNsConfigurationManager
   apnsConfiguration: APNsConfigurationManager
+  fcmConfiguration: FCMConfigurationManager
   pushEventCoordinator: PushCoordinatorEventAdapter
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatPushJobs: ChatPushJobManager
   groupPushEvents: GroupPushEventWatcher
   groupPushEvents: GroupPushEventWatcher
@@ -140,10 +146,21 @@ export function createApplication(options: ApplicationOptions = {}): Application
   }
   }
   const apnsConfiguration = options.apnsConfiguration
   const apnsConfiguration = options.apnsConfiguration
     ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
     ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
+  const initialFCMSettings: FCMConfigurationSnapshot = config.fcmSettings ?? {
+    source: config.fcm || config.fcmConfigurationError ? 'environment' : 'none',
+    editable: !(config.fcm || config.fcmConfigurationError),
+    ...(config.fcm ? { input: { ...config.fcm }, config: config.fcm } : {}),
+    warnings: [],
+    ...(config.fcmConfigurationError ? { configurationError: config.fcmConfigurationError } : {}),
+  }
+  const fcmConfiguration = options.fcmConfiguration
+    ?? new FCMConfigurationManager(config.home, initialFCMSettings)
   const push = options.push ?? new PushCoordinator({
   const push = options.push ?? new PushCoordinator({
     home: config.home,
     home: config.home,
     apns: config.apns,
     apns: config.apns,
     apnsConfigurationError: config.apnsConfigurationError,
     apnsConfigurationError: config.apnsConfigurationError,
+    fcm: config.fcm,
+    fcmConfigurationError: config.fcmConfigurationError,
     isUserActive: userID => auth.isUserActive(userID),
     isUserActive: userID => auth.isUserActive(userID),
     userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
     userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
   })
   })
@@ -273,6 +290,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     profileIdentities,
     profileIdentities,
     push,
     push,
     apnsConfiguration,
     apnsConfiguration,
+    fcmConfiguration,
   })
   })
   app.use(router.routes())
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(router.allowedMethods({ throw: false }))
@@ -301,6 +319,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     profileIdentities,
     profileIdentities,
     push,
     push,
     apnsConfiguration,
     apnsConfiguration,
+    fcmConfiguration,
     pushEventCoordinator,
     pushEventCoordinator,
     chatPushJobs,
     chatPushJobs,
     groupPushEvents,
     groupPushEvents,
@@ -326,7 +345,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
     : createHttpServer(runtime.app.callback())
-  const synchronizePushObservers = (enabled = runtime.push.capabilities().enabled) => {
+  const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
     if (enabled) {
     if (enabled) {
       runtime.chatPushJobs.start()
       runtime.chatPushJobs.start()
       runtime.groupPushEvents.start()
       runtime.groupPushEvents.start()

+ 14 - 0
src/server/config.ts

@@ -4,6 +4,7 @@ import { basename, resolve } from 'node:path'
 import { readFileSync } from 'node:fs'
 import { readFileSync } from 'node:fs'
 import type { APNsEnvironment } from './apns.js'
 import type { APNsEnvironment } from './apns.js'
 import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
 import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
+import { loadFCMConfiguration, type FCMConfigurationSnapshot } from './fcmConfiguration.js'
 export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 
 export interface APNsProviderConfig {
 export interface APNsProviderConfig {
@@ -14,6 +15,12 @@ export interface APNsProviderConfig {
   environments?: APNsEnvironment[]
   environments?: APNsEnvironment[]
 }
 }
 
 
+export interface FCMProviderConfig {
+  serviceAccountFile: string
+  projectId: string
+  packageName: string
+}
+
 export interface ServerConfig {
 export interface ServerConfig {
   host: string
   host: string
   port: number
   port: number
@@ -39,6 +46,9 @@ export interface ServerConfig {
   apns?: APNsProviderConfig
   apns?: APNsProviderConfig
   apnsConfigurationError?: string
   apnsConfigurationError?: string
   apnsSettings?: APNsConfigurationSnapshot
   apnsSettings?: APNsConfigurationSnapshot
+  fcm?: FCMProviderConfig
+  fcmConfigurationError?: string
+  fcmSettings?: FCMConfigurationSnapshot
 }
 }
 
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
@@ -167,6 +177,7 @@ export function loadServerConfig(
   const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
   const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
     || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
     || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
   const apnsSettings = loadAPNsConfiguration(home, env)
   const apnsSettings = loadAPNsConfiguration(home, env)
+  const fcmSettings = loadFCMConfiguration(home, env)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
     throw new Error(
@@ -204,5 +215,8 @@ export function loadServerConfig(
     ...(apnsSettings.config ? { apns: apnsSettings.config } : {}),
     ...(apnsSettings.config ? { apns: apnsSettings.config } : {}),
     ...(apnsSettings.configurationError ? { apnsConfigurationError: apnsSettings.configurationError } : {}),
     ...(apnsSettings.configurationError ? { apnsConfigurationError: apnsSettings.configurationError } : {}),
     apnsSettings,
     apnsSettings,
+    ...(fcmSettings.config ? { fcm: fcmSettings.config } : {}),
+    ...(fcmSettings.configurationError ? { fcmConfigurationError: fcmSettings.configurationError } : {}),
+    fcmSettings,
   }
   }
 }
 }

+ 294 - 0
src/server/fcm.ts

@@ -0,0 +1,294 @@
+import { createPrivateKey, sign, type KeyObject } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import type { FCMProviderConfig } from './config.js'
+import type { APNsSendResult } from './apns.js'
+
+export const FCM_ENDPOINT_ORIGIN = 'https://fcm.googleapis.com'
+export const FCM_OAUTH_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
+export const FCM_MAX_DATA_BYTES = 4_096
+
+export interface FCMRequest {
+  fid: string
+  data: Record<string, string>
+  collapseId?: string
+  ttlSeconds?: number
+  priority?: 'high' | 'normal'
+  validateOnly?: boolean
+}
+
+export interface FCMTransportResponse {
+  status: number
+  headers?: Headers | Record<string, string | string[] | undefined>
+  body?: string
+}
+
+export interface FCMTransport {
+  request(url: URL, init: RequestInit): Promise<FCMTransportResponse>
+}
+
+export type FCMSendResult = APNsSendResult
+
+export class FCMProbeError extends Error {
+  constructor(readonly result: FCMSendResult) {
+    super(result.reason || `FCM_PROBE_HTTP_${result.status}`)
+    this.name = 'FCMProbeError'
+  }
+}
+
+interface ServiceAccountCredentials {
+  clientEmail: string
+  privateKey: string
+  tokenUri: string
+}
+
+export interface FCMProviderOptions {
+  transport?: FCMTransport
+  now?: () => number
+}
+
+const ACCESS_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1_000
+const FCM_QUOTA_RETRY_MILLISECONDS = 60_000
+const FCM_TRANSIENT_RETRY_MILLISECONDS = 10_000
+
+function encodedJSON(value: unknown): string {
+  return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
+}
+
+function clean(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+function serviceAccount(path: string): ServiceAccountCredentials {
+  const value = JSON.parse(readFileSync(path, 'utf8')) as unknown
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new Error('FCM service account file must contain a JSON object')
+  }
+  const record = value as Record<string, unknown>
+  const clientEmail = clean(record.client_email)
+  const privateKey = clean(record.private_key)
+  const tokenUri = clean(record.token_uri) || 'https://oauth2.googleapis.com/token'
+  if (!clientEmail || !privateKey || !tokenUri) throw new Error('FCM service account credentials are incomplete')
+  if (tokenUri !== 'https://oauth2.googleapis.com/token') throw new Error('FCM OAuth token URI is invalid')
+  return { clientEmail, privateKey, tokenUri }
+}
+
+export function createFCMServiceAccountJWT(
+  credentials: ServiceAccountCredentials,
+  now = Date.now(),
+  suppliedKey?: KeyObject,
+): string {
+  const issuedAt = Math.floor(now / 1_000)
+  const header = encodedJSON({ alg: 'RS256', typ: 'JWT' })
+  const claims = encodedJSON({
+    iss: credentials.clientEmail,
+    scope: FCM_OAUTH_SCOPE,
+    aud: credentials.tokenUri,
+    iat: issuedAt,
+    exp: issuedAt + 3_600,
+  })
+  const signingInput = `${header}.${claims}`
+  const key = suppliedKey ?? createPrivateKey(credentials.privateKey)
+  const signature = sign('RSA-SHA256', Buffer.from(signingInput, 'ascii'), key).toString('base64url')
+  return `${signingInput}.${signature}`
+}
+
+function firstHeader(headers: FCMTransportResponse['headers'], name: string): string | undefined {
+  if (!headers) return undefined
+  if (headers instanceof Headers) return headers.get(name) ?? undefined
+  const value = headers[name] ?? headers[name.toLowerCase()]
+  return Array.isArray(value) ? value[0] : value
+}
+
+function retryAfterMilliseconds(value: string | undefined, now: number): number | undefined {
+  if (!value) return undefined
+  const seconds = Number(value)
+  if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1_000)
+  const at = Date.parse(value)
+  return Number.isNaN(at) ? undefined : Math.max(0, at - now)
+}
+
+function errorDetails(body: string | undefined): { reason?: string; status?: string } {
+  if (!body) return {}
+  try {
+    const value = JSON.parse(body) as { error?: { message?: unknown; status?: unknown; details?: unknown[] } }
+    const error = value.error
+    const status = clean(error?.status)
+    let reason = ''
+    for (const detail of error?.details ?? []) {
+      if (!detail || typeof detail !== 'object' || Array.isArray(detail)) continue
+      const code = clean((detail as Record<string, unknown>).errorCode)
+      if (code) {
+        reason = code
+        break
+      }
+    }
+    return {
+      ...(reason ? { reason: reason.slice(0, 128) } : {}),
+      ...(status ? { status } : {}),
+    }
+  } catch {
+    return { reason: 'FCM_ERROR_RESPONSE_INVALID' }
+  }
+}
+
+export function classifyFCMResponse(
+  response: FCMTransportResponse,
+  now = Date.now(),
+): FCMSendResult {
+  const details = errorDetails(response.body)
+  const common = {
+    status: response.status,
+    ...(details.reason ? { reason: details.reason } : {}),
+  }
+  if (response.status >= 200 && response.status < 300) return { disposition: 'success', ...common }
+  if (details.reason === 'UNREGISTERED' || details.reason === 'INVALID_ARGUMENT') {
+    return { disposition: 'unregister', ...common }
+  }
+  if (response.status === 429 || response.status >= 500
+    || details.status === 'RESOURCE_EXHAUSTED' || details.status === 'UNAVAILABLE' || details.status === 'INTERNAL') {
+    const quotaLimited = response.status === 429 || details.status === 'RESOURCE_EXHAUSTED'
+    const retryAfterMs = retryAfterMilliseconds(firstHeader(response.headers, 'retry-after'), now)
+      ?? (quotaLimited ? FCM_QUOTA_RETRY_MILLISECONDS : FCM_TRANSIENT_RETRY_MILLISECONDS)
+    return { disposition: 'retry', ...common, retryAfterMs }
+  }
+  if (response.status === 401 || response.status === 403
+    || details.reason === 'SENDER_ID_MISMATCH' || details.reason === 'THIRD_PARTY_AUTH_ERROR') {
+    return { disposition: 'configuration', ...common }
+  }
+  return { disposition: 'failed', ...common }
+}
+
+export class FetchFCMTransport implements FCMTransport {
+  constructor(
+    readonly fetchImpl: typeof fetch = fetch,
+    readonly timeoutMilliseconds = 15_000,
+  ) {}
+
+  async request(url: URL, init: RequestInit): Promise<FCMTransportResponse> {
+    const response = await this.fetchImpl(url, {
+      ...init,
+      signal: init.signal ?? AbortSignal.timeout(this.timeoutMilliseconds),
+    })
+    const body = (await response.text()).slice(0, 64 * 1_024)
+    return { status: response.status, headers: response.headers, body }
+  }
+}
+
+export class FCMProvider {
+  private readonly transport: FCMTransport
+  private readonly now: () => number
+  private readonly credentials: ServiceAccountCredentials
+  private readonly privateKey: KeyObject
+  private token?: { value: string; expiresAt: number }
+
+  constructor(
+    readonly config: FCMProviderConfig,
+    options: FCMProviderOptions = {},
+  ) {
+    this.transport = options.transport ?? new FetchFCMTransport()
+    this.now = options.now ?? Date.now
+    this.credentials = serviceAccount(config.serviceAccountFile)
+    this.privateKey = createPrivateKey(this.credentials.privateKey)
+  }
+
+  async probe(): Promise<void> {
+    const result = await this.send({
+      fid: 'probe-target-is-intentionally-invalid',
+      data: { probe: '1' },
+      priority: 'normal',
+      validateOnly: true,
+    })
+    // validate_only may either accept a syntactically valid probe target or
+    // reject the intentionally nonexistent FID after credentials are checked.
+    if (result.disposition === 'success' || result.disposition === 'unregister') return
+    throw new FCMProbeError(result)
+  }
+
+  async send(request: FCMRequest): Promise<FCMSendResult> {
+    for (const [key, value] of Object.entries(request.data)) {
+      const normalizedKey = key.toLowerCase()
+      if (typeof value !== 'string') {
+        return { disposition: 'failed', status: 400, reason: 'DataValueMustBeString' }
+      }
+      if (normalizedKey === 'from' || normalizedKey === 'message_type'
+        || normalizedKey.startsWith('google.') || normalizedKey.startsWith('gcm.')) {
+        return { disposition: 'failed', status: 400, reason: 'ReservedDataKey' }
+      }
+    }
+    if (Buffer.byteLength(JSON.stringify(request.data), 'utf8') > FCM_MAX_DATA_BYTES) {
+      return { disposition: 'failed', status: 400, reason: 'PayloadTooLarge' }
+    }
+    const authorization = await this.authorizationToken()
+    if ('result' in authorization) return authorization.result
+    const ttlSeconds = Math.max(0, Math.min(2_419_200, Math.trunc(request.ttlSeconds ?? 86_400)))
+    const endpoint = new URL(
+      `/v1/projects/${encodeURIComponent(this.config.projectId)}/messages:send`,
+      FCM_ENDPOINT_ORIGIN,
+    )
+    const payload = {
+      ...(request.validateOnly ? { validate_only: true } : {}),
+      message: {
+        fid: request.fid,
+        data: request.data,
+        android: {
+          priority: request.priority === 'normal' ? 'NORMAL' : 'HIGH',
+          ttl: `${ttlSeconds}s`,
+          restricted_package_name: this.config.packageName,
+        },
+      },
+    }
+    try {
+      const response = await this.transport.request(endpoint, {
+        method: 'POST',
+        headers: {
+          authorization: `Bearer ${authorization.token}`,
+          'content-type': 'application/json; charset=utf-8',
+        },
+        body: JSON.stringify(payload),
+      })
+      const result = classifyFCMResponse(response, this.now())
+      if (response.status === 401) this.token = undefined
+      return result
+    } catch {
+      return { disposition: 'retry', status: 0, reason: 'FCM transport unavailable' }
+    }
+  }
+
+  close(): void {
+    this.token = undefined
+  }
+
+  private async authorizationToken(): Promise<
+    { token: string } | { result: FCMSendResult }
+  > {
+    const now = this.now()
+    if (this.token && now + ACCESS_TOKEN_REFRESH_SKEW_MS < this.token.expiresAt) {
+      return { token: this.token.value }
+    }
+    const assertion = createFCMServiceAccountJWT(this.credentials, now, this.privateKey)
+    try {
+      const response = await this.transport.request(new URL(this.credentials.tokenUri), {
+        method: 'POST',
+        headers: { 'content-type': 'application/x-www-form-urlencoded; charset=utf-8' },
+        body: new URLSearchParams({
+          grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+          assertion,
+        }).toString(),
+      })
+      if (response.status < 200 || response.status >= 300) {
+        const result = classifyFCMResponse(response, now)
+        return { result: result.disposition === 'failed' ? { ...result, disposition: 'configuration' } : result }
+      }
+      const value = JSON.parse(response.body || '{}') as Record<string, unknown>
+      const token = clean(value.access_token)
+      const expiresIn = Number(value.expires_in)
+      if (!token || !Number.isFinite(expiresIn) || expiresIn <= 0) {
+        return { result: { disposition: 'configuration', status: response.status, reason: 'FCM OAuth response is invalid' } }
+      }
+      this.token = { value: token, expiresAt: now + expiresIn * 1_000 }
+      return { token }
+    } catch {
+      return { result: { disposition: 'retry', status: 0, reason: 'FCM OAuth transport unavailable' } }
+    }
+  }
+}

+ 337 - 0
src/server/fcmConfiguration.ts

@@ -0,0 +1,337 @@
+import { createPrivateKey, randomUUID } from 'node:crypto'
+import {
+  chmodSync,
+  closeSync,
+  constants as fsConstants,
+  existsSync,
+  fstatSync,
+  fsyncSync,
+  mkdirSync,
+  openSync,
+  readFileSync,
+  realpathSync,
+  renameSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs'
+import { dirname, isAbsolute, join, resolve } from 'node:path'
+import type { FCMProviderConfig } from './config.js'
+import { FCMProbeError, FCMProvider } from './fcm.js'
+import { HttpError } from './errors.js'
+
+export type FCMConfigurationSource = 'none' | 'file' | 'environment'
+export const DEFAULT_FCM_PACKAGE_NAME = 'cn.samien.yaoyao.hermes'
+
+export interface FCMConfigurationInput {
+  serviceAccountFile: string
+  projectId: string
+  packageName: string
+}
+
+export interface FCMConfigurationWarning {
+  code: 'fcm_service_account_permissions'
+  message: string
+  actualMode: string
+  recommendedMode: '0600'
+}
+
+export interface FCMConfigurationSnapshot {
+  source: FCMConfigurationSource
+  editable: boolean
+  input?: FCMConfigurationInput
+  config?: FCMProviderConfig
+  warnings: FCMConfigurationWarning[]
+  configurationError?: string
+}
+
+export type FCMConfigurationProbe = (config: FCMProviderConfig) => Promise<void>
+
+export interface FCMConfigurationManagerOptions {
+  probe?: FCMConfigurationProbe
+}
+
+interface StoredFCMConfiguration extends FCMConfigurationInput {
+  schemaVersion: 1
+  enabled: true
+  updatedAt: string
+}
+
+const MAX_SERVICE_ACCOUNT_BYTES = 128 * 1_024
+const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024
+
+function clean(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+function canonicalInput(value: Partial<FCMConfigurationInput>): FCMConfigurationInput {
+  const serviceAccountFile = clean(value.serviceAccountFile)
+  const projectId = clean(value.projectId)
+  const packageName = clean(value.packageName) || DEFAULT_FCM_PACKAGE_NAME
+  if (!serviceAccountFile || !isAbsolute(serviceAccountFile) || serviceAccountFile.length > 4_096
+    || /[\u0000-\u001f\u007f]/.test(serviceAccountFile)) {
+    throw new HttpError(400, 'FCM service account file must be an absolute local path', 'invalid_fcm_service_account_file')
+  }
+  if (!/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(projectId)) {
+    throw new HttpError(400, 'FCM project ID is invalid', 'invalid_fcm_project_id')
+  }
+  if (packageName !== DEFAULT_FCM_PACKAGE_NAME) {
+    throw new HttpError(400, `FCM package name must be ${DEFAULT_FCM_PACKAGE_NAME}`, 'invalid_fcm_package_name')
+  }
+  return { serviceAccountFile, projectId, packageName }
+}
+
+export function validateFCMConfiguration(
+  value: Partial<FCMConfigurationInput>,
+): FCMConfigurationSnapshot {
+  const requested = canonicalInput(value)
+  let serviceAccountFile: string
+  let encoded: Buffer
+  let mode: number
+  let descriptor: number | undefined
+  try {
+    serviceAccountFile = realpathSync(resolve(requested.serviceAccountFile))
+    descriptor = openSync(serviceAccountFile, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
+    const details = fstatSync(descriptor)
+    if (!details.isFile() || details.size < 1 || details.size > MAX_SERVICE_ACCOUNT_BYTES) {
+      throw new HttpError(400, 'FCM service account path must reference a readable JSON file', 'invalid_fcm_service_account_file')
+    }
+    encoded = readFileSync(descriptor)
+    mode = details.mode & 0o777
+    closeSync(descriptor)
+    descriptor = undefined
+  } catch (cause) {
+    if (descriptor !== undefined) closeSync(descriptor)
+    if (cause instanceof HttpError) throw cause
+    throw new HttpError(
+      400,
+      '8800 cannot read the FCM service account file; check the local path and service account permissions',
+      'invalid_fcm_service_account_file',
+    )
+  }
+  let credentials: Record<string, unknown>
+  try {
+    const value = JSON.parse(encoded.toString('utf8')) as unknown
+    if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
+    credentials = value as Record<string, unknown>
+  } catch {
+    throw new HttpError(400, 'FCM service account file must contain valid JSON', 'invalid_fcm_service_account_json')
+  }
+  if (clean(credentials.type) !== 'service_account'
+    || !clean(credentials.client_email)
+    || !clean(credentials.private_key)
+    || !clean(credentials.token_uri)) {
+    throw new HttpError(400, 'FCM service account credentials are incomplete', 'invalid_fcm_service_account')
+  }
+  if (clean(credentials.token_uri) !== 'https://oauth2.googleapis.com/token') {
+    throw new HttpError(400, 'FCM OAuth token URI is invalid', 'invalid_fcm_token_uri')
+  }
+  if (clean(credentials.project_id) !== requested.projectId) {
+    throw new HttpError(400, 'FCM project ID does not match the service account', 'fcm_project_id_mismatch')
+  }
+  let key
+  try { key = createPrivateKey(clean(credentials.private_key)) } catch {
+    throw new HttpError(400, 'FCM service account must contain an RSA private key', 'invalid_fcm_private_key')
+  }
+  if (key.asymmetricKeyType !== 'rsa') {
+    throw new HttpError(400, 'FCM service account must contain an RSA private key', 'invalid_fcm_private_key')
+  }
+  const actualMode = mode.toString(8).padStart(4, '0')
+  const warnings: FCMConfigurationWarning[] = (mode & ~0o600) !== 0
+    ? [{
+        code: 'fcm_service_account_permissions',
+        message: `FCM 已启用,但服务账号文件权限为 ${actualMode},建议调整为 0600;此提示不会影响当前推送。`,
+        actualMode,
+        recommendedMode: '0600',
+      }]
+    : []
+  const input = { ...requested, serviceAccountFile }
+  return { source: 'file', editable: true, input, config: { ...input }, warnings }
+}
+
+function environmentInput(env: NodeJS.ProcessEnv): { present: boolean; input: FCMConfigurationInput } {
+  const input = {
+    serviceAccountFile: clean(env.HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE),
+    projectId: clean(env.HERMES_YAOYAO_FCM_PROJECT_ID),
+    packageName: clean(env.HERMES_YAOYAO_FCM_PACKAGE_NAME) || DEFAULT_FCM_PACKAGE_NAME,
+  }
+  return {
+    present: Boolean(
+      env.HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE?.trim()
+      || env.HERMES_YAOYAO_FCM_PROJECT_ID?.trim()
+      || env.HERMES_YAOYAO_FCM_PACKAGE_NAME?.trim(),
+    ),
+    input,
+  }
+}
+
+function storedInput(value: unknown): FCMConfigurationInput {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new Error('FCM configuration file must contain an object')
+  }
+  const record = value as Record<string, unknown>
+  if (record.schemaVersion !== 1 || record.enabled !== true) throw new Error('Unsupported FCM configuration file')
+  return {
+    serviceAccountFile: clean(record.serviceAccountFile),
+    projectId: clean(record.projectId),
+    packageName: clean(record.packageName),
+  }
+}
+
+export function fcmConfigurationPath(home: string): string {
+  return join(home, 'push', 'fcm-config.json')
+}
+
+function readConfigurationFile(path: string): Buffer {
+  const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
+  try {
+    const details = fstatSync(descriptor)
+    if (!details.isFile() || details.size < 1 || details.size > MAX_CONFIGURATION_FILE_BYTES) {
+      throw new Error('FCM configuration path is not a regular file')
+    }
+    return readFileSync(descriptor)
+  } finally {
+    closeSync(descriptor)
+  }
+}
+
+export function loadFCMConfiguration(
+  home: string,
+  env: NodeJS.ProcessEnv = process.env,
+): FCMConfigurationSnapshot {
+  const fromEnvironment = environmentInput(env)
+  if (fromEnvironment.present) {
+    try {
+      const validated = validateFCMConfiguration(fromEnvironment.input)
+      return { ...validated, source: 'environment', editable: false }
+    } catch (cause) {
+      return {
+        source: 'environment', editable: false, input: fromEnvironment.input, warnings: [],
+        configurationError: cause instanceof Error ? cause.message : 'FCM environment configuration is invalid',
+      }
+    }
+  }
+  const path = fcmConfigurationPath(home)
+  if (!existsSync(path)) return { source: 'none', editable: true, warnings: [] }
+  try {
+    const input = storedInput(JSON.parse(readConfigurationFile(path).toString('utf8')))
+    const validated = validateFCMConfiguration(input)
+    chmodSync(path, 0o600)
+    return { ...validated, source: 'file', editable: true }
+  } catch (cause) {
+    return {
+      source: 'file', editable: true, warnings: [],
+      configurationError: cause instanceof Error ? cause.message : 'FCM configuration file is invalid',
+    }
+  }
+}
+
+function writeAtomic(path: string, value: Buffer | string): void {
+  const directory = dirname(path)
+  mkdirSync(directory, { recursive: true, mode: 0o700 })
+  chmodSync(directory, 0o700)
+  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
+  let descriptor: number | undefined
+  try {
+    descriptor = openSync(temporary, 'wx', 0o600)
+    writeFileSync(descriptor, value)
+    fsyncSync(descriptor)
+    closeSync(descriptor)
+    descriptor = undefined
+    renameSync(temporary, path)
+    chmodSync(path, 0o600)
+  } catch (cause) {
+    if (descriptor !== undefined) closeSync(descriptor)
+    try { unlinkSync(temporary) } catch { /* already absent */ }
+    throw cause
+  }
+}
+
+async function defaultProbe(config: FCMProviderConfig): Promise<void> {
+  const provider = new FCMProvider(config)
+  try {
+    await provider.probe()
+  } catch (cause) {
+    if (cause instanceof FCMProbeError && cause.result.disposition === 'retry') {
+      throw new HttpError(
+        503,
+        'Unable to connect to FCM; check outbound network access and retry',
+        'fcm_probe_unavailable',
+      )
+    }
+    throw cause
+  } finally {
+    provider.close()
+  }
+}
+
+function cloneSnapshot(value: FCMConfigurationSnapshot): FCMConfigurationSnapshot {
+  return {
+    ...value,
+    ...(value.input ? { input: { ...value.input } } : {}),
+    ...(value.config ? { config: { ...value.config } } : {}),
+    warnings: value.warnings.map(warning => ({ ...warning })),
+  }
+}
+
+export class FCMConfigurationManager {
+  readonly path: string
+  readonly #probe: FCMConfigurationProbe
+  #snapshot: FCMConfigurationSnapshot
+  #operation: Promise<void> = Promise.resolve()
+
+  constructor(home: string, initial: FCMConfigurationSnapshot, options: FCMConfigurationManagerOptions = {}) {
+    this.path = fcmConfigurationPath(home)
+    this.#snapshot = cloneSnapshot(initial)
+    this.#probe = options.probe ?? defaultProbe
+  }
+
+  snapshot(): FCMConfigurationSnapshot {
+    return cloneSnapshot(this.#snapshot)
+  }
+
+  update(
+    input: Partial<FCMConfigurationInput>,
+    apply: (config: FCMProviderConfig) => void | Promise<void>,
+  ): Promise<FCMConfigurationSnapshot> {
+    const operation = this.#operation.then(() => this.#update(input, apply))
+    this.#operation = operation.then(() => undefined, () => undefined)
+    return operation
+  }
+
+  async #update(
+    input: Partial<FCMConfigurationInput>,
+    apply: (config: FCMProviderConfig) => void | Promise<void>,
+  ): Promise<FCMConfigurationSnapshot> {
+    if (this.#snapshot.source === 'environment') {
+      throw new HttpError(409, 'FCM configuration is managed by service environment variables', 'fcm_environment_managed')
+    }
+    const validated = validateFCMConfiguration(input)
+    const config = validated.config!
+    try {
+      await this.#probe(config)
+    } catch (cause) {
+      if (cause instanceof HttpError) throw cause
+      throw new HttpError(
+        422,
+        `FCM credentials could not be verified: ${cause instanceof Error ? cause.message : 'unknown error'}`,
+        'fcm_credentials_rejected',
+      )
+    }
+    const previous = existsSync(this.path) ? readConfigurationFile(this.path) : undefined
+    const stored: StoredFCMConfiguration = {
+      schemaVersion: 1, enabled: true, ...validated.input!, updatedAt: new Date().toISOString(),
+    }
+    try {
+      writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`)
+      await apply(config)
+    } catch (cause) {
+      try {
+        if (previous) writeAtomic(this.path, previous)
+        else if (existsSync(this.path)) unlinkSync(this.path)
+      } catch { /* preserve the primary configuration error */ }
+      throw cause
+    }
+    this.#snapshot = { ...validated, source: 'file', editable: true }
+    return this.snapshot()
+  }
+}

+ 450 - 121
src/server/pushCoordinator.ts

@@ -18,7 +18,14 @@ import {
   type APNsRequest,
   type APNsRequest,
   type APNsSendResult,
   type APNsSendResult,
 } from './apns.js'
 } from './apns.js'
-import { DEFAULT_APNS_TOPIC, type APNsProviderConfig } from './config.js'
+import { FCMProvider, type FCMRequest } from './fcm.js'
+import {
+  DEFAULT_APNS_TOPIC,
+  type APNsProviderConfig,
+  type FCMProviderConfig,
+} from './config.js'
+import { DEFAULT_FCM_PACKAGE_NAME } from './fcmConfiguration.js'
+import { notificationPlainText } from './notificationText.js'
 
 
 export const PUSH_EVENT_KINDS = [
 export const PUSH_EVENT_KINDS = [
   'chat.completed',
   'chat.completed',
@@ -31,6 +38,18 @@ export const PUSH_EVENT_KINDS = [
 ] as const
 ] as const
 
 
 export type PushEventKind = typeof PUSH_EVENT_KINDS[number]
 export type PushEventKind = typeof PUSH_EVENT_KINDS[number]
+export type PushPlatform = 'ios' | 'android'
+
+export interface AndroidPushCapabilities {
+  enabled: boolean
+  provider: 'fcm'
+  packageName: string
+  previewMode: 'title-and-summary'
+  events: readonly PushEventKind[]
+  maxSummaryCharacters: 180
+  maximumSummaryCharacters: 180
+  configurationError?: string
+}
 
 
 export interface PushCapabilities {
 export interface PushCapabilities {
   protocolVersion: 1
   protocolVersion: 1
@@ -42,6 +61,15 @@ export interface PushCapabilities {
   maxSummaryCharacters: 180
   maxSummaryCharacters: 180
   maximumSummaryCharacters: 180
   maximumSummaryCharacters: 180
   configurationError?: string
   configurationError?: string
+  platforms: {
+    ios: {
+      enabled: boolean
+      provider: 'apns'
+      topic: string
+      environments: readonly APNsEnvironment[]
+    }
+    android: AndroidPushCapabilities
+  }
 }
 }
 
 
 export interface PushStatus {
 export interface PushStatus {
@@ -57,20 +85,38 @@ export interface PushStatus {
   lastErrorAt?: string
   lastErrorAt?: string
 }
 }
 
 
-export interface InstallationRegistration {
+export interface FCMPushStatus {
+  configured: boolean
+  healthy: boolean
+  provider: 'fcm'
+  packageName: string
+  projectId?: string
+  registrationCount: number
+  pendingCount: number
+  subscriptionCount: number
+  lastSuccessAt?: string
+  lastError?: string
+  lastErrorAt?: string
+}
+
+interface InstallationRegistrationBase {
   userId: string
   userId: string
   installationId: string
   installationId: string
   clientAccountId: string
   clientAccountId: string
-  deviceToken: string
-  environment: APNsEnvironment
   appVersion?: string
   appVersion?: string
   authorizationVersion?: number
   authorizationVersion?: number
 }
 }
 
 
+export type InstallationRegistration = InstallationRegistrationBase & (
+  | { platform?: 'ios'; deviceToken: string; environment: APNsEnvironment; fid?: never }
+  | { platform: 'android'; fid: string; deviceToken?: never; environment?: never }
+)
+
 export interface PushInstallation {
 export interface PushInstallation {
   installationId: string
   installationId: string
   clientAccountId: string
   clientAccountId: string
-  environment: APNsEnvironment
+  platform: PushPlatform
+  environment?: APNsEnvironment
   appVersion?: string
   appVersion?: string
   updatedAt: string
   updatedAt: string
 }
 }
@@ -141,12 +187,21 @@ export interface PushSender {
   close?(): void
   close?(): void
 }
 }
 
 
+export interface FCMSender {
+  send(request: FCMRequest): Promise<APNsSendResult>
+  close?(): void
+}
+
 export interface PushCoordinatorOptions {
 export interface PushCoordinatorOptions {
   home: string
   home: string
   apns?: APNsProviderConfig
   apns?: APNsProviderConfig
   apnsConfigurationError?: string
   apnsConfigurationError?: string
   provider?: PushSender
   provider?: PushSender
   providerFactory?: (config: APNsProviderConfig) => PushSender
   providerFactory?: (config: APNsProviderConfig) => PushSender
+  fcm?: FCMProviderConfig
+  fcmConfigurationError?: string
+  fcmProvider?: FCMSender
+  fcmProviderFactory?: (config: FCMProviderConfig) => FCMSender
   now?: () => number
   now?: () => number
   autoFlush?: boolean
   autoFlush?: boolean
   baseRetryMilliseconds?: number
   baseRetryMilliseconds?: number
@@ -156,13 +211,26 @@ export interface PushCoordinatorOptions {
   userAuthorizationVersion?: (userId: string) => number | undefined
   userAuthorizationVersion?: (userId: string) => number | undefined
 }
 }
 
 
-interface InstallationRecord extends PushInstallation {
+interface InstallationRecordBase extends PushInstallation {
   userId: string
   userId: string
-  deviceToken: string
   badge: number
   badge: number
   authorizationVersion?: number
   authorizationVersion?: number
 }
 }
 
 
+interface IOSInstallationRecord extends InstallationRecordBase {
+  platform: 'ios'
+  deviceToken: string
+  environment: APNsEnvironment
+}
+
+interface AndroidInstallationRecord extends InstallationRecordBase {
+  platform: 'android'
+  fid: string
+  environment?: never
+}
+
+type InstallationRecord = IOSInstallationRecord | AndroidInstallationRecord
+
 interface GroupSubscriptionRecord extends PushGroupSubscription {
 interface GroupSubscriptionRecord extends PushGroupSubscription {
   userId: string
   userId: string
 }
 }
@@ -173,6 +241,7 @@ interface OutboxRecord {
   userId: string
   userId: string
   installationId: string
   installationId: string
   clientAccountId: string
   clientAccountId: string
+  platform: PushPlatform
   kind: PushEventKind
   kind: PushEventKind
   title: string
   title: string
   body: string
   body: string
@@ -193,7 +262,7 @@ interface StoredStatus {
 }
 }
 
 
 interface PushState {
 interface PushState {
-  schemaVersion: 1
+  schemaVersion: 2
   installations: InstallationRecord[]
   installations: InstallationRecord[]
   groupSubscriptions: GroupSubscriptionRecord[]
   groupSubscriptions: GroupSubscriptionRecord[]
   outbox: OutboxRecord[]
   outbox: OutboxRecord[]
@@ -202,7 +271,7 @@ interface PushState {
   chatJobs: PushChatJob[]
   chatJobs: PushChatJob[]
   ambiguousChatSessions: Record<string, number>
   ambiguousChatSessions: Record<string, number>
   chatRecoveryDisabledUntil?: number
   chatRecoveryDisabledUntil?: number
-  status: StoredStatus
+  providerStatus: Record<PushPlatform, StoredStatus>
 }
 }
 
 
 const MAX_SUMMARY_CHARACTERS = 180
 const MAX_SUMMARY_CHARACTERS = 180
@@ -218,14 +287,14 @@ const DEDUPE_LIFETIME_MS = 90 * 24 * 60 * 60 * 1_000
 
 
 function emptyState(): PushState {
 function emptyState(): PushState {
   return {
   return {
-    schemaVersion: 1,
+    schemaVersion: 2,
     installations: [],
     installations: [],
     groupSubscriptions: [],
     groupSubscriptions: [],
     outbox: [],
     outbox: [],
     processedEvents: {},
     processedEvents: {},
     chatJobs: [],
     chatJobs: [],
     ambiguousChatSessions: {},
     ambiguousChatSessions: {},
-    status: {},
+    providerStatus: { ios: {}, android: {} },
   }
   }
 }
 }
 
 
@@ -236,12 +305,13 @@ function isObject(value: unknown): value is Record<string, unknown> {
 function parseState(raw: string): PushState {
 function parseState(raw: string): PushState {
   const value = JSON.parse(raw) as unknown
   const value = JSON.parse(raw) as unknown
   if (!isObject(value)
   if (!isObject(value)
-    || value.schemaVersion !== 1
+    || (value.schemaVersion !== 1 && value.schemaVersion !== 2)
     || !Array.isArray(value.installations)
     || !Array.isArray(value.installations)
     || !Array.isArray(value.groupSubscriptions)
     || !Array.isArray(value.groupSubscriptions)
     || !Array.isArray(value.outbox)
     || !Array.isArray(value.outbox)
     || !isObject(value.processedEvents)
     || !isObject(value.processedEvents)
-    || !isObject(value.status)) {
+    || (value.schemaVersion === 1 && !isObject(value.status))
+    || (value.schemaVersion === 2 && !isObject(value.providerStatus))) {
     throw new Error('Unsupported or malformed push state')
     throw new Error('Unsupported or malformed push state')
   }
   }
   if (value.chatJobs !== undefined && !Array.isArray(value.chatJobs)) {
   if (value.chatJobs !== undefined && !Array.isArray(value.chatJobs)) {
@@ -255,11 +325,37 @@ function parseState(raw: string): PushState {
   const migratedAmbiguous = Object.fromEntries(Object.entries(legacyOwners).flatMap(([sessionID, owners]) => (
   const migratedAmbiguous = Object.fromEntries(Object.entries(legacyOwners).flatMap(([sessionID, owners]) => (
     Array.isArray(owners) && new Set(owners.map(String)).size > 1 ? [[sessionID, Date.now()]] : []
     Array.isArray(owners) && new Set(owners.map(String)).size > 1 ? [[sessionID, Date.now()]] : []
   )))
   )))
+  const installations = (value.installations as Array<Record<string, unknown>>).map(record => {
+    if (value.schemaVersion === 1) return { ...record, platform: 'ios' }
+    return record
+  }) as unknown as InstallationRecord[]
+  if (installations.some(record => record.platform !== 'ios' && record.platform !== 'android')) {
+    throw new Error('Malformed push installation platform')
+  }
+  if (installations.some(record => record.platform === 'ios'
+    ? typeof record.deviceToken !== 'string'
+      || (record.environment !== 'development' && record.environment !== 'production')
+    : typeof record.fid !== 'string')) {
+    throw new Error('Malformed push installation target')
+  }
+  const outbox = (value.outbox as Array<Record<string, unknown>>).map(record => {
+    if (value.schemaVersion === 1) return { ...record, platform: 'ios' }
+    return record
+  }) as unknown as OutboxRecord[]
+  if (outbox.some(record => record.platform !== 'ios' && record.platform !== 'android')) {
+    throw new Error('Malformed push outbox platform')
+  }
+  const providerStatus = value.schemaVersion === 1
+    ? { ios: value.status as StoredStatus, android: {} }
+    : value.providerStatus as Record<PushPlatform, StoredStatus>
+  if (!isObject(providerStatus.ios) || !isObject(providerStatus.android)) {
+    throw new Error('Malformed push provider status')
+  }
   return {
   return {
-    schemaVersion: 1,
-    installations: value.installations as InstallationRecord[],
+    schemaVersion: 2,
+    installations,
     groupSubscriptions: value.groupSubscriptions as GroupSubscriptionRecord[],
     groupSubscriptions: value.groupSubscriptions as GroupSubscriptionRecord[],
-    outbox: value.outbox as OutboxRecord[],
+    outbox,
     processedEvents: value.processedEvents as Record<string, number>,
     processedEvents: value.processedEvents as Record<string, number>,
     ...(isObject(value.groupWatch) ? { groupWatch: value.groupWatch as unknown as PushGroupWatchAnchor } : {}),
     ...(isObject(value.groupWatch) ? { groupWatch: value.groupWatch as unknown as PushGroupWatchAnchor } : {}),
     chatJobs: (value.chatJobs ?? []) as PushChatJob[],
     chatJobs: (value.chatJobs ?? []) as PushChatJob[],
@@ -270,7 +366,10 @@ function parseState(raw: string): PushState {
     ...(typeof value.chatRecoveryDisabledUntil === 'number'
     ...(typeof value.chatRecoveryDisabledUntil === 'number'
       ? { chatRecoveryDisabledUntil: value.chatRecoveryDisabledUntil }
       ? { chatRecoveryDisabledUntil: value.chatRecoveryDisabledUntil }
       : {}),
       : {}),
-    status: value.status as StoredStatus,
+    providerStatus: {
+      ios: providerStatus.ios,
+      android: providerStatus.android,
+    },
   }
   }
 }
 }
 
 
@@ -288,7 +387,10 @@ class PushStateStore {
     if (!existsSync(this.path)) return
     if (!existsSync(this.path)) return
     try {
     try {
       chmodSync(this.path, 0o600)
       chmodSync(this.path, 0o600)
-      this.state = parseState(readFileSync(this.path, 'utf8'))
+      const raw = readFileSync(this.path, 'utf8')
+      const parsed = JSON.parse(raw) as { schemaVersion?: unknown }
+      this.state = parseState(raw)
+      if (parsed.schemaVersion === 1) this.write(this.state)
     } catch {
     } catch {
       this.loadError = '推送状态文件无法读取;为避免覆盖现有数据,推送已停用'
       this.loadError = '推送状态文件无法读取;为避免覆盖现有数据,推送已停用'
     }
     }
@@ -342,6 +444,13 @@ function normalizedToken(value: string): string {
   return normalized
   return normalized
 }
 }
 
 
+function normalizedFID(value: string): string {
+  const normalized = value.trim()
+  if (normalized.length < 16 || normalized.length > 4_096
+    || !/^[A-Za-z0-9_:.\-]+$/.test(normalized)) throw new Error('fid is invalid')
+  return normalized
+}
+
 function limitedText(value: string, maximum: number, name: string): string {
 function limitedText(value: string, maximum: number, name: string): string {
   const normalized = value.trim()
   const normalized = value.trim()
   if (!normalized) throw new Error(`${name} is required`)
   if (!normalized) throw new Error(`${name} is required`)
@@ -352,7 +461,8 @@ function publicInstallation(record: InstallationRecord): PushInstallation {
   return {
   return {
     installationId: record.installationId,
     installationId: record.installationId,
     clientAccountId: record.clientAccountId,
     clientAccountId: record.clientAccountId,
-    environment: record.environment,
+    platform: record.platform,
+    ...(record.platform === 'ios' ? { environment: record.environment } : {}),
     ...(record.appVersion ? { appVersion: record.appVersion } : {}),
     ...(record.appVersion ? { appVersion: record.appVersion } : {}),
     updatedAt: record.updatedAt,
     updatedAt: record.updatedAt,
   }
   }
@@ -367,9 +477,23 @@ function dedupeKey(userId: string, eventId: string): string {
   return `${dedupePrefix(userId)}${digest}`
   return `${dedupePrefix(userId)}${digest}`
 }
 }
 
 
-function safeError(result: APNsSendResult): string {
-  const reason = result.reason || `APNs HTTP ${result.status}`
-  return reason.replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]').replace(/[\r\n]+/g, ' ').slice(0, 500)
+function safeError(result: APNsSendResult, platform: PushPlatform = 'ios'): string {
+  const reason = result.reason || `${platform === 'ios' ? 'APNs' : 'FCM'} HTTP ${result.status}`
+  return reason
+    .replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]')
+    .replace(/[A-Za-z0-9_:.\-]{80,}/g, '[opaque-token]')
+    .replace(/[\r\n]+/g, ' ')
+    .slice(0, 500)
+}
+
+function safeProviderException(cause: unknown, installation: InstallationRecord): string {
+  const target = installation.platform === 'ios' ? installation.deviceToken : installation.fid
+  return (cause instanceof Error ? cause.message : String(cause))
+    .split(target).join('[push-target]')
+    .replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]')
+    .replace(/[A-Za-z0-9_:.-]{80,}/g, '[opaque-token]')
+    .replace(/[\r\n]+/g, ' ')
+    .slice(0, 500) || `${installation.platform === 'ios' ? 'APNs' : 'FCM'} provider failed`
 }
 }
 
 
 function canonicalGroupWatchAnchor(input: PushGroupWatchAnchor, now: number): PushGroupWatchAnchor {
 function canonicalGroupWatchAnchor(input: PushGroupWatchAnchor, now: number): PushGroupWatchAnchor {
@@ -417,6 +541,10 @@ export class PushCoordinator {
   private apns?: APNsProviderConfig
   private apns?: APNsProviderConfig
   private apnsConfigurationError?: string
   private apnsConfigurationError?: string
   private readonly providerFactory: (config: APNsProviderConfig) => PushSender
   private readonly providerFactory: (config: APNsProviderConfig) => PushSender
+  private fcmProvider?: FCMSender
+  private fcm?: FCMProviderConfig
+  private fcmConfigurationError?: string
+  private readonly fcmProviderFactory: (config: FCMProviderConfig) => FCMSender
   private readonly now: () => number
   private readonly now: () => number
   private readonly autoFlush: boolean
   private readonly autoFlush: boolean
   private readonly baseRetryMilliseconds: number
   private readonly baseRetryMilliseconds: number
@@ -427,9 +555,9 @@ export class PushCoordinator {
   private timer?: NodeJS.Timeout
   private timer?: NodeJS.Timeout
   private activeFlush?: Promise<PushFlushResult>
   private activeFlush?: Promise<PushFlushResult>
   private closed = false
   private closed = false
-  private runtimeError?: { message: string; at: string }
-  private flushFailureCount = 0
-  private flushBlockedUntil = 0
+  private readonly runtimeErrors: Partial<Record<PushPlatform, { message: string; at: string }>> = {}
+  private readonly flushFailureCount: Record<PushPlatform, number> = { ios: 0, android: 0 }
+  private readonly flushBlockedUntil: Record<PushPlatform, number> = { ios: 0, android: 0 }
   private correlationSecret?: Buffer
   private correlationSecret?: Buffer
   private correlationError?: string
   private correlationError?: string
   private readonly enabledListeners = new Set<(enabled: boolean) => void>()
   private readonly enabledListeners = new Set<(enabled: boolean) => void>()
@@ -442,6 +570,10 @@ export class PushCoordinator {
     this.apnsConfigurationError = options.apnsConfigurationError
     this.apnsConfigurationError = options.apnsConfigurationError
     this.providerFactory = options.providerFactory ?? (config => new APNsProvider(config))
     this.providerFactory = options.providerFactory ?? (config => new APNsProvider(config))
     this.provider = options.provider ?? (options.apns ? this.providerFactory(options.apns) : undefined)
     this.provider = options.provider ?? (options.apns ? this.providerFactory(options.apns) : undefined)
+    this.fcm = options.fcm
+    this.fcmConfigurationError = options.fcmConfigurationError
+    this.fcmProviderFactory = options.fcmProviderFactory ?? (config => new FCMProvider(config))
+    this.fcmProvider = options.fcmProvider ?? (options.fcm ? this.fcmProviderFactory(options.fcm) : undefined)
     this.now = options.now ?? Date.now
     this.now = options.now ?? Date.now
     this.autoFlush = options.autoFlush ?? true
     this.autoFlush = options.autoFlush ?? true
     this.baseRetryMilliseconds = options.baseRetryMilliseconds ?? 1_000
     this.baseRetryMilliseconds = options.baseRetryMilliseconds ?? 1_000
@@ -449,7 +581,7 @@ export class PushCoordinator {
     this.maxAttempts = options.maxAttempts ?? 36
     this.maxAttempts = options.maxAttempts ?? 36
     this.isUserActive = options.isUserActive ?? (() => true)
     this.isUserActive = options.isUserActive ?? (() => true)
     this.userAuthorizationVersion = options.userAuthorizationVersion
     this.userAuthorizationVersion = options.userAuthorizationVersion
-    if (options.apns && this.provider && !options.apnsConfigurationError) {
+    if (this.isAnyProviderEnabled()) {
       try { this.loadCorrelationSecret() } catch (cause) {
       try { this.loadCorrelationSecret() } catch (cause) {
         this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
         this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
       }
       }
@@ -465,7 +597,7 @@ export class PushCoordinator {
   }
   }
 
 
   private async applyAPNsConfiguration(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
   private async applyAPNsConfiguration(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
-    const wasEnabled = this.capabilities().enabled
+    const wasEnabled = this.isAnyProviderEnabled()
     const previous = this.provider
     const previous = this.provider
     const next = apns && !configurationError ? this.providerFactory(apns) : undefined
     const next = apns && !configurationError ? this.providerFactory(apns) : undefined
     if (this.closed) {
     if (this.closed) {
@@ -476,19 +608,19 @@ export class PushCoordinator {
     this.apns = apns
     this.apns = apns
     this.apnsConfigurationError = configurationError
     this.apnsConfigurationError = configurationError
     this.provider = next
     this.provider = next
-    this.runtimeError = undefined
-    this.flushFailureCount = 0
-    this.flushBlockedUntil = 0
-    this.correlationError = undefined
-    if (apns && next && !configurationError) {
+    delete this.runtimeErrors.ios
+    this.flushFailureCount.ios = 0
+    this.flushBlockedUntil.ios = 0
+    if (this.isAnyProviderEnabled()) {
+      this.correlationError = undefined
       try { this.loadCorrelationSecret() } catch (cause) {
       try { this.loadCorrelationSecret() } catch (cause) {
         this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
         this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
       }
       }
     }
     }
     try {
     try {
       this.store.mutate(state => {
       this.store.mutate(state => {
-        delete state.status.lastError
-        delete state.status.lastErrorAt
+        delete state.providerStatus.ios.lastError
+        delete state.providerStatus.ios.lastErrorAt
       })
       })
     } catch { /* Existing push state errors remain authoritative. */ }
     } catch { /* Existing push state errors remain authoritative. */ }
     if (previous && previous !== next) {
     if (previous && previous !== next) {
@@ -496,12 +628,63 @@ export class PushCoordinator {
       if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
       if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
       else closePrevious()
       else closePrevious()
     }
     }
-    if (!next && this.timer) {
+    if (!this.isAnyProviderEnabled() && this.timer) {
       clearTimeout(this.timer)
       clearTimeout(this.timer)
       this.timer = undefined
       this.timer = undefined
     }
     }
     this.scheduleNextFlush()
     this.scheduleNextFlush()
-    const enabled = this.capabilities().enabled
+    const enabled = this.isAnyProviderEnabled()
+    if (enabled !== wasEnabled) {
+      for (const listener of this.enabledListeners) {
+        try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
+      }
+    }
+  }
+
+  configureFCM(fcm?: FCMProviderConfig, configurationError?: string): Promise<void> {
+    const operation = this.configurationOperation.then(() => this.applyFCMConfiguration(fcm, configurationError))
+    this.configurationOperation = operation.then(() => undefined, () => undefined)
+    return operation
+  }
+
+  private async applyFCMConfiguration(fcm?: FCMProviderConfig, configurationError?: string): Promise<void> {
+    const wasEnabled = this.isAnyProviderEnabled()
+    const previous = this.fcmProvider
+    const next = fcm && !configurationError ? this.fcmProviderFactory(fcm) : undefined
+    if (this.closed) {
+      next?.close?.()
+      return
+    }
+    this.providerGeneration += 1
+    this.fcm = fcm
+    this.fcmConfigurationError = configurationError
+    this.fcmProvider = next
+    delete this.runtimeErrors.android
+    this.flushFailureCount.android = 0
+    this.flushBlockedUntil.android = 0
+    if (this.isAnyProviderEnabled()) {
+      this.correlationError = undefined
+      try { this.loadCorrelationSecret() } catch (cause) {
+        this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
+      }
+    }
+    try {
+      this.store.mutate(state => {
+        delete state.providerStatus.android.lastError
+        delete state.providerStatus.android.lastErrorAt
+      })
+    } catch { /* Existing push state errors remain authoritative. */ }
+    if (previous && previous !== next) {
+      const closePrevious = () => { try { previous.close?.() } catch { /* provider is already detached */ } }
+      if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
+      else closePrevious()
+    }
+    if (!this.isAnyProviderEnabled() && this.timer) {
+      clearTimeout(this.timer)
+      this.timer = undefined
+    }
+    this.scheduleNextFlush()
+    const enabled = this.isAnyProviderEnabled()
     if (enabled !== wasEnabled) {
     if (enabled !== wasEnabled) {
       for (const listener of this.enabledListeners) {
       for (const listener of this.enabledListeners) {
         try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
         try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
@@ -516,9 +699,12 @@ export class PushCoordinator {
 
 
   capabilities(): PushCapabilities {
   capabilities(): PushCapabilities {
     const configurationError = this.apnsConfigurationError ?? this.store.loadError
     const configurationError = this.apnsConfigurationError ?? this.store.loadError
+    const fcmConfigurationError = this.fcmConfigurationError ?? this.store.loadError
+    const iosEnabled = Boolean(this.apns && this.provider && !configurationError)
+    const androidEnabled = Boolean(this.fcm && this.fcmProvider && !fcmConfigurationError)
     return {
     return {
       protocolVersion: 1,
       protocolVersion: 1,
-      enabled: Boolean(this.apns && this.provider && !configurationError),
+      enabled: iosEnabled,
       topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
       topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
       environments: this.apns?.environments ?? ['development', 'production'],
       environments: this.apns?.environments ?? ['development', 'production'],
       previewMode: 'title-and-summary',
       previewMode: 'title-and-summary',
@@ -526,29 +712,81 @@ export class PushCoordinator {
       maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
       maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
       maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
       maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
       ...(configurationError ? { configurationError } : {}),
       ...(configurationError ? { configurationError } : {}),
+      platforms: {
+        ios: {
+          enabled: iosEnabled,
+          provider: 'apns',
+          topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
+          environments: this.apns?.environments ?? ['development', 'production'],
+        },
+        android: {
+          enabled: androidEnabled,
+          provider: 'fcm',
+          packageName: this.fcm?.packageName ?? DEFAULT_FCM_PACKAGE_NAME,
+          previewMode: 'title-and-summary',
+          events: PUSH_EVENT_KINDS,
+          maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
+          maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
+          ...(fcmConfigurationError ? { configurationError: fcmConfigurationError } : {}),
+        },
+      },
     }
     }
   }
   }
 
 
+  isAnyProviderEnabled(): boolean {
+    const capabilities = this.capabilities()
+    return capabilities.platforms.ios.enabled || capabilities.platforms.android.enabled
+  }
+
   status(): PushStatus {
   status(): PushStatus {
     const state = this.store.snapshot()
     const state = this.store.snapshot()
     const configurationError = this.apnsConfigurationError ?? this.store.loadError
     const configurationError = this.apnsConfigurationError ?? this.store.loadError
-    const latestSuccess = state.status.lastSuccessAt ? Date.parse(state.status.lastSuccessAt) : 0
-    const latestError = state.status.lastErrorAt ? Date.parse(state.status.lastErrorAt) : 0
+    const stored = state.providerStatus.ios
+    const runtimeError = this.runtimeErrors.ios
+    const latestSuccess = stored.lastSuccessAt ? Date.parse(stored.lastSuccessAt) : 0
+    const latestError = stored.lastErrorAt ? Date.parse(stored.lastErrorAt) : 0
     const configured = Boolean(this.apns && this.provider && !configurationError)
     const configured = Boolean(this.apns && this.provider && !configurationError)
     return {
     return {
       configured,
       configured,
-      healthy: configured && !this.correlationError && !this.runtimeError && latestSuccess >= latestError,
+      healthy: configured && !this.correlationError && !runtimeError && latestSuccess >= latestError,
       topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
       topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
       environments: this.apns?.environments ?? ['development', 'production'],
       environments: this.apns?.environments ?? ['development', 'production'],
-      registrationCount: state.installations.length,
-      pendingCount: state.outbox.length,
+      registrationCount: state.installations.filter(item => item.platform === 'ios').length,
+      pendingCount: state.outbox.filter(item => item.platform === 'ios').length,
+      subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
+      ...(stored.lastSuccessAt ? { lastSuccessAt: stored.lastSuccessAt } : {}),
+      ...(configurationError || this.correlationError || runtimeError?.message || stored.lastError
+        ? { lastError: configurationError ?? this.correlationError ?? runtimeError?.message ?? stored.lastError }
+        : {}),
+      ...(runtimeError?.at || stored.lastErrorAt
+        ? { lastErrorAt: runtimeError?.at ?? stored.lastErrorAt }
+        : {}),
+    }
+  }
+
+  fcmStatus(): FCMPushStatus {
+    const state = this.store.snapshot()
+    const configurationError = this.fcmConfigurationError ?? this.store.loadError
+    const stored = state.providerStatus.android
+    const runtimeError = this.runtimeErrors.android
+    const latestSuccess = stored.lastSuccessAt ? Date.parse(stored.lastSuccessAt) : 0
+    const latestError = stored.lastErrorAt ? Date.parse(stored.lastErrorAt) : 0
+    const configured = Boolean(this.fcm && this.fcmProvider && !configurationError)
+    return {
+      configured,
+      healthy: configured && !this.correlationError && !runtimeError && latestSuccess >= latestError,
+      provider: 'fcm',
+      packageName: this.fcm?.packageName ?? DEFAULT_FCM_PACKAGE_NAME,
+      ...(this.fcm?.projectId ? { projectId: this.fcm.projectId } : {}),
+      registrationCount: state.installations.filter(item => item.platform === 'android').length,
+      pendingCount: state.outbox.filter(item => item.platform === 'android').length,
       subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
       subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
-      ...(state.status.lastSuccessAt ? { lastSuccessAt: state.status.lastSuccessAt } : {}),
-      ...(configurationError || this.correlationError || this.runtimeError?.message || state.status.lastError
-        ? { lastError: configurationError ?? this.correlationError ?? this.runtimeError?.message ?? state.status.lastError }
+      ...(stored.lastSuccessAt ? { lastSuccessAt: stored.lastSuccessAt } : {}),
+      ...(configurationError || this.correlationError || runtimeError?.message || stored.lastError
+        ? { lastError: configurationError ?? this.correlationError ?? runtimeError?.message ?? stored.lastError }
         : {}),
         : {}),
-      ...(this.runtimeError?.at || state.status.lastErrorAt
-        ? { lastErrorAt: this.runtimeError?.at ?? state.status.lastErrorAt }
+      ...(runtimeError?.at || stored.lastErrorAt
+        ? { lastErrorAt: runtimeError?.at ?? stored.lastErrorAt }
         : {}),
         : {}),
     }
     }
   }
   }
@@ -557,9 +795,19 @@ export class PushCoordinator {
     const userId = requiredIdentifier(input.userId, 'userId')
     const userId = requiredIdentifier(input.userId, 'userId')
     const installationId = requiredIdentifier(input.installationId, 'installationId')
     const installationId = requiredIdentifier(input.installationId, 'installationId')
     const clientAccountId = requiredIdentifier(input.clientAccountId, 'clientAccountId')
     const clientAccountId = requiredIdentifier(input.clientAccountId, 'clientAccountId')
-    const deviceToken = normalizedToken(input.deviceToken)
-    if (input.environment !== 'development' && input.environment !== 'production') {
-      throw new Error('environment is invalid')
+    let target: Pick<AndroidInstallationRecord, 'platform' | 'fid'>
+      | Pick<IOSInstallationRecord, 'platform' | 'deviceToken' | 'environment'>
+    if (input.platform === 'android') {
+      target = { platform: 'android', fid: normalizedFID(input.fid) }
+    } else {
+      if (input.environment !== 'development' && input.environment !== 'production') {
+        throw new Error('environment is invalid')
+      }
+      target = {
+        platform: 'ios',
+        deviceToken: normalizedToken(input.deviceToken),
+        environment: input.environment,
+      }
     }
     }
     const appVersion = input.appVersion
     const appVersion = input.appVersion
       ? requiredIdentifier(input.appVersion, 'appVersion', 64)
       ? requiredIdentifier(input.appVersion, 'appVersion', 64)
@@ -572,33 +820,46 @@ export class PushCoordinator {
     }
     }
     const updatedAt = new Date(this.now()).toISOString()
     const updatedAt = new Date(this.now()).toISOString()
     const installation = this.store.mutate(state => {
     const installation = this.store.mutate(state => {
-      const existing = state.installations.find(item => item.userId === userId
+      const existingIndex = state.installations.findIndex(item => item.userId === userId
         && item.installationId === installationId
         && item.installationId === installationId
         && item.clientAccountId === clientAccountId)
         && item.clientAccountId === clientAccountId)
-      if (existing) {
-        existing.deviceToken = deviceToken
-        existing.environment = input.environment
-        existing.updatedAt = updatedAt
-        if (authorizationVersion !== undefined) existing.authorizationVersion = authorizationVersion
-        else delete existing.authorizationVersion
-        if (appVersion) existing.appVersion = appVersion
-        else delete existing.appVersion
-        return publicInstallation(existing)
+      if (existingIndex >= 0) {
+        const existing = state.installations[existingIndex]!
+        const base = {
+          userId,
+          installationId,
+          clientAccountId,
+          ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
+          ...(appVersion ? { appVersion } : {}),
+          badge: existing.badge,
+          updatedAt,
+        }
+        const next: InstallationRecord = target.platform === 'android'
+          ? { ...base, ...target }
+          : { ...base, ...target }
+        state.installations[existingIndex] = next
+        if (existing.platform !== next.platform) {
+          state.outbox = state.outbox.filter(item => !(item.userId === userId
+            && item.installationId === installationId
+            && item.clientAccountId === clientAccountId))
+        }
+        return publicInstallation(next)
       }
       }
       if (state.installations.filter(item => item.userId === userId).length >= MAX_INSTALLATIONS_PER_USER) {
       if (state.installations.filter(item => item.userId === userId).length >= MAX_INSTALLATIONS_PER_USER) {
         throw new Error('Push installation limit reached')
         throw new Error('Push installation limit reached')
       }
       }
-      const record: InstallationRecord = {
+      const base = {
         userId,
         userId,
         installationId,
         installationId,
         clientAccountId,
         clientAccountId,
-        deviceToken,
-        environment: input.environment,
         ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
         ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
         ...(appVersion ? { appVersion } : {}),
         ...(appVersion ? { appVersion } : {}),
         badge: 0,
         badge: 0,
         updatedAt,
         updatedAt,
       }
       }
+      const record: InstallationRecord = target.platform === 'android'
+        ? { ...base, ...target }
+        : { ...base, ...target }
       state.installations.push(record)
       state.installations.push(record)
       return publicInstallation(record)
       return publicInstallation(record)
     })
     })
@@ -931,7 +1192,7 @@ export class PushCoordinator {
   }
   }
 
 
   enqueue(input: PushNotification): number {
   enqueue(input: PushNotification): number {
-    if (!this.capabilities().enabled) return 0
+    if (!this.isAnyProviderEnabled()) return 0
     const eventId = requiredIdentifier(input.eventId, 'eventId', 512)
     const eventId = requiredIdentifier(input.eventId, 'eventId', 512)
     const userId = requiredIdentifier(input.userId, 'userId')
     const userId = requiredIdentifier(input.userId, 'userId')
     try {
     try {
@@ -940,8 +1201,10 @@ export class PushCoordinator {
       return 0
       return 0
     }
     }
     if (!PUSH_EVENT_KINDS.includes(input.kind)) throw new Error('kind is invalid')
     if (!PUSH_EVENT_KINDS.includes(input.kind)) throw new Error('kind is invalid')
-    const title = limitedText(input.title, 120, 'title')
-    const body = limitedText(input.body, MAX_SUMMARY_CHARACTERS, 'body')
+    const title = notificationPlainText(input.title, { fallback: '新消息', maximum: 120 })
+    const body = notificationPlainText(input.body, {
+      fallback: '请打开夭夭查看详情', maximum: MAX_SUMMARY_CHARACTERS,
+    })
     const clientAccountId = input.clientAccountId
     const clientAccountId = input.clientAccountId
       ? requiredIdentifier(input.clientAccountId, 'clientAccountId')
       ? requiredIdentifier(input.clientAccountId, 'clientAccountId')
       : undefined
       : undefined
@@ -958,7 +1221,8 @@ export class PushCoordinator {
     const now = this.now()
     const now = this.now()
     const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
     const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
     if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
     if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
-    const allowedEnvironments = new Set(this.capabilities().environments)
+    const capabilities = this.capabilities()
+    const allowedEnvironments = new Set(capabilities.environments)
     const queued = this.store.mutate(state => {
     const queued = this.store.mutate(state => {
       for (const [key, processedAt] of Object.entries(state.processedEvents)) {
       for (const [key, processedAt] of Object.entries(state.processedEvents)) {
         if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
         if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
@@ -974,7 +1238,9 @@ export class PushCoordinator {
       if (state.processedEvents[key] !== undefined) return 0
       if (state.processedEvents[key] !== undefined) return 0
       const targets = state.installations.filter(item => item.userId === userId
       const targets = state.installations.filter(item => item.userId === userId
         && this.registrationIsAuthorized(item)
         && this.registrationIsAuthorized(item)
-        && allowedEnvironments.has(item.environment)
+        && (item.platform === 'ios'
+          ? capabilities.platforms.ios.enabled && allowedEnvironments.has(item.environment)
+          : capabilities.platforms.android.enabled)
         && (!clientAccountId || item.clientAccountId === clientAccountId))
         && (!clientAccountId || item.clientAccountId === clientAccountId))
       if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
       if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
       state.processedEvents[key] = now
       state.processedEvents[key] = now
@@ -986,6 +1252,7 @@ export class PushCoordinator {
           userId,
           userId,
           installationId: target.installationId,
           installationId: target.installationId,
           clientAccountId: target.clientAccountId,
           clientAccountId: target.clientAccountId,
+          platform: target.platform,
           kind: input.kind,
           kind: input.kind,
           title,
           title,
           body,
           body,
@@ -1007,26 +1274,30 @@ export class PushCoordinator {
 
 
   flushDue(): Promise<PushFlushResult> {
   flushDue(): Promise<PushFlushResult> {
     if (this.activeFlush) return this.activeFlush
     if (this.activeFlush) return this.activeFlush
-    if (this.closed || !this.provider || !this.capabilities().enabled) {
+    if (this.closed || !this.isAnyProviderEnabled()) {
       return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
       return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
     }
     }
-    const provider = this.provider
     const generation = this.providerGeneration
     const generation = this.providerGeneration
-    this.activeFlush = this.flushInternal(provider, generation)
+    this.activeFlush = this.flushInternal(generation)
       .then(result => {
       .then(result => {
         if (generation === this.providerGeneration) {
         if (generation === this.providerGeneration) {
-          this.flushFailureCount = 0
-          this.flushBlockedUntil = 0
-          this.runtimeError = undefined
+          for (const platform of ['ios', 'android'] as const) {
+            this.flushFailureCount[platform] = 0
+            this.flushBlockedUntil[platform] = 0
+            delete this.runtimeErrors[platform]
+          }
         }
         }
         return result
         return result
       })
       })
       .catch(cause => {
       .catch(cause => {
         if (generation === this.providerGeneration) {
         if (generation === this.providerGeneration) {
-          this.flushFailureCount += 1
-          const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount - 1, 6)))
-          this.flushBlockedUntil = this.now() + delay
-          this.recordRuntimeError(cause)
+          for (const platform of ['ios', 'android'] as const) {
+            if (!this.platformEnabled(platform)) continue
+            this.flushFailureCount[platform] += 1
+            const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount[platform] - 1, 6)))
+            this.flushBlockedUntil[platform] = this.now() + delay
+            this.recordRuntimeError(platform, cause)
+          }
         }
         }
         throw cause
         throw cause
       })
       })
@@ -1037,10 +1308,12 @@ export class PushCoordinator {
     return this.activeFlush
     return this.activeFlush
   }
   }
 
 
-  private async flushInternal(provider: PushSender, generation: number): Promise<PushFlushResult> {
+  private async flushInternal(generation: number): Promise<PushFlushResult> {
     const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
     const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
     const due = this.store.snapshot().outbox
     const due = this.store.snapshot().outbox
-      .filter(item => item.nextAttemptAt <= this.now())
+      .filter(item => item.nextAttemptAt <= this.now()
+        && this.platformEnabled(item.platform)
+        && this.flushBlockedUntil[item.platform] <= this.now())
       .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
       .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
       .slice(0, 100)
       .slice(0, 100)
     for (const candidate of due) {
     for (const candidate of due) {
@@ -1075,7 +1348,13 @@ export class PushCoordinator {
         summary.failed += 1
         summary.failed += 1
         continue
         continue
       }
       }
-      if (installation && !this.capabilities().environments.includes(installation.environment)) {
+      if (installation && installation.platform !== item.platform) {
+        this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
+        summary.failed += 1
+        continue
+      }
+      if (installation?.platform === 'ios'
+        && !this.capabilities().environments.includes(installation.environment)) {
         this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
         this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
         summary.failed += 1
         summary.failed += 1
         continue
         continue
@@ -1088,33 +1367,51 @@ export class PushCoordinator {
         summary.failed += 1
         summary.failed += 1
         continue
         continue
       }
       }
-      const payload: Record<string, unknown> = {
-        ...item.data,
-        aps: {
-          alert: { title: item.title, body: item.body },
-          sound: 'default',
-          badge: installation.badge,
-          ...(item.threadId ? { 'thread-id': item.threadId } : {}),
-        },
-        version: 1,
-        eventId: item.eventId,
-        kind: item.kind,
-        clientAccountId: item.clientAccountId,
+      let result: APNsSendResult
+      try {
+        result = installation.platform === 'ios'
+          ? await this.provider!.send({
+              deviceToken: installation.deviceToken,
+              environment: installation.environment,
+              payload: {
+                ...item.data,
+                aps: {
+                  alert: { title: item.title, body: item.body },
+                  sound: 'default',
+                  badge: installation.badge,
+                  ...(item.threadId ? { 'thread-id': item.threadId } : {}),
+                },
+                version: 1,
+                eventId: item.eventId,
+                kind: item.kind,
+                clientAccountId: item.clientAccountId,
+              },
+              apnsId: item.id,
+              ...(item.collapseId ? { collapseId: item.collapseId } : {}),
+              expiration: Math.floor(item.expiresAt / 1_000),
+            })
+          : await this.fcmProvider!.send({
+              fid: installation.fid,
+              data: this.fcmData(item),
+              ttlSeconds: Math.max(0, Math.ceil((item.expiresAt - this.now()) / 1_000)),
+              priority: 'high',
+            })
+      } catch (cause) {
+        // Provider implementations normally return a classified result, but a
+        // programming/runtime exception in one sender must not starve the
+        // other platform's durable queue.
+        result = {
+          disposition: 'retry',
+          status: 0,
+          reason: safeProviderException(cause, installation),
+        }
       }
       }
-      const result = await provider.send({
-        deviceToken: installation.deviceToken,
-        environment: installation.environment,
-        payload,
-        apnsId: item.id,
-        ...(item.collapseId ? { collapseId: item.collapseId } : {}),
-        expiration: Math.floor(item.expiresAt / 1_000),
-      })
       const at = new Date(this.now()).toISOString()
       const at = new Date(this.now()).toISOString()
       if (generation !== this.providerGeneration) {
       if (generation !== this.providerGeneration) {
         if (result.disposition === 'success') {
         if (result.disposition === 'success') {
           this.store.mutate(current => {
           this.store.mutate(current => {
             current.outbox = current.outbox.filter(entry => entry.id !== item.id)
             current.outbox = current.outbox.filter(entry => entry.id !== item.id)
-            current.status.lastSuccessAt = at
+            current.providerStatus[item.platform].lastSuccessAt = at
           })
           })
           summary.delivered += 1
           summary.delivered += 1
         } else {
         } else {
@@ -1129,7 +1426,7 @@ export class PushCoordinator {
       if (result.disposition === 'success') {
       if (result.disposition === 'success') {
         this.store.mutate(current => {
         this.store.mutate(current => {
           current.outbox = current.outbox.filter(entry => entry.id !== item.id)
           current.outbox = current.outbox.filter(entry => entry.id !== item.id)
-          current.status.lastSuccessAt = at
+          current.providerStatus[item.platform].lastSuccessAt = at
         })
         })
         summary.delivered += 1
         summary.delivered += 1
       } else if (result.disposition === 'unregister') {
       } else if (result.disposition === 'unregister') {
@@ -1138,10 +1435,7 @@ export class PushCoordinator {
           const latest = current.installations.find(entry => entry.userId === item.userId
           const latest = current.installations.find(entry => entry.userId === item.userId
             && entry.installationId === item.installationId
             && entry.installationId === item.installationId
             && entry.clientAccountId === item.clientAccountId)
             && entry.clientAccountId === item.clientAccountId)
-          registrationRotated = Boolean(latest && (
-            latest.deviceToken !== installation.deviceToken
-              || latest.environment !== installation.environment
-          ))
+          registrationRotated = Boolean(latest && this.registrationIdentity(latest) !== this.registrationIdentity(installation))
           if (registrationRotated) {
           if (registrationRotated) {
             const pending = current.outbox.find(entry => entry.id === item.id)
             const pending = current.outbox.find(entry => entry.id === item.id)
             if (pending) {
             if (pending) {
@@ -1175,16 +1469,16 @@ export class PushCoordinator {
               pending.nextAttemptAt = this.now() + delay
               pending.nextAttemptAt = this.now() + delay
             }
             }
           }
           }
-          current.status.lastError = safeError(result)
-          current.status.lastErrorAt = at
+          current.providerStatus[item.platform].lastError = safeError(result, item.platform)
+          current.providerStatus[item.platform].lastErrorAt = at
         })
         })
         if (exhausted) summary.failed += 1
         if (exhausted) summary.failed += 1
         else summary.retried += 1
         else summary.retried += 1
       } else {
       } else {
         this.store.mutate(current => {
         this.store.mutate(current => {
           current.outbox = current.outbox.filter(entry => entry.id !== item.id)
           current.outbox = current.outbox.filter(entry => entry.id !== item.id)
-          current.status.lastError = safeError(result)
-          current.status.lastErrorAt = at
+          current.providerStatus[item.platform].lastError = safeError(result, item.platform)
+          current.providerStatus[item.platform].lastErrorAt = at
         })
         })
         summary.failed += 1
         summary.failed += 1
       }
       }
@@ -1192,16 +1486,50 @@ export class PushCoordinator {
     return summary
     return summary
   }
   }
 
 
+  private platformEnabled(platform: PushPlatform): boolean {
+    const capabilities = this.capabilities()
+    return platform === 'ios' ? capabilities.platforms.ios.enabled : capabilities.platforms.android.enabled
+  }
+
+  private registrationIdentity(installation: InstallationRecord): string {
+    return installation.platform === 'ios'
+      ? `ios:${installation.environment}:${installation.deviceToken}`
+      : `android:${installation.fid}`
+  }
+
+  private fcmData(item: OutboxRecord): Record<string, string> {
+    const data: Record<string, string> = {}
+    for (const [key, value] of Object.entries(item.data)) {
+      if (value === undefined || value === null) continue
+      if (typeof value === 'string') data[key] = value
+      else if (typeof value === 'number' || typeof value === 'boolean') data[key] = String(value)
+      else data[key] = JSON.stringify(value)
+    }
+    return {
+      ...data,
+      version: '1',
+      eventId: item.eventId,
+      kind: item.kind,
+      clientAccountId: item.clientAccountId,
+      title: item.title,
+      body: item.body,
+      ...(item.collapseId ? { collapseId: item.collapseId } : {}),
+      ...(item.threadId ? { threadId: item.threadId } : {}),
+      ...(item.roomId ? { roomId: item.roomId } : {}),
+    }
+  }
+
   private scheduleNextFlush(): void {
   private scheduleNextFlush(): void {
-    if (!this.autoFlush || this.closed || !this.provider || !this.capabilities().enabled || this.activeFlush) return
+    if (!this.autoFlush || this.closed || !this.isAnyProviderEnabled() || this.activeFlush) return
     if (this.timer) clearTimeout(this.timer)
     if (this.timer) clearTimeout(this.timer)
     this.timer = undefined
     this.timer = undefined
-    const next = this.store.snapshot().outbox.reduce<number | undefined>((earliest, item) => (
-      earliest === undefined || item.nextAttemptAt < earliest ? item.nextAttemptAt : earliest
-    ), undefined)
+    const next = this.store.snapshot().outbox.reduce<number | undefined>((earliest, item) => {
+      if (!this.platformEnabled(item.platform)) return earliest
+      const attemptAt = Math.max(item.nextAttemptAt, this.flushBlockedUntil[item.platform])
+      return earliest === undefined || attemptAt < earliest ? attemptAt : earliest
+    }, undefined)
     if (next === undefined) return
     if (next === undefined) return
-    const scheduledAt = Math.max(next, this.flushBlockedUntil)
-    const delay = Math.max(0, Math.min(2_147_483_647, scheduledAt - this.now()))
+    const delay = Math.max(0, Math.min(2_147_483_647, next - this.now()))
     this.timer = setTimeout(() => {
     this.timer = setTimeout(() => {
       this.timer = undefined
       this.timer = undefined
       void this.flushDue().catch(() => undefined)
       void this.flushDue().catch(() => undefined)
@@ -1214,21 +1542,22 @@ export class PushCoordinator {
     if (this.timer) clearTimeout(this.timer)
     if (this.timer) clearTimeout(this.timer)
     this.timer = undefined
     this.timer = undefined
     this.provider?.close?.()
     this.provider?.close?.()
+    this.fcmProvider?.close?.()
     this.enabledListeners.clear()
     this.enabledListeners.clear()
   }
   }
 
 
-  private recordRuntimeError(cause: unknown): void {
+  private recordRuntimeError(platform: PushPlatform, cause: unknown): void {
     const raw = cause instanceof Error ? cause.message : String(cause)
     const raw = cause instanceof Error ? cause.message : String(cause)
     const message = raw
     const message = raw
       .replace(/[a-fA-F0-9]{32,}/g, '[redacted]')
       .replace(/[a-fA-F0-9]{32,}/g, '[redacted]')
       .replace(/[\r\n]+/g, ' ')
       .replace(/[\r\n]+/g, ' ')
       .slice(0, 500) || '推送后台任务失败'
       .slice(0, 500) || '推送后台任务失败'
     const at = new Date(this.now()).toISOString()
     const at = new Date(this.now()).toISOString()
-    this.runtimeError = { message, at }
+    this.runtimeErrors[platform] = { message, at }
     try {
     try {
       this.store.mutate(state => {
       this.store.mutate(state => {
-        state.status.lastError = message
-        state.status.lastErrorAt = at
+        state.providerStatus[platform].lastError = message
+        state.providerStatus[platform].lastErrorAt = at
       })
       })
     } catch {
     } catch {
       // The in-memory status remains available when the push state volume is
       // The in-memory status remains available when the push state volume is

+ 3 - 3
src/server/pushEventAdapter.ts

@@ -71,18 +71,18 @@ export class PushCoordinatorEventAdapter implements PushEventCoordinator {
 
 
   observeChat(_observation: ChatPushObservation): void {}
   observeChat(_observation: ChatPushObservation): void {}
   saveChatJob(job: ChatPushJob): void {
   saveChatJob(job: ChatPushJob): void {
-    if (this.push.capabilities().enabled) this.push.saveChatJob(job)
+    if (this.push.isAnyProviderEnabled()) this.push.saveChatJob(job)
   }
   }
   completeChatJob(jobID: string): void { this.push.completeChatJob(jobID) }
   completeChatJob(jobID: string): void { this.push.completeChatJob(jobID) }
   pendingChatJobs(): readonly ChatPushJob[] {
   pendingChatJobs(): readonly ChatPushJob[] {
-    return this.push.capabilities().enabled ? this.push.pendingChatJobs() : []
+    return this.push.isAnyProviderEnabled() ? this.push.pendingChatJobs() : []
   }
   }
   promptDigest(localUserID: string, prompt: string): string {
   promptDigest(localUserID: string, prompt: string): string {
     return this.push.promptDigest(localUserID, prompt)
     return this.push.promptDigest(localUserID, prompt)
   }
   }
   canRecoverChatJob(job: ChatPushJob): boolean { return this.push.chatJobRecoveryAllowed(job) }
   canRecoverChatJob(job: ChatPushJob): boolean { return this.push.chatJobRecoveryAllowed(job) }
   enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
   enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
-    if (!this.push.capabilities().enabled) return 'ignored'
+    if (!this.push.isAnyProviderEnabled()) return 'ignored'
     if ('roomID' in candidate) {
     if ('roomID' in candidate) {
       if (candidate.messageSequence !== undefined) {
       if (candidate.messageSequence !== undefined) {
         const lastSequence = this.push.groupSubscriptionLastMessageSeq(candidate.localUserID, candidate.roomID)
         const lastSequence = this.push.groupSubscriptionLastMessageSeq(candidate.localUserID, candidate.roomID)

+ 55 - 9
src/server/routes.ts

@@ -36,6 +36,10 @@ import {
   APNsConfigurationManager,
   APNsConfigurationManager,
   type APNsConfigurationInput,
   type APNsConfigurationInput,
 } from './apnsConfiguration.js'
 } from './apnsConfiguration.js'
+import {
+  FCMConfigurationManager,
+  type FCMConfigurationInput,
+} from './fcmConfiguration.js'
 
 
 type JsonObject = Record<string, unknown>
 type JsonObject = Record<string, unknown>
 
 
@@ -53,6 +57,7 @@ export interface RouteDependencies {
   profileIdentities: UpstreamProfileIdentityService
   profileIdentities: UpstreamProfileIdentityService
   push: PushCoordinator
   push: PushCoordinator
   apnsConfiguration: APNsConfigurationManager
   apnsConfiguration: APNsConfigurationManager
+  fcmConfiguration: FCMConfigurationManager
 }
 }
 
 
 function body(ctx: Koa.Context): JsonObject {
 function body(ctx: Koa.Context): JsonObject {
@@ -367,7 +372,7 @@ function pushSystemStatus(dependencies: RouteDependencies): Record<string, unkno
   const runtime = dependencies.push.status()
   const runtime = dependencies.push.status()
   const settings = dependencies.apnsConfiguration.snapshot()
   const settings = dependencies.apnsConfiguration.snapshot()
   const input = settings.input
   const input = settings.input
-  return {
+  const apns = {
     ...runtime,
     ...runtime,
     source: settings.source,
     source: settings.source,
     editable: settings.editable,
     editable: settings.editable,
@@ -379,6 +384,20 @@ function pushSystemStatus(dependencies: RouteDependencies): Record<string, unkno
     warnings: settings.warnings,
     warnings: settings.warnings,
     ...(settings.configurationError ? { configurationError: settings.configurationError } : {}),
     ...(settings.configurationError ? { configurationError: settings.configurationError } : {}),
   }
   }
+  const fcmRuntime = dependencies.push.fcmStatus()
+  const fcmSettings = dependencies.fcmConfiguration.snapshot()
+  const fcmInput = fcmSettings.input
+  const fcm = {
+    ...fcmRuntime,
+    source: fcmSettings.source,
+    editable: fcmSettings.editable,
+    serviceAccountFile: fcmInput?.serviceAccountFile,
+    projectId: fcmInput?.projectId ?? fcmRuntime.projectId,
+    packageName: fcmInput?.packageName ?? fcmRuntime.packageName,
+    warnings: fcmSettings.warnings,
+    ...(fcmSettings.configurationError ? { configurationError: fcmSettings.configurationError } : {}),
+  }
+  return { ...apns, providers: { apns, fcm } }
 }
 }
 
 
 function pushRequest<T>(operation: () => T): T {
 function pushRequest<T>(operation: () => T): T {
@@ -1071,21 +1090,35 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.put('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
   router.put('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
     const user = dependencies.auth.require(ctx)
     const user = dependencies.auth.require(ctx)
     const request = body(ctx)
     const request = body(ctx)
-    if (request.environment !== 'development' && request.environment !== 'production') {
+    const platform = request.platform === undefined || request.platform === 'ios' ? 'ios'
+      : request.platform === 'android' ? 'android' : undefined
+    if (!platform) {
+      throw new HttpError(400, 'platform must be ios or android', 'invalid_push_request')
+    }
+    if (platform === 'ios' && request.environment !== 'development' && request.environment !== 'production') {
       throw new HttpError(400, 'environment must be development or production', 'invalid_push_request')
       throw new HttpError(400, 'environment must be development or production', 'invalid_push_request')
     }
     }
-    const environment = request.environment
-    const installation = pushRequest(() => dependencies.push.registerInstallation({
+    const common = {
       userId: user.id,
       userId: user.id,
       installationId: canonicalUUID(ctx.params.installationID, 'installation ID'),
       installationId: canonicalUUID(ctx.params.installationID, 'installation ID'),
       clientAccountId: canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
       clientAccountId: canonicalUUID(ctx.params.clientAccountID, 'client account ID'),
-      deviceToken: typeof request.token === 'string'
-        ? request.token
-        : typeof request.deviceToken === 'string' ? request.deviceToken : '',
-      environment,
       appVersion: typeof request.appVersion === 'string' ? request.appVersion : undefined,
       appVersion: typeof request.appVersion === 'string' ? request.appVersion : undefined,
       authorizationVersion: dependencies.auth.pushAuthorizationVersion(user.id),
       authorizationVersion: dependencies.auth.pushAuthorizationVersion(user.id),
-    }))
+    }
+    const installation = platform === 'android'
+      ? pushRequest(() => dependencies.push.registerInstallation({
+          ...common,
+          platform: 'android',
+          fid: typeof request.fid === 'string' ? request.fid : '',
+        }))
+      : pushRequest(() => dependencies.push.registerInstallation({
+          ...common,
+          platform: 'ios',
+          deviceToken: typeof request.token === 'string'
+            ? request.token
+            : typeof request.deviceToken === 'string' ? request.deviceToken : '',
+          environment: request.environment as 'development' | 'production',
+        }))
     json(ctx, 200, { installation })
     json(ctx, 200, { installation })
   })
   })
   router.delete('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
   router.delete('/api/push/v1/installations/:installationID/accounts/:clientAccountID', (ctx) => {
@@ -1733,6 +1766,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     })
     })
     json(ctx, 200, pushSystemStatus(dependencies))
     json(ctx, 200, pushSystemStatus(dependencies))
   })
   })
+  router.put('/api/app/system/push-config/fcm', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    const input: Partial<FCMConfigurationInput> = {
+      serviceAccountFile: typeof request.serviceAccountFile === 'string' ? request.serviceAccountFile : '',
+      projectId: typeof request.projectId === 'string' ? request.projectId : '',
+      packageName: typeof request.packageName === 'string' ? request.packageName : '',
+    }
+    await dependencies.fcmConfiguration.update(input, async config => {
+      await dependencies.push.configureFCM(config)
+    })
+    json(ctx, 200, pushSystemStatus(dependencies))
+  })
   router.post('/api/app/system/update/check', async (ctx) => {
   router.post('/api/app/system/update/check', async (ctx) => {
     await withJar(ctx, async (jar) => {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       await requireGatewayAuthentication(ctx, dependencies, jar)

+ 60 - 0
tests/client/pushProtocol.test.ts

@@ -3,6 +3,7 @@ import {
   getGroupPushSubscriptions,
   getGroupPushSubscriptions,
   getPushCapabilities,
   getPushCapabilities,
   getPushSystemStatus,
   getPushSystemStatus,
+  saveFCMPushSystemConfig,
   savePushSystemConfig,
   savePushSystemConfig,
   setGroupPushSubscription,
   setGroupPushSubscription,
 } from '@/api/push'
 } from '@/api/push'
@@ -14,6 +15,65 @@ afterEach(() => {
 })
 })
 
 
 describe('push client protocol', () => {
 describe('push client protocol', () => {
+  it('normalizes FCM provider status and protects configuration mutations', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      return new Response(JSON.stringify({
+        configured: false,
+        healthy: false,
+        registrationCount: 0,
+        pendingCount: 0,
+        source: 'none',
+        editable: true,
+        environments: [],
+        warnings: [],
+        providers: {
+          fcm: {
+            configured: true,
+            healthy: true,
+            registration_count: 2,
+            pending_count: 1,
+            source: 'file',
+            editable: true,
+            service_account_file: '/srv/secrets/firebase-service-account.json',
+            project_id: 'yaoyao-test-project',
+            package_name: 'cn.samien.yaoyao.hermes',
+            warnings: [{ code: 'fcm_service_account_permissions', message: '建议调整为 0600' }],
+          },
+        },
+      }), { status: 200, headers: { 'content-type': 'application/json' } })
+    }))
+    setApiCsrfToken('csrf-fcm')
+
+    await expect(getPushSystemStatus()).resolves.toMatchObject({
+      providers: { fcm: {
+        configured: true,
+        registrationCount: 2,
+        pendingCount: 1,
+        serviceAccountFile: '/srv/secrets/firebase-service-account.json',
+        projectId: 'yaoyao-test-project',
+        warnings: [{ code: 'fcm_service_account_permissions' }],
+      } },
+    })
+    await expect(saveFCMPushSystemConfig({
+      serviceAccountFile: '/srv/secrets/firebase-service-account.json',
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    })).resolves.toMatchObject({ providers: { fcm: { configured: true } } })
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/system/push-status',
+      '/api/app/system/push-config/fcm',
+    ])
+    expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-fcm')
+    expect(calls[1]!.init?.body).toBe(JSON.stringify({
+      serviceAccountFile: '/srv/secrets/firebase-service-account.json',
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    }))
+  })
+
   it('uses the versioned push API and protects subscription mutations', async () => {
   it('uses the versioned push API and protects subscription mutations', async () => {
     const calls: Array<{ path: string; init?: RequestInit }> = []
     const calls: Array<{ path: string; init?: RequestInit }> = []
     vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
     vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {

+ 39 - 1
tests/client/systemManagementDialog.test.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { nextTick } from 'vue'
 import { nextTick } from 'vue'
 import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
 import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
 import { listUsers } from '@/api/admin'
 import { listUsers } from '@/api/admin'
-import { getPushSystemStatus, savePushSystemConfig, type PushSystemStatus } from '@/api/push'
+import { getPushSystemStatus, saveFCMPushSystemConfig, savePushSystemConfig, type PushSystemStatus } from '@/api/push'
 
 
 vi.mock('@/api/admin', () => ({
 vi.mock('@/api/admin', () => ({
   createUser: vi.fn(async () => undefined),
   createUser: vi.fn(async () => undefined),
@@ -15,6 +15,7 @@ vi.mock('@/api/admin', () => ({
 
 
 vi.mock('@/api/push', () => ({
 vi.mock('@/api/push', () => ({
   getPushSystemStatus: vi.fn(),
   getPushSystemStatus: vi.fn(),
+  saveFCMPushSystemConfig: vi.fn(),
   savePushSystemConfig: vi.fn(),
   savePushSystemConfig: vi.fn(),
 }))
 }))
 
 
@@ -64,6 +65,7 @@ beforeEach(() => {
   vi.mocked(listUsers).mockResolvedValue([])
   vi.mocked(listUsers).mockResolvedValue([])
   vi.mocked(getPushSystemStatus).mockResolvedValue(pushStatus())
   vi.mocked(getPushSystemStatus).mockResolvedValue(pushStatus())
   vi.mocked(savePushSystemConfig).mockResolvedValue(pushStatus({ configured: true, healthy: true, source: 'file' }))
   vi.mocked(savePushSystemConfig).mockResolvedValue(pushStatus({ configured: true, healthy: true, source: 'file' }))
+  vi.mocked(saveFCMPushSystemConfig).mockResolvedValue(pushStatus())
 })
 })
 
 
 afterEach(() => {
 afterEach(() => {
@@ -72,6 +74,42 @@ afterEach(() => {
 })
 })
 
 
 describe('System management APNs configuration', () => {
 describe('System management APNs configuration', () => {
+  it('validates and saves an FCM service-account path without exposing secret contents', async () => {
+    const fcm = {
+      configured: false,
+      healthy: false,
+      registrationCount: 0,
+      pendingCount: 0,
+      source: 'none' as const,
+      editable: true,
+      managementAvailable: true,
+      packageName: 'cn.samien.yaoyao.hermes',
+      warnings: [],
+    }
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ providers: { fcm } }))
+    vi.mocked(saveFCMPushSystemConfig).mockResolvedValueOnce(pushStatus({
+      providers: { fcm: { ...fcm, configured: true, healthy: true, source: 'file' } },
+    }))
+    const wrapper = await mountOpen()
+
+    enter('fcm-service-account-file', '  /srv/secrets/firebase-service-account.json  ')
+    enter('fcm-project-id', ' yaoyao-test-project ')
+    enter('fcm-package-name', ' cn.samien.yaoyao.hermes ')
+    await nextTick()
+    const form = input('fcm-service-account-file').closest('form')!
+    form.dispatchEvent(new Event('submit'))
+    await flushPromises()
+
+    expect(saveFCMPushSystemConfig).toHaveBeenCalledWith({
+      serviceAccountFile: '/srv/secrets/firebase-service-account.json',
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    })
+    expect(document.body.textContent).toContain('Firebase 权限与项目验证通过,FCM 已启用')
+    expect(document.body.textContent).toContain('FCM 推送服务正常')
+    wrapper.unmount()
+  })
+
   it('sends a server-local path and both environments through validation and enablement', async () => {
   it('sends a server-local path and both environments through validation and enablement', async () => {
     vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ environments: [] }))
     vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ environments: [] }))
     vi.mocked(savePushSystemConfig).mockResolvedValueOnce(pushStatus({
     vi.mocked(savePushSystemConfig).mockResolvedValueOnce(pushStatus({

+ 171 - 0
tests/server/fcm.test.ts

@@ -0,0 +1,171 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  classifyFCMResponse,
+  createFCMServiceAccountJWT,
+  FCMProbeError,
+  FCMProvider,
+  type FCMTransport,
+} from '../../src/server/fcm.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function serviceAccount(): { path: string; privateKey: string } {
+  const root = mkdtempSync(join(tmpdir(), 'yaoyao-fcm-'))
+  roots.push(root)
+  const { privateKey } = generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+  })
+  const path = join(root, 'service-account.json')
+  writeFileSync(path, JSON.stringify({
+    type: 'service_account',
+    project_id: 'yaoyao-test-project',
+    client_email: 'push@yaoyao-test-project.iam.gserviceaccount.com',
+    private_key: privateKey,
+    token_uri: 'https://oauth2.googleapis.com/token',
+  }), { mode: 0o600 })
+  return { path, privateKey }
+}
+
+describe('FCM HTTP v1 provider', () => {
+  it('creates a short-lived RS256 OAuth assertion with the messaging scope', () => {
+    const { privateKey } = serviceAccount()
+    const jwt = createFCMServiceAccountJWT({
+      clientEmail: 'push@example.test',
+      privateKey,
+      tokenUri: 'https://oauth2.googleapis.com/token',
+    }, 1_800_000)
+    const [header, claims] = jwt.split('.').slice(0, 2).map(value => (
+      JSON.parse(Buffer.from(value!, 'base64url').toString('utf8')) as Record<string, unknown>
+    ))
+    expect(header).toMatchObject({ alg: 'RS256', typ: 'JWT' })
+    expect(claims).toMatchObject({
+      iss: 'push@example.test',
+      aud: 'https://oauth2.googleapis.com/token',
+      scope: 'https://www.googleapis.com/auth/firebase.messaging',
+      iat: 1_800,
+      exp: 5_400,
+    })
+  })
+
+  it('uses fid data-only delivery, package restriction, validate_only, and cached OAuth', async () => {
+    const account = serviceAccount()
+    const requests: Array<{ url: URL; init: RequestInit }> = []
+    const transport: FCMTransport = {
+      request: async (url, init) => {
+        requests.push({ url: new URL(url), init })
+        if (url.hostname === 'oauth2.googleapis.com') {
+          return { status: 200, body: JSON.stringify({ access_token: 'oauth-access-token', expires_in: 3_600 }) }
+        }
+        return { status: 200, body: JSON.stringify({ name: 'projects/test/messages/1' }) }
+      },
+    }
+    const provider = new FCMProvider({
+      serviceAccountFile: account.path,
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    }, { transport, now: () => 1_800_000 })
+
+    await expect(provider.send({
+      fid: 'fcm-registration-id-1234567890',
+      data: { eventId: 'event-1', collapseId: 'chat:1' },
+      collapseId: 'must-not-be-sent-as-android-collapse-key',
+      ttlSeconds: 30,
+    })).resolves.toMatchObject({ disposition: 'success' })
+    await expect(provider.send({ fid: 'fcm-registration-id-2', data: { eventId: 'event-2' } }))
+      .resolves.toMatchObject({ disposition: 'success' })
+
+    expect(requests.filter(item => item.url.hostname === 'oauth2.googleapis.com')).toHaveLength(1)
+    const delivery = requests.find(item => item.url.hostname === 'fcm.googleapis.com')!
+    const body = JSON.parse(String(delivery.init.body)) as Record<string, any>
+    expect(delivery.url.pathname).toBe('/v1/projects/yaoyao-test-project/messages:send')
+    expect(body.message).toMatchObject({
+      fid: 'fcm-registration-id-1234567890',
+      data: { eventId: 'event-1', collapseId: 'chat:1' },
+      android: {
+        priority: 'HIGH',
+        ttl: '30s',
+        restricted_package_name: 'cn.samien.yaoyao.hermes',
+      },
+    })
+    expect(body.message).not.toHaveProperty('token')
+    expect(body.message.android).not.toHaveProperty('collapse_key')
+    expect(body).not.toHaveProperty('validate_only')
+
+    requests.length = 0
+    const probeTransport: FCMTransport = {
+      request: async (url, init) => {
+        requests.push({ url: new URL(url), init })
+        if (url.hostname === 'oauth2.googleapis.com') {
+          return { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
+        }
+        return {
+          status: 400,
+          body: JSON.stringify({ error: { status: 'INVALID_ARGUMENT', details: [{ errorCode: 'INVALID_ARGUMENT' }] } }),
+        }
+      },
+    }
+    const probe = new FCMProvider(provider.config, { transport: probeTransport, now: () => 1_800_000 })
+    await expect(probe.probe()).resolves.toBeUndefined()
+    expect(requests.at(-1)!.url.searchParams.has('validate_only')).toBe(false)
+    expect(JSON.parse(String(requests.at(-1)!.init.body))).toMatchObject({ validate_only: true })
+
+    const successfulProbe = new FCMProvider(provider.config, {
+      transport: {
+        request: async (url) => url.hostname === 'oauth2.googleapis.com'
+          ? { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
+          : { status: 200, body: JSON.stringify({ name: 'projects/test/messages/probe' }) },
+      },
+      now: () => 1_800_000,
+    })
+    await expect(successfulProbe.probe()).resolves.toBeUndefined()
+
+    const unavailableProbe = new FCMProvider(provider.config, {
+      transport: {
+        request: async (url) => url.hostname === 'oauth2.googleapis.com'
+          ? { status: 200, body: JSON.stringify({ access_token: 'probe-token', expires_in: 3_600 }) }
+          : { status: 503, body: '{}' },
+      },
+      now: () => 1_800_000,
+    })
+    await expect(unavailableProbe.probe()).rejects.toBeInstanceOf(FCMProbeError)
+  })
+
+  it('retries throttling, removes only explicit invalid targets, and rejects oversized data locally', async () => {
+    expect(classifyFCMResponse({ status: 404, body: '{}' })).toMatchObject({ disposition: 'failed' })
+    expect(classifyFCMResponse({
+      status: 404,
+      body: JSON.stringify({ error: { status: 'NOT_FOUND', details: [{ errorCode: 'UNREGISTERED' }] } }),
+    })).toMatchObject({ disposition: 'unregister', reason: 'UNREGISTERED' })
+    expect(classifyFCMResponse({ status: 429, headers: { 'retry-after': '2' }, body: '{}' }))
+      .toMatchObject({ disposition: 'retry', retryAfterMs: 2_000 })
+    expect(classifyFCMResponse({ status: 429, body: '{}' }))
+      .toMatchObject({ disposition: 'retry', retryAfterMs: 60_000 })
+    expect(classifyFCMResponse({ status: 503, body: '{}' }))
+      .toMatchObject({ disposition: 'retry', retryAfterMs: 10_000 })
+
+    const account = serviceAccount()
+    let contacted = false
+    const provider = new FCMProvider({
+      serviceAccountFile: account.path,
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    }, { transport: { request: async () => { contacted = true; return { status: 500 } } } })
+    await expect(provider.send({ fid: 'fcm-registration-id-1234', data: { value: 'x'.repeat(4_097) } }))
+      .resolves.toMatchObject({ disposition: 'failed', reason: 'PayloadTooLarge' })
+    expect(contacted).toBe(false)
+
+    await expect(provider.send({ fid: 'fcm-registration-id-1234', data: { 'google.internal': 'value' } }))
+      .resolves.toMatchObject({ disposition: 'failed', reason: 'ReservedDataKey' })
+    expect(contacted).toBe(false)
+  })
+})

+ 99 - 0
tests/server/fcmConfiguration.test.ts

@@ -0,0 +1,99 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  FCMConfigurationManager,
+  fcmConfigurationPath,
+  loadFCMConfiguration,
+  validateFCMConfiguration,
+} from '../../src/server/fcmConfiguration.js'
+import { HttpError } from '../../src/server/errors.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture(mode = 0o600) {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-fcm-config-'))
+  roots.push(home)
+  const { privateKey } = generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+  })
+  const serviceAccountFile = join(home, 'service-account.json')
+  writeFileSync(serviceAccountFile, JSON.stringify({
+    type: 'service_account',
+    project_id: 'yaoyao-test-project',
+    client_email: 'push@yaoyao-test-project.iam.gserviceaccount.com',
+    private_key: privateKey,
+    token_uri: 'https://oauth2.googleapis.com/token',
+  }), { mode })
+  chmodSync(serviceAccountFile, mode)
+  return { home, serviceAccountFile }
+}
+
+describe('FCM configuration', () => {
+  it('validates the project, RSA credentials, package, and file permissions without copying secrets', async () => {
+    const { home, serviceAccountFile } = fixture(0o644)
+    const input = {
+      serviceAccountFile,
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    }
+    const validated = validateFCMConfiguration(input)
+    expect(validated).toMatchObject({
+      source: 'file',
+      editable: true,
+      warnings: [{ code: 'fcm_service_account_permissions', actualMode: '0644', recommendedMode: '0600' }],
+    })
+    const manager = new FCMConfigurationManager(
+      home,
+      { source: 'none', editable: true, warnings: [] },
+      { probe: async () => undefined },
+    )
+    await expect(manager.update(input, () => undefined)).resolves.toMatchObject({ source: 'file' })
+    const stored = readFileSync(fcmConfigurationPath(home), 'utf8')
+    expect(stored).toContain(serviceAccountFile)
+    expect(stored).toContain('yaoyao-test-project')
+    expect(stored).not.toContain('private_key')
+    expect(statSync(fcmConfigurationPath(home)).mode & 0o777).toBe(0o600)
+  })
+
+  it('keeps environment configuration read-only and reports partial configuration safely', async () => {
+    const { home, serviceAccountFile } = fixture()
+    const loaded = loadFCMConfiguration(home, {
+      HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE: serviceAccountFile,
+      HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project',
+    })
+    expect(loaded).toMatchObject({ source: 'environment', editable: false, config: { projectId: 'yaoyao-test-project' } })
+    const manager = new FCMConfigurationManager(home, loaded, { probe: async () => undefined })
+    await expect(manager.update(loaded.input!, () => undefined)).rejects.toMatchObject({
+      status: 409,
+      code: 'fcm_environment_managed',
+    })
+
+    const partial = loadFCMConfiguration(home, { HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project' })
+    expect(partial).toMatchObject({ source: 'environment', editable: false })
+    expect(partial.configurationError).toMatch(/absolute local path/)
+  })
+
+  it('preserves an unavailable-provider status instead of misreporting it as rejected credentials', async () => {
+    const { home, serviceAccountFile } = fixture()
+    const manager = new FCMConfigurationManager(
+      home,
+      { source: 'none', editable: true, warnings: [] },
+      { probe: async () => { throw new HttpError(503, 'FCM unavailable', 'fcm_probe_unavailable') } },
+    )
+
+    await expect(manager.update({
+      serviceAccountFile,
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    }, () => undefined)).rejects.toMatchObject({ status: 503, code: 'fcm_probe_unavailable' })
+  })
+})

+ 133 - 0
tests/server/pushCoordinator.test.ts

@@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
 import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
+import type { FCMRequest } from '../../src/server/fcm.js'
 import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
 import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
 import {
 import {
   PushCoordinator,
   PushCoordinator,
+  type FCMSender,
   type PushSender,
   type PushSender,
 } from '../../src/server/pushCoordinator.js'
 } from '../../src/server/pushCoordinator.js'
 
 
@@ -97,6 +99,137 @@ describe('push configuration', () => {
 })
 })
 
 
 describe('PushCoordinator durable state and delivery', () => {
 describe('PushCoordinator durable state and delivery', () => {
+  it('atomically migrates schema 1 APNs state to schema 2 without changing durable recovery data', () => {
+    const home = root()
+    const directory = join(home, 'push')
+    mkdirSync(directory)
+    const statePath = join(directory, 'state.json')
+    writeFileSync(statePath, JSON.stringify({
+      schemaVersion: 1,
+      installations: [{
+        userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
+        deviceToken: 'ab'.repeat(32), environment: 'development', badge: 2, updatedAt: '2026-01-01T00:00:00.000Z',
+      }],
+      groupSubscriptions: [{
+        userId: 'user-a', roomId: 'room-1', enabled: true, updatedAt: '2026-01-01T00:00:00.000Z',
+      }],
+      outbox: [{
+        id: 'outbox-1', eventId: 'pending-event', userId: 'user-a', installationId: 'phone-1',
+        clientAccountId: 'account-a', kind: 'chat.completed', title: '完成', body: '正文',
+        data: { sessionId: 'session-1' }, createdAt: 1, expiresAt: 4_000_000_000_000,
+        attempts: 2, nextAttemptAt: 2_000_000_000_000,
+      }],
+      processedEvents: { existing: 123 },
+      groupWatch: { epoch: 'epoch-1', cursor: 7 },
+      chatJobs: [],
+      ambiguousChatSessions: { ambiguous: 456 },
+      status: { lastSuccessAt: '2026-01-01T00:00:00.000Z' },
+    }))
+
+    const coordinator = new PushCoordinator({ home, autoFlush: false })
+    const migrated = JSON.parse(readFileSync(statePath, 'utf8')) as Record<string, any>
+    expect(migrated).toMatchObject({
+      schemaVersion: 2,
+      installations: [{ platform: 'ios', environment: 'development' }],
+      outbox: [{ platform: 'ios', id: 'outbox-1', attempts: 2 }],
+      processedEvents: { existing: 123 },
+      groupWatch: { epoch: 'epoch-1', cursor: 7 },
+      ambiguousChatSessions: { ambiguous: 456 },
+      providerStatus: { ios: { lastSuccessAt: '2026-01-01T00:00:00.000Z' }, android: {} },
+    })
+    expect(migrated).not.toHaveProperty('status')
+    coordinator.close()
+  })
+
+  it('isolates an unexpected APNs sender exception from the FCM queue and redacts its target', async () => {
+    const deviceToken = 'ab'.repeat(32)
+    const fcmRequests: FCMRequest[] = []
+    const coordinator = new PushCoordinator({
+      home: root(),
+      apns,
+      provider: { send: async () => { throw new Error(`transport leaked ${deviceToken}`) } },
+      fcm: {
+        serviceAccountFile: '/private/not-read-when-provider-is-injected.json',
+        projectId: 'yaoyao-test-project',
+        packageName: 'cn.samien.yaoyao.hermes',
+      },
+      fcmProvider: {
+        send: async request => {
+          fcmRequests.push(request)
+          return { disposition: 'success', status: 200 }
+        },
+      },
+      autoFlush: false,
+    })
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-ios', clientAccountId: 'account-a',
+      deviceToken, environment: 'development',
+    })
+    coordinator.registerInstallation({
+      platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a',
+      fid: 'fcm-registration-id-1234567890',
+    })
+    expect(coordinator.enqueue({
+      eventId: 'dual-provider-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文',
+    })).toBe(2)
+
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1, retried: 1 })
+    expect(fcmRequests).toHaveLength(1)
+    expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
+    expect(coordinator.status().lastError).not.toContain(deviceToken)
+    expect(coordinator.fcmStatus()).toMatchObject({ pendingCount: 0, healthy: true })
+    coordinator.close()
+  })
+
+  it('keeps Android FIDs private and delivers high-priority FCM data independently of APNs', async () => {
+    const requests: FCMRequest[] = []
+    const sender: FCMSender = {
+      send: async request => {
+        requests.push(request)
+        return { disposition: 'success', status: 200 }
+      },
+    }
+    const coordinator = new PushCoordinator({
+      home: root(),
+      fcm: {
+        serviceAccountFile: '/private/not-read-when-provider-is-injected.json',
+        projectId: 'yaoyao-test-project',
+        packageName: 'cn.samien.yaoyao.hermes',
+      },
+      fcmProvider: sender,
+      autoFlush: false,
+    })
+    const fid = 'fcm-registration-id-1234567890'
+    expect(coordinator.registerInstallation({
+      platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a',
+      fid, appVersion: '1.0.0',
+    })).toEqual(expect.objectContaining({ platform: 'android', installationId: 'phone-android' }))
+    expect(coordinator.registerInstallation({
+      platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a', fid,
+    })).not.toHaveProperty('fid')
+    expect(coordinator.capabilities()).toMatchObject({
+      enabled: false,
+      platforms: { ios: { enabled: false }, android: { enabled: true, provider: 'fcm' } },
+    })
+    expect(coordinator.isAnyProviderEnabled()).toBe(true)
+    expect(coordinator.enqueue({
+      eventId: 'android-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文',
+      collapseId: 'chat:1', data: { sessionId: 'session-1' },
+    })).toBe(1)
+    await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
+    expect(requests).toEqual([expect.objectContaining({
+      fid,
+      priority: 'high',
+      data: expect.objectContaining({
+        eventId: 'android-event', kind: 'chat.completed', title: '完成', body: '正文',
+        collapseId: 'chat:1', sessionId: 'session-1',
+      }),
+    })])
+    expect(coordinator.status()).toMatchObject({ configured: false, registrationCount: 0, pendingCount: 0 })
+    expect(coordinator.fcmStatus()).toMatchObject({ configured: true, healthy: true, registrationCount: 1, pendingCount: 0 })
+    coordinator.close()
+  })
+
   it('hot-swaps during an active flush without closing the in-flight provider early', async () => {
   it('hot-swaps during an active flush without closing the in-flight provider early', async () => {
     const home = root()
     const home = root()
     let releaseSend!: (value: APNsSendResult) => void
     let releaseSend!: (value: APNsSendResult) => void

+ 92 - 1
tests/server/pushRoutes.test.ts

@@ -1,5 +1,5 @@
 import { generateKeyPairSync } from 'node:crypto'
 import { generateKeyPairSync } from 'node:crypto'
-import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import request from 'supertest'
 import request from 'supertest'
@@ -12,6 +12,10 @@ import {
   apnsConfigurationPath,
   apnsConfigurationPath,
   validateAPNsConfiguration,
   validateAPNsConfiguration,
 } from '../../src/server/apnsConfiguration.js'
 } from '../../src/server/apnsConfiguration.js'
+import {
+  FCMConfigurationManager,
+  fcmConfigurationPath,
+} from '../../src/server/fcmConfiguration.js'
 import { createAuthenticatedApplication } from './authenticatedApplication.js'
 import { createAuthenticatedApplication } from './authenticatedApplication.js'
 
 
 const roots: string[] = []
 const roots: string[] = []
@@ -23,6 +27,93 @@ afterEach(() => {
 })
 })
 
 
 describe('iOS push routes', () => {
 describe('iOS push routes', () => {
+  it('configures FCM independently and registers Android FIDs without returning them', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-fcm-web-config-'))
+    roots.push(home)
+    const serviceAccountFile = join(home, 'firebase-service-account.json')
+    const { privateKey } = generateKeyPairSync('rsa', {
+      modulusLength: 2048,
+      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+      publicKeyEncoding: { type: 'spki', format: 'pem' },
+    })
+    writeFileSync(serviceAccountFile, JSON.stringify({
+      type: 'service_account',
+      project_id: 'yaoyao-test-project',
+      client_email: 'push@yaoyao-test-project.iam.gserviceaccount.com',
+      private_key: privateKey,
+      token_uri: 'https://oauth2.googleapis.com/token',
+    }), { mode: 0o600 })
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const push = new PushCoordinator({
+      home,
+      autoFlush: false,
+      fcmProviderFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
+    })
+    const fcmConfiguration = new FCMConfigurationManager(
+      home,
+      { source: 'none', editable: true, warnings: [] },
+      { probe: async () => undefined },
+    )
+    const runtime = createAuthenticatedApplication({ config, push, fcmConfiguration })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const mutation = () => agent.put('/api/app/system/push-config/fcm')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .send({
+        serviceAccountFile,
+        projectId: 'yaoyao-test-project',
+        packageName: 'cn.samien.yaoyao.hermes',
+      })
+
+    await mutation().expect(403)
+    const saved = await mutation().set('X-CSRF-Token', bootstrap.body.csrfToken).expect(200)
+    expect(saved.body).toMatchObject({
+      configured: false,
+      providers: {
+        apns: { configured: false },
+        fcm: {
+          configured: true,
+          healthy: true,
+          source: 'file',
+          editable: true,
+          serviceAccountFile: realpathSync(serviceAccountFile),
+          projectId: 'yaoyao-test-project',
+          packageName: 'cn.samien.yaoyao.hermes',
+        },
+      },
+    })
+    expect(statSync(fcmConfigurationPath(home)).mode & 0o777).toBe(0o600)
+    expect(readFileSync(fcmConfigurationPath(home), 'utf8')).not.toContain('private_key')
+
+    const installationID = '11111111-1111-4111-8111-111111111111'
+    const accountID = '22222222-2222-4222-8222-222222222222'
+    const fid = 'fcm-registration-id-1234567890'
+    const registered = await agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .send({ platform: 'android', fid, appVersion: '1.0.0' })
+      .expect(200)
+    expect(registered.body.installation).toMatchObject({
+      platform: 'android', installationId: installationID, clientAccountId: accountID,
+    })
+    expect(JSON.stringify(registered.body)).not.toContain(fid)
+    expect(push.fcmStatus()).toMatchObject({ registrationCount: 1 })
+
+    const capabilities = await agent.get('/api/push/v1/capabilities')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    expect(capabilities.body).toMatchObject({
+      enabled: false,
+      platforms: { ios: { enabled: false }, android: { enabled: true, packageName: 'cn.samien.yaoyao.hermes' } },
+    })
+  })
+
   it('validates and enables a server-local APNs key path without blocking broad permissions', async () => {
   it('validates and enables a server-local APNs key path without blocking broad permissions', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-web-config-'))
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-web-config-'))
     roots.push(home)
     roots.push(home)

+ 12 - 0
tests/server/pushRuntime.test.ts

@@ -60,6 +60,7 @@ describe('dynamic push runtime', () => {
       home,
       home,
       autoFlush: false,
       autoFlush: false,
       providerFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
       providerFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
+      fcmProviderFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
     })
     })
     const runtime = createAuthenticatedApplication({ config, push })
     const runtime = createAuthenticatedApplication({ config, push })
     const chatStart = vi.spyOn(runtime.chatPushJobs, 'start').mockImplementation(() => undefined)
     const chatStart = vi.spyOn(runtime.chatPushJobs, 'start').mockImplementation(() => undefined)
@@ -80,6 +81,17 @@ describe('dynamic push runtime', () => {
     expect(chatStop).toHaveBeenCalledTimes(2)
     expect(chatStop).toHaveBeenCalledTimes(2)
     expect(groupStop).toHaveBeenCalledTimes(2)
     expect(groupStop).toHaveBeenCalledTimes(2)
 
 
+    await push.configureFCM({
+      serviceAccountFile: '/private/unused.json',
+      projectId: 'yaoyao-test-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+    })
+    expect(chatStart).toHaveBeenCalledTimes(2)
+    expect(groupStart).toHaveBeenCalledTimes(2)
+    await push.configureFCM()
+    expect(chatStop).toHaveBeenCalledTimes(3)
+    expect(groupStop).toHaveBeenCalledTimes(3)
+
     await node.close()
     await node.close()
   })
   })
 })
 })