api-server/docs/architecture/m-member-01-production-drift-runbook.md
wangdl 648bee0ad0
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 40s
Deploy API Server / current-integration (push) Successful in 33s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m7s
docs: M-MEMBER-01-CLOSE-06A 生产 Schema Drift 修复 Runbook + Seed
- appAccountToken 全量回填 SQL(UUID 幂等,已验证 0 NULL / 0 dup / 0 invalid)
- MembershipPlan 数据更新(Free/Premium quotas)
- StoreProduct seed(Apple monthly/yearly)
- PlanQuota 18 条全面 seed(9 种类型 x 2 计划)
- 生产修复 Runbook(8 步 Migration + 回滚预案)
- 本地验证: 68 tables, schema 与 Prisma 一致
2026-06-27 13:37:40 +08:00

327 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# M-MEMBER-01-CLOSE-06A: 生产 Schema Drift 修复 Runbook
> 生成时间2026-06-27
> 目标服务器120.53.227.155 (蜂驰云 8核32G)
> 数据库zhixi_prod (MySQL 8.0.46 Docker)
>
> ⚠️ 生产执行前必须:全量备份 mysqldump
---
## 一、Drift 现状
生产 DB 虽然 `_prisma_migrations` 有 33 条记录(含 M-MEMBER-01 的 3 个迁移),但物理 schema 不匹配:
| 缺失项 | 迁移 |
|--------|------|
| `User.appAccountToken` (CHAR(36) UNIQUE) | `20260624000001_m_member_01_02_expand_schema` |
| `MembershipPlan.monthlyQuizCount` (INT) | 同上 |
| `StoreProduct` 表 | 同上 |
| `UserMembership` 扩展列 (source, status, originalTransactionId, transactionId, environment 等) | 同上 |
| `PlanQuota` 表 | 同上 |
| `AppStoreTransaction` 表 | `20260624000002_m_member_01_03_transaction_notification` |
| `AppStoreNotification` 表 | 同上 |
| `QuotaUsage.resource` UNIQUE 索引 | `20260624000003_m_member_01_07_quotausage_unique` |
本地环境已通过空库 Migration 验证 Migration SQL 本身正确33 迁移 → 75 表)。
---
## 二、修复前检查清单
- [ ] **全量备份**`docker exec mysql mysqldump -uroot -p... --single-transaction --routines --triggers zhixi_prod | gzip > /data/backups/pre_drift_fix_$(date +%Y%m%d_%H%M%S).sql.gz`
- [ ] **验证备份**`gunzip -c backup.sql.gz | head -20`
- [ ] **确认无活跃事务**`SHOW PROCESSLIST;`
- [ ] **停止 API/Worker**`systemctl stop zhixi-nest-api.service zhixi-nest-worker.service`
- [ ] **通知相关人员**
---
## 三、应用 Migration SQL逐条执行每步验证
### Step 1: User.appAccountToken
```sql
ALTER TABLE `User`
ADD COLUMN `appAccountToken` CHAR(36) NULL,
ADD UNIQUE INDEX `User_appAccountToken_key` (`appAccountToken`);
```
验证:
```sql
SHOW COLUMNS FROM User LIKE 'appAccount%';
-- 应有 appAccountToken CHAR(36) UNIQUE
```
### Step 2: MembershipPlan.monthlyQuizCount
```sql
ALTER TABLE `MembershipPlan`
ADD COLUMN `monthlyQuizCount` INT NOT NULL DEFAULT 0;
```
验证:
```sql
SHOW COLUMNS FROM MembershipPlan LIKE 'monthlyQuiz%';
```
### Step 3: StoreProduct 表
```sql
CREATE TABLE `StoreProduct` (
`id` VARCHAR(191) NOT NULL,
`platform` VARCHAR(16) NOT NULL,
`productId` VARCHAR(100) NOT NULL,
`planId` VARCHAR(191) NOT NULL,
`billingPeriod` VARCHAR(16) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `StoreProduct_platform_productId_key` (`platform`, `productId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
验证:
```sql
SHOW TABLES LIKE 'StoreProduct';
```
### Step 4: UserMembership 扩展
```sql
ALTER TABLE `UserMembership`
ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT 'admin',
ADD COLUMN `status` VARCHAR(32) NOT NULL DEFAULT 'active',
ADD COLUMN `productId` VARCHAR(100) NULL,
ADD COLUMN `originalTransactionId` VARCHAR(255) NULL,
ADD COLUMN `transactionId` VARCHAR(255) NULL,
ADD COLUMN `environment` VARCHAR(16) NULL,
ADD COLUMN `purchaseDate` DATETIME(3) NULL,
ADD COLUMN `expiresDate` DATETIME(3) NULL,
ADD COLUMN `gracePeriodExpiresAt` DATETIME(3) NULL,
ADD COLUMN `autoRenewStatus` VARCHAR(32) NULL,
ADD COLUMN `revocationDate` DATETIME(3) NULL,
ADD COLUMN `revocationReason` VARCHAR(64) NULL,
ADD COLUMN `ownershipType` VARCHAR(32) NULL,
ADD COLUMN `appAccountToken` VARCHAR(255) NULL,
ADD COLUMN `serverNotificationId` VARCHAR(191) NULL,
ADD UNIQUE INDEX `UserMembership_originalTransactionId_key` (`originalTransactionId`),
ADD UNIQUE INDEX `UserMembership_transactionId_key` (`transactionId`),
ADD INDEX `UserMembership_expiresDate_idx` (`expiresDate`),
ADD INDEX `UserMembership_source_idx` (`source`),
ADD INDEX `UserMembership_status_idx` (`status`);
```
验证:
```sql
SHOW COLUMNS FROM UserMembership LIKE 'source';
SHOW COLUMNS FROM UserMembership LIKE 'status';
```
### Step 5: PlanQuota 表
```sql
CREATE TABLE `PlanQuota` (
`id` VARCHAR(191) NOT NULL,
`planId` VARCHAR(191) NOT NULL,
`quotaType` VARCHAR(64) NOT NULL,
`limit` INT NOT NULL DEFAULT 0,
`unit` VARCHAR(32) NOT NULL,
`windowType` VARCHAR(32) NOT NULL,
`isUnlimited` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `PlanQuota_planId_quotaType_key` (`planId`, `quotaType`),
INDEX `PlanQuota_quotaType_idx` (`quotaType`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
验证:
```sql
SHOW TABLES LIKE 'PlanQuota';
```
### Step 6: AppStoreTransaction 表
```sql
CREATE TABLE `AppStoreTransaction` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`membershipId` VARCHAR(191) NULL,
`productId` VARCHAR(100) NOT NULL,
`transactionId` VARCHAR(255) NOT NULL,
`originalTransactionId` VARCHAR(255) NOT NULL,
`appAccountToken` VARCHAR(255) NOT NULL,
`environment` VARCHAR(16) NOT NULL,
`purchaseDate` DATETIME(3) NOT NULL,
`originalPurchaseDate` DATETIME(3) NOT NULL,
`expiresDate` DATETIME(3) NULL,
`revocationDate` DATETIME(3) NULL,
`revocationReason` VARCHAR(64) NULL,
`ownershipType` VARCHAR(32) NULL,
`signedDate` DATETIME(3) NOT NULL,
`transactionReason` VARCHAR(64) NULL,
`webOrderLineItemId` VARCHAR(255) NULL,
`rawPayloadHash` CHAR(64) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `AppStoreTransaction_transactionId_key` (`transactionId`),
INDEX `AppStoreTransaction_originalTransactionId_idx` (`originalTransactionId`),
INDEX `AppStoreTransaction_userId_idx` (`userId`),
INDEX `AppStoreTransaction_productId_idx` (`productId`),
INDEX `AppStoreTransaction_expiresDate_idx` (`expiresDate`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
验证:
```sql
SHOW TABLES LIKE 'AppStoreTransaction';
```
### Step 7: AppStoreNotification 表
```sql
CREATE TABLE `AppStoreNotification` (
`id` VARCHAR(191) NOT NULL,
`notificationUuid` VARCHAR(255) NOT NULL,
`notificationType` VARCHAR(64) NOT NULL,
`subtype` VARCHAR(64) NULL,
`environment` VARCHAR(16) NOT NULL,
`signedDate` DATETIME(3) NOT NULL,
`originalTransactionId` VARCHAR(255) NULL,
`transactionId` VARCHAR(255) NULL,
`userId` VARCHAR(191) NULL,
`processedStatus` VARCHAR(32) NOT NULL DEFAULT 'received',
`processingStartedAt` DATETIME(3) NULL,
`processedAt` DATETIME(3) NULL,
`retryCount` INT NOT NULL DEFAULT 0,
`nextRetryAt` DATETIME(3) NULL,
`errorCode` VARCHAR(64) NULL,
`errorMessage` VARCHAR(1000) NULL,
`payloadHash` CHAR(64) NOT NULL,
`payloadObjectKey` VARCHAR(500) NULL,
`payloadExpiresAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `AppStoreNotification_notificationUuid_key` (`notificationUuid`),
INDEX `AppStoreNotification_originalTransactionId_idx` (`originalTransactionId`),
INDEX `AppStoreNotification_notificationType_idx` (`notificationType`),
INDEX `AppStoreNotification_processedStatus_idx` (`processedStatus`),
INDEX `AppStoreNotification_createdAt_idx` (`createdAt`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
验证:
```sql
SHOW TABLES LIKE 'AppStoreNotification';
```
### Step 8: QuotaUsage.resource UNIQUE 约束
```sql
ALTER TABLE `QuotaUsage`
ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`);
```
验证:
```sql
SHOW INDEXES FROM QuotaUsage WHERE Key_name = 'QuotaUsage_resource_key';
```
---
## 四、迁移后验证
```sql
-- 表数: 应为 68+(含 UploadSession
SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='zhixi_prod';
-- 关键表存在
SELECT TABLE_NAME FROM information_schema.tables WHERE table_schema='zhixi_prod'
AND TABLE_NAME IN ('AppStoreTransaction','AppStoreNotification','PlanQuota','StoreProduct')
ORDER BY TABLE_NAME;
-- appAccountToken 列
SHOW COLUMNS FROM User LIKE 'appAccount%';
-- UserMembership 扩展
SELECT COLUMN_NAME FROM information_schema.columns
WHERE table_schema='zhixi_prod' AND table_name='UserMembership'
AND column_name IN ('source','status','originalTransactionId','transactionId');
```
---
## 五、数据回填
### appAccountToken 回填
```sql
-- 1. 先确认 NULL 数量
SELECT COUNT(*) FROM User WHERE appAccountToken IS NULL;
-- 预期: 7
-- 2. 回填
UPDATE User SET appAccountToken = UUID() WHERE appAccountToken IS NULL;
-- 3. 验证
SELECT COUNT(*) FROM User WHERE appAccountToken IS NULL; -- 0
SELECT COUNT(*) FROM User WHERE appAccountToken IS NOT NULL; -- 7
SELECT COUNT(*) FROM (SELECT appAccountToken, COUNT(*) AS cnt FROM User GROUP BY appAccountToken HAVING cnt > 1) AS d; -- 0
```
### Seed 数据
```bash
# 上传 seed 文件到服务器
scp -i PJPZ/zhixi.pem prisma/seed-membership.sql ubuntu@120.53.227.155:/tmp/
# 执行
ssh ubuntu@120.53.227.155 "docker exec -i mysql mysql -uroot -p'...' zhixi_prod < /tmp/seed-membership.sql"
```
---
## 六、恢复服务
```bash
systemctl start zhixi-nest-api.service
systemctl start zhixi-nest-worker.service
# 等待启动
sleep 10
# 检查健康
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/api/membership/plans
```
---
## 七、回滚预案
如果迁移失败,恢复备份:
```bash
# 停止服务
systemctl stop zhixi-nest-api.service zhixi-nest-worker.service
# 恢复备份
gunzip -c /data/backups/pre_drift_fix_*.sql.gz | docker exec -i mysql mysql -uroot -p'...' zhixi_prod
# 重启
systemctl start zhixi-nest-api.service zhixi-nest-worker.service
```
---
## 八、已知限制
| 限制 | 说明 | 后续处理 |
|------|------|----------|
| PlanQuota.limit 是 INT最大 2GB | 10GB 存储配额无法在 PlanQuota.limit 中表达 | 后续迁移改为 BIGINT |
| UploadSession 无 migration | 表在 schema.prisma 中但无对应 migration 文件 | 需创建 migration |
| MembershipPlan 部分列无迁移新增 | monthlyQuizCount 等通过此 Runbook 补充 | 后续统一 migration |