# 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 |