- 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>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
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,
|
||
},
|
||
};
|
||
}
|
||
}
|