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

9.9 KiB
Raw Blame History

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/Workersystemctl 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