fix(server): rcat event parse (#13781)

This commit is contained in:
DarkSky
2025-10-20 17:20:41 +08:00
committed by GitHub
parent 5c0e3b8a7f
commit 01c164a78a
2 changed files with 58 additions and 28 deletions

View File

@@ -3,6 +3,8 @@ import { z } from 'zod';
import { Config, EventBus } from '../../../base'; import { Config, EventBus } from '../../../base';
import { Public } from '../../../core/auth'; import { Public } from '../../../core/auth';
import { FeatureService } from '../../../core/features';
import { Models } from '../../../models';
const RcEventSchema = z const RcEventSchema = z
.object({ .object({
@@ -52,7 +54,9 @@ export class RevenueCatWebhookController {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly event: EventBus private readonly event: EventBus,
private readonly models: Models,
private readonly feature: FeatureService
) {} ) {}
@Public() @Public()
@@ -70,28 +74,49 @@ export class RevenueCatWebhookController {
if (parsed.success) { if (parsed.success) {
const event = parsed.data.event; const event = parsed.data.event;
const { id, app_user_id: appUserId, type } = event; const { id, app_user_id: appUserId, type } = event;
if ( if (
event.environment.toLowerCase() === environment?.toLowerCase() event.environment.toLowerCase() === environment?.toLowerCase()
) { ) {
const logParams = {
appUserId,
familyShare: event.is_family_share,
environment: event.environment,
};
this.logger.log( this.logger.log(
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.` `[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.`
); );
if (appUserId) {
if ( const user = await this.models.user.get(appUserId);
appUserId && if (user) {
(typeof event.is_family_share !== 'boolean' || if (
!event.is_family_share) (typeof event.is_family_share !== 'boolean' ||
) { !event.is_family_share) &&
// immediately ack and process asynchronously (environment.toLowerCase() === 'production' ||
this.event this.feature.isStaff(user.email))
.emitAsync('revenuecat.webhook', { appUserId, event }) ) {
.catch((e: Error) => { // immediately ack and process asynchronously
this.logger.error( this.event
'Failed to handle RevenueCat Webhook event.', .emitAsync('revenuecat.webhook', { appUserId, event })
e .catch((e: Error) => {
this.logger.error(
'Failed to handle RevenueCat Webhook event.',
e
);
});
return;
} else {
this.logger.warn(
`[${id}] RevenueCat Webhook received for non-acceptable params.`,
logParams
); );
}); }
}
} }
this.logger.warn(
`RevenueCat Webhook received for unknown user`,
logParams
);
} }
} else { } else {
this.logger.warn( this.logger.warn(

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { z } from 'zod'; import { z } from 'zod';
import { Config } from '../../../base'; import { Config } from '../../../base';
@@ -34,7 +34,7 @@ const zRcV2RawEntitlementItem = z
products: z products: z
.object({ items: z.array(zRcV2RawProduct).default([]) }) .object({ items: z.array(zRcV2RawProduct).default([]) })
.partial() .partial()
.nullable(), .nullish(),
}) })
.passthrough(); .passthrough();
@@ -75,7 +75,7 @@ const zRcV2RawEnvelope = z
.object({ .object({
app_user_id: z.string().optional(), app_user_id: z.string().optional(),
id: z.string().optional(), id: z.string().optional(),
subscriptions: z.array(zRcV2RawSubscription).default([]), items: z.array(zRcV2RawSubscription).default([]),
}) })
.passthrough(); .passthrough();
@@ -96,6 +96,8 @@ export type Subscription = z.infer<typeof Subscription>;
@Injectable() @Injectable()
export class RevenueCatService { export class RevenueCatService {
private readonly logger = new Logger(RevenueCatService.name);
constructor(private readonly config: Config) {} constructor(private readonly config: Config) {}
private get apiKey(): string { private get apiKey(): string {
@@ -132,17 +134,15 @@ export class RevenueCatService {
); );
} }
const envParsed = zRcV2RawEnvelope.safeParse(await res.json()); const json = await res.json();
const envParsed = zRcV2RawEnvelope.safeParse(json);
if (envParsed.success) { if (envParsed.success) {
return envParsed.data.subscriptions return envParsed.data.items
.flatMap(sub => { .flatMap(sub => {
const items = sub.entitlements.items ?? []; const items = sub.entitlements.items ?? [];
return items.map(ent => { return items.map(ent => {
const product = ent.products?.items?.[0]; const product = ent.products?.items?.[0];
if (!product) {
return null;
}
return { return {
identifier: ent.lookup_key, identifier: ent.lookup_key,
isTrial: sub.status === 'trialing', isTrial: sub.status === 'trialing',
@@ -151,20 +151,25 @@ export class RevenueCatService {
sub.status === 'active' || sub.status === 'active' ||
sub.status === 'trialing', sub.status === 'trialing',
latestPurchaseDate: sub.starts_at latestPurchaseDate: sub.starts_at
? new Date(sub.starts_at * 1000) ? new Date(sub.starts_at)
: null, : null,
expirationDate: sub.current_period_ends_at expirationDate: sub.current_period_ends_at
? new Date(sub.current_period_ends_at * 1000) ? new Date(sub.current_period_ends_at)
: null, : null,
productId: product.store_identifier, productId: product?.store_identifier,
store: sub.store ?? product.app.type, store: sub.store ?? product?.app.type,
willRenew: sub.auto_renewal_status === 'will_renew', willRenew: sub.auto_renewal_status === 'will_renew',
duration: product.subscription?.duration ?? null, duration: product?.subscription?.duration ?? null,
}; };
}); });
}) })
.filter((s): s is Subscription => s !== null); .filter((s): s is Subscription => s !== null);
} }
this.logger.error(
`RevenueCat subscription parse failed: ${JSON.stringify(
envParsed.error.format()
)}`
);
return null; return null;
} }
} }