Skip to content

08 · iGaming 多平台批量推送实战

目标读者:要落地"一键推送所有平台"的工程师和运营副组长 核心问题:N 个 Firebase 项目、每天 5 次、每次给每个项目发相同内容 —— 完整架构怎么搭? 前置:本系列 01-07 全部 相关: 09-岗位指南与SOP/02-运营线/01-副组长-工作指南 4.1 最后更新:2026-04-11


一、场景还原

业务现状: - iGaming 公司有 N 个平台 - 每个平台是 独立的 Firebase 项目(独立的包名、APNs 证书、service account) - 每天 5 次定时推送(11:03 / 15:03 / 19:03 / 21:03 / 00:03 WIB) - 每次给每个平台发送 相同内容的优惠码通知 - 目前是人工在 Firebase Console 一个个配定时任务

目标: - 一个脚本 → 一次运行 → 所有平台同步推送 - 错误隔离(一个平台失败不影响其它) - 自动 Telegram 通知运营群 - systemd timer 定时触发 - 优惠码从配置文件加载,不用改代码


二、架构蓝图

                  ┌────────────────────────────┐
                  │   systemd timer (×5)        │
                  │   Asia/Jakarta 11:03 / ...  │
                  └──────────────┬─────────────┘
                                 │
                                 ▼
                  ┌────────────────────────────┐
                  │   multi-platform-push.js    │
                  │   Node.js 入口脚本          │
                  └──────────────┬─────────────┘
                                 │
                  ┌──────────────┼──────────────┐
                  │              │              │
        ┌─────────▼─────┐  ┌─────▼─────┐  ┌─────▼─────┐
        │ 读配置文件     │  │ 加载秘密  │  │ 构造消息  │
        │ platforms.yaml │  │ *-sa.json │  │ 模板渲染  │
        └───────────────┘  └───────────┘  └───────────┘
                                 │
                                 ▼
        ┌────────────────────────────────────────┐
        │ 为每个平台初始化独立的 Firebase app 实例  │
        │ initializeApp(cfg, name=platform_id)    │
        └────────────┬───────────────────────────┘
                     │
                     │  Promise.allSettled (并行)
                     │
        ┌────────────┴────────────┬────────────┬────────────┐
        │                         │            │            │
        ▼                         ▼            ▼            ▼
  ┌──────────┐              ┌──────────┐  ┌──────────┐  ┌──────────┐
  │Platform A│              │Platform B│  │Platform C│  │Platform N│
  │ app 实例 │              │ app 实例 │  │ app 实例 │  │ app 实例 │
  └────┬─────┘              └────┬─────┘  └────┬─────┘  └────┬─────┘
       │                         │              │            │
       │ send({topic,...})       │              │            │
       ▼                         ▼              ▼            ▼
  Firebase A                Firebase B     Firebase C    Firebase N
       │                         │              │            │
       │   fanout 到 topic       │              │            │
       ▼                         ▼              ▼            ▼
  所有 A 的设备            所有 B 的设备   所有 C 的设备  所有 N 的设备

        ┌────────────────────────────────────────┐
        │ 汇总结果 → Telegram 通知 → 写日志       │
        │ healthchecks.io ping 确认成功           │
        └────────────────────────────────────────┘

三、项目目录结构(推荐)

/opt/igaming/
├── scripts/
│   └── multi-platform-push.js       # 推送主脚本
├── config/
│   └── platforms.yaml               # 平台配置(公开)
├── secrets/                         # .gitignore
│   ├── platform-a-sa.json
│   ├── platform-b-sa.json
│   └── platform-c-sa.json
├── logs/
│   └── fcm-push.jsonl
├── .env                             # TG_BOT_TOKEN 等
├── package.json
└── /etc/systemd/system/
    ├── fcm-push@.service
    ├── fcm-push-1100.timer
    ├── fcm-push-1500.timer
    ├── fcm-push-1900.timer
    ├── fcm-push-2100.timer
    └── fcm-push-0000.timer

四、配置文件(YAML)

# config/platforms.yaml
# 作者: Bob
# 每个平台一项;service_account 是 JSON 文件路径

platforms:
  - id: platform_a
    name: 平台A
    project_id: igaming-platform-a
    service_account_path: ./secrets/platform-a-sa.json
    topic: all_users              # 预先订阅的 topic
    enabled: true

  - id: platform_b
    name: 平台B
    project_id: igaming-platform-b
    service_account_path: ./secrets/platform-b-sa.json
    topic: all_users
    enabled: true

  - id: platform_c
    name: 平台C
    project_id: igaming-platform-c
    service_account_path: ./secrets/platform-c-sa.json
    topic: all_users
    enabled: false                 # 临时禁用此平台

# 优惠码模板(运营每天改这里)
campaign:
  title: '{{time_label}} 限时福利'
  body: '输入 {{promo_code}}  {{amount}} 红包,仅限前 1000 名'
  promo_code: LUCKY888
  amount: 588
  deeplink_template: 'igaming://promo/{{promo_code}}'
  ttl_seconds: 14400              # 4 小时

五、核心代码:Node.js 多平台并行推送脚本

// scripts/multi-platform-push.js
// 作者: Bob
// 用途: 在一个进程内为 N 个 Firebase 项目并行推送相同优惠码通知

import { initializeApp, cert, getApps } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { readFileSync } from 'fs';
import yaml from 'js-yaml';

// ====== 1. 加载配置 ======
const cfg = yaml.load(readFileSync('./config/platforms.yaml', 'utf8'));
const platforms = cfg.platforms.filter((p) => p.enabled);
const campaign = cfg.campaign;

// ====== 2. 为每个平台初始化命名 app(核心!)======
function initPlatforms() {
  const apps = {};
  for (const p of platforms) {
    // 防御:重复初始化会抛 app/duplicate-app
    const existing = getApps().find((a) => a.name === p.id);
    const app = existing ?? initializeApp(
      {
        credential: cert(JSON.parse(readFileSync(p.service_account_path, 'utf8'))),
        projectId: p.project_id,
      },
      p.id, // ⚠️ 第二个参数 name 必须唯一(用 platform.id)
    );
    apps[p.id] = { app, config: p };
    console.log(`[init] ${p.id} → projectId=${p.project_id}`);
  }
  return apps;
}

// ====== 3. 模板渲染(优惠码动态替换)======
function renderTemplate(str, vars) {
  return str.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
}

function buildMessage(campaign, timeLabel) {
  const vars = {
    promo_code: campaign.promo_code,
    amount: campaign.amount,
    time_label: timeLabel,
  };
  const title = renderTemplate(campaign.title, vars);
  const body = renderTemplate(campaign.body, vars);
  const deeplink = renderTemplate(campaign.deeplink_template, vars);

  return {
    notification: { title, body },
    data: {
      promo_code: campaign.promo_code,
      deeplink,
      campaign_id: `daily_${timeLabel}_${new Date().toISOString().slice(0, 10)}`,
    },
    android: {
      priority: 'high',
      ttl: `${campaign.ttl_seconds}s`,
      notification: {
        channelId: 'promo_high',
        sound: 'default',
        color: '#FFD700',
      },
    },
    apns: {
      headers: { 'apns-priority': '10', 'apns-push-type': 'alert' },
      payload: { aps: { sound: 'default', badge: 1, 'mutable-content': 1 } },
      fcmOptions: { analyticsLabel: `daily_${timeLabel}` },
    },
    fcmOptions: { analyticsLabel: `daily_${timeLabel}` },
  };
}

// ====== 4. 单个平台的推送(错误隔离)======
async function pushToPlatform({ app, config }, payload) {
  const start = Date.now();
  const result = { platform: config.id, name: config.name };
  try {
    const messaging = getMessaging(app); // ⚠️ 必须传 app 实例
    const message = { ...payload, topic: config.topic };
    const messageId = await messaging.send(message);
    result.success = true;
    result.messageId = messageId;
  } catch (e) {
    result.success = false;
    result.errorCode = e.code;
    result.errorMessage = e.message;
  }
  result.durationMs = Date.now() - start;
  return result;
}

// ====== 5. 主函数:所有平台并行推送 ======
async function sendAllPlatforms(timeLabel = '11点') {
  const apps = initPlatforms();
  const payload = buildMessage(campaign, timeLabel);

  // ⚠️ Promise.allSettled 保证一个平台失败不阻塞其它平台
  const tasks = Object.values(apps).map((entry) => pushToPlatform(entry, payload));
  const results = await Promise.allSettled(tasks);

  const flat = results.map((r) =>
    r.status === 'fulfilled' ? r.value : { success: false, errorMessage: r.reason },
  );

  // ====== 6. 汇总日志 ======
  const summary = {
    timestamp: new Date().toISOString(),
    timeLabel,
    promoCode: campaign.promo_code,
    total: flat.length,
    success: flat.filter((r) => r.success).length,
    failed: flat.filter((r) => !r.success).length,
    details: flat,
  };
  console.log(JSON.stringify(summary, null, 2));

  // ====== 7. Telegram 通知 ======
  await notifyTelegram(summary);

  // ====== 8. healthchecks.io ping ======
  await pingHealthcheck(summary);

  return summary;
}

async function notifyTelegram(summary) {
  const TG_TOKEN = process.env.TG_BOT_TOKEN;
  const TG_CHAT = process.env.TG_CHAT_ID;
  if (!TG_TOKEN || !TG_CHAT) return;

  const lines = [
    `*FCM 推送 ${summary.timeLabel}* (${summary.promoCode})`,
    `成功 ${summary.success} / 失败 ${summary.failed}`,
    '',
    ...summary.details.map((d) =>
      d.success
        ? `✓ ${d.platform} (${d.durationMs}ms)`
        : `✗ ${d.platform}: ${d.errorCode || d.errorMessage}`,
    ),
  ];
  await fetch(`https://api.telegram.org/bot${TG_TOKEN}/sendMessage`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      chat_id: TG_CHAT,
      text: lines.join('\n'),
      parse_mode: 'Markdown',
    }),
  });
}

async function pingHealthcheck(summary) {
  const URL = process.env.HEALTHCHECK_URL;
  if (!URL) return;
  const endpoint = summary.failed > 0 ? `${URL}/fail` : URL;
  await fetch(endpoint, {
    method: 'POST',
    body: JSON.stringify(summary),
  }).catch(() => {});
}

// ====== 9. CLI 入口 ======
const timeLabel = process.argv[2] || '11点';
sendAllPlatforms(timeLabel)
  .then(() => process.exit(0))
  .catch((e) => {
    console.error('FATAL', e);
    process.exit(1);
  });

六、systemd 定时器部署

6.1 Service 单元(参数化)

# /etc/systemd/system/fcm-push@.service
[Unit]
Description=FCM Push (%i)
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
WorkingDirectory=/opt/igaming
EnvironmentFile=/opt/igaming/.env
ExecStart=/usr/bin/node scripts/multi-platform-push.js %i
User=igaming
Group=igaming

6.2 五个 Timer(一个时点一个)

# /etc/systemd/system/fcm-push-1100.timer
[Unit]
Description=FCM Push 11:03 WIB

[Timer]
OnCalendar=*-*-* 11:03:00 Asia/Jakarta
Persistent=true
Unit=fcm-push@11点.service

[Install]
WantedBy=timers.target

其它 4 个类推:

文件 OnCalendar 参数
fcm-push-1100.timer *-*-* 11:03:00 Asia/Jakarta 11点
fcm-push-1500.timer *-*-* 15:03:00 Asia/Jakarta 15点
fcm-push-1900.timer *-*-* 19:03:00 Asia/Jakarta 19点
fcm-push-2100.timer *-*-* 21:03:00 Asia/Jakarta 21点
fcm-push-0000.timer *-*-* 00:03:00 Asia/Jakarta 午夜

6.3 启用

sudo systemctl daemon-reload
sudo systemctl enable --now fcm-push-1100.timer
sudo systemctl enable --now fcm-push-1500.timer
sudo systemctl enable --now fcm-push-1900.timer
sudo systemctl enable --now fcm-push-2100.timer
sudo systemctl enable --now fcm-push-0000.timer

# 查看所有 timer 状态
sudo systemctl list-timers | grep fcm-push

# 手动触发一次(测试)
sudo systemctl start 'fcm-push@11点.service'

# 看日志
sudo journalctl -u 'fcm-push@11点.service' -n 100

七、Docker 化部署(可选备选方案)

# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ENV TZ=Asia/Jakarta
CMD ["node", "scripts/multi-platform-push.js"]
# docker-compose.yml —— 配合 ofelia 做容器内 cron
services:
  fcm-push:
    build: .
    volumes:
      - ./secrets:/app/secrets:ro
      - ./config:/app/config:ro
    environment:
      - TG_BOT_TOKEN=${TG_BOT_TOKEN}
      - TG_CHAT_ID=${TG_CHAT_ID}
      - HEALTHCHECK_URL=${HEALTHCHECK_URL}
    labels:
      ofelia.enabled: 'true'
      ofelia.job-exec.push-1100.schedule: '0 3 11 * * *'
      ofelia.job-exec.push-1100.command: 'node scripts/multi-platform-push.js 11点'
      ofelia.job-exec.push-1500.schedule: '0 3 15 * * *'
      ofelia.job-exec.push-1500.command: 'node scripts/multi-platform-push.js 15点'
      ofelia.job-exec.push-1900.schedule: '0 3 19 * * *'
      ofelia.job-exec.push-1900.command: 'node scripts/multi-platform-push.js 19点'
      ofelia.job-exec.push-2100.schedule: '0 3 21 * * *'
      ofelia.job-exec.push-2100.command: 'node scripts/multi-platform-push.js 21点'
      ofelia.job-exec.push-0000.schedule: '0 3 0 * * *'
      ofelia.job-exec.push-0000.command: 'node scripts/multi-platform-push.js 午夜'

  ofelia:
    image: mcuadros/ofelia:latest
    command: daemon --docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

八、错误隔离与并行的关键细节

8.1 必须用 Promise.allSettled,不是 Promise.all ⚠️

API 行为
Promise.all 任何一个 reject 就抛出,会丢失其它平台的成功结果
Promise.allSettled 等所有 promise 完成(无论成败),每个独立 result

错误示范:

// ❌ 不要这样写
const results = await Promise.all(tasks);
// 如果 platform_c 失败,platform_a/b 的成功结果全部丢失

正确写法:

// ✅ 正确
const results = await Promise.allSettled(tasks);

8.2 app 实例命名空间是全局的

问题:重复 initializeApp(cfg, 'platform_a') 会抛 app/duplicate-app

解法:idempotent 初始化(已内置在上面的 initPlatforms()):

const existing = getApps().find((a) => a.name === p.id);
const app = existing ?? initializeApp({ /* ... */ }, p.id);

8.3 mismatched-credential 防御

问题:把 A 平台的 SA 用在 B 平台的 messaging 上 → 全批 403。

解法:严格绑定 app → messaging → send:

const messaging = getMessaging(app); // ← 必须传这个 app 实例
const message = { ...payload, topic: config.topic };
await messaging.send(message);

8.4 service account 的存放

  • ❌ 不要 commit 到 git
  • ✅ 放 secrets/ 目录
  • ✅ 加 .gitignore
  • ✅ 文件权限 chmod 600 secrets/*.json
  • ✅ 生产环境可以用 Vault / GCP Secret Manager / Doppler 注入

九、Topic 预订阅(客户端配合事项)

这套方案的前提是:每个平台的 app 客户端在启动时把设备订阅到 all_users topic

告知客户端工程师(Android):

// Android 客户端
FirebaseMessaging.getInstance().subscribeToTopic("all_users")
    .addOnCompleteListener { task ->
        if (task.isSuccessful) {
            Log.d("FCM", "Subscribed to all_users")
        }
    }

告知客户端工程师(iOS):

Messaging.messaging().subscribe(toTopic: "all_users") { error in
    if let error = error {
        print("Topic 订阅失败: \(error)")
    }
}

或后端批量补订(针对存量用户):

// 从数据库拿出所有 token,按平台分组,批量订阅
async function batchSubscribe(messaging, tokens) {
  for (let i = 0; i < tokens.length; i += 1000) {
    const batch = tokens.slice(i, i + 1000);
    await messaging.subscribeToTopic(batch, 'all_users');
    console.log(`订阅 ${batch.length}`);
  }
}

十、可观测性:实时指标

10.1 JSON 行日志(推荐)

每次执行写一行到 /opt/igaming/logs/fcm-push.jsonl:

{"timestamp":"2026-04-11T04:03:00Z","timeLabel":"11点","promoCode":"LUCKY888","total":3,"success":3,"failed":0,"details":[...]}
{"timestamp":"2026-04-11T08:03:00Z","timeLabel":"15点","promoCode":"TEA15","total":3,"success":2,"failed":1,"details":[...]}

查询示例(用 jq):

# 查最近 10 次推送结果
tail -n 10 logs/fcm-push.jsonl | jq '{time: .timestamp, label: .timeLabel, success: .success, failed: .failed}'

# 查某个平台最近失败记录
tail -n 100 logs/fcm-push.jsonl | \
  jq -r 'select(.failed > 0) | .details[] | select(.success == false) | "\(.platform): \(.errorCode)"'

# 统计当月成功率
jq -s 'map({s:.success, f:.failed}) | add' logs/fcm-push.jsonl

10.2 可选:Prometheus 指标

在脚本末尾推送到 Pushgateway:

// 可选扩展
await fetch(`http://pushgateway:9091/metrics/job/fcm-push/platform/${platformId}`, {
  method: 'POST',
  body: `fcm_push_success{platform="${platformId}"} ${summary.success}\nfcm_push_failed{platform="${platformId}"} ${summary.failed}\n`,
});

然后在 Grafana 做仪表盘,实时看: - 每日推送成功率趋势 - 每个平台的失败率 - 响应时间 P95 / P99


十一、部署检查清单

上线前过一遍这个清单:

基础配置

  • [ ] 每个平台的 service account JSON 已下载
  • [ ] JSON 放在 secrets/ 目录且 chmod 600
  • [ ] .gitignore 已屏蔽 secrets/.env
  • [ ] config/platforms.yaml 已填写所有平台

客户端准备

  • [ ] 所有平台 app 已调用 subscribeToTopic('all_users')
  • [ ] 存量用户已通过后端批量补订
  • [ ] Android 客户端已创建 promo_high notification channel
  • [ ] iOS 客户端已实现 Notification Service Extension(如果要富通知)

脚本测试

  • [ ] node scripts/multi-platform-push.js 测试 能手动跑通
  • [ ] 用 validate_only: true 先测过 payload 格式
  • [ ] Telegram 通知能收到
  • [ ] 失败场景也能收到告警

定时器

  • [ ] 5 个 systemd timer 已启用
  • [ ] systemctl list-timers | grep fcm-push 看到下次执行时间
  • [ ] 时区验证:date 命令显示 Asia/Jakarta
  • [ ] Persistent=true 已设

监控

  • [ ] healthchecks.io 账号已创建
  • [ ] 5 个 check 都已接入
  • [ ] 告警渠道(Telegram / Email)已配

安全

  • [ ] service account JSON 权限最小(仅 firebase.messaging scope)
  • [ ] .env 不在 git 里
  • [ ] VPS 防火墙只开必要端口

十二、从 0 到 1 的实施路径

建议按这个顺序执行,每步都可以独立验证:

  Step 1 · 准备一个平台的 MVP
           │
           ├─ 下载 1 个平台的 service account JSON
           ├─ 客户端 app 订阅 all_users topic
           ├─ 手动运行脚本,发一条测试推送
           └─ 验证:自己手机收到 ✅
                    │
                    ▼
  Step 2 · 扩展到多平台
           │
           ├─ 下载剩余平台的 service account
           ├─ 配置 platforms.yaml
           ├─ 脚本支持多 app 初始化
           └─ 验证:所有平台都收到 ✅
                    │
                    ▼
  Step 3 · 加入错误处理和监控
           │
           ├─ Promise.allSettled
           ├─ Telegram 通知
           ├─ healthchecks.io ping
           └─ 验证:模拟一个平台失败,看通知 ✅
                    │
                    ▼
  Step 4 · systemd timer 定时化
           │
           ├─ 配置 5 个 timer
           ├─ 启用 Persistent=true
           └─ 验证:自然触发一次 ✅
                    │
                    ▼
  Step 5 · 运营上线
           │
           ├─ 通知运营停用 Firebase Console 手动配置
           ├─ 让运营每天只改 platforms.yaml 里的 promo_code
           └─ 验证:连续跑 3 天无故障 ✅
                    │
                    ▼
  Step 6 · 迭代优化
           │
           ├─ 加 Grafana 仪表盘
           ├─ 加 A/B 测试支持(不同 topic)
           └─ 加 Web UI 让运营不用改 YAML

每次迭代都可以独立交付,无需一步到位


十三、陷阱清单(踩过的坑)

  1. service account 跨项目混用 把 A 项目的 SA 给 B 项目的 token 发推,全批 403 mismatched-credential必须严格绑定 app ↔ messaging ↔ send

  2. Promise.all vs allSettledPromise.all 会让一个平台的失败掩盖所有其它平台的结果,是新手最常踩的坑

  3. 同一进程并行 N 个 fanout 的性能 跨项目并行是安全的(N 个不同 project 配额独立)。同项目内不要同时跑多个 fanout

  4. 印尼时区Asia/Jakarta,不要写偏移量。systemd timer 的 OnCalendar 原生支持时区后缀。

  5. 优惠码 hard-code 在脚本里很危险 推荐每天通过 .env 或 web UI 改 campaign.promo_code,避免改代码再部署

  6. app/duplicate-app 同名 app 不能重复 init,用 getApps().find() 先查存在。

  7. Telegram 通知一定要有 运营立刻知道哪个平台没推出去,不用看日志。


十四、权威来源

  • https://firebase.google.com/docs/admin/setup#initialize_multiple_apps(命名 app 多实例的官方代码示例)
  • https://firebase.google.com/docs/cloud-messaging/send/admin-sdk
  • https://firebase.google.com/docs/cloud-messaging/manage-tokens
  • https://firebase.google.com/docs/cloud-messaging/scale-fcm

十五、衔接副组长工作流

这套自动化上线后,副组长工作指南 4.1 优惠兑换码全链路 中的阶段 ② · 排程阶段 ③ · 执行将从"每天 5 × N 次人工配置"简化为"改一下 YAML 里的 promo_code",剩下的交给 systemd timer。

预期效果: - 副组长每天节省 30-60 分钟纯手工操作 - 图片张冠李戴、文案复制漏、时间点配错等人肉错误彻底消除 - 增加一个平台的成本从"30 分钟 × 5 次"降到"在 YAML 里加一行"