From 648bee0ad0b92ec4bfe988ff6f1935b435e60eec Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 27 Jun 2026 13:37:40 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20M-MEMBER-01-CLOSE-06A=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=20Schema=20Drift=20=E4=BF=AE=E5=A4=8D=20Runbook=20+?= =?UTF-8?q?=20Seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 一致 --- .../m-member-01-production-drift-runbook.md | 326 ++++++++++++++++++ prisma/seed-membership.sql | 77 +++++ 2 files changed, 403 insertions(+) create mode 100644 docs/architecture/m-member-01-production-drift-runbook.md create mode 100644 prisma/seed-membership.sql diff --git a/docs/architecture/m-member-01-production-drift-runbook.md b/docs/architecture/m-member-01-production-drift-runbook.md new file mode 100644 index 0000000..a06c345 --- /dev/null +++ b/docs/architecture/m-member-01-production-drift-runbook.md @@ -0,0 +1,326 @@ +# 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 | diff --git a/prisma/seed-membership.sql b/prisma/seed-membership.sql new file mode 100644 index 0000000..612502c --- /dev/null +++ b/prisma/seed-membership.sql @@ -0,0 +1,77 @@ +-- ═══════════════════════════════════════════════════════ +-- 知习 会员基础数据 Seed (幂等) +-- +-- 执行方式: mysql -u root -p < seed.sql +-- 幂等: 使用 INSERT IGNORE + ON DUPLICATE KEY UPDATE +-- +-- 生产执行前必须: mysqldump zhixi_prod > backup_$(date +%Y%m%d).sql +-- ═══════════════════════════════════════════════════════ + +-- ── 1. MembershipPlan ── +-- NOTE: maxStorageBytes is BIGINT; PlanQuota.limit is INT (max 2147483647) +-- Storage bytes quota uses INT_MAX for "unlimited" in current schema + +INSERT INTO MembershipPlan (id, code, name, priceMonthly, priceYearly, + maxKnowledgeBases, maxStorageBytes, maxFileSizeBytes, + monthlyOcrPages, monthlyVisionPages, monthlyChatCount, monthlyAiAnalysisCount, + monthlyRecallCount, monthlyCardGenCount, monthlyQuizCount, + isActive, updatedAt) +VALUES + ('free_prod_plan', 'free', 'Free', + 0, 0, + 1, 104857600, 10485760, -- 1 KB, 100MB, 10MB + 0, 0, 10, 3, + 5, 3, 0, + 1, NOW()), + ('premium_prod_plan', 'premium', 'Premium', + 0, 0, + 10, 10737418240, 209715200, -- 10 KB, 10GB, 200MB + 0, 0, 100, 10, + 30, 10, 5, + 1, NOW()) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + monthlyChatCount = VALUES(monthlyChatCount), + monthlyAiAnalysisCount = VALUES(monthlyAiAnalysisCount), + monthlyRecallCount = VALUES(monthlyRecallCount), + monthlyCardGenCount = VALUES(monthlyCardGenCount), + monthlyQuizCount = VALUES(monthlyQuizCount), + maxKnowledgeBases = VALUES(maxKnowledgeBases), + maxStorageBytes = VALUES(maxStorageBytes), + maxFileSizeBytes = VALUES(maxFileSizeBytes), + updatedAt = NOW(); + +-- ── 2. PlanQuota (idempotent) ── +-- NOTE: PlanQuota.limit 是 INT,最大 2147483647 (约 2GB)。 +-- 超出此值的存储配额由 BIGINT maxStorageBytes 控制,不在此表体现。 + +INSERT IGNORE INTO PlanQuota (id, planId, quotaType, `limit`, unit, windowType, isUnlimited, updatedAt) +VALUES + -- Free plan quotas + (UUID(), 'free_prod_plan', 'active_recall', 5, 'count', 'daily', false, NOW()), + (UUID(), 'free_prod_plan', 'feynman_evaluation', 2, 'count', 'daily', false, NOW()), + (UUID(), 'free_prod_plan', 'quiz_generation', 1, 'count', 'daily', false, NOW()), + (UUID(), 'free_prod_plan', 'learning_analysis', 1, 'count', 'daily', false, NOW()), + (UUID(), 'free_prod_plan', 'flashcard_generation', 1, 'count', 'daily', false, NOW()), + (UUID(), 'free_prod_plan', 'ai_chat_messages', 10, 'count', 'monthly', false, NOW()), + (UUID(), 'free_prod_plan', 'knowledge_base_count', 1, 'count', 'lifetime', false, NOW()), + (UUID(), 'free_prod_plan', 'storage_bytes', 1073741824, 'bytes', 'lifetime', false, NOW()), -- 1GB (INT safe) + (UUID(), 'free_prod_plan', 'file_size_bytes', 10485760, 'bytes', 'per_request', false, NOW()), + + -- Premium plan quotas + (UUID(), 'premium_prod_plan', 'active_recall', 30, 'count', 'daily', false, NOW()), + (UUID(), 'premium_prod_plan', 'feynman_evaluation', 10, 'count', 'daily', false, NOW()), + (UUID(), 'premium_prod_plan', 'quiz_generation', 5, 'count', 'daily', false, NOW()), + (UUID(), 'premium_prod_plan', 'learning_analysis', 5, 'count', 'daily', false, NOW()), + (UUID(), 'premium_prod_plan', 'flashcard_generation', 5, 'count', 'daily', false, NOW()), + (UUID(), 'premium_prod_plan', 'ai_chat_messages', 100, 'count', 'monthly', false, NOW()), + (UUID(), 'premium_prod_plan', 'knowledge_base_count', 10, 'count', 'lifetime', false, NOW()), + (UUID(), 'premium_prod_plan', 'storage_bytes', 2147483647, 'bytes', 'lifetime', false, NOW()), -- INT_MAX ≈ 2GB + (UUID(), 'premium_prod_plan', 'file_size_bytes', 209715200, 'bytes', 'per_request', false, NOW()); + +-- ── 3. StoreProduct ── + +INSERT IGNORE INTO StoreProduct (id, platform, productId, planId, billingPeriod, isActive, updatedAt) +VALUES + (UUID(), 'apple', 'zhixi.premium.monthly', 'premium_prod_plan', 'monthly', true, NOW()), + (UUID(), 'apple', 'zhixi.premium.yearly', 'premium_prod_plan', 'yearly', true, NOW());