- 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 一致
327 lines
9.9 KiB
Markdown
327 lines
9.9 KiB
Markdown
# 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 |
|