api-server/src/modules/membership/effective-membership.service.ts
wangdl 9566de63a2
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
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>
2026-06-26 20:03:03 +08:00

150 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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