From c7e6d154562ac3e48e6c8c0ed5d02a6164568f3e Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 7 Apr 2023 11:39:53 +0800 Subject: [PATCH] feat(server): add account transaction (#1014) --- server/prisma/schema.prisma | 72 ++++++++++- server/src/account/account.controller.ts | 18 ++- server/src/auth/auth.controller.ts | 53 +------- server/src/auth/auth.module.ts | 2 - server/src/auth/auth.service.ts | 80 +----------- server/src/auth/casdoor.service.ts | 120 ------------------ server/src/constants.ts | 28 ---- server/src/region/bundle.service.ts | 6 +- .../dto/upgrade-subscription.dto.ts | 12 +- .../src/subscription/renewal-task.service.ts | 17 ++- .../subscription/subscription.controller.ts | 67 +++++++++- 11 files changed, 176 insertions(+), 299 deletions(-) delete mode 100644 server/src/auth/casdoor.service.ts diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1c11587a34..58953cb8ff 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -1,10 +1,36 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema +// These models cover multiple aspects, such as subscriptions, accounts, applications, +// storage, databases, cloud functions, gateways, and SMS verification codes. +// Here's a brief description: +// +// 1. Subscription models (Subscription and SubscriptionRenewal): Represent the state +// and plans of subscriptions and their renewals. +// 2. Account models (Account and AccountChargeOrder): Track account balances and +// recharge records. +// 3. Application models (Application and ApplicationConfiguration): Represent +// application configurations and states. +// 4. Storage models (StorageUser and StorageBucket): Represent the state and policies +// of storage users and buckets. +// 5. Database models (Database, DatabasePolicy, and DatabasePolicyRule): Represent the +// state, policies, and rules of databases. +// 6. Cloud Function models (CloudFunction and CronTrigger): Represent the configuration +// and state of cloud functions and scheduled triggers. +// 7. Gateway models (RuntimeDomain, BucketDomain, and WebsiteHosting): Represent the +// state and configuration of runtime domains, bucket domains, and website hosting. +// 8. Authentication provider models (AuthProvider): Represent the configuration and state +// of authentication providers. +// 9. SMS verification code models (SmsVerifyCode): Represent the type, state, and +// related information of SMS verification codes. +// +// These models together form a complete cloud service system, covering subscription +// management, account management, application deployment, storage management, database +// management, cloud function deployment and execution, gateway configuration, and SMS +// verification, among other functionalities. + generator client { provider = "prisma-client-js" - // previewFeatures = ["interactiveTransactions"] - // binaryTargets = ["native", "linux-arm64-openssl-1.1.x"] binaryTargets = ["native"] } @@ -212,6 +238,17 @@ model Runtime { // subscriptions schemas +// Subscription section mainly consists of two models: Subscription and SubscriptionRenewal. +// +// 1. Subscription: Represents the state, phase, and renewal plan of a subscription. It includes +// the created, updated, and deleted states (SubscriptionState enum); the pending, valid, expired, +// expired and stopped, and deleted phases (SubscriptionPhase enum); and manual, monthly, or +// yearly renewal plans (SubscriptionRenewalPlan enum). This model also contains the associated +// application (Application). +// +// 2. SubscriptionRenewal: Represents the state, duration, and amount of a subscription renewal. +// It includes the pending, paid, and failed renewal phases (SubscriptionRenewalPhase enum). + enum SubscriptionState { Created Deleted @@ -274,6 +311,27 @@ model SubscriptionRenewal { createdBy String @db.ObjectId } +// desired state of resource +enum SubscriptionUpgradePhase { + Pending + Completed + Failed +} + +model SubscriptionUpgrade { + id String @id @default(auto()) @map("_id") @db.ObjectId + appid String + subscriptionId String + originalBundleId String @db.ObjectId + targetBundleId String @db.ObjectId + phase SubscriptionUpgradePhase @default(Pending) + restart Boolean @default(false) + message String? + lockedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // accounts schemas model Account { @@ -285,6 +343,16 @@ model Account { createdBy String @unique @db.ObjectId } +model AccountTransaction { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + balance Int + message String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + enum AccountChargePhase { Pending Paid diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index 390fc6e93e..c8f00553ad 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Get, - HttpCode, Logger, Param, Post, @@ -22,6 +21,7 @@ import { PaymentChannelService } from './payment/payment-channel.service' import { WeChatPayOrderResponse, WeChatPayTradeState } from './payment/types' import { WeChatPayService } from './payment/wechat-pay.service' import { Response } from 'express' +import * as assert from 'assert' @ApiTags('Account') @Controller('accounts') @@ -160,12 +160,28 @@ export class AccountController { return } + // get account + const account = await tx.account.findFirst({ + where: { id: order.accountId }, + }) + assert(account, `account not found ${order.accountId}`) + // update account balance await tx.account.update({ where: { id: order.accountId }, data: { balance: { increment: order.amount } }, }) + // create account transaction + await tx.accountTransaction.create({ + data: { + accountId: order.accountId, + amount: order.amount, + balance: order.amount + account.balance, + message: 'account charge', + }, + }) + this.logger.log(`wechatpay order success: ${tradeOrderId}`) }) } catch (err) { diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index d5895d31ec..d1b4d03cbb 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,20 +1,10 @@ -import { - Body, - Controller, - Get, - Post, - Query, - Req, - Res, - UseGuards, -} from '@nestjs/common' +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, } from '@nestjs/swagger' -import { Response } from 'express' import { ApiResponseUtil, ResponseUtil } from '../utils/response' import { IRequest } from '../utils/interface' import { UserDto } from '../user/dto/user.response' @@ -27,47 +17,6 @@ import { Pat2TokenDto } from './dto/pat2token.dto' export class AuthController { constructor(private readonly authService: AuthService) {} - /** - * Redirect to login page - */ - @ApiResponse({ status: 302 }) - @ApiOperation({ summary: 'Redirect to login page' }) - @Get('login') - async getSigninUrl(@Res() res: Response): Promise { - const url = this.authService.getSignInUrl() - return res.redirect(url) - } - - /** - * Redirect to register page - * @param res - * @returns - */ - @ApiResponse({ status: 302 }) - @ApiOperation({ summary: 'Redirect to register page' }) - @Get('register') - async getSignupUrl(@Res() res: Response) { - const url = this.authService.getSignUpUrl() - return res.redirect(url) - } - - /** - * Get user token by auth code - * @param code - * @returns - */ - @ApiOperation({ summary: 'Get user token by auth code' }) - @ApiResponse({ type: ResponseUtil }) - @Get('code2token') - async code2token(@Query('code') code: string) { - const token = await this.authService.code2token(code) - if (!token) { - return ResponseUtil.error('invalid code') - } - - return ResponseUtil.ok(token) - } - /** * Get user token by PAT * @param pat diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index a10301bee3..2291d03e9f 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -5,7 +5,6 @@ import { PassportModule } from '@nestjs/passport' import { ServerConfig } from '../constants' import { UserModule } from '../user/user.module' import { AuthService } from './auth.service' -import { CasdoorService } from './casdoor.service' import { JwtStrategy } from './jwt.strategy' import { AuthController } from './auth.controller' import { HttpModule } from '@nestjs/axios' @@ -31,7 +30,6 @@ import { AuthenticationService } from './authentication.service' providers: [ AuthService, JwtStrategy, - CasdoorService, PatService, UserPasswordService, PhoneService, diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 3f93f226a1..11f5e6cb99 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,9 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' -import { CasdoorService } from './casdoor.service' import { User } from '@prisma/client' -import { UserService } from '../user/user.service' -import { ServerConfig } from '../constants' import * as assert from 'node:assert' import { PatService } from 'src/user/pat.service' @@ -12,66 +9,9 @@ export class AuthService { logger: Logger = new Logger(AuthService.name) constructor( private readonly jwtService: JwtService, - private readonly casdoorService: CasdoorService, - private readonly userService: UserService, private readonly patService: PatService, ) {} - /** - * Get user token by casdoor code: - * - code is casdoor code - * - user token is laf server token, NOT casdoor token - * @param code - * @returns - */ - async code2token(code: string): Promise { - try { - const casdoorUser = await this.casdoorService.code2user(code) - if (!casdoorUser) return null - - // Get or create laf user - assert(casdoorUser.id, 'casdoor user id is empty') - const profile = await this.userService.getProfileByOpenid(casdoorUser.id) - let user = profile?.user - if (!user) { - user = await this.userService.create({ - username: casdoorUser.username, - email: casdoorUser.email, - phone: casdoorUser.phone, - profile: { - create: { - openid: casdoorUser.id, - name: casdoorUser.displayName, - avatar: casdoorUser.avatar, - from: ServerConfig.CASDOOR_CLIENT_ID, - }, - }, - }) - } else { - // update it - user = await this.userService.updateUser({ - where: { id: user.id }, - data: { - username: casdoorUser.username, - email: casdoorUser.email, - phone: casdoorUser.phone, - profile: { - update: { - name: casdoorUser.displayName, - avatar: casdoorUser.avatar, - }, - }, - }, - }) - } - - return this.getAccessTokenByUser(user) - } catch (error) { - this.logger.error(error) - return null - } - } - /** * Get token by PAT * @param user @@ -94,26 +34,8 @@ export class AuthService { * @returns */ getAccessTokenByUser(user: User): string { - const payload = { - sub: user.id, - } + const payload = { sub: user.id } const token = this.jwtService.sign(payload) return token } - - /** - * Generate login url - * @returns - */ - getSignInUrl(): string { - return this.casdoorService.getSignInUrl() - } - - /** - * Generate register url - * @returns - */ - getSignUpUrl(): string { - return this.casdoorService.getSignUpUrl() - } } diff --git a/server/src/auth/casdoor.service.ts b/server/src/auth/casdoor.service.ts deleted file mode 100644 index 841c8ade2c..0000000000 --- a/server/src/auth/casdoor.service.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { HttpService } from '@nestjs/axios' -import { Injectable, Logger } from '@nestjs/common' -import * as querystring from 'node:querystring' -import { ServerConfig } from '../constants' - -@Injectable() -export class CasdoorService { - private logger = new Logger() - constructor(private readonly httpService: HttpService) {} - - /** - * Get user from code directly - * @param code - * @returns - */ - async code2user(code: string) { - const token = await this.code2token(code) - const user = await this.getUserInfo(token) - return user - } - - /** - * Get user token by code - * @param code - * @returns - */ - async code2token(code: string): Promise { - try { - const url = `${ServerConfig.CASDOOR_ENDPOINT}/api/login/oauth/access_token` - const res = await this.httpService.axiosRef.post(url, { - client_id: ServerConfig.CASDOOR_CLIENT_ID, - client_secret: ServerConfig.CASDOOR_CLIENT_SECRET, - grant_type: 'authorization_code', - code, - }) - - const data = res.data as { - access_token: string - refresh_token: string - id_token: string - token_type: string - expires_in: number - scope: string - } - - return data?.access_token - } catch (error) { - return null - } - } - - /** - * Get user info by token - * @param token - * @returns - */ - async getUserInfo(token: string) { - try { - const url = `${ServerConfig.CASDOOR_ENDPOINT}/api/userinfo` - const res = await this.httpService.axiosRef.get(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - - const data = res.data - const user: CasdoorUserInfo = { - id: data.id || data.sub, - username: data.name, - displayName: data.displayName || data.preferred_username || '', - email: data.email || undefined, - phone: data.phone || undefined, - avatar: data.avatar || data.picture || '', - } - return user - } catch (err) { - this.logger.error(err) - return null - } - } - - /** - * Generate login url - * @returns - */ - getSignInUrl(): string { - const endpoint = ServerConfig.CASDOOR_ENDPOINT - const query = { - client_id: ServerConfig.CASDOOR_CLIENT_ID, - redirect_uri: ServerConfig.CASDOOR_REDIRECT_URI, - response_type: 'code', - scope: 'openid,profile,phone,email', - state: 'casdoor', - } - const encoded_query = querystring.encode(query) - const base_api = `${endpoint}/login/oauth/authorize` - const url = `${base_api}?${encoded_query}` - return url - } - - /** - * Generate register url - * @returns - */ - getSignUpUrl(): string { - const endpoint = ServerConfig.CASDOOR_ENDPOINT - const app_name = ServerConfig.CASDOOR_APP_NAME - const url = `${endpoint}/signup/${app_name}` - return url - } -} - -interface CasdoorUserInfo { - id: string - username: string - email?: string - phone?: string - displayName: string - avatar: string -} diff --git a/server/src/constants.ts b/server/src/constants.ts index 24ad11bdde..f769612125 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -21,34 +21,6 @@ export class ServerConfig { return process.env.JWT_EXPIRES_IN || '7d' } - static get CASDOOR_ENDPOINT() { - return process.env.CASDOOR_ENDPOINT - } - - static get CASDOOR_APP_NAME() { - return process.env.CASDOOR_APP_NAME - } - - static get CASDOOR_CLIENT_ID() { - return process.env.CASDOOR_CLIENT_ID - } - - static get CASDOOR_CLIENT_SECRET() { - return process.env.CASDOOR_CLIENT_SECRET - } - - static get CASDOOR_REDIRECT_URI() { - return process.env.CASDOOR_REDIRECT_URI - } - - static get CASDOOR_PUBLIC_CERT() { - return process.env.CASDOOR_PUBLIC_CERT - } - - static get CASDOOR_ORG_NAME() { - return process.env.CASDOOR_ORG_NAME - } - static get SYSTEM_NAMESPACE() { return process.env.SYSTEM_NAMESPACE || 'laf-system' } diff --git a/server/src/region/bundle.service.ts b/server/src/region/bundle.service.ts index 6652b5c64f..0616a880a3 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/region/bundle.service.ts @@ -8,9 +8,9 @@ export class BundleService { constructor(private readonly prisma: PrismaService) {} - async findOne(id: string) { - return this.prisma.bundle.findUnique({ - where: { id }, + async findOne(id: string, regionId: string) { + return this.prisma.bundle.findFirst({ + where: { id, regionId }, }) } diff --git a/server/src/subscription/dto/upgrade-subscription.dto.ts b/server/src/subscription/dto/upgrade-subscription.dto.ts index db7f0e27f4..4765628cac 100644 --- a/server/src/subscription/dto/upgrade-subscription.dto.ts +++ b/server/src/subscription/dto/upgrade-subscription.dto.ts @@ -1 +1,11 @@ -export class UpgradeSubscriptionDto {} +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator' + +export class UpgradeSubscriptionDto { + @IsString() + @IsNotEmpty() + targetBundleId: string + + @IsBoolean() + @IsNotEmpty() + restart: boolean +} diff --git a/server/src/subscription/renewal-task.service.ts b/server/src/subscription/renewal-task.service.ts index 6a7d5908cd..2047185df3 100644 --- a/server/src/subscription/renewal-task.service.ts +++ b/server/src/subscription/renewal-task.service.ts @@ -5,12 +5,11 @@ import { } from '.prisma/client' import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { times } from 'lodash' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' import { AccountService } from 'src/account/account.service' -import { Subscription } from 'rxjs' +import { Subscription } from '@prisma/client' @Injectable() export class SubscriptionRenewalTaskService { @@ -28,7 +27,7 @@ export class SubscriptionRenewalTaskService { } // Phase `Pending` -> `Paid` - times(this.concurrency, () => this.handlePendingPhase()) + this.handlePendingPhase() } /** @@ -86,7 +85,7 @@ export class SubscriptionRenewalTaskService { if (priceAmount !== 0) { await db.collection('Account').updateOne( { - createdBy: userid, + _id: account._id, balance: { $gte: priceAmount }, }, { $inc: { balance: -priceAmount } }, @@ -94,6 +93,16 @@ export class SubscriptionRenewalTaskService { ) } + // Create account transaction + await db.collection('AccountTransaction').insertOne({ + accountId: account._id, + amount: -priceAmount, + balance: account.balance - priceAmount, + message: `subscription renewal order ${renewal._id}`, + createdAt: new Date(), + updatedAt: new Date(), + }) + // Update subscription 'expiredAt' time await db.collection('Subscription').updateOne( { _id: new ObjectId(renewal.subscriptionId) }, diff --git a/server/src/subscription/subscription.controller.ts b/server/src/subscription/subscription.controller.ts index bad1cec280..f170b93906 100644 --- a/server/src/subscription/subscription.controller.ts +++ b/server/src/subscription/subscription.controller.ts @@ -24,7 +24,7 @@ import { RegionService } from 'src/region/region.service' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { RenewSubscriptionDto } from './dto/renew-subscription.dto' import * as assert from 'assert' -import { SubscriptionPhase } from '@prisma/client' +import { SubscriptionState } from '@prisma/client' import { AccountService } from 'src/account/account.service' @ApiTags('Subscription') @@ -66,7 +66,7 @@ export class SubscriptionController { } // check bundleId exists - const bundle = await this.bundleService.findOne(dto.bundleId) + const bundle = await this.bundleService.findOne(dto.bundleId, region.id) if (!bundle) { return ResponseUtil.error(`bundle ${dto.bundleId} not found`) } @@ -77,7 +77,7 @@ export class SubscriptionController { where: { createdBy: user.id, bundleId: dto.bundleId, - phase: { not: SubscriptionPhase.Deleted }, + state: { not: SubscriptionState.Deleted }, }, }) if (count >= LIMIT_COUNT) { @@ -163,7 +163,11 @@ export class SubscriptionController { return ResponseUtil.error(`subscription ${id} not found`) } - const bundle = await this.bundleService.findOne(subscription.bundleId) + const app = subscription.application + const bundle = await this.bundleService.findOne( + subscription.bundleId, + app.regionId, + ) assert(bundle, `bundle ${subscription.bundleId} not found`) const option = this.bundleService.getSubscriptionOption(bundle, duration) @@ -199,11 +203,60 @@ export class SubscriptionController { /** * TODO: Upgrade a subscription */ - @ApiOperation({ summary: 'Upgrade a subscription (TODO)' }) + @ApiOperation({ summary: 'Upgrade a subscription - TODO' }) @UseGuards(JwtAuthGuard) @Patch(':id/upgrade') - async upgrade(@Param('id') id: string, @Body() dto: UpgradeSubscriptionDto) { - return 'TODO' + async upgrade( + @Param('id') id: string, + @Body() dto: UpgradeSubscriptionDto, + @Req() req: IRequest, + ) { + const { targetBundleId, restart } = dto + + // get subscription + const user = req.user + const subscription = await this.subscriptService.findOne(user.id, id) + if (!subscription) { + return ResponseUtil.error(`subscription ${id} not found`) + } + + // get target bundle + const app = subscription.application + const targetBundle = await this.bundleService.findOne( + targetBundleId, + app.regionId, + ) + if (!targetBundle) { + return ResponseUtil.error(`bundle ${targetBundleId} not found`) + } + + // check bundle is upgradeable + const bundle = await this.bundleService.findOne( + subscription.bundleId, + app.regionId, + ) + assert(bundle, `bundle ${subscription.bundleId} not found`) + + if (bundle.id === targetBundle.id) { + return ResponseUtil.error(`bundle is the same`) + } + + // check if target bundle limit count is reached + const LIMIT_COUNT = targetBundle.limitCountPerUser || 0 + const count = await this.prisma.subscription.count({ + where: { + createdBy: user.id, + bundleId: targetBundle.id, + state: { not: SubscriptionState.Deleted }, + }, + }) + if (count >= LIMIT_COUNT) { + return ResponseUtil.error( + `application count limit is ${LIMIT_COUNT} for bundle ${targetBundle.name}`, + ) + } + + return ResponseUtil.error(`not implemented`) } /**