# Payment Kit Migration Runbook — 从 Blocklet Server 迁移到 Cloudflare Workers

> Date: 2026-04-08
> Status: Draft
> Scope: 将运行在 Blocklet Server 上的 Payment Kit（含支付数据、Stripe 配置、订阅、发票等）迁移到 Cloudflare Workers
>
> 参考: [DID 仓库迁移 Runbook](https://github.com/ArcBlock/did/blob/master/planning/29-blocklet-migration-runbook.md)
>
> 前置文档:
> - [MIGRATION-CHALLENGES.md](./MIGRATION-CHALLENGES.md) — 平台限制与支付链路挑战
> - [README.md](./README.md) — CF Workers 部署指南

---

## 前提条件

- Cloudflare 账号已创建，`wrangler` CLI 已安装
- 有 Blocklet Server 的 SSH 访问权限或数据库文件访问权限
- **AUTH_SERVICE (blocklet-service)** 已按 DID 仓库 Runbook 完成迁移部署
- **MEDIA_KIT** Worker 已部署
- Payment Kit CF Workers 代码已构建可部署

---

## 总览

```
Phase 0: 准备 (30 min)
  ├── 收集信息
  └── 创建 Cloudflare 资源

Phase 1: 密钥导出与验证 (1 hr)
  ├── 导出 APP_SK（兼作 DID 签名 + AES 加密密钥）
  ├── 验证 DID 派生一致性
  └── 导出/确认 Stripe 密钥

Phase 2: 数据导出 (1 hr)
  ├── 38 张业务表的 SQLite 导出
  └── 行数统计与完整性校验

Phase 3: 数据转换 (1 hr)
  ├── Stripe 加密密钥处理
  ├── 内网地址替换
  └── exchange_rate_providers URL 修正

Phase 4: Schema 部署 (15 min)
  └── wrangler d1 migrations apply

Phase 5: 数据导入 (1-2 hr)
  ├── 使用 migration-sql/ 分片文件逐表导入
  └── 大表分片导入（credit_transactions、webhook_attempts 等）

Phase 6: 验证 (2 hr)
  ├── Checkout 流程验证（Stripe + 链上支付）
  ├── Stripe Webhook 验证
  ├── 订阅续费验证
  └── Admin 管理面板验证

Phase 7: DNS 切换 (15 min)
  └── 域名指向 CF Worker

Total: ~6-8 hours
```

---

## Phase 0: 准备

### 0.1 收集 Blocklet 信息

SSH 到 Blocklet Server:

```bash
# 1. 确认 Payment Kit 的 DID 和 PID
echo "APP_DID: $BLOCKLET_APP_ID"
echo "APP_PID: $BLOCKLET_APP_PID"

# 2. 确认数据目录和数据库路径
echo "DATA_DIR: $BLOCKLET_DATA_DIR"
# Payment Kit 的数据库通常在: {data-dir}/payment.db 或 {data-dir}/data.db

# 3. 确认域名
echo "APP_URL: $BLOCKLET_APP_URL"

# 4. 确认 Stripe 是否启用
# 查看 payment_methods 表是否有 type='stripe' 的记录

# 5. 统计数据量（决定迁移时间窗口）
PAYMENT_DB="$BLOCKLET_DATA_DIR/data.db"
sqlite3 $PAYMENT_DB "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" \
  | while read t; do echo "$t: $(sqlite3 $PAYMENT_DB "SELECT count(*) FROM $t")"; done
```

### 0.2 确认依赖 Workers 已部署

| Worker | 用途 | 验证方法 |
|--------|------|---------|
| `blocklet-service` | DID 认证、用户管理 | `curl https://blocklet-service.your.workers.dev/.well-known/service/api/did/session` |
| `media-kit` | 文件上传、图片处理 | `curl https://media-kit.your.workers.dev/` |

> **重要**: AUTH_SERVICE 必须先完成迁移（包括用户数据）。Payment Kit 的 customers 表通过 DID 关联用户，
> 如果 AUTH_SERVICE 没有对应用户数据，登录和权限验证会失败。

### 0.3 创建 Cloudflare 资源

```bash
# 1. D1 数据库
wrangler d1 create payment-kit-prod
# 记录 database_id，填入 wrangler.json

# 2. KV Namespace（DID Connect session 存储）
wrangler kv namespace create DID_CONNECT_KV
# 记录 id，填入 wrangler.json

# 3. CF Queue（任务队列）
wrangler queues create payment-kit-jobs

# 4. Dead Letter Queue（可选，用于失败任务追踪）
wrangler queues create payment-kit-jobs-dlq
```

---

## Phase 1: 密钥导出与验证

### 1.1 导出 APP_SK

```bash
# 方法 A: 从环境变量（推荐）
ssh blocklet-server
echo $BLOCKLET_APP_SK
# 输出: hex 字符串

# 方法 B: 从 server.db
sqlite3 /path/to/server.db \
  "SELECT appSk FROM blocklets WHERE appDid='$BLOCKLET_APP_ID'"
```

> **Payment Kit 特殊性**: APP_SK 在 Payment Kit 中有双重用途:
> 1. **DID 签名**: DID Connect 认证、链上交易签名
> 2. **AES 加密密钥**: Stripe 密钥通过 `AES(PBKDF2(APP_SK, BLOCKLET_DID))` 加密后存储在
>    `payment_methods` 表的 `metadata` 字段中
>
> 如果 APP_SK 导出错误，不仅登录失败，所有 Stripe 加密配置也无法解密。

### 1.2 验证 DID 派生一致性

```bash
node -e "
const { fromSecretKey } = require('@ocap/wallet');
const { types } = require('@ocap/mcrypto');
const sk = '<EXPORTED_APP_SK>';
const wallet = fromSecretKey(sk, {
  role: types.RoleType.ROLE_APPLICATION,
  pk: types.KeyType.ED25519,
  hash: types.HashType.SHA3,
});
console.log('Derived APP DID:', wallet.address);
console.log('Expected:       ', '<BLOCKLET_APP_ID>');
console.log('Match:', wallet.address === '<BLOCKLET_APP_ID>');
"
```

**如果 DID 不匹配，STOP! 不要继续迁移。**

### 1.3 确认 Stripe 密钥

Payment Kit 的 Stripe secret key 加密存储在 `payment_methods.metadata`，用 `AES(PBKDF2(APP_EK, APP_PID))` 加密。CF Worker 首请求时通过 `AUTH_SERVICE.getAppEk(APP_PID)` 拿 EK 后自动解密，**不需要**在 wrangler secret 里单独设置 Stripe API key。

```bash
# 查看 Stripe 配置
sqlite3 $PAYMENT_DB \
  "SELECT id, type, metadata FROM payment_methods WHERE type='stripe'"
```

前提：复用原 `APP_PID`，且 blocklet-service 上该 PID 的 EK 与 Blocklet Server 一致。EK 不一致时整条链断，必须让 DID 团队修复 EK，而不是退回到 env 覆盖。

Stripe Webhook Secret 是另一件事：CF 部署会新建 webhook endpoint，Stripe 给的是新的 `whsec_...`，**必须**在 Phase 5 用 `wrangler secret put STRIPE_WEBHOOK_SECRET` 设置。

### 1.4 记录关键标识

```bash
# 记录以下值，后续步骤需要用到:
cat << 'INFO'
APP_SK:                <hex>
APP_DID:               <zNK...>
APP_PID:               <zNK...>  (可能与 APP_DID 相同)
COMPONENT_DID:         z2qaCNvKMv5GjouKdcDWexv6WqtHbpNPQDnAk  (固定值)
STRIPE_WEBHOOK_SECRET: whsec_...  (Phase 5 新建 endpoint 后才有，先留空)
APP_URL:               https://your-domain.com
INFO
```

---

## Phase 2: 数据导出

### 2.1 全表导出

Payment Kit 共有 38 张业务表。已准备好 `migration-sql/` 目录，包含预生成的 per-table SQL 导出文件。

如果需要重新导出（数据有更新）:

```bash
PAYMENT_DB="$BLOCKLET_DATA_DIR/data.db"

# 列出所有表和行数
sqlite3 $PAYMENT_DB "SELECT name FROM sqlite_master WHERE type='table' \
  AND name NOT LIKE 'sqlite_%' AND name != 'SequelizeMeta'" \
  | while read t; do echo "$t: $(sqlite3 $PAYMENT_DB "SELECT count(*) FROM $t")"; done

# 导出所有表为 INSERT 语句
for table in $(sqlite3 $PAYMENT_DB "SELECT name FROM sqlite_master WHERE type='table' \
  AND name NOT LIKE 'sqlite_%' AND name != 'SequelizeMeta'"); do
  echo "Exporting $table..."
  sqlite3 $PAYMENT_DB ".mode insert $table" "SELECT * FROM $table" \
    > "migration-sql/${table}.sql"
  # 改为幂等 INSERT
  sed -i '' 's/INSERT INTO /INSERT OR IGNORE INTO /g' "migration-sql/${table}.sql"
done
```

### 2.2 大表分片

D1 的单条 SQL 最大 100KB 限制，大表必须分片:

```bash
# 对大表进行分片（每片 500 行）
for table in credit_transactions invoice_items invoices meter_events \
             payment_intents payment_stats price_quotes webhook_attempts; do
  FILE="migration-sql/${table}.sql"
  if [ -f "$FILE" ] && [ $(wc -l < "$FILE") -gt 500 ]; then
    split -l 500 "$FILE" "migration-sql/${table}-part"
    # 重命名分片文件
    i=1
    for part in migration-sql/${table}-part*; do
      mv "$part" "migration-sql/${table}-part${i}.sql"
      i=$((i+1))
    done
    echo "$table: split into $((i-1)) parts"
  fi
done
```

### 2.3 完整的表清单（38 张）

| 序号 | 表名 | 典型行数 | 分片 | 说明 |
|------|------|---------|------|------|
| 001 | archive_locks | <10 | 否 | 归档锁 |
| 002 | archive_metadata | <100 | 否 | 归档元数据 |
| 003 | auto_recharge_configs | <100 | 否 | 自动充值配置 |
| 004 | checkout_sessions | ~1K | 否 | 结账会话 |
| 005 | coupons | <100 | 否 | 优惠券 |
| 006 | credit_grants | ~1K | 否 | 额度授予 |
| 007 | credit_transactions | **~10K+** | **7 片** | 额度交易记录 |
| 008 | customers | ~1K | 否 | 客户 |
| 009 | discounts | <100 | 否 | 折扣 |
| 010 | exchange_rate_providers | <10 | 否 | 汇率提供商 |
| 011 | invoice_items | **~5K+** | **6 片** | 发票行项目 |
| 012 | invoices | **~5K+** | **7 片** | 发票 |
| 013 | jobs | ~1K | 否 | 后台任务 |
| 014 | locks | <10 | 否 | 分布式锁 |
| 015 | meter_events | **~5K+** | **7 片** | 计量事件 |
| 016 | meters | <100 | 否 | 计量器 |
| 017 | payment_currencies | <100 | 否 | 支付货币配置 |
| 018 | payment_intents | ~2K | **2 片** | 支付意向 |
| 019 | payment_links | <100 | 否 | 支付链接 |
| 020 | payment_methods | <10 | 否 | 支付方式（含加密 Stripe 密钥） |
| 021 | payment_stats | ~3K | **5 片** | 支付统计 |
| 022 | payouts | <100 | 否 | 提现 |
| 023 | price_quotes | ~2K | **2 片** | 报价 |
| 024 | prices | ~100 | 否 | 价格 |
| 025 | pricing_tables | <100 | 否 | 定价表 |
| 026 | product_vendors | <100 | 否 | 产品供应商 |
| 027 | products | ~100 | 否 | 产品 |
| 028 | promotion_codes | <100 | 否 | 促销码 |
| 029 | refunds | <100 | 否 | 退款 |
| 030 | revenue_snapshots | <100 | 否 | 收入快照 |
| 031 | settings | <100 | 否 | 配置 |
| 032 | setup_intents | <100 | 否 | Setup Intent |
| 033 | subscription_items | ~500 | 否 | 订阅项 |
| 034 | subscriptions | ~500 | 否 | 订阅 |
| 035 | tax_rates | <100 | 否 | 税率 |
| 036 | usage_records | <100 | 否 | 用量记录 |
| 037 | webhook_attempts | **~20K+** | **38 片** | Webhook 投递记录 |
| 038 | webhook_endpoints | <10 | 否 | Webhook 端点 |

### 2.4 校验源/目标 Schema 兼容性

```bash
# 对比 Blocklet Server SQLite 和 CF Worker D1 的表结构
PAYMENT_DB="$BLOCKLET_DATA_DIR/data.db"

for table in customers invoices subscriptions payment_methods checkout_sessions; do
  echo "=== $table ==="
  echo "Source columns:"
  sqlite3 $PAYMENT_DB "PRAGMA table_info($table)" | cut -d'|' -f2
  echo "Target columns (from migration SQL):"
  # 对比 migrations/0001_initial_schema.sql 中的 CREATE TABLE
done
```

> **重要**: Blocklet Server SQLite 是 Sequelize `sync()` 自动建表（camelCase 列名），
> CF Worker D1 的 migration 可能使用 snake_case。确认列名映射关系。

---

## Phase 3: 数据转换

### 3.1 Stripe 加密密钥处理

`payment_methods` 表中 `type='stripe'` 的记录，`metadata` 字段包含加密的 Stripe secret key，加密方式 `AES(PBKDF2(APP_EK, APP_PID))`。**这些数据原样导入 D1 即可，不需要任何转换**。

CF Worker 首请求时的解密链路：

```
initFromAuthService(env)
  → AUTH_SERVICE.getAppEk(APP_PID)
  → PBKDF2(appEk, APP_PID, 256, 32, sha512) → _password
PaymentMethod.getStripeClient()
  → decrypt(encryptedKey, _password)
  → new Stripe(plaintextKey)
```

由 `blocklets/core/cloudflare/shims/blocklet-sdk/security.ts` 实现，代码路径和 Blocklet Server 完全一致。

**前提条件**：目标 blocklet-service 上 `APP_PID` 对应的 EK 必须和原 Blocklet Server 一致。如果 EK 不一致，加密字段无法解密，必须在 blocklet-service 侧修复 EK，而不是退回到 env 覆盖。

### 3.2 内网地址替换

Blocklet Server 环境下，某些配置可能引用内网地址:

```bash
# 扫描所有导出 SQL 文件中的内网地址
grep -rn '192\.168\.\|localhost\|127\.0\.0\.1\|10\.0\.\|172\.1[6-9]\.\|172\.2[0-9]\.\|172\.3[01]\.' \
  migration-sql/*.sql

# 常见需要替换的:
# - exchange_rate_providers 表的 API URL
# - webhook_endpoints 表的回调 URL
# - settings 表的内部配置
```

### 3.3 exchange_rate_providers URL 修正

`exchange_rate_providers` 表可能包含 Blocklet Server 内部代理 URL:

```bash
# 检查
sqlite3 $PAYMENT_DB "SELECT id, name, api_url FROM exchange_rate_providers"

# 典型需要替换的:
# http://192.168.x.x:port/api/exchange-rate → https://api.coingecko.com/api/v3/...
# http://localhost:port/... → 公网 API 端点
```

修改导出的 SQL 文件:

```bash
sed -i '' 's|http://192\.168\.[0-9.]*:[0-9]*/|https://public-api-endpoint.com/|g' \
  migration-sql/010-exchange_rate_providers.sql
```

### 3.4 webhook_endpoints URL 修正

如果有自定义 webhook endpoint 指向旧域名或内网:

```bash
sqlite3 $PAYMENT_DB "SELECT id, url FROM webhook_endpoints"
# 确认所有 URL 在 CF Workers 环境下可达
```

### 3.5 DID Connect tokens 表

CF Worker 使用 `_did_connect_tokens` 表（存在 D1 中）而非 KV 存储 DID Connect session，
以保证强一致性。该表由 schema migration 自动创建，无需从 Blocklet Server 迁移历史 token 数据
（用户会重新登录）。

> 注: wrangler.json 中仍保留了 `DID_CONNECT_KV` 配置，但实际 token 已迁移到 D1。
> KV 可用于其他缓存用途。

---

## Phase 4: Schema 部署

### 4.1 应用 D1 Schema Migration

Payment Kit 使用 `migrations_dir` 配置，包含 3 个 migration 文件:

| 文件 | 内容 |
|------|------|
| `0001_initial_schema.sql` | 40 张表的 CREATE TABLE（含 `_did_connect_tokens`） |
| `0002_indexes.sql` | 72 个索引 |
| `0003_locks_and_constraints.sql` | 锁表初始数据和约束 |

```bash
cd blocklets/core/cloudflare

# 使用 wrangler d1 migrations apply（推荐，会记录已执行的 migration）
wrangler d1 migrations apply payment-kit-prod --remote

# 或逐文件手动执行
for f in migrations/0001_initial_schema.sql migrations/0002_indexes.sql migrations/0003_locks_and_constraints.sql; do
  echo "Applying $(basename $f)..."
  wrangler d1 execute payment-kit-prod --remote --file="$f"
done
```

### 4.2 验证表已创建

```bash
wrangler d1 execute payment-kit-prod --remote \
  --command "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"

# 预期: 40 张表（38 业务表 + _did_connect_tokens + d1_migrations）
```

---

## Phase 5: 数据导入

### 5.1 导入策略

使用 `migration-sql/` 目录下的预生成文件，按序号逐表导入。大表使用分片文件。

**导入顺序很重要** — 有外键依赖的表需要先导入被依赖方:

```
1. 基础表: customers, products, prices, meters, coupons, tax_rates
2. 配置表: payment_methods, payment_currencies, exchange_rate_providers, settings
3. 业务表: subscriptions, invoices, checkout_sessions, ...
4. 交易表: credit_transactions, payment_intents, payment_stats, ...
5. 日志表: webhook_attempts, jobs, meter_events
```

### 5.2 执行导入

```bash
cd blocklets/core/cloudflare

# 小表: 直接导入
for f in migration-sql/001-*.sql migration-sql/002-*.sql migration-sql/003-*.sql \
         migration-sql/005-*.sql migration-sql/008-*.sql migration-sql/009-*.sql \
         migration-sql/010-*.sql migration-sql/014-*.sql migration-sql/016-*.sql \
         migration-sql/017-*.sql migration-sql/019-*.sql migration-sql/020-*.sql \
         migration-sql/022-*.sql migration-sql/024-*.sql migration-sql/025-*.sql \
         migration-sql/026-*.sql migration-sql/027-*.sql migration-sql/028-*.sql \
         migration-sql/029-*.sql migration-sql/030-*.sql migration-sql/031-*.sql \
         migration-sql/032-*.sql migration-sql/033-*.sql migration-sql/034-*.sql \
         migration-sql/035-*.sql migration-sql/036-*.sql migration-sql/038-*.sql; do
  if [ -f "$f" ] && ! echo "$f" | grep -q "part"; then
    echo "Importing $(basename $f)..."
    wrangler d1 execute payment-kit-prod --remote --file="$f"
  fi
done

# 大表分片导入 — 逐片执行
for table in credit_transactions invoice_items invoices meter_events \
             payment_intents payment_stats price_quotes webhook_attempts; do
  for part in migration-sql/*${table}-part*.sql; do
    if [ -f "$part" ]; then
      echo "Importing $(basename $part)..."
      wrangler d1 execute payment-kit-prod --remote --file="$part"
    fi
  done
done
```

> **注意**: 如果某个分片导入失败（SQL 语法错误或约束冲突），检查错误后可重新执行
> （所有 INSERT 都使用 `INSERT OR IGNORE`，幂等安全）。

### 5.3 验证导入结果

```bash
# 逐表对比源和目标行数
TABLES="archive_locks archive_metadata auto_recharge_configs checkout_sessions \
  coupons credit_grants credit_transactions customers discounts \
  exchange_rate_providers invoice_items invoices jobs locks meter_events \
  meters payment_currencies payment_intents payment_links payment_methods \
  payment_stats payouts price_quotes prices pricing_tables product_vendors \
  products promotion_codes refunds revenue_snapshots settings setup_intents \
  subscription_items subscriptions tax_rates usage_records webhook_attempts \
  webhook_endpoints"

for table in $TABLES; do
  count=$(wrangler d1 execute payment-kit-prod --remote \
    --command "SELECT count(*) as c FROM $table" --json 2>/dev/null \
    | python3 -c "import sys,json; print(json.loads(sys.stdin.read())[0]['results'][0]['c'])" 2>/dev/null)
  echo "$table: $count"
done
```

将上述输出与 Phase 2.1 的源数据行数对比，确认一致。

### 5.4 设置 Secrets

```bash
# 必需: 应用私钥
wrangler secret put APP_SK
# 粘贴 Phase 1.1 导出的 hex 字符串

# 必需（启用 Stripe 时）: 新 webhook endpoint 的 signing secret
wrangler secret put STRIPE_WEBHOOK_SECRET
# 粘贴 whsec_...
# 注意：这是 CF Worker 新建 webhook endpoint 后 Stripe Dashboard 给的新 secret，
# 不是 Blocklet Server 原 endpoint 的旧 secret。Stripe API key 不需要设置，
# 走 DB + EK 解密路径（见 Phase 3.1）。

# 可选: 自定义域名
wrangler secret put APP_URL
# 粘贴 https://your-domain.com
```

### 5.5 部署 Worker

```bash
cd blocklets/core

# 1. 构建前端
npx vite build --config cloudflare/vite.config.ts --outDir cloudflare/public

# 2. 构建 Worker
cd cloudflare
node run-build.js

# 3. 部署
npx wrangler deploy --config wrangler.json
```

---

## Phase 6: 验证

### 6.1 基础验证

```bash
WORKER_URL="https://payment-kit.your.workers.dev"

# __blocklet__.js 输出
curl -s "$WORKER_URL/__blocklet__.js?type=json" | jq '{
  appId, appPid, appPk, appName
}'

# 确认:
# - appId 与原 Blocklet Server 一致
# - appPk 与原 Blocklet Server 一致（base58 公钥）
```

### 6.2 Admin 面板验证

```
https://payment-kit.your.workers.dev/admin/
```

- [ ] 能登录（通过 AUTH_SERVICE 的 DID Connect）
- [ ] Products 列表显示迁移的产品
- [ ] Prices 列表显示迁移的价格
- [ ] Customers 列表显示迁移的客户
- [ ] Subscriptions 列表显示迁移的订阅
- [ ] Invoices 列表显示迁移的发票
- [ ] Payment Methods 配置正确（Stripe 连接状态）
- [ ] Payment Currencies 配置正确

### 6.3 Checkout 流程验证

| 流程 | 测试步骤 | 预期结果 |
|------|---------|---------|
| **Stripe 一次性支付** | 创建 checkout session → 填表 → Stripe 支付 → 回调 | 发票状态变为 paid |
| **链上支付 (TBA)** | 创建 checkout session → DID Connect → 链上转账 | 发票状态变为 paid |
| **订阅 (Stripe)** | 选择订阅产品 → Stripe 支付 → 创建订阅 | 订阅状态 active |
| **促销码** | 输入促销码 → 金额折扣 | 折扣正确计算 |
| **动态定价** | 切换支付货币 → 汇率获取 | 汇率正常显示 |

### 6.4 Stripe Webhook 验证

```bash
# 1. 在 Stripe Dashboard 中添加新的 Webhook Endpoint
# URL: https://payment-kit.your.workers.dev/api/stripe/webhooks
# Events: payment_intent.succeeded, invoice.paid, customer.subscription.updated, ...

# 2. 发送测试事件
stripe trigger payment_intent.succeeded --webhook-endpoint=<endpoint_id>

# 3. 检查 D1 中的处理结果
wrangler d1 execute payment-kit-prod --remote \
  --command "SELECT id, event_type, status FROM webhook_attempts ORDER BY created_at DESC LIMIT 5"
```

> **重要**: Stripe Webhook 签名验证使用 `STRIPE_WEBHOOK_SECRET`。新的 CF Worker endpoint
> 会有新的 signing secret，必须在 Stripe Dashboard 创建新的 webhook endpoint 并获取新的 secret。
> 旧的 Blocklet Server webhook endpoint 保留（回滚用），但可以先禁用。

### 6.5 订阅续费验证

```bash
# 检查 cron trigger 是否正常工作
# wrangler.json 配置了 "*/5 * * * *"（每 5 分钟）
# 观察 D1 中的 jobs 表是否有新的续费任务

wrangler d1 execute payment-kit-prod --remote \
  --command "SELECT id, type, status, next_run_at FROM jobs WHERE type LIKE '%renewal%' ORDER BY next_run_at DESC LIMIT 5"
```

### 6.6 Credit 系统验证

```bash
# 检查 credit_grants 和 credit_transactions
wrangler d1 execute payment-kit-prod --remote \
  --command "SELECT customer_id, SUM(amount) as total FROM credit_transactions GROUP BY customer_id LIMIT 10"
```

### 6.7 Service Binding 验证

```bash
# 验证 AUTH_SERVICE binding 正常
curl -s "$WORKER_URL/.well-known/service/api/did/session" \
  -H "Cookie: login_token=<valid_jwt>"
# 预期: 返回用户 session 信息

# 验证 MEDIA_KIT binding 正常
# 上传一个文件测试（通过 admin 面板）
```

---

## Phase 7: DNS 切换

### 7.1 切换前检查

- [ ] Phase 6 所有验证项通过
- [ ] Stripe Webhook 新 endpoint 已配置并测试
- [ ] 监控告警已配置
- [ ] 选择低流量时段执行

### 7.2 执行 DNS 切换

```bash
# 方法 A: Cloudflare Custom Domains（推荐）
wrangler deploy --route your-domain.com/*

# 方法 B: 在 Cloudflare DNS 中手动操作
# 将原域名的 A/CNAME 记录改为指向 CF Worker
```

### 7.3 切换后操作

1. **Stripe Webhook**: 确认新 endpoint 正常接收事件，禁用旧的 Blocklet Server endpoint
2. **原 Blocklet Server**: 停止 Payment Kit blocklet（但保留数据，不删除）
3. **监控**: 观察 24 小时
   - Checkout 成功率
   - Webhook 处理延迟
   - 订阅续费是否正常触发
   - Error rate（CF Worker Logs）

---

## 回滚方案

### 触发条件

- Checkout 支付流程完全不可用
- Stripe Webhook 处理持续失败
- 订阅续费系统性失败

### 回滚步骤

1. **DNS 回滚**: 将域名指回原 Blocklet Server (5 min)
2. **Stripe Webhook 回滚**: 重新启用旧 endpoint，禁用新 endpoint
3. **原 Blocklet Server 数据完整**: 未删除任何原始数据
4. **增量数据处理**: 迁移窗口期间在 CF Worker 上产生的新数据（新订单、新订阅）需要手动同步回 Blocklet Server

### 最小化回滚风险

- 在低流量时段执行迁移
- 迁移前冻结 Blocklet Server 上的写操作（设为只读或停止接受新订单）
- 保留原 Blocklet Server 至少 30 天

---

## Checklist 总览

```
准备:
  [ ] 收集 Payment Kit DID, data dir, domain, 数据量信息
  [ ] 确认 AUTH_SERVICE (blocklet-service) 已迁移部署
  [ ] 确认 MEDIA_KIT Worker 已部署
  [ ] 创建 D1 database + KV namespace + Queue
  [ ] 记录所有 resource ID

密钥:
  [ ] 导出 APP_SK
  [ ] DID 派生验证通过
  [ ] 确认 Stripe 密钥处理策略（环境变量 vs 数据库解密）
  [ ] 从 Stripe Dashboard 获取明文密钥（策略 A）

数据导出:
  [ ] 38 张表全部导出为 SQL
  [ ] 大表分片完成（credit_transactions、webhook_attempts 等）
  [ ] 行数统计记录（用于导入后对比）
  [ ] 校验源/目标 Schema 列名一致性

数据转换:
  [ ] exchange_rate_providers URL 内网地址已替换
  [ ] webhook_endpoints URL 已检查
  [ ] settings 表内网引用已清理
  [ ] payment_methods 表 Stripe 加密数据处理策略已确认

Schema 部署:
  [ ] D1 migrations apply 成功（3 个文件）
  [ ] 40 张表已创建确认

数据导入:
  [ ] 小表导入完成
  [ ] 大表分片导入完成（含重试）
  [ ] 行数验证: 源 vs 目标一致
  [ ] Secrets 设置: APP_SK, STRIPE_WEBHOOK_SECRET

部署:
  [ ] 前端构建
  [ ] Worker 构建
  [ ] Worker 部署成功

验证:
  [ ] __blocklet__.js 输出正确（appId, appPk 匹配）
  [ ] Admin 面板登录正常
  [ ] Products/Prices/Customers/Subscriptions 数据完整
  [ ] Checkout → Stripe 支付端到端通过
  [ ] Checkout → 链上支付端到端通过
  [ ] Stripe Webhook 接收并处理成功
  [ ] 订阅续费 cron 正常触发
  [ ] Credit 余额计算正确
  [ ] AUTH_SERVICE binding 正常
  [ ] MEDIA_KIT binding 正常

切换:
  [ ] Stripe Dashboard 新 webhook endpoint 已创建
  [ ] DNS 切换完成
  [ ] 旧 Stripe webhook endpoint 已禁用
  [ ] 原 Blocklet Server 已停止（保留数据）
  [ ] 监控 24h 无异常
```

---

## 实战经验（持续更新）

### 1. APP_SK 双用途容易遗漏

Payment Kit 的 APP_SK 不仅用于 DID 签名，还用于 Stripe 密钥加密。
如果只验证了 DID 派生，没有验证 Stripe 解密，可能在 checkout 支付时才发现问题。

**规则**: Phase 1 验证时，除了 DID 派生，还要验证能否用 APP_SK 解密 payment_methods 中的 Stripe 密钥
（或确认使用环境变量覆盖策略）。

### 2. Webhook Signing Secret 是 endpoint 级别的

每个 Stripe Webhook endpoint 有独立的 signing secret。
CF Worker 的新 URL 需要在 Stripe Dashboard 创建新的 endpoint，获取新的 `whsec_` 密钥。
不能复用 Blocklet Server 的 `STRIPE_WEBHOOK_SECRET`（除非把旧 endpoint 的 URL 改成新的）。

**规则**: 要么在 Stripe Dashboard 修改旧 endpoint 的 URL（保持 secret 不变），
要么创建新 endpoint + 新 secret + 更新 `STRIPE_WEBHOOK_SECRET` 环境变量。

### 3. D1 单条 SQL 100KB 限制

webhook_attempts 表数据量大（38 个分片），每个分片文件不能超过 100KB。
如果单行数据很大（如 webhook payload），可能需要更小的分片粒度。

**规则**: 分片后检查每个文件大小: `ls -la migration-sql/*-part*.sql | awk '{print $5, $9}' | sort -rn | head`

### 4. Queue 任务不迁移

Blocklet Server 上未完成的 queue 任务（jobs 表中 `status='pending'`）不需要迁移。
CF Worker 使用 CF Queues + Cron Trigger，重新调度即可。但要确保没有"正在执行中"的关键任务
（如 Stripe 退款中间状态）。

**规则**: 迁移前检查 `SELECT * FROM jobs WHERE status IN ('pending', 'processing')` 并等待完成。
