- 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 一致
9.9 KiB
9.9 KiB
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
ALTER TABLE `User`
ADD COLUMN `appAccountToken` CHAR(36) NULL,
ADD UNIQUE INDEX `User_appAccountToken_key` (`appAccountToken`);
验证:
SHOW COLUMNS FROM User LIKE 'appAccount%';
-- 应有 appAccountToken CHAR(36) UNIQUE
Step 2: MembershipPlan.monthlyQuizCount
ALTER TABLE `MembershipPlan`
ADD COLUMN `monthlyQuizCount` INT NOT NULL DEFAULT 0;
验证:
SHOW COLUMNS FROM MembershipPlan LIKE 'monthlyQuiz%';
Step 3: StoreProduct 表
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;
验证:
SHOW TABLES LIKE 'StoreProduct';
Step 4: UserMembership 扩展
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`);
验证:
SHOW COLUMNS FROM UserMembership LIKE 'source';
SHOW COLUMNS FROM UserMembership LIKE 'status';
Step 5: PlanQuota 表
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;
验证:
SHOW TABLES LIKE 'PlanQuota';
Step 6: AppStoreTransaction 表
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;
验证:
SHOW TABLES LIKE 'AppStoreTransaction';
Step 7: AppStoreNotification 表
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;
验证:
SHOW TABLES LIKE 'AppStoreNotification';
Step 8: QuotaUsage.resource UNIQUE 约束
ALTER TABLE `QuotaUsage`
ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`);
验证:
SHOW INDEXES FROM QuotaUsage WHERE Key_name = 'QuotaUsage_resource_key';
四、迁移后验证
-- 表数: 应为 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 回填
-- 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 数据
# 上传 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"
六、恢复服务
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
七、回滚预案
如果迁移失败,恢复备份:
# 停止服务
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 |