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 一致
This commit is contained in:
parent
ea3652f5f9
commit
648bee0ad0
326
docs/architecture/m-member-01-production-drift-runbook.md
Normal file
326
docs/architecture/m-member-01-production-drift-runbook.md
Normal file
@ -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 |
|
||||
77
prisma/seed-membership.sql
Normal file
77
prisma/seed-membership.sql
Normal file
@ -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());
|
||||
Loading…
x
Reference in New Issue
Block a user