# ShortPro 域名管理与 PWA 分流方案

**版本** v1.0  
**日期** 2026-05-14  
**状态** 设计草案,待评审  
**适用范围** ShortPro 印尼市场 Web/PWA 端

---

## 0. 文档目的

为 ShortPro 印尼业务设计一套抗封锁、可持续运营的域名与 PWA 分发架构。在 Kominfo 周期性封域名的现实下,实现:

- **已安装用户**:任意单一域名被封不丢失访问能力
- **新增用户**:任意时刻都有可用的安装入口
- **运营团队**:对域名健康度有完整可观测性,域名封禁从"事故"变成"KPI 可控的运营动作"

---

## 1. 设计原则

1. **三层域名彻底解耦**:发现 / 安装 / 数据各自独立,任一层失效不传染至其他层
2. **用户确定性分桶**:`hash(device_id) % N` 把用户均匀分布到多个安装域名,单域被封只影响一个桶(N 分之一用户)
3. **基础设施多样化**:注册商、DNS、IP 段、TLS 证书、CDN、Server 指纹全部分散,避免被批量识别封禁
4. **时间换迁移**:Service Worker 缓存让被封域名上的 PWA 继续可用数周到数月,用这段时间通过召回机制平滑迁移用户
5. **离线优先**:app shell 必须 precache,PWA 启动**不依赖任何网络请求**
6. **召回机制 Day 1 建好**:不能等域名被封了才想怎么联系用户

---

## 2. 整体架构

```
┌─────────────────────────────────────────────────────────────────┐
│  推广渠道:WhatsApp / TikTok / KOL / TG Channel / 短链          │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  第一层:分发域名池(Discovery)                                  │
│  spro.id / sp1.app / getshort.app / ...                         │
│  作用:动态告诉用户去哪个安装域名                                 │
│  特性:静态 JS / JSON 配置,几乎无业务逻辑,易替换                 │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
                  hash(device_id) % N
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  第二层:安装域名池(Install)                                    │
│  dramaku-app.com / nontonpro.app / kisahdrama.net / ...         │
│  作用:PWA 真正的安装来源,manifest scope 绑定                    │
│  特性:用户加桌面后图标绑死此域名                                 │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
                Service Worker 接管所有请求
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  第三层:API / 资源域名池                                        │
│  api-1.xxx / api-2.xxx(接口)                                   │
│  cdn-1.xxx / cdn-2.xxx(视频/图片)                              │
│  作用:App 内所有数据请求的目的地                                 │
│  特性:与安装域名解耦,优先级轮询 + 故障自动 fallback             │
└─────────────────────────────────────────────────────────────────┘
```

---

## 3. 域名架构详述

### 3.1 第一层:分发域名(Discovery Pool)

| 维度 | 配置 |
|---|---|
| 数量 | 活跃 3-5 个,冷备 5-10 个 |
| 作用 | 告诉用户"现在去哪个域名装 App" |
| 内容 | 几乎静态的 H5 + 一段 JS 跳转逻辑 |
| 推广渠道 | WhatsApp 邀请链接、TikTok bio、KOL 物料、印尼论坛 |
| 域名风格 | 短、好口播,如 `spro.id` / `sp1.app` / `getshort.app` |
| 部署 | Cloudflare Pages / Vercel + 自有域名 + CDN |

**备份发现路径**(防分发域名也被封):

- Telegram Channel bio(`@shortpro_id`)永远写最新分发域名
- GitHub Gist 公开 JSON(`gist.github.com/shortpro/domains.json`)
- Pastebin / Hastebin 备份
- bit.ly / lnk.bio 短链指向当前活跃分发域名

这些托管平台属于 Google / Microsoft / Cloudflare 基建,印尼几乎不会单独封禁。

### 3.2 第二层:安装域名(Install Pool)

| 维度 | 配置 |
|---|---|
| 数量 | 活跃 5-10 个,冷备 5-10 个 |
| 作用 | PWA 真正的安装来源,manifest.json 的 origin |
| 命名 | 无规律、混合 TLD,避免被批量识别 |
| 注册商 | 至少分散到 5 家(GoDaddy / Namecheap / Porkbun / Cloudflare Registrar / 印尼本地) |
| DNS | 独立 NS,不全押 Cloudflare |
| IP | 不同 IP 段,跨 ASN |
| TLS | 不要全用同一 CA;Let's Encrypt + ZeroSSL + Cloudflare 混搭 |

**命名示例**(故意不规律):

```
dramaku-app.com
nontonpro.app
kisahdrama.net
layardrama.com
ceritamu.app
serial-malam.id
filmwiks.co
tontonan.app
```

**关键约束**:

- 每个域名跑的是同一份代码,但 manifest 的 `start_url` 和 `scope` 各自指向自己
- PWA 一旦"加到主屏幕",桌面图标和此 origin **绑死**(浏览器硬规则,无法绕开)
- 这意味着:用户从 `dramaku-app.com` 装的 PWA,桌面图标永远打开 `dramaku-app.com`

### 3.3 第三层:API / 资源域名(API Pool)

**这是整套架构最关键的一层**——它决定了"装在桌面上的 App 在安装域名被封后能不能继续用"。

| 子池 | 用途 | 数量 |
|---|---|---|
| `api-*.xxx` | 业务接口(登录、视频元数据、coin 交易等) | 活跃 3-5 个 |
| `cdn-*.xxx` | 视频流、图片、字幕 | 活跃 3-5 个 |
| `static-*.xxx` | JS/CSS 静态包(可选) | 活跃 2-3 个 |

**与安装域名彻底解耦的设计要点**:

1. PWA 代码里 **不写死任何 API URL**,基础 URL 从 IndexedDB 读
2. 首次启动:从一个 stable config 端点拉最新 API 池清单(Cloudflare Workers + KV,基本不会被封)
3. SW 内置 fallback 列表,主接口失败自动切到下一个
4. 接口域和视频域分开:接口被封不影响视频播放,反之亦然
5. **TLS 指纹 / Server header 错开**:别让所有 API 域共用同一证书 fingerprint,防 ISP 按指纹批量识别

---

## 4. 用户分桶策略

### 4.1 分桶算法

```
bucket = hash(device_id) mod N

其中:
  device_id = 首次访问时生成的 UUID,存 localStorage + IndexedDB(双备份)
  N         = 当前活跃安装域名数
  hash      = FNV-1a 32-bit(轻量、分布均匀,不需要加密强度)
```

**为什么用确定性 hash 而不是随机分配**:

- 出事能精确定位影响范围:桶 3 用户集体反馈 → 知道是哪个域名挂了
- 灰度发布可以按桶推
- 域名被封时,只迁移那个桶的用户,其他人完全无感
- 同一设备多次访问,落到同一个桶(避免反复重装)

### 4.2 分桶持久化

服务端必须存一份映射表,即使 device_id 丢失,登录后仍能恢复:

```sql
CREATE TABLE user_install_binding (
  user_id          BIGINT      PRIMARY KEY,
  device_id        VARCHAR(64),
  install_origin   VARCHAR(128) NOT NULL,
  bucket           SMALLINT     NOT NULL,
  assigned_at      TIMESTAMP    DEFAULT NOW(),
  status           VARCHAR(16)  DEFAULT 'active',  -- active/deprecated/migrating
  migrated_to      VARCHAR(128) DEFAULT NULL,
  INDEX idx_origin (install_origin),
  INDEX idx_bucket (bucket)
);
```

### 4.3 不同获客渠道的入口策略

| 渠道 | 入口 | 分桶逻辑 |
|---|---|---|
| WhatsApp 邀请 | 分发域名 | 动态分桶 |
| TikTok / IG 广告 | 分发域名 | 动态分桶 |
| KOL 专属链接 | KOL 专属安装域名 | 不分桶,强绑定该域名(便于追踪 + 风险隔离) |
| 自然搜索 | 主分发域名 | 动态分桶 |
| 老用户拉新 | 分发域名 + referral code | 动态分桶 + 关联推荐人 |

---

## 5. PWA 安装流程

### 5.1 时序

```
用户                分发域名              Dispatch API         安装域名            浏览器
 │                    │                      │                    │                  │
 │─ 点 WA 链接 ──────▶│                      │                    │                  │
 │                    │─ 读/建 device_id     │                    │                  │
 │                    │─ POST /dispatch ────▶│                    │                  │
 │                    │                      │─ hash + 查表       │                  │
 │                    │◀── { install_origin, │                    │                  │
 │                    │      bucket }        │                    │                  │
 │                    │─ 302 跳转 ─────────────────────────────▶│                  │
 │                    │                      │                    │─ 加载 PWA        │
 │                    │                      │                    │─ 注册 SW         │
 │                    │                      │                    │─ Precache shell  │
 │                    │                      │                    │─ 提示加桌面 ────▶│
 │◀────────────────────────────────────── 用户点"加到主屏幕"────────────────────────│
 │                    │                      │                    │─ 写映射表        │
```

### 5.2 Dispatch 接口契约

**Endpoint** `POST https://spro.id/dispatch`

**Request**
```json
{
  "device_id": "8f3a-e7b2-...",
  "channel": "whatsapp",
  "referrer": "user_12345",
  "ua": "Mozilla/5.0 ... Chrome/...",
  "lang": "id-ID"
}
```

**Response**
```json
{
  "install_origin": "https://dramaku-app.com",
  "bucket": 3,
  "expires_at": 1715731200,
  "fallback_origins": [
    "https://nontonpro.app",
    "https://kisahdrama.net"
  ]
}
```

`fallback_origins` 写进客户端 localStorage,万一首选安装域名访问失败,前端可以自己切到 fallback。

---

## 6. Service Worker 设计(核心)

### 6.1 缓存策略总览

| 资源类型 | 策略 | 缓存名 | 失效 |
|---|---|---|---|
| App shell(HTML/CSS/JS/字体) | Cache-first | `shell-v{version}` | 仅版本升级 |
| 导航请求(navigate) | Cache-first(回退到 shell) | `shell-v{version}` | 同上 |
| API 请求 | Network with pool fallback | 不缓存(或短 TTL) | 实时 |
| 图片/视频缩略图 | Cache-first + LRU | `media-v1` | LRU 50MB 上限 |
| 视频流 | 不走 SW(直接走 CDN) | - | - |

### 6.2 完整 SW 代码模板

```javascript
// sw.js
const SHELL_VERSION = 'shell-v20260514-01';
const MEDIA_CACHE = 'media-v1';
const SHELL_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/assets/app.js',
  '/assets/app.css',
  '/assets/fonts/inter.woff2',
  '/assets/icons/icon-192.png',
  '/assets/icons/icon-512.png',
];

// API 池配置(初始,后续从 config 端点动态更新)
const API_POOL_DEFAULT = [
  'https://api-1.shortpro-api.com',
  'https://api-2.shortpro-api.com',
  'https://api-3.shortpro-api.com',
];
const CONFIG_ENDPOINT = 'https://config.shortpro.workers.dev/v1/config';

// ============ 安装阶段:precache app shell ============
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_VERSION)
      .then(cache => cache.addAll(SHELL_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// ============ 激活阶段:清理旧版本 ============
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys
          .filter(k => k.startsWith('shell-') && k !== SHELL_VERSION)
          .map(k => caches.delete(k))
      )
    ).then(() => self.clients.claim())
  );
});

// ============ 请求拦截 ============
self.addEventListener('fetch', (event) => {
  const req = event.request;
  const url = new URL(req.url);

  // 1. 导航请求 → cache-first,网络失败也无所谓
  if (req.mode === 'navigate') {
    event.respondWith(handleNavigation(req));
    return;
  }

  // 2. API 请求 → 池化路由 + fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(routeToApiPool(req));
    return;
  }

  // 3. 媒体资源 → cache-first
  if (req.destination === 'image') {
    event.respondWith(cacheFirstMedia(req));
    return;
  }

  // 4. 其他静态资源 → cache-first
  event.respondWith(
    caches.match(req).then(r => r || fetch(req))
  );
});

// 导航处理:始终返回 cached shell
async function handleNavigation(req) {
  const cache = await caches.open(SHELL_VERSION);
  const cached = await cache.match('/index.html');
  if (cached) return cached;
  
  try {
    return await fetch(req);
  } catch {
    return cache.match('/offline.html');
  }
}

// API 池路由:从 IndexedDB 读最新池子,挨个试
async function routeToApiPool(req) {
  const pool = await getApiPool();
  const path = new URL(req.url).pathname + new URL(req.url).search;
  
  for (const base of pool) {
    try {
      const target = base + path;
      const response = await fetch(target, {
        method: req.method,
        headers: req.headers,
        body: req.method !== 'GET' ? await req.clone().blob() : undefined,
        credentials: 'include',
      });
      
      if (response.ok || response.status < 500) {
        return response;
      }
    } catch (err) {
      console.warn(`[API Pool] ${base} failed:`, err.message);
      reportApiFailure(base, err.message); // 异步上报,不 await
    }
  }
  
  // 所有 API 都挂了
  return new Response(
    JSON.stringify({ error: 'all_api_unreachable' }),
    { status: 503, headers: { 'Content-Type': 'application/json' } }
  );
}

// 从 IndexedDB 读 API 池(每次启动刷新一次)
async function getApiPool() {
  const db = await openDB();
  const stored = await db.get('config', 'api_pool');
  return stored?.value || API_POOL_DEFAULT;
}

// 后台静默刷新 config(在 activate 后调用,或定时调用)
async function refreshConfig() {
  try {
    const resp = await fetch(CONFIG_ENDPOINT, { cache: 'no-store' });
    const config = await resp.json();
    const db = await openDB();
    await db.put('config', { key: 'api_pool', value: config.api_pool });
    
    // 同时检查当前安装域名是否被标记为废弃
    if (config.deprecated_origins?.includes(self.location.origin)) {
      notifyMigration(config.new_install_url);
    }
  } catch (err) {
    console.warn('[Config] refresh failed:', err);
  }
}

// 通知前端弹出迁移引导横幅
function notifyMigration(newUrl) {
  self.clients.matchAll().then(clients => {
    clients.forEach(client => {
      client.postMessage({
        type: 'MIGRATION_REQUIRED',
        newInstallUrl: newUrl,
      });
    });
  });
}

// 媒体缓存(LRU 简化版,实际可用 workbox)
async function cacheFirstMedia(req) {
  const cache = await caches.open(MEDIA_CACHE);
  const cached = await cache.match(req);
  if (cached) return cached;
  
  const response = await fetch(req);
  if (response.ok) cache.put(req, response.clone());
  return response;
}

// 启动时刷新 config
self.addEventListener('activate', (event) => {
  event.waitUntil(refreshConfig());
});

// 接收前端消息(例如手动触发 config 刷新)
self.addEventListener('message', (event) => {
  if (event.data?.type === 'REFRESH_CONFIG') {
    refreshConfig();
  }
});
```

### 6.3 前端迁移引导(配合 SW 的 MIGRATION_REQUIRED 消息)

```javascript
// app.js 启动逻辑
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type === 'MIGRATION_REQUIRED') {
    showMigrationBanner(event.data.newInstallUrl);
  }
});

function showMigrationBanner(newUrl) {
  const banner = document.createElement('div');
  banner.className = 'migration-banner';
  banner.innerHTML = `
    <div class="banner-content">
      <span>🚀 Aplikasi sudah upgrade! Klik untuk pindah ke versi terbaru</span>
      <button onclick="migrateTo('${newUrl}')">Pindah</button>
      <button onclick="this.parentElement.parentElement.remove()">Nanti</button>
    </div>
  `;
  document.body.appendChild(banner);
}

function migrateTo(newUrl) {
  // 携带 device_id 和当前 user token,新域名落地页可以无感登录
  const deviceId = localStorage.getItem('device_id');
  const migrationToken = generateMigrationToken(); // 一次性 token
  window.location.href = `${newUrl}/migrate?d=${deviceId}&t=${migrationToken}`;
}
```

---

## 7. 故障切换机制

### 7.1 安装域名被封

**已安装用户** → 几乎无感:
- SW 拦截导航 → cache hit → shell 加载 → API 调用走 API 池 → 正常使用
- 这就是三层架构的核心收益

**新增用户**:
- 服务端把此域名 status 改为 `cooling`
- Dispatch 算法把这个域名从分桶池移除,N 减 1
- 原来分到此桶的新用户重新打散到其他桶
- 此域名进入观察期(7-14 天后若仍不可达,转为 `dead`)

### 7.2 API 域名被封

- SW 内置优先级列表,主接口失败自动切下一个
- 启动时拉一次最新 API 清单(从 Cloudflare Workers KV)
- 所有失败异步上报到自有埋点(同样走 API 池,确保自身能上报)
- 失败率超过阈值(比如 5 分钟内 30% 失败)自动从池中移除

### 7.3 分发域名被封

影响范围最小,只影响新增:

1. 监控告警 → 运营切换推广物料里的链接
2. WhatsApp 群发新链接给现有用户(虽然他们已装,但可能要分享给朋友)
3. Telegram Channel、KOL bio、短链指向同步更新
4. 备份发现路径(Gist / Pastebin)同步更新

---

## 8. 用户召回机制(必须 Day 1 建好)

域名被封是必然事件,差别只是何时。用户召回能力直接决定业务能不能扛过封禁周期。

### 8.1 召回渠道优先级

| 优先级 | 渠道 | 覆盖率 | 实时性 | 印尼适用度 |
|---|---|---|---|---|
| P0 | FCM 推送 | 60-70% | 秒级 | ⭐⭐⭐⭐⭐ |
| P1 | WhatsApp 广播 | 80%+(注册时收集) | 分钟级 | ⭐⭐⭐⭐⭐ |
| P2 | 邮件 | 50% | 分钟级 | ⭐⭐⭐ |
| P3 | 应用内迁移横幅 | 100%(仍能打开的) | 实时 | ⭐⭐⭐⭐⭐ |
| P4 | Telegram Channel 置顶 | 长尾 | 静态 | ⭐⭐⭐ |
| P5 | TikTok / IG bio 更新 | 长尾 | 静态 | ⭐⭐⭐⭐ |

### 8.2 FCM 推送细节

- 走 Google 基建,印尼不会封
- 注册 PWA 时主动请求通知权限(印尼用户接受度较高)
- 推送 payload 带 deeplink 到最新分发域名
- 即使原安装域名彻底死,推送通知点击仍可跳转到新域名

### 8.3 WhatsApp 召回

- 注册流程**强制收集** WhatsApp 号(印尼场景非常自然,不会被认为是侵犯隐私)
- 通过 WhatsApp Business API(Twilio / 360dialog / Wati)群发
- 模板示例:
  ```
  Halo {nama}! 🎬
  ShortPro pindah ke alamat baru. Klik untuk lanjut nonton drama favoritmu:
  👉 {new_url}
  ```
- 控制频率,避免被 WA 风控

### 8.4 离线 fallback 页

每个安装域名的 SW 必须 precache 一个 `offline.html`,内容包括:

- 当前入口暂不可用的说明
- 备用入口的动态加载(从 Cloudflare Workers JSON)
- WhatsApp 客服联系方式
- Telegram Channel 链接

```html
<!-- offline.html 关键片段 -->
<script>
  fetch('https://config.shortpro.workers.dev/v1/fallback')
    .then(r => r.json())
    .then(data => {
      document.getElementById('new-url').href = data.discovery_url;
      document.getElementById('new-url').textContent = data.discovery_url;
    })
    .catch(() => {
      document.getElementById('new-url').href = 'https://t.me/shortpro_id';
    });
</script>
```

---

## 9. 域名采购与运营节奏

### 9.1 采购规模

| 类型 | 持有量 | 活跃量 | 冷备量 |
|---|---|---|---|
| 分发域名 | 8-10 | 3-5 | 5 |
| 安装域名 | 20-25 | 5-10 | 10-15 |
| API 域名 | 10-15 | 3-5 | 7-10 |
| **合计** | **40+** | **15-20** | **25+** |

### 9.2 采购策略

- **注册商分散到 5+ 家**:GoDaddy / Namecheap / Porkbun / Cloudflare Registrar / 印尼本地(Niagahoster / IDwebhost)
- **TLD 多样化**:.com / .app / .id / .co / .net / .io,别全押一种
- **批量错峰**:每批不超过 5 个,间隔 2-4 周,避免同一注册时间被批量识别
- **WHOIS**:开 privacy,但准备好真实联系信息(出问题能申诉)
- **付费 1 年**:别一次买 5 年,被封了就是沉没成本

### 9.3 CDN / 基础设施分散

| 域名类型 | 推荐 CDN |
|---|---|
| 分发域名 | Cloudflare Pages / Vercel(便宜、静态) |
| 安装域名 | Cloudflare + Bunny.net + Fastly(混搭) |
| API 域名 | 自建 ALB + Cloudflare 中转 |
| 视频 CDN | Bunny Stream + 火山 / 阿里(东南亚节点)|

### 9.4 轮换节奏

- **每周轮换 1 个**:让"老"域名进入冷备休眠 14 天,降低批量老化风险
- **冷备域名定期热身**:每月 1 次小流量探活,确保需要时立刻能用
- **dead 域名处置**:确认彻底封死后,3 个月不续费(避免成本黑洞)

---

## 10. 监控与健康检查

### 10.1 多 ISP 印尼侧探测

部署探测节点(可用低成本 VPS 或者付费 uptime 服务 + 印尼出口):

| ISP | 占比 | 监测方式 |
|---|---|---|
| Telkomsel | ~40% | 印尼云主机 + Telkomsel SIM 卡 |
| Indosat Ooredoo | ~25% | 同上,Indosat SIM |
| XL Axiata | ~15% | XL SIM |
| Smartfren | ~10% | Smartfren SIM |
| 其他 | ~10% | 通用印尼云主机 |

每 5 分钟从各 ISP 出口 DNS + HTTPS 探测全部活跃域名,失败 3 次连续触发告警。

### 10.2 Kominfo TrustPositif 监控

- 每天 1 次扫描 [https://trustpositif.komdigi.go.id](https://trustpositif.komdigi.go.id) 数据库
- 命中告警 + 自动从 dispatch 池移除
- 内部 wiki 维护"被封域名时间线",积累经验

### 10.3 客户端埋点

每次启动上报(发送到 API 池):

```json
{
  "event": "app_launch",
  "device_id": "...",
  "install_origin": "https://dramaku-app.com",
  "bucket": 3,
  "api_origin_in_use": "https://api-2.xxx",
  "sw_version": "v20260514-01",
  "shell_cache_hit": true,
  "online": true,
  "ts": 1715731200
}
```

后台 dashboard 按 `install_origin` 维度统计:

- DAU / 启动次数(突然为 0 → 被封)
- API 失败率(突然飙升 → 域名劫持)
- SW 缓存命中率(异常低 → 缓存策略问题)

---

## 11. 实施计划(6 周)

### Week 1 — 域名与基础设施
- [ ] 采购首批 20 个域名(分布在 5+ 注册商)
- [ ] DNS 配置,IP/CDN 分散部署
- [ ] Cloudflare Workers config 端点搭建
- [ ] Service Worker 缓存策略原型代码

### Week 2 — 三层架构基础
- [ ] 分发域名静态页 + dispatch 接口
- [ ] 安装域名模板部署(一份代码 N 份部署)
- [ ] API 网关 + 多域名路由
- [ ] 数据库表结构(user_install_binding 等)

### Week 3 — 分桶与 SW
- [ ] 分桶算法 + 服务端持久化
- [ ] Service Worker 完整实现(precache / 路由 / fallback)
- [ ] API 池动态配置流程
- [ ] 健康检查接口

### Week 4 — 召回机制
- [ ] FCM 集成 + 推送模板
- [ ] WhatsApp Business API 接入
- [ ] 离线 fallback 页 + 迁移引导横幅
- [ ] 邮件召回模板

### Week 5 — 监控与运营工具
- [ ] 多 ISP 印尼侧探测节点
- [ ] Kominfo TrustPositif 监控脚本
- [ ] 客户端埋点 + dashboard
- [ ] 运营 SOP 文档(域名被封 → 切换 → 召回的标准动作)

### Week 6 — 灰度与压测
- [ ] 内测 100 用户,验证三层切换流程
- [ ] 模拟封禁演练(主动下线 1 个安装域名,观察故障切换)
- [ ] 大盘灰度 10% → 50% → 100%
- [ ] 复盘 + 调优

---

## 12. 关键技术坑与对策

| # | 坑 | 对策 |
|---|---|---|
| 1 | manifest.json 必须每个安装域名独立 host,跨 origin 不算同一个 PWA | 部署脚本自动生成各域名版 manifest |
| 2 | Service Worker scope 只能管自己的源 | 接受此限制,通过分桶 + 召回机制弥补 |
| 3 | 登录态跨域无法共享 | device_id + 一次性 migration token + WhatsApp OTP 兜底 |
| 4 | 部分 Android 浏览器(三星浏览器、UC)PWA 支持差 | 引导用 Chrome,首屏检测浏览器并提示 |
| 5 | ISP 按 TLS 指纹批量封 | 各安装域名 TLS 证书不要共用同一 CA / fingerprint |
| 6 | DNS over HTTPS 在印尼部分被干扰 | SW 不依赖 DOH,正常 DNS + cache 兜底 |
| 7 | FCM 在国行 ROM(印尼小米/Vivo 部分机型)不稳定 | WhatsApp 召回作为主路径 |
| 8 | WhatsApp Business 模板审核严格 | 提前备 5+ 个不同话术模板 |
| 9 | 域名突然被注册商收回(GoDaddy 偶发) | 注册商分散,域名分批,WHOIS 真实联系方式备用 |

---

## 13. 成功指标(KPI)

| 维度 | 指标 | 目标 |
|---|---|---|
| 稳定性 | 单域名被封后 7 天内 DAU 流失率 | < 10% |
| 召回效率 | 域名失效后 72h 内重新激活率 | > 70% |
| 系统健康 | SW 缓存命中率 | > 95% |
| 系统健康 | API 池 fallback 触发率 | < 1% |
| 运营效率 | 域名被封到完成切换平均耗时 | < 30 分钟 |
| 成本 | 单用户月均域名/CDN 成本 | < $0.05 |

---

## 14. 风险与边界

**这套方案能做到的**:

- 单一域名被封,大多数已安装用户继续可用数周到数月
- 新增用户始终有可用入口
- 域名被封从事故变成可控的运营动作

**这套方案做不到的**:

- 永久豁免封锁:被封域名上的 PWA 终有一天会失活,只是时间问题
- 100% 召回:再好的机制也会有 10-30% 的长尾用户流失
- 防住国家级深度封锁:如果印尼上 GFW 级别的全栈封锁,任何方案都难

**结论**:这是一套"延迟战术 + 平滑迁移"的组合拳,目标不是不被封,而是**让封禁不影响业务连续性**。

---

## 附录 A:Config 端点 schema

`GET https://config.shortpro.workers.dev/v1/config`

```json
{
  "version": "20260514-01",
  "api_pool": [
    "https://api-1.xxx",
    "https://api-2.xxx",
    "https://api-3.xxx"
  ],
  "cdn_pool": [
    "https://cdn-1.xxx",
    "https://cdn-2.xxx"
  ],
  "deprecated_origins": [
    "https://oldsite.com"
  ],
  "new_install_url": "https://spro.id",
  "fallback_discovery": [
    "https://t.me/shortpro_id",
    "https://gist.github.com/shortpro/domains"
  ],
  "min_sw_version": "v20260501-01",
  "ttl": 3600
}
```

## 附录 B:常用命令参考

```bash
# 多 ISP 探测脚本(从印尼云主机跑)
for domain in $(cat domains.txt); do
  for ns in 8.8.8.8 1.1.1.1 202.134.0.155; do  # 含 Telkomsel DNS
    dig +short @$ns $domain
  done
done

# Kominfo TrustPositif 检查
curl -s "https://trustpositif.komdigi.go.id/api/check?url=$DOMAIN"

# Cloudflare Workers config 更新
wrangler kv:key put --binding=CONFIG "v1" --path=./config.json
```
