mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 17:46:18 +08:00
feat(server): improve subscription sync stability (#14703)
This commit is contained in:
@@ -37,7 +37,10 @@ import {
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import {
|
||||
AccessController,
|
||||
WorkspacePolicyService,
|
||||
} from '../../../core/permission';
|
||||
import {
|
||||
ContextBlob,
|
||||
ContextCategories,
|
||||
@@ -408,6 +411,7 @@ export class CopilotContextRootResolver {
|
||||
export class CopilotContextResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly models: Models,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly context: CopilotContextService,
|
||||
@@ -667,6 +671,12 @@ export class CopilotContextResolver {
|
||||
const blobId = createHash('sha256').update(buffer).digest('base64url');
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(session.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
await this.policy.assertCanUploadBlob(user.id, session.workspaceId);
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(
|
||||
blobId,
|
||||
|
||||
@@ -37,7 +37,11 @@ import {
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { Admin } from '../../core/common';
|
||||
import { DocReader } from '../../core/doc';
|
||||
import { AccessController, DocAction } from '../../core/permission';
|
||||
import {
|
||||
AccessController,
|
||||
DocAction,
|
||||
WorkspacePolicyService,
|
||||
} from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import type { ListSessionOptions, UpdateChatSession } from '../../models';
|
||||
import { processImage } from '../../native';
|
||||
@@ -378,6 +382,7 @@ export class CopilotResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly prompt: PromptService,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly storage: CopilotStorage,
|
||||
@@ -778,6 +783,10 @@ export class CopilotResolver {
|
||||
delete options.blob;
|
||||
delete options.blobs;
|
||||
|
||||
if (blobs.length) {
|
||||
await this.policy.assertCanUploadBlob(user.id, workspaceId);
|
||||
}
|
||||
|
||||
for (const blob of blobs) {
|
||||
const uploaded = await this.storage.handleUpload(user.id, blob);
|
||||
const detectedMime =
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import {
|
||||
AccessController,
|
||||
WorkspacePolicyService,
|
||||
} from '../../../core/permission';
|
||||
import { WorkspaceType } from '../../../core/workspaces';
|
||||
import { COPILOT_LOCKER } from '../resolver';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../utils';
|
||||
@@ -72,6 +75,7 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly copilotWorkspace: CopilotWorkspaceService
|
||||
) {}
|
||||
|
||||
@@ -215,10 +219,11 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
|
||||
@Args('fileId', { type: () => String })
|
||||
fileId: string
|
||||
): Promise<boolean> {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Settings.Update');
|
||||
await this.policy.assertWorkspaceRoleAction(
|
||||
user.id,
|
||||
workspaceId,
|
||||
'Workspace.Settings.Update'
|
||||
);
|
||||
|
||||
return await this.copilotWorkspace.removeFile(workspaceId, fileId);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
UserFriendlyError,
|
||||
WorkspaceLicenseAlreadyExists,
|
||||
} from '../../base';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { Models } from '../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
@@ -59,7 +60,8 @@ export class LicenseService {
|
||||
private readonly db: PrismaClient,
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models,
|
||||
private readonly crypto: CryptoHelper
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
@@ -83,6 +85,7 @@ export class LicenseService {
|
||||
workspaceId,
|
||||
quantity,
|
||||
});
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -96,7 +99,7 @@ export class LicenseService {
|
||||
}: Events['workspace.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.SelfHostedTeam:
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
await this.policy.handleTeamPlanCanceled(workspaceId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { URLHelper } from '../../../base';
|
||||
import { InvalidOauthResponse, URLHelper } from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import type { OAuthState } from '../types';
|
||||
import { OAuthAccount, OAuthProvider, Tokens } from './def';
|
||||
@@ -13,11 +13,17 @@ interface AuthTokenResponse {
|
||||
|
||||
export interface UserInfo {
|
||||
login: string;
|
||||
email: string;
|
||||
email: string | null;
|
||||
avatar_url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserEmailInfo {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GithubOAuthProvider extends OAuthProvider {
|
||||
provider = OAuthProviderName.GitHub;
|
||||
@@ -30,7 +36,7 @@ export class GithubOAuthProvider extends OAuthProvider {
|
||||
return `https://github.com/login/oauth/authorize?${this.url.stringify({
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
scope: 'user',
|
||||
scope: 'read:user user:email',
|
||||
...this.config.args,
|
||||
state,
|
||||
})}`;
|
||||
@@ -56,16 +62,36 @@ export class GithubOAuthProvider extends OAuthProvider {
|
||||
async getUser(tokens: Tokens, _state: OAuthState): Promise<OAuthAccount> {
|
||||
const user = await this.fetchJson<UserInfo>('https://api.github.com/user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
|
||||
const email = user.email ?? (await this.getVerifiedEmail(tokens));
|
||||
if (!email) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'GitHub account did not have a verified email address.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.login,
|
||||
avatarUrl: user.avatar_url,
|
||||
email: user.email,
|
||||
email,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
private async getVerifiedEmail(tokens: Tokens) {
|
||||
const emails = await this.fetchJson<UserEmailInfo[]>(
|
||||
'https://api.github.com/user/emails',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
emails.find(email => email.primary && email.verified)?.email ??
|
||||
emails.find(email => email.verified)?.email
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
@@ -18,6 +18,7 @@ declare global {
|
||||
'nightly.cleanExpiredOnetimeSubscriptions': {};
|
||||
'nightly.notifyAboutToExpireWorkspaceSubscriptions': {};
|
||||
'nightly.reconcileRevenueCatSubscriptions': {};
|
||||
'nightly.reconcileStripeSubscriptions': {};
|
||||
'nightly.reconcileStripeRefunds': {};
|
||||
'nightly.revenuecat.syncUser': { userId: string };
|
||||
}
|
||||
@@ -25,6 +26,8 @@ declare global {
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionCronJobs {
|
||||
private readonly logger = new Logger(SubscriptionCronJobs.name);
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly event: EventBus,
|
||||
@@ -61,6 +64,12 @@ export class SubscriptionCronJobs {
|
||||
{ jobId: 'nightly-payment-reconcile-revenuecat-subscriptions' }
|
||||
);
|
||||
|
||||
await this.queue.add(
|
||||
'nightly.reconcileStripeSubscriptions',
|
||||
{},
|
||||
{ jobId: 'nightly-payment-reconcile-stripe-subscriptions' }
|
||||
);
|
||||
|
||||
await this.queue.add(
|
||||
'nightly.reconcileStripeRefunds',
|
||||
{},
|
||||
@@ -202,6 +211,48 @@ export class SubscriptionCronJobs {
|
||||
await this.rcHandler.syncAppUser(payload.userId);
|
||||
}
|
||||
|
||||
@OnJob('nightly.reconcileStripeSubscriptions')
|
||||
async reconcileStripeSubscriptions() {
|
||||
const stripe = this.stripeFactory.stripe;
|
||||
const subs = await this.db.subscription.findMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: { not: null },
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { stripeSubscriptionId: true },
|
||||
});
|
||||
|
||||
const subscriptionIds = Array.from(
|
||||
new Set(
|
||||
subs
|
||||
.map(sub => sub.stripeSubscriptionId)
|
||||
.filter((id): id is string => !!id)
|
||||
)
|
||||
);
|
||||
|
||||
for (const subscriptionId of subscriptionIds) {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
subscriptionId,
|
||||
{ expand: ['customer'] }
|
||||
);
|
||||
await this.subscription.saveStripeSubscription(subscription);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to reconcile stripe subscription ${subscriptionId}`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('nightly.reconcileStripeRefunds')
|
||||
async reconcileStripeRefunds() {
|
||||
const stripe = this.stripeFactory.stripe;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EventBus, OnEvent } from '../../base';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from './types';
|
||||
@@ -10,7 +11,8 @@ export class PaymentEventHandlers {
|
||||
constructor(
|
||||
private readonly workspace: WorkspaceService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
private readonly event: EventBus,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
@@ -40,6 +42,7 @@ export class PaymentEventHandlers {
|
||||
// we only send emails when the team workspace is activated
|
||||
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
|
||||
}
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -54,7 +57,7 @@ export class PaymentEventHandlers {
|
||||
}: Events['workspace.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.Team:
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
await this.policy.handleTeamPlanCanceled(workspaceId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -81,6 +84,7 @@ export class PaymentEventHandlers {
|
||||
recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1',
|
||||
'subscription activated'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -105,6 +109,7 @@ export class PaymentEventHandlers {
|
||||
'free_plan_v1',
|
||||
'lifetime subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -121,6 +126,7 @@ export class PaymentEventHandlers {
|
||||
'free_plan_v1',
|
||||
'subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user