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_highnotification 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
每次迭代都可以独立交付,无需一步到位。
十三、陷阱清单(踩过的坑)¶
-
service account 跨项目混用 把 A 项目的 SA 给 B 项目的 token 发推,全批 403
mismatched-credential。必须严格绑定 app ↔ messaging ↔ send。 -
Promise.allvsallSettled用Promise.all会让一个平台的失败掩盖所有其它平台的结果,是新手最常踩的坑。 -
同一进程并行 N 个 fanout 的性能 跨项目并行是安全的(N 个不同 project 配额独立)。同项目内不要同时跑多个 fanout。
-
印尼时区 用
Asia/Jakarta,不要写偏移量。systemd timer 的OnCalendar原生支持时区后缀。 -
优惠码 hard-code 在脚本里很危险 推荐每天通过
.env或 web UI 改campaign.promo_code,避免改代码再部署。 -
app/duplicate-app 同名 app 不能重复 init,用
getApps().find()先查存在。 -
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 里加一行"