feat(membership): implement subscription entitlement and quota backend
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
5570c30e0e
commit
9566de63a2
@ -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 中执行
|
||||
@ -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;
|
||||
@ -0,0 +1,3 @@
|
||||
-- M-MEMBER-01-07: QuotaUsage.resource UNIQUE 防并发重复预占
|
||||
ALTER TABLE `QuotaUsage`
|
||||
ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`);
|
||||
@ -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
|
||||
@ -1465,16 +1489,57 @@ model UserMembership {
|
||||
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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, string> = {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
try {
|
||||
let result: any;
|
||||
if (dto.jobType === 'quiz_generation') {
|
||||
return this.quizRouter.generateQuiz(req.user.id, dto);
|
||||
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 (['learning_state_analysis', 'weak_point_analysis', 'next_action_planning'].includes(dto.jobType)) {
|
||||
return this.learningAnalysisRouter.analyze(req.user.id, 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')
|
||||
|
||||
219
src/modules/membership/apple/app-store-jws-verifier.ts
Normal file
219
src/modules/membership/apple/app-store-jws-verifier.ts
Normal file
@ -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<JWTPayload> {
|
||||
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<string>('apple.bundleId', '') || '';
|
||||
}
|
||||
|
||||
getExpectedEnvironment(): string {
|
||||
return this.configService.get<string>('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' };
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
300
src/modules/membership/apple/app-store-notification.service.ts
Normal file
300
src/modules/membership/apple/app-store-notification.service.ts
Normal file
@ -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<void> {
|
||||
// 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<boolean> {
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
225
src/modules/membership/apple/app-store-transaction.service.ts
Normal file
225
src/modules/membership/apple/app-store-transaction.service.ts
Normal file
@ -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<SubmitResult> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const existing = await this.prisma.userMembership.findFirst({
|
||||
where: { originalTransactionId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existing && existing.userId !== userId) {
|
||||
throw new ConflictException('APPLE_SUBSCRIPTION_ALREADY_BOUND');
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/modules/membership/effective-membership.service.spec.ts
Normal file
93
src/modules/membership/effective-membership.service.spec.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
149
src/modules/membership/effective-membership.service.ts
Normal file
149
src/modules/membership/effective-membership.service.ts
Normal file
@ -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<EffectiveMembership> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
192
src/modules/membership/effective-quota.service.ts
Normal file
192
src/modules/membership/effective-quota.service.ts
Normal file
@ -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<EffectiveQuota> {
|
||||
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<ReservationResult> {
|
||||
// 读取限额
|
||||
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<number> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/modules/membership/membership.controller.ts
Normal file
106
src/modules/membership/membership.controller.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
src/modules/membership/membership.module.ts
Normal file
32
src/modules/membership/membership.module.ts
Normal file
@ -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 {}
|
||||
132
src/modules/membership/quota-guard.service.ts
Normal file
132
src/modules/membership/quota-guard.service.ts
Normal file
@ -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<QuotaCheckResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user