From 9566de63a20550a3c40b11e7a87688f4b74d9afa Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 26 Jun 2026 20:03:03 +0800 Subject: [PATCH] feat(membership): implement subscription entitlement and quota backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add User.appAccountToken, StoreProduct, PlanQuota models - Extend UserMembership with Apple IAP fields + source/status - Add AppStoreTransaction (immutable) + AppStoreNotification (Durable Inbox) - Implement EffectiveMembershipService (multi-grant → effectivePlan) - Implement EffectiveQuotaService (PlanQuota-driven, Legacy/Shadow/Enforced) - Implement AppStoreJwsVerifier (x5c chain → Apple Root CA G3) - Implement AppStoreTransactionService (JWS verify + atomic projection) - Implement AppStoreNotificationService (9 event types + lifecycle) - Wire QuotaGuard across 5 AI Job types (active_recall/feynman/quiz/learning/flashcard) - Add Membership API: GET /plans, /me, /usage, POST /apple/transactions - Add webhook: POST /webhooks/app-store/v2 (@Public) - Add 11 unit tests for EffectiveMembershipService - Migration: 3 SQL files (expand_schema, transaction_notification, quotausage_unique) Co-Authored-By: Claude --- .../migration.sql | 72 +++++ .../migration.sql | 62 ++++ .../migration.sql | 3 + prisma/schema.prisma | 151 ++++++++- src/app.module.ts | 2 + src/modules/ai-runtime/ai-runtime.module.ts | 3 +- .../ai-runtime/quiz-execution-router.spec.ts | 6 + .../ai-runtime/quiz-execution-router.ts | 11 +- .../ai-runtime/user-ai.controller.spec.ts | 3 +- src/modules/ai-runtime/user-ai.controller.ts | 41 ++- .../apple/app-store-jws-verifier.ts | 219 +++++++++++++ .../apple/app-store-membership.controller.ts | 34 ++ .../app-store-notification.controller.ts | 37 +++ .../apple/app-store-notification.service.ts | 300 ++++++++++++++++++ .../apple/app-store-transaction.service.ts | 225 +++++++++++++ .../effective-membership.service.spec.ts | 93 ++++++ .../effective-membership.service.ts | 149 +++++++++ .../membership/effective-quota.service.ts | 192 +++++++++++ .../membership/membership.controller.ts | 106 +++++++ src/modules/membership/membership.module.ts | 32 ++ src/modules/membership/quota-guard.service.ts | 132 ++++++++ src/modules/users/users.service.ts | 15 +- 22 files changed, 1869 insertions(+), 19 deletions(-) create mode 100644 prisma/migrations/20260624000001_m_member_01_02_expand_schema/migration.sql create mode 100644 prisma/migrations/20260624000002_m_member_01_03_transaction_notification/migration.sql create mode 100644 prisma/migrations/20260624000003_m_member_01_07_quotausage_unique/migration.sql create mode 100644 src/modules/membership/apple/app-store-jws-verifier.ts create mode 100644 src/modules/membership/apple/app-store-membership.controller.ts create mode 100644 src/modules/membership/apple/app-store-notification.controller.ts create mode 100644 src/modules/membership/apple/app-store-notification.service.ts create mode 100644 src/modules/membership/apple/app-store-transaction.service.ts create mode 100644 src/modules/membership/effective-membership.service.spec.ts create mode 100644 src/modules/membership/effective-membership.service.ts create mode 100644 src/modules/membership/effective-quota.service.ts create mode 100644 src/modules/membership/membership.controller.ts create mode 100644 src/modules/membership/membership.module.ts create mode 100644 src/modules/membership/quota-guard.service.ts diff --git a/prisma/migrations/20260624000001_m_member_01_02_expand_schema/migration.sql b/prisma/migrations/20260624000001_m_member_01_02_expand_schema/migration.sql new file mode 100644 index 0000000..59f584c --- /dev/null +++ b/prisma/migrations/20260624000001_m_member_01_02_expand_schema/migration.sql @@ -0,0 +1,72 @@ +-- M-MEMBER-01-02: Expand 账号令牌、商品映射、会员 Grant 与 PlanQuota Schema +-- Expand only — 不删除旧字段、不破坏 Admin 手动会员 + +-- 1. User.appAccountToken +ALTER TABLE `User` + ADD COLUMN `appAccountToken` CHAR(36) NULL, + ADD UNIQUE INDEX `User_appAccountToken_key` (`appAccountToken`); + +-- 2. MembershipPlan.monthlyQuizCount +ALTER TABLE `MembershipPlan` + ADD COLUMN `monthlyQuizCount` INT NOT NULL DEFAULT 0; + +-- 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; + +-- 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`); + +-- 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; + +-- 6. 回填说明(不在此 Migration 中执行) +-- appAccountToken 回填: +-- 1. 分批 UPDATE User SET appAccountToken = UUID() WHERE appAccountToken IS NULL +-- 2. 确认无重复后执行 ALTER TABLE User MODIFY COLUMN appAccountToken CHAR(36) NOT NULL +-- 3. 回填脚本另行提供,不在 Schema Migration 中执行 diff --git a/prisma/migrations/20260624000002_m_member_01_03_transaction_notification/migration.sql b/prisma/migrations/20260624000002_m_member_01_03_transaction_notification/migration.sql new file mode 100644 index 0000000..8c2bde4 --- /dev/null +++ b/prisma/migrations/20260624000002_m_member_01_03_transaction_notification/migration.sql @@ -0,0 +1,62 @@ +-- M-MEMBER-01-03: 不可变 App Store 交易、通知 Inbox 与 Payload 审计模型 + +-- 1. 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; + +-- 2. AppStoreNotification(Durable Inbox) +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; diff --git a/prisma/migrations/20260624000003_m_member_01_07_quotausage_unique/migration.sql b/prisma/migrations/20260624000003_m_member_01_07_quotausage_unique/migration.sql new file mode 100644 index 0000000..002edd6 --- /dev/null +++ b/prisma/migrations/20260624000003_m_member_01_07_quotausage_unique/migration.sql @@ -0,0 +1,3 @@ +-- M-MEMBER-01-07: QuotaUsage.resource UNIQUE 防并发重复预占 +ALTER TABLE `QuotaUsage` + ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05e1004..7a728a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,9 @@ model User { updatedAt DateTime @updatedAt deletedAt DateTime? + // M-MEMBER-01-02: Apple IAP 账号绑定令牌。UUID,服务端生成,与 userId 解耦,跨设备稳定。 + appAccountToken String? @unique @db.Char(36) + authAccounts AuthAccount[] refreshTokens RefreshToken[] memberships UserMembership[] @@ -63,6 +66,8 @@ model User { aiRuntimeJobs AiRuntimeJob[] aiRuntimeResults AiRuntimeResult[] aiAnalysisResultsNew AiLearningAnalysis[] + appStoreTransactions AppStoreTransaction[] + appStoreNotifications AppStoreNotification[] @@index([email]) @@index([status]) @@ -1195,12 +1200,31 @@ model MembershipPlan { monthlyAiAnalysisCount Int @default(0) monthlyRecallCount Int @default(0) monthlyCardGenCount Int @default(0) + monthlyQuizCount Int @default(0) // M-MEMBER-01-02 isActive Boolean @default(true) memberships UserMembership[] + storeProducts StoreProduct[] + planQuotas PlanQuota[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +// M-MEMBER-01-02: Apple Product ID → MembershipPlan 映射 +model StoreProduct { + id String @id @default(cuid()) + platform String @db.VarChar(16) // "apple" + productId String @db.VarChar(100) // Apple Product ID + planId String + billingPeriod String @db.VarChar(16) // "monthly" | "yearly" + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + plan MembershipPlan @relation(fields: [planId], references: [id]) + + @@unique([platform, productId]) +} + model AdminConversation { id String @id @default(cuid()) adminUserId String @@ -1462,19 +1486,60 @@ model TaskLog { } model UserMembership { - id String @id @default(cuid()) - userId String - planId String - startedAt DateTime @default(now()) - expiresAt DateTime? - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + planId String + // M-MEMBER-01-02: 会员来源与状态 + source String @default("admin") @db.VarChar(32) // "apple" | "admin" | "trial" | "compensation" + status String @default("active") @db.VarChar(32) // active | grace_period | billing_retry | expired | revoked + startedAt DateTime @default(now()) + expiresAt DateTime? + // M-MEMBER-01-02: active Boolean 保留兼容,由 status + expiresAt 计算 + active Boolean @default(true) + // Apple IAP 字段(非 Apple 来源为 null) + productId String? @db.VarChar(100) + originalTransactionId String? @db.VarChar(255) + transactionId String? @db.VarChar(255) + environment String? @db.VarChar(16) // "Sandbox" | "Production" + purchaseDate DateTime? + expiresDate DateTime? // Apple 签名的到期时间 + gracePeriodExpiresAt DateTime? + autoRenewStatus String? @db.VarChar(32) // "on" | "off" + revocationDate DateTime? + revocationReason String? @db.VarChar(64) + ownershipType String? @db.VarChar(32) + appAccountToken String? @db.VarChar(255) // 发放时的用户令牌快照 + serverNotificationId String? // 最后一次通知引用 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) plan MembershipPlan @relation(fields: [planId], references: [id]) + @@unique([originalTransactionId]) + @@unique([transactionId]) @@index([userId]) + @@index([expiresDate]) + @@index([source]) + @@index([status]) +} + +// M-MEMBER-01-02: 通用额度模型,避免 daily/monthly 混乱 +model PlanQuota { + id String @id @default(cuid()) + planId String + quotaType String @db.VarChar(64) // knowledge_base_count | storage_bytes | ocr_pages | ai_chat_messages | active_recall 等 + limit Int @default(0) // 额度上限(isUnlimited=true 时忽略) + unit String @db.VarChar(32) // "count" | "bytes" | "pages" + windowType String @db.VarChar(32) // "daily" | "monthly" | "lifetime" | "per_request" + isUnlimited Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + plan MembershipPlan @relation(fields: [planId], references: [id]) + + @@unique([planId, quotaType]) + @@index([quotaType]) } model UserDevice { @@ -1512,7 +1577,8 @@ model QuotaUsage { userId String quotaType String @db.VarChar(32) amount Int - resource String? @db.VarChar(255) + // N1: @unique 防止并发重复预占 + resource String? @unique @db.VarChar(255) createdAt DateTime @default(now()) @@index([userId, quotaType]) @@ -2342,3 +2408,70 @@ model OutboxEvent { @@index([lockedAt]) @@index([aggregateType, aggregateId]) } + +// ═══════════════════════════════════════════════ +// M-MEMBER-01-03: App Store 交易与通知审计模型 +// ═══════════════════════════════════════════════ + +// 不可变交易记录。每次续订产生新记录,禁止 update 覆盖历史。 +model AppStoreTransaction { + id String @id @default(cuid()) + userId String + membershipId String? // → UserMembership.id(发放时写入) + productId String @db.VarChar(100) + transactionId String @unique @db.VarChar(255) + originalTransactionId String @db.VarChar(255) + appAccountToken String @db.VarChar(255) + environment String @db.VarChar(16) // "Sandbox" | "Production" + purchaseDate DateTime + originalPurchaseDate DateTime + expiresDate DateTime? + revocationDate DateTime? + revocationReason String? @db.VarChar(64) + ownershipType String? @db.VarChar(32) + signedDate DateTime + transactionReason String? @db.VarChar(64) + webOrderLineItemId String? @db.VarChar(255) + rawPayloadHash String @db.Char(64) // SHA-256 hex (64 chars) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@index([originalTransactionId]) + @@index([userId]) + @@index([productId]) + @@index([expiresDate]) +} + +// Server Notification V2 Durable Inbox +model AppStoreNotification { + id String @id @default(cuid()) + notificationUuid String @unique @db.VarChar(255) + notificationType String @db.VarChar(64) // SUBSCRIBED | DID_RENEW | REFUND | REVOKE 等 + subtype String? @db.VarChar(64) + environment String @db.VarChar(16) + signedDate DateTime + originalTransactionId String? @db.VarChar(255) // 可能尚未解析 + transactionId String? @db.VarChar(255) // 可能尚未解析 + userId String? // 解析后写入 + // Durable Inbox 状态机:received → processing → processed | failed | ignored + processedStatus String @default("received") @db.VarChar(32) + processingStartedAt DateTime? + processedAt DateTime? + retryCount Int @default(0) + nextRetryAt DateTime? + errorCode String? @db.VarChar(64) + errorMessage String? @db.VarChar(1000) + // Payload 存储:完整 JWS → COS 对象,数据库仅存 hash + key + payloadHash String @db.Char(64) // SHA-256 hex (64 chars) + payloadObjectKey String? @db.VarChar(500) // COS object key(加密存储) + payloadExpiresAt DateTime? // 90 天自动清理 + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id]) + + @@index([originalTransactionId]) + @@index([notificationType]) + @@index([processedStatus]) + @@index([createdAt]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 990556d..dfb5940 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { AdminConversationModule } from './modules/admin-conversation/admin-conv import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module'; import { AdminAuditLogModule } from './modules/admin-audit-log/admin-audit-log.module'; import { UsersModule } from './modules/users/users.module'; +import { MembershipModule } from './modules/membership/membership.module'; import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module'; import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module'; import { DocumentImportModule } from './modules/document-import/document-import.module'; @@ -141,6 +142,7 @@ import appleConfig from './config/apple.config'; AdminAiChatModule, AdminAuditLogModule, UsersModule, + MembershipModule, KnowledgeBaseModule, KnowledgeItemsModule, KnowledgeSourceModule, diff --git a/src/modules/ai-runtime/ai-runtime.module.ts b/src/modules/ai-runtime/ai-runtime.module.ts index 1fbfb09..5db7705 100644 --- a/src/modules/ai-runtime/ai-runtime.module.ts +++ b/src/modules/ai-runtime/ai-runtime.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { AiJobModule } from '../ai-job/ai-job.module'; +import { MembershipModule } from '../membership/membership.module'; import { UserAiController } from './user-ai.controller'; import { UserAiService } from './user-ai.service'; import { CredentialEncryptionService } from './credential-encryption.service'; @@ -17,7 +18,7 @@ import { QuizExecutionRouter } from './quiz-execution-router'; import { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router'; @Module({ - imports: [ConfigModule, PrismaModule, forwardRef(() => AiJobModule)], + imports: [ConfigModule, PrismaModule, forwardRef(() => AiJobModule), MembershipModule], controllers: [UserAiController, RuntimeInternalController], providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService, QuizExecutionRouter, LearningAnalysisExecutionRouter], exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService], diff --git a/src/modules/ai-runtime/quiz-execution-router.spec.ts b/src/modules/ai-runtime/quiz-execution-router.spec.ts index 4bfa00c..5027eab 100644 --- a/src/modules/ai-runtime/quiz-execution-router.spec.ts +++ b/src/modules/ai-runtime/quiz-execution-router.spec.ts @@ -4,6 +4,7 @@ import { FeatureFlagService } from '../config/feature-flag.service'; import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; import { UserAiService } from './user-ai.service'; +import { QuotaGuardService } from '../membership/quota-guard.service'; import * as crypto from 'crypto'; function dto(overrides?: any) { @@ -41,6 +42,10 @@ describe('QuizExecutionRouter', () => { })), }; mockLegacy = { createAnalysisJob: jest.fn() }; + const mockQuotaGuard = { + check: jest.fn().mockResolvedValue({ allowed: true, quotaType: 'quiz_generation', limit: 30, used: 0, remaining: 30, resetAt: null, upgradeAvailable: false }), + buildError: jest.fn().mockReturnValue({ errorCode: 'QUOTA_EXCEEDED', message: '', quotaType: 'quiz_generation', limit: 30, used: 30, remaining: 0, resetAt: null, upgradeAvailable: true }), + }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -49,6 +54,7 @@ describe('QuizExecutionRouter', () => { { provide: QuizGenerationSnapshotBuilder, useValue: mockSnapshot }, { provide: AiJobCreationService, useValue: mockCreation }, { provide: UserAiService, useValue: mockLegacy }, + { provide: QuotaGuardService, useValue: mockQuotaGuard }, ], }).compile(); diff --git a/src/modules/ai-runtime/quiz-execution-router.ts b/src/modules/ai-runtime/quiz-execution-router.ts index 15fcd42..4d87de7 100644 --- a/src/modules/ai-runtime/quiz-execution-router.ts +++ b/src/modules/ai-runtime/quiz-execution-router.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import * as crypto from 'crypto'; import { FeatureFlagService } from '../config/feature-flag.service'; import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; @@ -6,6 +6,7 @@ import type { QuizGenerationSnapshotInput } from '../ai-job/quiz-generation-snap import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; import { UserAiService } from './user-ai.service'; import type { CreateAnalysisJobDto } from './user-ai.dto'; +import { QuotaGuardService } from '../membership/quota-guard.service'; /** * M-AI-07-05 / M-AI-07-CLEANUP: Quiz Execution Router @@ -34,9 +35,17 @@ export class QuizExecutionRouter { private readonly snapshotBuilder: QuizGenerationSnapshotBuilder, private readonly creationService: AiJobCreationService, private readonly legacyService: UserAiService, + private readonly quotaGuard: QuotaGuardService, ) {} async generateQuiz(userId: string, dto: CreateAnalysisJobDto) { + // M-MEMBER-01-08: quiz_generation 接入统一额度(参考集成) + const quotaOpId = dto.idempotencyKey ?? `quiz_gen:${dto.targetType}:${dto.targetId}:${Date.now()}`; + const check = await this.quotaGuard.check(userId, 'quiz_generation', quotaOpId); + if (!check.allowed) { + throw new BadRequestException(this.quotaGuard.buildError(check)); + } + const useUnified = await this.shouldUseUnified(userId); if (!useUnified) { diff --git a/src/modules/ai-runtime/user-ai.controller.spec.ts b/src/modules/ai-runtime/user-ai.controller.spec.ts index 5e92246..483c665 100644 --- a/src/modules/ai-runtime/user-ai.controller.spec.ts +++ b/src/modules/ai-runtime/user-ai.controller.spec.ts @@ -42,7 +42,8 @@ describe('UserAiController', () => { const mockLearningAnalysisRouter = { analyze: jest.fn(), }; - controller = new UserAiController(mockService, mockQuizRouter, mockLearningAnalysisRouter); + const mockQuotaGuard = { check: jest.fn().mockResolvedValue({ allowed: true, quotaType: 'test', limit: 30, used: 0, remaining: 30, resetAt: null, upgradeAvailable: false }), buildError: jest.fn().mockReturnValue({ errorCode: 'QUOTA_EXCEEDED', message: '', quotaType: 'test', limit: 30, used: 30, remaining: 0, resetAt: null, upgradeAvailable: true }), confirm: jest.fn().mockResolvedValue(undefined), cancel: jest.fn().mockResolvedValue(undefined) }; + controller = new UserAiController(mockService, mockQuizRouter, mockLearningAnalysisRouter, mockQuotaGuard); }); const req = (id = 'u1') => ({ user: { id } }) as any; diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts index 190bebc..2f3b949 100644 --- a/src/modules/ai-runtime/user-ai.controller.ts +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -1,15 +1,26 @@ -import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query } from '@nestjs/common'; +import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query, BadRequestException } from '@nestjs/common'; import { UserAiService } from './user-ai.service'; import { QuizExecutionRouter } from './quiz-execution-router'; import { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router'; +import { QuotaGuardService } from '../membership/quota-guard.service'; import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto'; @Controller('ai') export class UserAiController { + // M-MEMBER-01-CLOSE-01: jobType → quotaType + private static readonly JOB_QUOTA: Record = { + active_recall: 'active_recall', + feynman_evaluation: 'feynman_evaluation', + quiz_generation: 'quiz_generation', + learning_state_analysis: 'learning_analysis_manual', + flashcard_generation: 'flashcard_generation', + }; + constructor( private readonly service: UserAiService, private readonly quizRouter: QuizExecutionRouter, private readonly learningAnalysisRouter: LearningAnalysisExecutionRouter, + private readonly quotaGuard: QuotaGuardService, ) {} // ── Profile ── @@ -68,13 +79,31 @@ export class UserAiController { @Post('jobs') async createAnalysisJob(@Req() req: any, @Body() dto: CreateAnalysisJobDto) { - if (dto.jobType === 'quiz_generation') { - return this.quizRouter.generateQuiz(req.user.id, dto); + const userId = req.user.id; + const quotaType = UserAiController.JOB_QUOTA[dto.jobType]; + const opId = dto.idempotencyKey ?? `${dto.jobType}:${dto.targetType}:${dto.targetId}`; + + // 统一额度预占(Child Job 和定时任务不在此路径) + if (quotaType) { + const check = await this.quotaGuard.check(userId, quotaType, opId); + if (!check.allowed) throw new BadRequestException(this.quotaGuard.buildError(check)); } - if (['learning_state_analysis', 'weak_point_analysis', 'next_action_planning'].includes(dto.jobType)) { - return this.learningAnalysisRouter.analyze(req.user.id, dto); + + try { + let result: any; + if (dto.jobType === 'quiz_generation') { + result = await this.quizRouter.generateQuiz(userId, dto); + } else if (['learning_state_analysis', 'weak_point_analysis', 'next_action_planning'].includes(dto.jobType)) { + result = await this.learningAnalysisRouter.analyze(userId, dto); + } else { + result = await this.service.createAnalysisJob(userId, dto); + } + if (quotaType) this.quotaGuard.confirm(opId).catch(() => {}); + return result; + } catch (err) { + if (quotaType) this.quotaGuard.cancel(opId).catch(() => {}); + throw err; } - return this.service.createAnalysisJob(req.user.id, dto); } @Post('jobs/:jobId/cancel') diff --git a/src/modules/membership/apple/app-store-jws-verifier.ts b/src/modules/membership/apple/app-store-jws-verifier.ts new file mode 100644 index 0000000..3ee68fe --- /dev/null +++ b/src/modules/membership/apple/app-store-jws-verifier.ts @@ -0,0 +1,219 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createLocalJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import * as crypto from 'crypto'; + +/** + * M-MEMBER-01-05: App Store JWS Verifier + * + * 验证 Apple App Store 交易和通知的 JWS 签名 + x5c 证书链。 + * 独立于 AppleAuthService(Sign in with Apple)。 + * + * App Store 证书链: + * Apple Root CA - G3 → Apple Computer, Inc. Root + * (https://www.apple.com/certificateauthority/) + */ + +// B1:Apple App Store 已知根证书(PEM)。 +// App Store Server API 的叶子证书必须链到此根。 +// 来源:https://www.apple.com/certificateauthority/ +// 指纹:Apple Root CA - G3 (EC P-384, valid 2014-2039) +const APPLE_ROOT_CA_G3_PEM = `-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHhf +T6T3EoDt29obPx4iNzhMLjq7hpqUGRiMnYGtYp4MDDPRdP1dDvyeEKwBmYlRxGNo +Bfv70YG1s0T7phCBaKNFMEMwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8E +BAMCAQYwHQYDVR0OBBYEFGPuG5y5jHlpBcQDBMYVwO/vAQF7MAoGCCqGSM49BAMD +A2gAMGUCMQCSSMcoRGFp1Mq2IEuc4ovA3lbB3fLGCYBbUMrHokB9AYw5aBNWB2UV +zAPpJyF/awIwXYcVE1CzkIM+5tu8DknM2Nhpx/r6wSR2cAJN9/fZx3oQzEdSQThm +dYLHJaV8ae/c +-----END CERTIFICATE-----`; + +export interface AppStoreJwsHeader { + alg: string; + x5c?: string[]; +} + +@Injectable() +export class AppStoreJwsVerifier { + private readonly logger = new Logger(AppStoreJwsVerifier.name); + + // 预解析的根证书 + private readonly rootCerts: crypto.X509Certificate[]; + + constructor(private readonly configService: ConfigService) { + // 加载已知 Apple 根证书 + this.rootCerts = []; + for (const pem of [APPLE_ROOT_CA_G3_PEM]) { + try { + this.rootCerts.push(new crypto.X509Certificate(pem)); + } catch (err: any) { + this.logger.error(`Failed to load Apple root CA: ${err.message}`); + } + } + if (this.rootCerts.length === 0) { + this.logger.error('No Apple root CAs loaded! JWS verification will fail.'); + } + } + + async verify(signedPayload: string): Promise { + if (!signedPayload || typeof signedPayload !== 'string') { + throw new BadRequestException('signedPayload is required'); + } + + try { + const parts = signedPayload.split('.'); + if (parts.length !== 3) { + throw new BadRequestException('Invalid JWS format'); + } + + const headerJson = Buffer.from(parts[0], 'base64url').toString('utf-8'); + const header: AppStoreJwsHeader = JSON.parse(headerJson); + + if (!header.x5c || header.x5c.length === 0) { + throw new BadRequestException('JWS header missing x5c certificate chain'); + } + + // B1 修复:验证完整 x5c 证书链 → Apple 根证书 + const chain = header.x5c.map((der) => this.x5cToPem(der)); + this.verifyCertificateChain(chain); + + // 证书链验证通过后,用叶子证书公钥验证 JWS 签名 + const leafJwk = this.certToJwk(chain[0]); + const keySet = createLocalJWKSet({ keys: [leafJwk] }); + + const { payload } = await jwtVerify(signedPayload, keySet, { + algorithms: ['ES256'], + }); + + this.logger.log('App Store JWS verified (signature + certificate chain)'); + return payload; + + } catch (err: any) { + if (err instanceof BadRequestException) throw err; + + this.logger.error( + `App Store JWS verification failed: ${err?.message ?? err}`, + ); + throw new BadRequestException('JWS verification failed'); + } + } + + getExpectedBundleId(): string { + return this.configService.get('apple.bundleId', '') || ''; + } + + getExpectedEnvironment(): string { + return this.configService.get('apple.environment', 'Production'); + } + + // ── Certificate chain validation ── + + /** + * B1:验证 x5c 证书链是否追溯到 Apple 已知根证书。 + * + * 链结构:chain[0] = leaf, chain[1..n-1] = intermediates, chain[n-1] = root + * 验证每一步: + * 1. 证书未过期 + * 2. 下级证书的 issuer 匹配上级证书的 subject + * 3. 上级证书的公钥签署了下级证书 + * 4. 链末端(root)必须匹配已知 Apple 根证书 + */ + private verifyCertificateChain(chainPems: string[]): void { + if (chainPems.length < 1) { + throw new BadRequestException('Empty certificate chain'); + } + + const certs = chainPems.map((pem) => { + try { + return new crypto.X509Certificate(pem); + } catch { + throw new BadRequestException('Invalid certificate in x5c chain'); + } + }); + + const now = new Date(); + + // 验证链中每个证书的链接关系 + for (let i = 0; i < certs.length - 1; i++) { + const child = certs[i]; + const parent = certs[i + 1]; + + // 1. 证书未过期 + this.checkCertValidity(child, now, `x5c[${i}]`); + + // 2. Issuer-Subject 匹配 + if (child.issuer !== parent.subject) { + throw new BadRequestException( + `Certificate chain broken at x5c[${i}]: issuer "${child.issuer}" != parent subject "${parent.subject}"`, + ); + } + + // 3. 上级证书签署了下级证书(验证签名) + if (!child.verify(parent.publicKey)) { + throw new BadRequestException( + `Certificate chain verification failed at x5c[${i}]: parent did not sign child`, + ); + } + } + + // 验证链末端(root) + const root = certs[certs.length - 1]; + this.checkCertValidity(root, now, `x5c[${certs.length - 1}]`); + + // Root 必须匹配 Apple 已知根证书 + const rootCN = this.extractCN(root.subject); + const isKnownRoot = this.rootCerts.some( + (r) => r.raw.equals(root.raw), + ); + if (!isKnownRoot) { + const details = `subject="${root.subject}" CN="${rootCN}"`; + this.logger.error(`Untrusted root certificate in x5c chain: ${details}`); + throw new BadRequestException( + `x5c root certificate is not a trusted Apple CA: ${rootCN}`, + ); + } + } + + private checkCertValidity( + cert: crypto.X509Certificate, + now: Date, + label: string, + ): void { + const validFrom = new Date(cert.validFrom); + const validTo = new Date(cert.validTo); + if (now < validFrom) { + throw new BadRequestException( + `${label} certificate not yet valid (${validFrom.toISOString()})`, + ); + } + if (now > validTo) { + throw new BadRequestException( + `${label} certificate expired (${validTo.toISOString()})`, + ); + } + } + + private extractCN(subject: string): string { + const match = subject.match(/CN=([^,]+)/); + return match ? match[1] : subject; + } + + // ── Helpers ── + + private x5cToPem(base64Der: string): string { + const pemBody = base64Der.match(/.{1,64}/g)?.join('\n') ?? base64Der; + return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----`; + } + + private certToJwk(pem: string): any { + const publicKey = crypto.createPublicKey({ key: pem, format: 'pem' }); + const jwk = publicKey.export({ format: 'jwk' }); + return { ...jwk, alg: 'ES256' }; + } +} diff --git a/src/modules/membership/apple/app-store-membership.controller.ts b/src/modules/membership/apple/app-store-membership.controller.ts new file mode 100644 index 0000000..8d3d6aa --- /dev/null +++ b/src/modules/membership/apple/app-store-membership.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Post, Req, Body, HttpCode } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { AppStoreTransactionService } from './app-store-transaction.service'; + +/** + * M-MEMBER-01-05: App Store 交易提交端点 + * + * POST /membership/apple/transactions + * 仅接受 signedTransactionInfo。客户端禁止提供 userId/isPremium/planCode 等作为权威。 + */ + +@ApiTags('membership') +@Controller('membership/apple') +export class AppStoreMembershipController { + constructor(private readonly txService: AppStoreTransactionService) {} + + @Post('transactions') + @HttpCode(200) + @ApiOperation({ summary: '提交 App Store 已验证交易' }) + async submitTransaction( + @Req() req: any, + @Body() body: { signedTransactionInfo: string }, + ) { + const result = await this.txService.submitTransaction( + req.user.id, + body.signedTransactionInfo, + ); + + return { + transactionId: result.transactionId, + status: result.status, + }; + } +} diff --git a/src/modules/membership/apple/app-store-notification.controller.ts b/src/modules/membership/apple/app-store-notification.controller.ts new file mode 100644 index 0000000..d3d155d --- /dev/null +++ b/src/modules/membership/apple/app-store-notification.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Post, Req, HttpCode, Logger } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Public } from '../../../common/decorators/public.decorator'; +import { AppStoreNotificationService } from './app-store-notification.service'; + +/** + * M-MEMBER-01-06: App Store Server Notifications V2 Webhook + * + * POST /webhooks/app-store/v2 + * @Public — 无需用户 Bearer Token。Apple 直接回调。 + */ +@Public() +@ApiTags('webhooks') +@Controller('webhooks/app-store') +export class AppStoreNotificationController { + private readonly logger = new Logger(AppStoreNotificationController.name); + + constructor(private readonly service: AppStoreNotificationService) {} + + @Post('v2') + @HttpCode(200) + @ApiOperation({ summary: 'App Store Server Notification V2(Apple 回调)' }) + async handleNotification(@Req() req: any) { + const signedPayload = req.body?.signedPayload; + if (!signedPayload) { + this.logger.warn('App Store notification missing signedPayload'); + return { ok: true }; // 快速 200 避免 Apple 重试 + } + + // 异步处理——不阻塞 200 响应 + this.service.processNotification(signedPayload).catch((err) => { + this.logger.error(`Async notification processing failed: ${err?.message ?? err}`); + }); + + return { ok: true }; + } +} diff --git a/src/modules/membership/apple/app-store-notification.service.ts b/src/modules/membership/apple/app-store-notification.service.ts new file mode 100644 index 0000000..545180c --- /dev/null +++ b/src/modules/membership/apple/app-store-notification.service.ts @@ -0,0 +1,300 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../infrastructure/database/prisma.service'; +import { AppStoreJwsVerifier } from './app-store-jws-verifier'; +import * as crypto from 'crypto'; + +/** + * M-MEMBER-01-06: App Store Server Notifications V2 处理服务 + * + * Durable Inbox:received → processing → processed/failed/ignored + * 生命周期投影:根据通知类型更新 Apple Grant + */ + +interface NotificationPayload { + notificationUUID: string; + notificationType: string; + subtype?: string; + data?: { + appAccountToken?: string; + bundleId?: string; + environment?: string; + signedDate?: number; + signedTransactionInfo?: string; + }; +} + +const SUPPORTED_TYPES = new Set([ + 'SUBSCRIBED', 'DID_RENEW', 'DID_CHANGE_RENEWAL_STATUS', + 'DID_FAIL_TO_RENEW', 'DID_RECOVER', 'GRACE_PERIOD_EXPIRED', + 'EXPIRED', 'REFUND', 'REVOKE', +]); + +@Injectable() +export class AppStoreNotificationService { + private readonly logger = new Logger(AppStoreNotificationService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly verifier: AppStoreJwsVerifier, + ) {} + + /** + * 异步处理通知。由 Controller 在 200 响应后调用。 + */ + async processNotification(signedPayload: string): Promise { + // 1. 验证 JWS + let payload: NotificationPayload; + try { + const verified = await this.verifier.verify(signedPayload); + payload = verified as unknown as NotificationPayload; + } catch (err: any) { + this.logger.error(`Notification JWS verification failed: ${err.message}`); + return; + } + + const uuid = payload.notificationUUID; + const hash = crypto.createHash('sha256').update(signedPayload).digest('hex'); + + // 2. Durable Inbox: 幂等落库 + const inbox = await this.upsertInbox(uuid, payload, hash); + if (inbox.processedStatus !== 'received') { + this.logger.log(`Duplicate notification ${uuid}, already ${inbox.processedStatus}`); + return; + } + + // 3. CAS: received → processing + const claimed = await this.claimForProcessing(inbox.id); + if (!claimed) { + this.logger.warn(`Notification ${uuid} already claimed by another worker`); + return; + } + + // 4. 处理 + try { + if (!SUPPORTED_TYPES.has(payload.notificationType)) { + await this.markIgnored(inbox.id, payload.notificationType); + return; + } + await this.applyLifecycleEvent(payload); + await this.markProcessed(inbox.id); + } catch (err: any) { + this.logger.error(`Notification ${uuid} processing failed: ${err.message}`); + await this.markFailed(inbox.id, err.message); + } + } + + // ── Durable Inbox ── + + private async upsertInbox(uuid: string, payload: NotificationPayload, hash: string) { + const existing = await this.prisma.appStoreNotification.findUnique({ + where: { notificationUuid: uuid }, + }); + if (existing) return existing; + + return this.prisma.appStoreNotification.create({ + data: { + notificationUuid: uuid, + notificationType: payload.notificationType, + subtype: payload.subtype ?? null, + environment: payload.data?.environment ?? 'Unknown', + signedDate: payload.data?.signedDate ? new Date(payload.data.signedDate) : new Date(), + payloadHash: hash, + processedStatus: 'received', + }, + }); + } + + private async claimForProcessing(id: string): Promise { + // CAS: received → processing + const result = await this.prisma.appStoreNotification.updateMany({ + where: { id, processedStatus: 'received' }, + data: { + processedStatus: 'processing', + processingStartedAt: new Date(), + }, + }); + return result.count > 0; + } + + private async markProcessed(id: string) { + await this.prisma.appStoreNotification.update({ + where: { id }, + data: { processedStatus: 'processed', processedAt: new Date() }, + }); + } + + private async markFailed(id: string, error: string) { + const existing = await this.prisma.appStoreNotification.findUnique({ where: { id } }); + const retryCount = (existing?.retryCount ?? 0) + 1; + const maxRetries = 5; + await this.prisma.appStoreNotification.update({ + where: { id }, + data: { + processedStatus: retryCount >= maxRetries ? 'failed' : 'received', + errorMessage: error?.substring(0, 1000), + retryCount, + nextRetryAt: retryCount < maxRetries ? new Date(Date.now() + Math.pow(2, retryCount) * 10000) : null, + }, + }); + } + + private async markIgnored(id: string, type: string) { + this.logger.log(`Ignoring unknown notification type: ${type}`); + await this.prisma.appStoreNotification.update({ + where: { id }, + data: { processedStatus: 'ignored', processedAt: new Date() }, + }); + } + + // ── Lifecycle Projection ── + + private async applyLifecycleEvent(payload: NotificationPayload) { + const type = payload.notificationType; + const env = payload.data?.environment ?? 'Unknown'; + + // 解析内层 JWS 提取关键字段 + let txId: string | null = null; + let originalTxId: string | null = null; + let expiresDate: Date | null = null; + if (payload.data?.signedTransactionInfo) { + try { + const txPayload = await this.verifier.verify(payload.data.signedTransactionInfo); + const tx = txPayload as any; + txId = tx.transactionId ?? null; + originalTxId = tx.originalTransactionId ?? null; + // N1:提取 expiresDate 用于续订延长 + if (tx.expiresDate) { + expiresDate = new Date(tx.expiresDate); + } + } catch { /* 无法解析内层 JWS,用已有信息尽力匹配 */ } + } + + // 只处理 source=apple 的 Grant + const appleGrant = originalTxId + ? await this.prisma.userMembership.findFirst({ + where: { originalTransactionId: originalTxId, source: 'apple' }, + }) + : null; + + if (!appleGrant && this.needsGrant(type)) { + this.logger.warn(`No Apple grant found for notification type=${type}, originalTxId=${originalTxId}`); + return; + } + + const signedDate = payload.data?.signedDate ? new Date(payload.data.signedDate) : new Date(); + + switch (type) { + case 'SUBSCRIBED': + // 新订阅——由 POST /transactions 处理,此处仅记录 + this.logger.log(`SUBSCRIBED: originalTxId=${originalTxId}`); + break; + + case 'DID_RENEW': + if (appleGrant) await this.applyRenewal(appleGrant.id, txId, expiresDate); + break; + + case 'DID_CHANGE_RENEWAL_STATUS': + if (appleGrant) await this.applyRenewalStatusChange(appleGrant.id, payload.subtype); + break; + + case 'DID_FAIL_TO_RENEW': + if (appleGrant) await this.applyFailureToRenew(appleGrant.id); + break; + + case 'DID_RECOVER': + if (appleGrant) await this.applyRecovery(appleGrant.id); + break; + + case 'GRACE_PERIOD_EXPIRED': + case 'EXPIRED': + if (appleGrant) await this.applyExpiration(appleGrant.id, signedDate); + break; + + case 'REFUND': + case 'REVOKE': + if (appleGrant) await this.applyRevocation(appleGrant.id, signedDate, payload.subtype); + break; + } + } + + // ── Lifecycle Actions ── + + private needsGrant(type: string): boolean { + return !['SUBSCRIBED'].includes(type); // SUBSCRIBED 由交易上报处理 + } + + private async applyRenewal(grantId: string, newTxId: string | null, expiresDate: Date | null) { + const data: any = { status: 'active' }; + if (newTxId) data.transactionId = newTxId; + // N1:更新 expiresAt/expiresDate 为续订后的新到期时间 + if (expiresDate) { + data.expiresAt = expiresDate; + data.expiresDate = expiresDate; + } + await this.prisma.userMembership.update({ where: { id: grantId }, data }); + this.logger.log(`DID_RENEW: grant=${grantId} renewed, expiresAt=${expiresDate?.toISOString() ?? 'unchanged'}`); + } + + private async applyRenewalStatusChange(grantId: string, subtype?: string) { + // subtype = 'AUTO_RENEW_DISABLED' | 'AUTO_RENEW_ENABLED' + const newStatus = subtype === 'AUTO_RENEW_ENABLED' ? 'on' : 'off'; + await this.prisma.userMembership.update({ + where: { id: grantId }, + data: { autoRenewStatus: newStatus }, + }); + this.logger.log(`DID_CHANGE_RENEWAL_STATUS: grant=${grantId} autoRenew=${newStatus}`); + } + + private async applyFailureToRenew(grantId: string) { + const grant = await this.prisma.userMembership.findUnique({ where: { id: grantId } }); + if (!grant) return; + + // 如果仍在 grace_period 内,保持 grace_period;否则进入 billing_retry + const now = new Date(); + const inGrace = grant.gracePeriodExpiresAt && grant.gracePeriodExpiresAt > now; + await this.prisma.userMembership.update({ + where: { id: grantId }, + data: { status: inGrace ? 'grace_period' : 'billing_retry' }, + }); + this.logger.log(`DID_FAIL_TO_RENEW: grant=${grantId} status=${inGrace ? 'grace_period' : 'billing_retry'}`); + } + + private async applyRecovery(grantId: string) { + await this.prisma.userMembership.update({ + where: { id: grantId }, + data: { status: 'active' }, + }); + this.logger.log(`DID_RECOVER: grant=${grantId} restored to active`); + } + + private async applyExpiration(grantId: string, signedDate: Date) { + // 乱序保护:旧事件不覆盖新状态 + const grant = await this.prisma.userMembership.findUnique({ where: { id: grantId } }); + if (grant?.expiresAt && grant.expiresAt > signedDate) { + this.logger.warn(`EXPIRED notification older than current expiresAt, ignoring`); + return; + } + await this.prisma.userMembership.update({ + where: { id: grantId }, + data: { status: 'expired' }, + }); + this.logger.log(`EXPIRED: grant=${grantId}`); + } + + private async applyRevocation(grantId: string, signedDate: Date, reason?: string) { + const grant = await this.prisma.userMembership.findUnique({ where: { id: grantId } }); + if (grant?.revocationDate && grant.revocationDate > signedDate) { + this.logger.warn(`REVOKE notification older than current revocationDate, ignoring`); + return; + } + await this.prisma.userMembership.update({ + where: { id: grantId }, + data: { + status: 'revoked', + revocationDate: signedDate, + revocationReason: reason ?? null, + }, + }); + this.logger.log(`REVOKE: grant=${grantId} reason=${reason}`); + } +} diff --git a/src/modules/membership/apple/app-store-transaction.service.ts b/src/modules/membership/apple/app-store-transaction.service.ts new file mode 100644 index 0000000..e7541f1 --- /dev/null +++ b/src/modules/membership/apple/app-store-transaction.service.ts @@ -0,0 +1,225 @@ +import { Injectable, Logger, BadRequestException, ForbiddenException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../../../infrastructure/database/prisma.service'; +import { AppStoreJwsVerifier } from './app-store-jws-verifier'; +import * as crypto from 'crypto'; + +/** + * M-MEMBER-01-05: App Store Transaction Service + * + * 接收已验证的交易 JWS payload,校验业务规则,原子投影会员权益。 + */ + +export interface TransactionPayload { + transactionId: string; + originalTransactionId: string; + bundleId: string; + productId: string; + environment: string; + appAccountToken?: string; + purchaseDate: number; + originalPurchaseDate: number; + expiresDate?: number; + revocationDate?: number; + revocationReason?: string; + transactionReason?: string; + webOrderLineItemId?: string; +} + +interface SubmitResult { + transactionId: string; + membershipId: string; + status: 'created' | 'existing'; +} + +@Injectable() +export class AppStoreTransactionService { + private readonly logger = new Logger(AppStoreTransactionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly verifier: AppStoreJwsVerifier, + ) {} + + async submitTransaction( + userId: string, + signedTransactionInfo: string, + ): Promise { + // 1. 验证 JWS + const payload = await this.verifier.verify(signedTransactionInfo); + // N5:rawPayloadHash 输入为原始 JWS 字符串(与 AppStoreNotification.payloadHash 一致) + const payloadHash = crypto.createHash('sha256').update(signedTransactionInfo).digest('hex'); + const tx = payload as unknown as TransactionPayload; + + // 2. 事务外快速失败校验 + this.validateBundleId(tx); + await this.validateProductId(tx); // N3: 查 StoreProduct 表 + this.validateEnvironment(tx); // N2: 匹配配置预期环境 + await this.validateAppAccountToken(userId, tx); // N4: timingSafeEqual + this.validateNotRevokedOrExpired(tx); + await this.validateOriginalTransactionOwnership(userId, tx.originalTransactionId); + + // 3. N1: 原子投影 + 事务内幂等(catch P2002) + try { + return await this.prisma.$transaction(async (db) => { + await db.appStoreTransaction.create({ + data: { + userId, + productId: tx.productId, + transactionId: tx.transactionId, + originalTransactionId: tx.originalTransactionId, + appAccountToken: tx.appAccountToken ?? '', + environment: tx.environment, + purchaseDate: new Date(tx.purchaseDate), + originalPurchaseDate: new Date(tx.originalPurchaseDate), + expiresDate: tx.expiresDate ? new Date(tx.expiresDate) : null, + revocationDate: tx.revocationDate ? new Date(tx.revocationDate) : null, + revocationReason: tx.revocationReason ?? null, + signedDate: new Date(), + transactionReason: tx.transactionReason ?? null, + webOrderLineItemId: tx.webOrderLineItemId ?? null, + rawPayloadHash: payloadHash, + }, + }); + + const plan = await db.membershipPlan.findUnique({ where: { code: 'premium' } }); + if (!plan) throw new Error('Premium plan not found'); + + const existingMembership = await db.userMembership.findFirst({ + where: { userId, originalTransactionId: tx.originalTransactionId }, + }); + + let membershipId: string; + + if (existingMembership) { + await db.userMembership.update({ + where: { id: existingMembership.id }, + data: { + status: 'active', + expiresAt: tx.expiresDate ? new Date(tx.expiresDate) : null, + expiresDate: tx.expiresDate ? new Date(tx.expiresDate) : null, + transactionId: tx.transactionId, + autoRenewStatus: null, + }, + }); + membershipId = existingMembership.id; + } else { + const membership = await db.userMembership.create({ + data: { + userId, planId: plan.id, source: 'apple', status: 'active', + productId: tx.productId, + originalTransactionId: tx.originalTransactionId, + transactionId: tx.transactionId, + environment: tx.environment, + purchaseDate: new Date(tx.purchaseDate), + expiresAt: tx.expiresDate ? new Date(tx.expiresDate) : null, + expiresDate: tx.expiresDate ? new Date(tx.expiresDate) : null, + appAccountToken: tx.appAccountToken ?? null, + }, + }); + membershipId = membership.id; + } + + await db.appStoreTransaction.update({ + where: { transactionId: tx.transactionId }, + data: { membershipId }, + }); + + return { transactionId: tx.transactionId, membershipId, status: 'created' as const }; + }); + } catch (err: any) { + // N1: P2002 = transactionId 已存在(并发幂等) + if (err?.code === 'P2002') { + const existing = await this.prisma.appStoreTransaction.findUnique({ + where: { transactionId: tx.transactionId }, + }); + if (existing) { + return { + transactionId: existing.transactionId, + membershipId: existing.membershipId ?? '', + status: 'existing', + }; + } + } + throw err; + } + } + + // ── Private validators ── + + private validateBundleId(tx: TransactionPayload): void { + const expected = this.verifier.getExpectedBundleId(); + if (expected && tx.bundleId !== expected) { + throw new ForbiddenException( + `Bundle ID mismatch: expected "${expected}", got "${tx.bundleId}"`, + ); + } + } + + // N3: 查询 StoreProduct 表,不再硬编码 Product ID + private async validateProductId(tx: TransactionPayload): Promise { + const product = await this.prisma.storeProduct.findFirst({ + where: { platform: 'apple', productId: tx.productId, isActive: true }, + select: { id: true }, + }); + if (!product) { + throw new BadRequestException(`Unknown or inactive product ID: ${tx.productId}`); + } + } + + // N2: 匹配配置预期环境,防止 Sandbox 交易污染 Production + private validateEnvironment(tx: TransactionPayload): void { + const expected = this.verifier.getExpectedEnvironment(); + if (expected && tx.environment !== expected) { + throw new ForbiddenException( + `Environment mismatch: expected "${expected}", got "${tx.environment}"`, + ); + } + // 兜底校验合法值 + if (!['Sandbox', 'Production'].includes(tx.environment)) { + throw new BadRequestException(`Unknown environment: ${tx.environment}`); + } + } + + // N4: timingSafeEqual 防时序攻击 + private async validateAppAccountToken(userId: string, tx: TransactionPayload): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { appAccountToken: true }, + }); + if (!user?.appAccountToken) { + throw new ForbiddenException('User has no appAccountToken'); + } + + const expected = Buffer.from(user.appAccountToken); + const actual = Buffer.from(tx.appAccountToken ?? ''); + + if (expected.length !== actual.length || + !crypto.timingSafeEqual(expected, actual)) { + throw new ForbiddenException( + 'appAccountToken mismatch: transaction does not belong to this user', + ); + } + } + + private validateNotRevokedOrExpired(tx: TransactionPayload): void { + if (tx.revocationDate) { + throw new BadRequestException('Transaction has been revoked'); + } + if (tx.expiresDate && Date.now() > tx.expiresDate) { + throw new BadRequestException('Transaction has expired'); + } + } + + private async validateOriginalTransactionOwnership( + userId: string, + originalTransactionId: string, + ): Promise { + const existing = await this.prisma.userMembership.findFirst({ + where: { originalTransactionId }, + select: { userId: true }, + }); + if (existing && existing.userId !== userId) { + throw new ConflictException('APPLE_SUBSCRIPTION_ALREADY_BOUND'); + } + } +} diff --git a/src/modules/membership/effective-membership.service.spec.ts b/src/modules/membership/effective-membership.service.spec.ts new file mode 100644 index 0000000..7622182 --- /dev/null +++ b/src/modules/membership/effective-membership.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EffectiveMembershipService } from './effective-membership.service'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +describe('EffectiveMembershipService', () => { + let service: EffectiveMembershipService; + let prisma: any; + + const planFree = { id: 'plan-free', code: 'free', name: 'Free' }; + const planPremium = { id: 'plan-premium', code: 'premium', name: 'Premium' }; + + beforeEach(async () => { + prisma = { + userMembership: { findMany: jest.fn().mockResolvedValue([]) }, + membershipPlan: { findUnique: jest.fn() }, + user: { findUnique: jest.fn().mockResolvedValue({ appAccountToken: 'uuid-001' }) }, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [EffectiveMembershipService, { provide: PrismaService, useValue: prisma }], + }).compile(); + service = module.get(EffectiveMembershipService); + }); + + it('Free user → effectivePlan=free', async () => { + prisma.userMembership.findMany.mockResolvedValue([]); + const result = await service.compute('u1'); + expect(result.effectivePlan).toBe('free'); + expect(result.grants).toHaveLength(0); + }); + + it('Admin permanent → premium', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'admin', status:'active', expiresAt:null, gracePeriodExpiresAt:null, autoRenewStatus:null, environment:null, startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + }); + + it('Apple active → premium', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'active', expiresAt:new Date(Date.now()+86400000), gracePeriodExpiresAt:null, autoRenewStatus:'on', environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + }); + + it('grace_period within window → premium', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'grace_period', expiresAt:new Date(Date.now()-3600000), gracePeriodExpiresAt:new Date(Date.now()+86400000), autoRenewStatus:'off', environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + }); + + it('expired → free', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'expired', expiresAt:new Date(Date.now()-86400000), gracePeriodExpiresAt:null, autoRenewStatus:null, environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('free'); + }); + + it('revoked → free', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'revoked', expiresAt:new Date(Date.now()+86400000), gracePeriodExpiresAt:null, autoRenewStatus:null, environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('free'); + }); + + it('Apple expired + Admin active → premium (Admin untouched)', async () => { + prisma.userMembership.findMany.mockResolvedValue([ + { id:'m1', source:'apple', status:'expired', expiresAt:new Date(Date.now()-86400000), gracePeriodExpiresAt:null, autoRenewStatus:null, environment:'Production', startedAt:new Date(), plan:planPremium }, + { id:'m2', source:'admin', status:'active', expiresAt:null, gracePeriodExpiresAt:null, autoRenewStatus:null, environment:null, startedAt:new Date(), plan:planPremium }, + ]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + expect(r.grants).toHaveLength(1); // only admin grant active + }); + + it('autoRenew off but not yet expired → still premium', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'active', expiresAt:new Date(Date.now()+86400000), gracePeriodExpiresAt:null, autoRenewStatus:'off', environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + }); + + it('billing_retry with valid grace → premium', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'billing_retry', expiresAt:new Date(Date.now()+86400000), gracePeriodExpiresAt:new Date(Date.now()+86400000), autoRenewStatus:'off', environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('premium'); + }); + + it('billing_retry with expired grace → free', async () => { + prisma.userMembership.findMany.mockResolvedValue([{ id:'m1', source:'apple', status:'billing_retry', expiresAt:new Date(Date.now()-3600000), gracePeriodExpiresAt:new Date(Date.now()-3600000), autoRenewStatus:'off', environment:'Production', startedAt:new Date(), plan:planPremium }]); + const r = await service.compute('u1'); + expect(r.effectivePlan).toBe('free'); + }); + + it('appAccountToken returned', async () => { + const r = await service.compute('u1'); + expect(r.appAccountToken).toBe('uuid-001'); + }); +}); diff --git a/src/modules/membership/effective-membership.service.ts b/src/modules/membership/effective-membership.service.ts new file mode 100644 index 0000000..2d5a0ff --- /dev/null +++ b/src/modules/membership/effective-membership.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +/** + * M-MEMBER-01-04: EffectiveMembershipService + * + * 计算所有来源(Apple/Admin/Trial/Compensation)的 Grant, + * 合并为单一 effectivePlan。 + * + * 首版规则:任意有效 premium grant → effectivePlan = premium,否则 free。 + * Apple expired/revoked 只影响对应 Apple grant,不触及 Admin/Trial/Compensation。 + */ + +export interface MembershipGrant { + id: string; + source: string; // "apple" | "admin" | "trial" | "compensation" + status: string; + planCode: string; // "free" | "premium" + planName: string; + startedAt: string; + expiresAt: string | null; + gracePeriodExpiresAt: string | null; + autoRenewStatus: string | null; + environment: string | null; +} + +export interface EffectiveMembership { + effectivePlan: 'free' | 'premium'; + grants: MembershipGrant[]; + appAccountToken: string | null; +} + +@Injectable() +export class EffectiveMembershipService { + private readonly logger = new Logger(EffectiveMembershipService.name); + + // status 值中哪些视为"当前有效"(保留权益) + private readonly ACTIVE_STATUSES = new Set(['active', 'grace_period', 'billing_retry']); + + constructor(private readonly prisma: PrismaService) {} + + /** + * 计算当前用户的有效会员。 + * + * 规则: + * status IN ('active', 'grace_period', 'billing_retry') + * AND (expiresAt IS NULL OR expiresAt > NOW()) + * → 该 Grant 视为有效 + * + * 任意有效 premium grant 存在 → effectivePlan = premium + * 否则 → effectivePlan = free + * + * billing_retry:仅在 expiresAt > NOW() 时有效(Apple 在重试期间保留权益, + * 但如果已超过原到期日且无 Grace Period,则不应视为有效)。 + */ + async compute(userId: string): Promise { + const now = new Date(); + + // 查询所有 UserMembership 记录(非 soft-deleted),include plan + const records = await this.prisma.userMembership.findMany({ + where: { userId }, + include: { plan: true }, + orderBy: { createdAt: 'desc' }, + }); + + // 过滤有效 Grant + const grants: MembershipGrant[] = []; + + for (const r of records) { + if (!this.ACTIVE_STATUSES.has(r.status)) continue; + + // Admin 永久会员:expiresAt = null → 始终有效 + // grace_period:用 gracePeriodExpiresAt 代替 expiresAt 判断 + if (r.status === 'grace_period') { + if (r.gracePeriodExpiresAt && r.gracePeriodExpiresAt <= now) continue; + } else { + const isExpired = r.expiresAt !== null && r.expiresAt <= now; + if (isExpired) continue; + } + + // billing_retry 额外检查:仅在有有效 Grace Period 时保留权益 + if (r.status === 'billing_retry') { + if (r.gracePeriodExpiresAt && r.gracePeriodExpiresAt <= now) continue; + } + + grants.push({ + id: r.id, + source: r.source, + status: r.status, + planCode: r.plan.code, + planName: r.plan.name, + startedAt: r.startedAt.toISOString(), + expiresAt: r.expiresAt?.toISOString() ?? null, + gracePeriodExpiresAt: r.gracePeriodExpiresAt?.toISOString() ?? null, + autoRenewStatus: r.autoRenewStatus, + environment: r.environment, + }); + } + + // 判定 effectivePlan + const hasPremium = grants.some(g => g.planCode === 'premium'); + const effectivePlan = hasPremium ? 'premium' : 'free'; + + // 获取 appAccountToken + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { appAccountToken: true }, + }); + + return { + effectivePlan, + grants, + appAccountToken: user?.appAccountToken ?? null, + }; + } + + /** + * 获取当前有效 Plan 的权益摘要。 + * 从 MembershipPlan 字段读取——本阶段不接入 EffectiveQuota(由 Issue 07 实现)。 + */ + async getEntitlements(userId: string) { + const membership = await this.compute(userId); + const planCode = membership.effectivePlan; + + const plan = await this.prisma.membershipPlan.findUnique({ + where: { code: planCode }, + }); + + if (!plan) { + return { planCode, entitlements: null }; + } + + return { + planCode, + entitlements: { + maxKnowledgeBases: plan.maxKnowledgeBases, + maxStorageBytes: Number(plan.maxStorageBytes), + maxFileSizeBytes: Number(plan.maxFileSizeBytes), + monthlyOcrPages: plan.monthlyOcrPages, + monthlyVisionPages: plan.monthlyVisionPages, + monthlyChatCount: plan.monthlyChatCount, + monthlyAiAnalysisCount: plan.monthlyAiAnalysisCount, + monthlyRecallCount: plan.monthlyRecallCount, + monthlyCardGenCount: plan.monthlyCardGenCount, + monthlyQuizCount: plan.monthlyQuizCount, + }, + }; + } +} diff --git a/src/modules/membership/effective-quota.service.ts b/src/modules/membership/effective-quota.service.ts new file mode 100644 index 0000000..8d71204 --- /dev/null +++ b/src/modules/membership/effective-quota.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { EffectiveMembershipService } from './effective-membership.service'; + +/** + * M-MEMBER-01-07: EffectiveQuotaService + * + * 统一额度引擎。迁移三阶段:legacy → shadow → enforced。 + * 本 Issue 默认 Shadow——不改变用户结果。 + */ + +export interface QuotaLimit { + quotaType: string; + limit: number; + unit: string; + windowType: string; // daily | monthly | lifetime | per_request + isUnlimited: boolean; +} + +export interface EffectiveQuota { + planCode: string; + limits: QuotaLimit[]; + overrides: string[]; // 哪些 quota 被 Admin 覆盖 +} + +export interface ReservationResult { + allowed: boolean; + quotaType: string; + limit: number; + used: number; + remaining: number; + reservationId?: string; +} + +@Injectable() +export class EffectiveQuotaService { + private readonly logger = new Logger(EffectiveQuotaService.name); + + // 迁移模式(由 Feature Flag 控制) + private mode: 'legacy' | 'shadow' | 'enforced' = 'shadow'; + + constructor( + private readonly prisma: PrismaService, + private readonly membershipService: EffectiveMembershipService, + ) {} + + setMode(mode: 'legacy' | 'shadow' | 'enforced') { + this.mode = mode; + } + + getMode() { return this.mode; } + + // ── Effective Quota ── + + async computeEffectiveQuota(userId: string): Promise { + const membership = await this.membershipService.compute(userId); + const planCode = membership.effectivePlan; + + // 从 PlanQuota 读取基础额度 + const plan = await this.prisma.membershipPlan.findUnique({ + where: { code: planCode }, + include: { planQuotas: true }, + }); + + const limits: QuotaLimit[] = []; + const overrides: string[] = []; + + if (plan?.planQuotas) { + for (const q of plan.planQuotas) { + limits.push({ + quotaType: q.quotaType, + limit: q.limit, + unit: q.unit, + windowType: q.windowType, + isUnlimited: q.isUnlimited, + }); + } + } + + // Admin Override(暂不实现——由后续 Feature Flag 控制) + // 当前仅使用 PlanQuota 默认值 + + return { planCode, limits, overrides }; + } + + // ── Reservation ── + + async reserve( + userId: string, + quotaType: string, + operationId: string, + amount: number = 1, + ): Promise { + // 读取限额 + const quota = await this.computeEffectiveQuota(userId); + const limit = quota.limits.find(l => l.quotaType === quotaType); + if (!limit) { + return { allowed: true, quotaType, limit: -1, used: 0, remaining: -1 }; + } + if (limit.isUnlimited) { + return { allowed: true, quotaType, limit: -1, used: 0, remaining: -1 }; + } + + // 统计当前窗口已用量 + const used = await this.countUsedInWindow(userId, quotaType, limit.windowType); + + // Enforced 模式才真正拦截 + const allowed = this.mode === 'enforced' ? (used + amount <= limit.limit) : true; + // N2: 非 enforced 模式下直接返回全量(无实际预占记录,used 始终为 0) + const remaining = this.mode === 'enforced' ? Math.max(0, limit.limit - used) : limit.limit; + + // N1: 使用 @unique(resource) + P2002 catch 防并发重复预占 + let reservationId: string | undefined; + if (this.mode === 'enforced' && allowed) { + try { + const record = await this.prisma.quotaUsage.create({ + data: { userId, quotaType, amount, resource: operationId }, + }); + reservationId = record.id; + } catch (err: any) { + if (err?.code === 'P2002') { + const existing = await this.prisma.quotaUsage.findUnique({ + where: { resource: operationId }, + select: { id: true }, + }); + reservationId = existing?.id; + } + } + } + + // Shadow 模式记录差异 + if (this.mode === 'shadow' && used + amount > limit.limit) { + this.logger.log( + `[Shadow] Would block: userId=${userId} quota=${quotaType} ` + + `used=${used} limit=${limit.limit} plan=${quota.planCode}`, + ); + } + + return { + allowed, + quotaType, + limit: limit.limit, + used, + remaining: Math.max(0, limit.limit - used), + reservationId, + }; + } + + async commit(_operationId: string) { + // Reservation 保留——记录已计入 used 统计 + } + + async release(operationId: string) { + // 失败/取消 → 删除预占记录(释放额度) + await this.prisma.quotaUsage.deleteMany({ + where: { resource: operationId }, + }); + } + + // ── Private ── + + private async countUsedInWindow( + userId: string, + quotaType: string, + windowType: string, + ): Promise { + const start = this.windowStart(windowType); + const records = await this.prisma.quotaUsage.findMany({ + where: { + userId, + quotaType, + resource: { not: null }, // 所有预占/已结算记录 + createdAt: { gte: start }, + }, + select: { amount: true }, + }); + return records.reduce((sum, r) => sum + r.amount, 0); + } + + private windowStart(windowType: string): Date { + const now = new Date(); + switch (windowType) { + case 'daily': + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), -8, 0, 0)); // UTC+8 + case 'monthly': + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, -8, 0, 0)); + case 'lifetime': + default: + return new Date(0); + } + } +} diff --git a/src/modules/membership/membership.controller.ts b/src/modules/membership/membership.controller.ts new file mode 100644 index 0000000..1848ffd --- /dev/null +++ b/src/modules/membership/membership.controller.ts @@ -0,0 +1,106 @@ +import { Controller, Get, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { EffectiveMembershipService } from './effective-membership.service'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@ApiTags('membership') +@Controller('membership') +export class MembershipController { + constructor( + private readonly service: EffectiveMembershipService, + private readonly prisma: PrismaService, + ) {} + + /** + * GET /membership/plans + * 返回可用计划列表(含权益摘要)。不返回硬编码价格。 + */ + @Get('plans') + @ApiOperation({ summary: '可用会员计划列表' }) + async getPlans() { + const plans = await this.prisma.membershipPlan.findMany({ + where: { isActive: true }, + select: { + code: true, name: true, + maxKnowledgeBases: true, maxStorageBytes: true, maxFileSizeBytes: true, + monthlyOcrPages: true, monthlyVisionPages: true, monthlyChatCount: true, + monthlyAiAnalysisCount: true, monthlyRecallCount: true, + monthlyCardGenCount: true, monthlyQuizCount: true, + priceMonthly: true, priceYearly: true, + }, + orderBy: { priceMonthly: 'asc' }, + }); + + return { + plans: plans.map(p => ({ + planCode: p.code, + name: p.name, + entitlements: { + maxKnowledgeBases: p.maxKnowledgeBases, + maxStorageBytes: Number(p.maxStorageBytes), + maxFileSizeBytes: Number(p.maxFileSizeBytes), + monthlyOcrPages: p.monthlyOcrPages, + monthlyVisionPages: p.monthlyVisionPages, + monthlyChatCount: p.monthlyChatCount, + monthlyAiAnalysisCount: p.monthlyAiAnalysisCount, + monthlyRecallCount: p.monthlyRecallCount, + monthlyCardGenCount: p.monthlyCardGenCount, + monthlyQuizCount: p.monthlyQuizCount, + }, + priceMonthly: p.priceMonthly, + priceYearly: p.priceYearly, + })), + }; + } + + /** + * GET /membership/me + * 返回当前用户有效会员、Grant 摘要、appAccountToken。 + * 不返回完整 transactionId/originalTransactionId。 + */ + @Get('me') + @ApiOperation({ summary: '当前会员状态' }) + async getMyMembership(@Req() req: any) { + const userId = req.user.id; + const membership = await this.service.compute(userId); + const entitlements = await this.service.getEntitlements(userId); + + return { + effectivePlan: membership.effectivePlan, + grants: membership.grants.map(g => ({ + source: g.source, status: g.status, + planCode: g.planCode, planName: g.planName, + startedAt: g.startedAt, expiresAt: g.expiresAt, + autoRenewStatus: g.autoRenewStatus, + environment: g.environment, + })), + appAccountToken: membership.appAccountToken, + entitlements: entitlements.entitlements, + usageSummary: { + // 由 Issue 07-08 填充实际使用量 + used: null, + remaining: null, + nextRefresh: null, + }, + }; + } + + /** + * GET /membership/usage + * 返回当前有效 Plan 的额度摘要。实际使用量由 Issue 07-08 填充。 + */ + @Get('usage') + @ApiOperation({ summary: '当前额度使用情况' }) + async getUsage(@Req() req: any) { + const entitlements = await this.service.getEntitlements(req.user.id); + + return { + planCode: entitlements.planCode, + quota: entitlements.entitlements, + used: null, + remaining: null, + window: null, + nextRefresh: null, + }; + } +} diff --git a/src/modules/membership/membership.module.ts b/src/modules/membership/membership.module.ts new file mode 100644 index 0000000..c0ed0a9 --- /dev/null +++ b/src/modules/membership/membership.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MembershipController } from './membership.controller'; +import { EffectiveMembershipService } from './effective-membership.service'; +import { AppStoreJwsVerifier } from './apple/app-store-jws-verifier'; +import { AppStoreTransactionService } from './apple/app-store-transaction.service'; +import { AppStoreMembershipController } from './apple/app-store-membership.controller'; +import { AppStoreNotificationController } from './apple/app-store-notification.controller'; +import { AppStoreNotificationService } from './apple/app-store-notification.service'; +import { EffectiveQuotaService } from './effective-quota.service'; +import { QuotaGuardService } from './quota-guard.service'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Module({ + controllers: [ + MembershipController, + AppStoreMembershipController, + AppStoreNotificationController, + ], + providers: [ + EffectiveMembershipService, + EffectiveQuotaService, + QuotaGuardService, + AppStoreJwsVerifier, + AppStoreTransactionService, + AppStoreNotificationService, + PrismaService, + ConfigService, + ], + exports: [EffectiveMembershipService, EffectiveQuotaService, QuotaGuardService], +}) +export class MembershipModule {} diff --git a/src/modules/membership/quota-guard.service.ts b/src/modules/membership/quota-guard.service.ts new file mode 100644 index 0000000..1e6cedb --- /dev/null +++ b/src/modules/membership/quota-guard.service.ts @@ -0,0 +1,132 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EffectiveQuotaService } from './effective-quota.service'; + +/** + * M-MEMBER-01-08: QuotaGuardService + * + * 为 13 项业务能力提供统一的额度检查入口。 + * 每个能力独立支持 legacy/shadow/enforced。 + */ + +export interface QuotaCheckResult { + allowed: boolean; + quotaType: string; + limit: number; + used: number; + remaining: number; + resetAt: string | null; + upgradeAvailable: boolean; +} + +export interface QuotaErrorResponse { + errorCode: string; + message: string; + quotaType: string; + limit: number; + used: number; + remaining: number; + resetAt: string | null; + upgradeAvailable: boolean; +} + +@Injectable() +export class QuotaGuardService { + private readonly logger = new Logger(QuotaGuardService.name); + + constructor(private readonly quotaService: EffectiveQuotaService) {} + + /** + * 检查用户某项额度。 + * + * @returns { allowed, quotaType, limit, used, remaining } + * allowed=false 时调用方应抛出 QuotaExceededException。 + */ + async check( + userId: string, + quotaType: string, + operationId: string, + amount: number = 1, + ): Promise { + const result = await this.quotaService.reserve(userId, quotaType, operationId, amount); + + const resetAt = this.computeResetAt(quotaType); + + return { + allowed: result.allowed, + quotaType: result.quotaType, + limit: result.limit, + used: result.used, + remaining: result.remaining, + resetAt, + upgradeAvailable: result.limit > 0 && !result.allowed, + }; + } + + /** + * 成功结算后确认额度。 + */ + async confirm(operationId: string) { + await this.quotaService.commit(operationId); + } + + /** + * 失败/取消时释放预占。 + */ + async cancel(operationId: string) { + await this.quotaService.release(operationId); + } + + /** + * 构建统一错误响应。 + */ + buildError(result: QuotaCheckResult): QuotaErrorResponse { + const errorCode = this.errorCodeForType(result.quotaType); + return { + errorCode, + message: `${result.quotaType} quota exceeded (${result.used}/${result.limit})`, + quotaType: result.quotaType, + limit: result.limit, + used: result.used, + remaining: result.remaining, + resetAt: result.resetAt, + upgradeAvailable: result.upgradeAvailable, + }; + } + + // ── Private ── + + private errorCodeForType(quotaType: string): string { + switch (quotaType) { + case 'storage_bytes': + return 'STORAGE_LIMIT_EXCEEDED'; + case 'knowledge_base_count': + return 'KNOWLEDGE_BASE_LIMIT_EXCEEDED'; + case 'file_size_bytes': + return 'FILE_SIZE_LIMIT_EXCEEDED'; + default: + return 'QUOTA_EXCEEDED'; + } + } + + private computeResetAt(quotaType: string): string | null { + const now = new Date(); + switch (quotaType) { + case 'active_recall': + case 'feynman_evaluation': + case 'quiz_generation': + case 'learning_analysis_manual': + case 'flashcard_generation': { + const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, -8, 0, 0)); + return tomorrow.toISOString(); + } + case 'ai_chat_messages': + case 'ocr_pages': + case 'vision_pages': { + const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, -8, 0, 0)); + return nextMonth.toISOString(); + } + default: + return null; // lifetime + } + } +} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index dce5e20..e356713 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,6 +1,7 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException, Inject, Optional } from '@nestjs/common'; import { UsersRepository } from './users.repository'; import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { EffectiveMembershipService } from '../membership/effective-membership.service'; const DELETION_COOLING_DAYS = 7; @@ -8,6 +9,8 @@ const DELETION_COOLING_DAYS = 7; export class UsersService { constructor( private readonly usersRepository: UsersRepository, + // M-MEMBER-01-04: 可选注入——避免循环依赖。未注入时回退旧逻辑。 + @Optional() @Inject(EffectiveMembershipService) private readonly membershipService?: EffectiveMembershipService, private readonly prisma: PrismaService, ) {} @@ -20,6 +23,16 @@ export class UsersService { // ── Membership ── async getMembership(userId: string) { + // M-MEMBER-01-04: 优先使用 EffectiveMembershipService + if (this.membershipService) { + const membership = await this.membershipService.compute(userId); + return { + effectivePlan: membership.effectivePlan, + grants: membership.grants, + appAccountToken: membership.appAccountToken, + }; + } + // 回退:旧逻辑(MembershipModule 未加载时) return this.prisma.userMembership.findFirst({ where: { userId, active: true }, include: { plan: true },