feat(membership): implement subscription entitlement and quota backend
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 29s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped

- 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:
wangdl 2026-06-26 20:03:03 +08:00
parent 5570c30e0e
commit 9566de63a2
22 changed files with 1869 additions and 19 deletions

View File

@ -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 中执行

View File

@ -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. AppStoreNotificationDurable 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;

View File

@ -0,0 +1,3 @@
-- M-MEMBER-01-07: QuotaUsage.resource UNIQUE 防并发重复预占
ALTER TABLE `QuotaUsage`
ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`);

View File

@ -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])
}

View File

@ -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,

View File

@ -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],

View File

@ -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();

View File

@ -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) {

View File

@ -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;

View File

@ -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')

View 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
* AppleAuthServiceSign in with Apple
*
* App Store
* Apple Root CA - G3 Apple Computer, Inc. Root
* (https://www.apple.com/certificateauthority/)
*/
// B1Apple 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' };
}
}

View File

@ -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,
};
}
}

View File

@ -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 TokenApple
*/
@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 V2Apple 回调)' })
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 };
}
}

View 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 Inboxreceived 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}`);
}
}

View 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);
// N5rawPayloadHash 输入为原始 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');
}
}
}

View 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');
});
});

View 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-deletedinclude 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,
},
};
}
}

View 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);
}
}
}

View 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,
};
}
}

View 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 {}

View 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
}
}
}

View File

@ -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 },