From 2cf64ec69a78dafa8a4624b0207010684f9d0728 Mon Sep 17 00:00:00 2001 From: maslow Date: Mon, 13 Mar 2023 23:00:22 +0800 Subject: [PATCH 1/3] feat(subscription): impl subscription and account --- .vscode/settings.json | 4 + server/fix-local-envs.sh | 12 + server/package-lock.json | 11 + server/package.json | 2 + server/prisma/schema.prisma | 156 ++++++++-- server/src/account/account.controller.ts | 68 +++++ server/src/account/account.module.ts | 14 + server/src/account/account.service.ts | 80 +++++ .../account/dto/create-charge-order.dto.ts | 15 + .../payment/payment-channel.service.ts | 49 ++++ server/src/account/payment/types.ts | 19 ++ .../src/account/payment/wechat-pay.service.ts | 77 +++++ server/src/app.module.ts | 4 + .../application/application-task.service.ts | 42 +-- .../src/application/application.controller.ts | 75 +---- server/src/application/application.service.ts | 18 +- server/src/auth/auth.module.ts | 3 +- server/src/constants.ts | 6 + server/src/initializer/initializer.service.ts | 14 +- server/src/instance/instance-task.service.ts | 2 +- server/src/region/bundle.service.ts | 7 + server/src/region/region.service.ts | 7 +- .../dto/create-subscription.dto.ts} | 2 +- .../dto/renew-subscription.dto.ts | 9 + .../dto/upgrade-subscription.dto.ts | 1 + .../entities/subscription.entity.ts | 1 + .../src/subscription/renewal-task.service.ts | 158 ++++++++++ .../subscription/subscription-task.service.ts | 273 ++++++++++++++++++ .../subscription/subscription.controller.ts | 222 ++++++++++++++ .../src/subscription/subscription.module.ts | 19 ++ .../src/subscription/subscription.service.ts | 120 ++++++++ server/src/utils/number.ts | 39 +++ server/src/utils/random.ts | 16 + 33 files changed, 1414 insertions(+), 131 deletions(-) create mode 100644 server/fix-local-envs.sh create mode 100644 server/src/account/account.controller.ts create mode 100644 server/src/account/account.module.ts create mode 100644 server/src/account/account.service.ts create mode 100644 server/src/account/dto/create-charge-order.dto.ts create mode 100644 server/src/account/payment/payment-channel.service.ts create mode 100644 server/src/account/payment/types.ts create mode 100644 server/src/account/payment/wechat-pay.service.ts rename server/src/{application/dto/create-application.dto.ts => subscription/dto/create-subscription.dto.ts} (95%) create mode 100644 server/src/subscription/dto/renew-subscription.dto.ts create mode 100644 server/src/subscription/dto/upgrade-subscription.dto.ts create mode 100644 server/src/subscription/entities/subscription.entity.ts create mode 100644 server/src/subscription/renewal-task.service.ts create mode 100644 server/src/subscription/subscription-task.service.ts create mode 100644 server/src/subscription/subscription.controller.ts create mode 100644 server/src/subscription/subscription.module.ts create mode 100644 server/src/subscription/subscription.service.ts create mode 100644 server/src/utils/number.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index be925eac08..eeff4d21d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "aarch", "alipay", "apiextensions", + "apiv", "appid", "automount", "binded", @@ -64,6 +65,7 @@ "lafyun", "languagedetector", "logtostderr", + "mchid", "millicores", "MINIO", "moby", @@ -97,6 +99,8 @@ "urlencode", "userid", "vitepress", + "wechat", + "WECHATPAY", "withs", "xmlparser", "zcube", diff --git a/server/fix-local-envs.sh b/server/fix-local-envs.sh new file mode 100644 index 0000000000..8734c09115 --- /dev/null +++ b/server/fix-local-envs.sh @@ -0,0 +1,12 @@ + +# remove MINIO_CLIENT_PATH line +sed -i '' '/MINIO_CLIENT_PATH/d' .env + +# replace 'mongo.laf-system.svc.cluster.local' with '127.0.0.1' +sed -i '' 's/mongodb-0.mongo.laf-system.svc.cluster.local/127.0.0.1/g' .env + +# replace 'w=majority' with 'w=majority&directConnection=true' +sed -i '' 's/w=majority/w=majority\&directConnection=true/g' .env + +# port forward mongo +kubectl port-forward mongodb-0 27017:27017 -n laf-system \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 4c2244fb0d..8690c36d60 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -28,6 +28,7 @@ "compression": "^1.7.4", "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", + "dayjs": "^1.11.7", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", @@ -9273,6 +9274,11 @@ "lodash.unset": "4.5.2" } }, + "node_modules/dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -24061,6 +24067,11 @@ "lodash.unset": "4.5.2" } }, + "dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/server/package.json b/server/package.json index c0900ead94..e029ff757d 100644 --- a/server/package.json +++ b/server/package.json @@ -6,6 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { + "intercept": "telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env", "prebuild": "npm run generate && rimraf dist", "generate": "prisma generate", "build": "nest build", @@ -43,6 +44,7 @@ "compression": "^1.7.4", "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", + "dayjs": "^1.11.7", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 988131688f..df61610ca9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -109,27 +109,36 @@ type BundleResource { storageCapacity Int // in MB networkTrafficOutbound Int // in MB - limitCountPerUser Int // limit count of application per user could create limitCountOfCloudFunction Int // limit count of cloud function per application limitCountOfBucket Int // limit count of bucket per application limitCountOfDatabasePolicy Int // limit count of database policy per application limitCountOfTrigger Int // limit count of trigger per application limitCountOfWebsiteHosting Int // limit count of website hosting per application + reservedTimeAfterExpired Int // in seconds limitDatabaseTPS Int // limit count of database TPS per application limitStorageTPS Int // limit count of storage TPS per application } -model Bundle { - id String @id @default(auto()) @map("_id") @db.ObjectId +type BundleSubscriptionOption { name String displayName String - regionId String @db.ObjectId - resource BundleResource - priority Int @default(0) - state String @default("Active") // Active, Inactive - price Int @default(0) - specialPrice Int @default(0) + duration Int // in seconds + price Float + specialPrice Float +} + +model Bundle { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + displayName String + regionId String @db.ObjectId + priority Int @default(0) + state String @default("Active") // Active, Inactive + limitCountPerUser Int // limit count of application per user could create + subscriptionOptions BundleSubscriptionOption[] + + resource BundleResource region Region @relation(fields: [regionId], references: [id]) @@ -137,14 +146,12 @@ model Bundle { } model ApplicationBundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - bundleId String @db.ObjectId - name String - displayName String - resource BundleResource - price Int @default(0) - specialPrice Int @default(0) + id String @id @default(auto()) @map("_id") @db.ObjectId + appid String @unique + bundleId String @db.ObjectId + name String + displayName String + resource BundleResource createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -169,6 +176,120 @@ model Runtime { Application Application[] } +// subscriptions schemas + +enum SubscriptionState { + Created + Deleted +} + +enum SubscriptionPhase { + Pending + Valid + Expired + ExpiredAndStopped + Deleted +} + +enum SubscriptionRenewalPlan { + Manual + Monthly + Yearly +} + +type SubscriptionApplicationCreateInput { + name String + state String + runtimeId String + regionId String +} + +model Subscription { + id String @id @default(auto()) @map("_id") @db.ObjectId + input SubscriptionApplicationCreateInput + bundleId String @db.ObjectId + appid String @unique + state SubscriptionState @default(Created) + phase SubscriptionPhase @default(Pending) + renewalPlan SubscriptionRenewalPlan @default(Manual) + expiredAt DateTime + lockedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId + + application Application? +} + +enum SubscriptionRenewalPhase { + Pending + Paid + Failed +} + +model SubscriptionRenewal { + id String @id @default(auto()) @map("_id") @db.ObjectId + subscriptionId String @db.ObjectId + duration Int // in seconds + amount Float + phase SubscriptionRenewalPhase @default(Pending) + message String? + lockedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId +} + +// accounts schemas + +model Account { + id String @id @default(auto()) @map("_id") @db.ObjectId + balance Int @default(0) + state String @default("Active") // Active, Inactive + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @unique @db.ObjectId +} + +enum AccountChargePhase { + Pending + Paid + Failed +} + +model AccountChargeOrder { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Float + phase AccountChargePhase @default(Pending) + channel PaymentChannelType + channelData Json? + message String? + createdAt DateTime @default(now()) + lockedAt DateTime + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId +} + +enum PaymentChannelType { + Manual + Alipay + WeChat + Stripe + Paypal + Google +} + +model PaymentChannel { + id String @id @default(auto()) @map("_id") @db.ObjectId + type PaymentChannelType + name String + spec Json + state String @default("Active") // Active, Inactive + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // application schemas // desired state of application @@ -213,6 +334,7 @@ model Application { database Database? domain RuntimeDomain? bundle ApplicationBundle? + subscription Subscription @relation(fields: [appid], references: [appid]) } type EnvironmentVariable { diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts new file mode 100644 index 0000000000..9446f8b27b --- /dev/null +++ b/server/src/account/account.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Get, + Logger, + Post, + Req, + UseGuards, +} from '@nestjs/common' +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' +import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' +import { IRequest } from 'src/utils/interface' +import { PriceRound } from 'src/utils/number' +import { ResponseUtil } from 'src/utils/response' +import { AccountService } from './account.service' +import { CreateChargeOrderDto } from './dto/create-charge-order.dto' + +@ApiTags('Account') +@Controller('accounts') +@ApiBearerAuth('Authorization') +export class AccountController { + private readonly logger = new Logger(AccountController.name) + + constructor(private readonly accountService: AccountService) {} + + /** + * Get account info + */ + @ApiOperation({ summary: 'Get account info' }) + @UseGuards(JwtAuthGuard) + @Get() + async findOne(@Req() req: IRequest) { + const user = req.user + const data = await this.accountService.findOne(user.id) + data.balance = data.balance / 100 + return data + } + + /** + * Create charge order + */ + @ApiOperation({ summary: 'Create charge order' }) + @UseGuards(JwtAuthGuard) + @Post('charge') + async charge(@Req() req: IRequest, @Body() dto: CreateChargeOrderDto) { + const user = req.user + const amount = PriceRound(dto.amount) + + // invoke payment + const { result, channelData } = await this.accountService.pay( + dto.paymentChannel, + amount, + ) + + // create charge order + const order = await this.accountService.createChargeOrder( + user.id, + amount, + dto.paymentChannel, + channelData, + ) + + return ResponseUtil.ok({ + order, + result, + }) + } +} diff --git a/server/src/account/account.module.ts b/server/src/account/account.module.ts new file mode 100644 index 0000000000..1201e0fcd5 --- /dev/null +++ b/server/src/account/account.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { AccountService } from './account.service' +import { AccountController } from './account.controller' +import { WeChatPaymentService } from './payment/wechat-pay.service' +import { PaymentChannelService } from './payment/payment-channel.service' +import { HttpModule } from '@nestjs/axios' + +@Module({ + imports: [HttpModule], + providers: [AccountService, WeChatPaymentService, PaymentChannelService], + controllers: [AccountController], + exports: [WeChatPaymentService, AccountService, PaymentChannelService], +}) +export class AccountModule {} diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts new file mode 100644 index 0000000000..7b5ed50711 --- /dev/null +++ b/server/src/account/account.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common' +import { PrismaService } from 'src/prisma/prisma.service' +import * as assert from 'assert' +import { AccountChargePhase, PaymentChannelType } from '@prisma/client' +import { WeChatPaymentService } from './payment/wechat-pay.service' +import { PaymentChannelService } from './payment/payment-channel.service' +import { TASK_LOCK_INIT_TIME } from 'src/constants' + +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly wechatPayService: WeChatPaymentService, + private readonly chanelService: PaymentChannelService, + ) {} + + async create(userid: string) { + const account = await this.prisma.account.create({ + data: { + balance: 0, + createdBy: userid, + }, + }) + + return account + } + + async findOne(userid: string) { + const account = await this.prisma.account.findUnique({ + where: { createdBy: userid }, + }) + + if (account) { + return account + } + + return this.create(userid) + } + + async createChargeOrder( + userid: string, + amount: number, + channel: PaymentChannelType, + channelData: any, + ) { + const account = await this.findOne(userid) + assert(account, 'Account not found') + + // create charge order + const order = await this.prisma.accountChargeOrder.create({ + data: { + accountId: account.id, + amount, + phase: AccountChargePhase.Pending, + channel: channel, + channelData: channelData, + createdBy: userid, + lockedAt: TASK_LOCK_INIT_TIME, + }, + }) + + return order + } + + async pay(channel: PaymentChannelType, amount: number) { + if (channel === PaymentChannelType.WeChat) { + const channelSpec = await this.chanelService.getWeChatPaySpec() + const channelData = await this.wechatPayService.getChannelData( + amount, + channelSpec, + ) + const result = await this.wechatPayService.pay(channelData, channelSpec) + return { result, channelData } + } + + throw new Error('Unsupported payment channel') + } +} diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts new file mode 100644 index 0000000000..e16c0a667b --- /dev/null +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger' +import { PaymentChannelType } from '@prisma/client' +import { IsEnum, IsNumber, IsPositive, IsString } from 'class-validator' + +export class CreateChargeOrderDto { + @ApiProperty({ example: 1000 }) + @IsPositive() + @IsNumber() + amount: number + + @ApiProperty() + @IsString() + @IsEnum(PaymentChannelType) + paymentChannel: PaymentChannelType +} diff --git a/server/src/account/payment/payment-channel.service.ts b/server/src/account/payment/payment-channel.service.ts new file mode 100644 index 0000000000..d75e4d5c4c --- /dev/null +++ b/server/src/account/payment/payment-channel.service.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common' +import { PaymentChannelType } from '@prisma/client' +import { PrismaService } from 'src/prisma/prisma.service' +import { WeChatPaymentChannelSpec } from './types' + +@Injectable() +export class PaymentChannelService { + private readonly logger = new Logger(PaymentChannelService.name) + + constructor(private readonly prisma: PrismaService) {} + + /** + * Get all payment channels + * @returns + */ + async findAll() { + const res = await this.prisma.paymentChannel.findMany({ + where: { + state: 'Inactive', + }, + select: { + id: true, + type: true, + name: true, + state: true, + /** + * Security Warning: DO NOT response sensitive information to client. + * KEEP IT false! + */ + spec: false, + }, + }) + return res + } + + async getWeChatPaySpec(): Promise { + const res = await this.prisma.paymentChannel.findFirst({ + where: { + type: PaymentChannelType.WeChat, + }, + }) + + if (!res) { + throw new Error('No WeChat Pay channel found') + } + + return res.spec as any + } +} diff --git a/server/src/account/payment/types.ts b/server/src/account/payment/types.ts new file mode 100644 index 0000000000..7ac5d0b461 --- /dev/null +++ b/server/src/account/payment/types.ts @@ -0,0 +1,19 @@ +export interface WeChatPaymentChannelSpec { + mchid: string + appid: string + apiV3Key: string + certificateSerialNumber: string + privateKey: string +} + +export interface WeChatPaymentRequestBody { + mchid: string + appid: string + description: string + out_trade_no: string + notify_url: string + amount: { + total: number + currency: string + } +} diff --git a/server/src/account/payment/wechat-pay.service.ts b/server/src/account/payment/wechat-pay.service.ts new file mode 100644 index 0000000000..692e900172 --- /dev/null +++ b/server/src/account/payment/wechat-pay.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common' +import { GenerateOrderNumber, GenerateRandomString } from 'src/utils/random' +import { WeChatPaymentChannelSpec, WeChatPaymentRequestBody } from './types' +import * as crypto from 'crypto' +import { PaymentChannelService } from './payment-channel.service' +import { HttpService } from '@nestjs/axios' +import { ServerConfig } from 'src/constants' + +@Injectable() +export class WeChatPaymentService { + static readonly API_BASE_URL = 'https://api.mch.weixin.qq.com' + static readonly API_NATIVE_PAY_URL = '/v3/pay/transactions/native' + + constructor( + private readonly channelService: PaymentChannelService, + private readonly httpService: HttpService, + ) {} + + async pay( + order: WeChatPaymentRequestBody, + channelSpec: WeChatPaymentChannelSpec, + ) { + const timestamp = Math.floor(Date.now() / 1000) + const nonceStr = GenerateRandomString(32) + const signature = this.createSign(timestamp, nonceStr, order, channelSpec) + const serialNo = channelSpec.certificateSerialNumber + + const token = `WECHATPAY2-SHA256-RSA2048 mchid="xxxx",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}",serial_no="${serialNo}"` + const headers = { + Authorization: token, + } + + const apiUrl = `${WeChatPaymentService.API_BASE_URL}${WeChatPaymentService.API_NATIVE_PAY_URL}}` + const res = await this.httpService.axiosRef.post(apiUrl, order, { + headers, + }) + + return res.data + } + + async getChannelData(amount: number, channelSpec: WeChatPaymentChannelSpec) { + const orderNumber = GenerateOrderNumber() + const data: WeChatPaymentRequestBody = { + mchid: channelSpec.mchid, + appid: channelSpec.appid, + description: 'Account charge', + out_trade_no: orderNumber, + notify_url: this.getNotifyUrl(), + amount: { + total: amount, + currency: 'CNY', + }, + } + return data + } + + createSign( + timestamp: number, + nonce_str: string, + order: WeChatPaymentRequestBody, + channelSpec: WeChatPaymentChannelSpec, + ) { + const method = 'POST' + const url = WeChatPaymentService.API_NATIVE_PAY_URL + const orderStr = JSON.stringify(order) + const signStr = `${method}\n${url}\n${timestamp}\n${nonce_str}\n${orderStr}\n` + const cert = channelSpec.privateKey + const sign = crypto.createSign('RSA-SHA256') + sign.update(signStr) + return sign.sign(cert, 'base64') + } + + getNotifyUrl() { + const apiUrl = ServerConfig.API_SERVER_URL + return `${apiUrl}/v1/accounts/payment/wechat/notify` + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 777792ac23..d772c4aba7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -18,6 +18,8 @@ import { TriggerModule } from './trigger/trigger.module' import { RegionModule } from './region/region.module' import { GatewayModule } from './gateway/gateway.module' import { PrismaModule } from './prisma/prisma.module' +import { SubscriptionModule } from './subscription/subscription.module' +import { AccountModule } from './account/account.module'; @Module({ imports: [ @@ -41,6 +43,8 @@ import { PrismaModule } from './prisma/prisma.module' RegionModule, GatewayModule, PrismaModule, + SubscriptionModule, + AccountModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index c353ceccac..11feb72d5c 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -184,16 +184,11 @@ export class ApplicationTaskService { .findOneAndUpdate( { phase: ApplicationPhase.Deleting, - lockedAt: { - $lt: new Date(Date.now() - 1000 * this.lockTimeout), - }, - }, - { - $set: { - lockedAt: new Date(), - }, + lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, + { $set: { lockedAt: new Date() } }, ) + if (!res.value) return // get region by appid @@ -335,16 +330,9 @@ export class ApplicationTaskService { */ async unlock(appid: string) { const db = SystemDatabase.db - await db.collection('Application').updateOne( - { - appid: appid, - }, - { - $set: { - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) + await db + .collection('Application') + .updateOne({ appid: appid }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) } /** @@ -353,17 +341,11 @@ export class ApplicationTaskService { async clearTimeoutLocks() { const db = SystemDatabase.db - await db.collection('Application').updateMany( - { - lockedAt: { - $lt: new Date(Date.now() - 1000 * this.lockTimeout), - }, - }, - { - $set: { - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) + await db + .collection('Application') + .updateMany( + { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) } }, + { $set: { lockedAt: TASK_LOCK_INIT_TIME } }, + ) } } diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index a97f93db27..e4001f8dca 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -1,87 +1,37 @@ import { Controller, Get, - Post, Body, Patch, Param, - Delete, UseGuards, Req, Logger, } from '@nestjs/common' -import { - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger' +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { IRequest } from '../utils/interface' import { JwtAuthGuard } from '../auth/jwt.auth.guard' import { ResponseUtil } from '../utils/response' import { ApplicationAuthGuard } from '../auth/application.auth.guard' -import { CreateApplicationDto } from './dto/create-application.dto' import { UpdateApplicationDto } from './dto/update-application.dto' import { ApplicationService } from './application.service' import { FunctionService } from '../function/function.service' import { StorageService } from 'src/storage/storage.service' import { RegionService } from 'src/region/region.service' -import { BundleService } from 'src/region/bundle.service' -import { PrismaService } from 'src/prisma/prisma.service' @ApiTags('Application') @Controller('applications') @ApiBearerAuth('Authorization') export class ApplicationController { private logger = new Logger(ApplicationController.name) + constructor( private readonly appService: ApplicationService, private readonly funcService: FunctionService, private readonly regionService: RegionService, private readonly storageService: StorageService, - private readonly bundleService: BundleService, - private readonly prisma: PrismaService, ) {} - /** - * Create application - * @returns - */ - @ApiOperation({ summary: 'Create a new application' }) - @UseGuards(JwtAuthGuard) - @Post() - async create(@Body() dto: CreateApplicationDto, @Req() req: IRequest) { - const user = req.user - const error = dto.validate() - if (error) { - return ResponseUtil.error(error) - } - - // check app count limit - const bundle = await this.bundleService.findOne(dto.bundleId) - const LIMIT_COUNT = bundle?.resource?.limitCountPerUser || 0 - const count = await this.prisma.application.count({ - where: { - createdBy: user.id, - bundle: { - bundleId: dto.bundleId, - }, - }, - }) - if (count >= LIMIT_COUNT) { - return ResponseUtil.error( - `application count limit is ${LIMIT_COUNT} for bundle ${bundle.name}`, - ) - } - - // create app - const app = await this.appService.create(user.id, dto) - if (!app) { - return ResponseUtil.error('create app error') - } - return ResponseUtil.ok(app) - } - /** * Get user application list * @param req @@ -110,7 +60,8 @@ export class ApplicationController { domain: true, }) - // [SECURITY ALERT] Do NOT response this region object to client since it contains sensitive information + // SECURITY ALERT!!! + // DO NOT response this region object to client since it contains sensitive information const region = await this.regionService.findOne(data.regionId) // TODO: remove these storage related code to standalone api @@ -185,22 +136,4 @@ export class ApplicationController { } return ResponseUtil.ok(res) } - - /** - * Delete an application - * @returns - */ - @ApiResponse({ type: ResponseUtil }) - @ApiOperation({ summary: 'Delete an application' }) - @Delete(':appid') - @UseGuards(JwtAuthGuard, ApplicationAuthGuard) - async remove(@Param('appid') appid: string) { - const res = await this.appService.remove(appid) - if (res === null) { - this.logger.error('delete application error') - return ResponseUtil.error('delete application error') - } - - return ResponseUtil.ok(res) - } } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 42e4a44f6b..32a235fa59 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import * as nanoid from 'nanoid' -import { CreateApplicationDto } from './dto/create-application.dto' import { ApplicationPhase, ApplicationState, Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { UpdateApplicationDto } from './dto/update-application.dto' @@ -10,13 +9,14 @@ import { TASK_LOCK_INIT_TIME, } from '../constants' import { GenerateAlphaNumericPassword } from '../utils/random' +import { CreateSubscriptionDto } from 'src/subscription/dto/create-subscription.dto' @Injectable() export class ApplicationService { private readonly logger = new Logger(ApplicationService.name) constructor(private readonly prisma: PrismaService) {} - async create(userid: string, dto: CreateApplicationDto) { + async create(userid: string, appid: string, dto: CreateSubscriptionDto) { try { // get bundle const bundle = await this.prisma.bundle.findFirstOrThrow({ @@ -33,11 +33,9 @@ export class ApplicationService { name: APPLICATION_SECRET_KEY, value: GenerateAlphaNumericPassword(64), } - const appid = await this.tryGenerateUniqueAppid() const data: Prisma.ApplicationCreateInput = { name: dto.name, - appid, state: dto.state || ApplicationState.Running, phase: ApplicationPhase.Creating, tags: [], @@ -53,7 +51,6 @@ export class ApplicationService { bundleId: bundle.id, name: bundle.name, displayName: bundle.displayName, - price: bundle.price, resource: { ...bundle.resource }, }, }, @@ -68,6 +65,11 @@ export class ApplicationService { dependencies: [], }, }, + subscription: { + connect: { + appid, + }, + }, } const application = await this.prisma.application.create({ data }) @@ -90,6 +92,12 @@ export class ApplicationService { not: ApplicationPhase.Deleted, }, }, + include: { + region: false, + bundle: true, + runtime: true, + subscription: true, + }, }) } diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 4e75202d10..f112e00d39 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common' +import { Global, Module } from '@nestjs/common' import { JwtModule } from '@nestjs/jwt' import { PassportModule } from '@nestjs/passport' import { ServerConfig } from '../constants' @@ -10,6 +10,7 @@ import { AuthController } from './auth.controller' import { HttpModule } from '@nestjs/axios' import { PatService } from 'src/user/pat.service' +@Global() @Module({ imports: [ PassportModule, diff --git a/server/src/constants.ts b/server/src/constants.ts index 52df22a90f..7e5d135555 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -67,6 +67,10 @@ export class ServerConfig { return process.env.DISABLED_GATEWAY_TASK === 'true' } + static get DISABLED_SUBSCRIPTION_TASK() { + return process.env.DISABLED_GATEWAY_TASK === 'true' + } + static get DISABLED_STORAGE_TASK() { return process.env.DISABLED_STORAGE_TASK === 'true' } @@ -173,7 +177,9 @@ export const MINIO_COMMON_USER_GROUP = 'laf_owner_by_prefix_group' export const MINIO_COMMON_USER_POLICY = 'laf_owner_by_prefix' // Date & times +export const ONE_DAY_IN_SECONDS = 60 * 60 * 24 // 1 day in seconds export const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7 // 7 days in seconds +export const ONE_MONTH_IN_SECONDS = 60 * 60 * 24 * 31 // 31 days in seconds export const FOREVER_IN_SECONDS = 60 * 60 * 24 * 365 * 1000 // 1000 years in seconds export const TASK_LOCK_INIT_TIME = new Date(0) // 1970-01-01 00:00:00 diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index 187e1deb0e..8f41b64ad5 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -77,6 +77,8 @@ export class InitializerService { data: { name: 'standard', displayName: 'Standard', + limitCountPerUser: 10, + priority: 0, resource: { limitCPU: 1 * CPU_UNIT, limitMemory: 512, @@ -87,17 +89,25 @@ export class InitializerService { storageCapacity: 1024 * 5, networkTrafficOutbound: 1024 * 5, - limitCountPerUser: 50, limitCountOfCloudFunction: 500, limitCountOfBucket: 10, limitCountOfDatabasePolicy: 10, limitCountOfTrigger: 10, limitCountOfWebsiteHosting: 10, + reservedTimeAfterExpired: 3600 * 24 * 7, limitDatabaseTPS: 100, limitStorageTPS: 1000, }, - priority: 0, + subscriptionOptions: [ + { + name: 'monthly', + displayName: 'Monthly', + duration: 31 * 24 * 3600, + price: 0, + specialPrice: 0, + }, + ], region: { connect: { name: 'default', diff --git a/server/src/instance/instance-task.service.ts b/server/src/instance/instance-task.service.ts index 18c8b9e702..4a408babf1 100644 --- a/server/src/instance/instance-task.service.ts +++ b/server/src/instance/instance-task.service.ts @@ -397,7 +397,7 @@ export class InstanceTaskService { */ async unlock(appid: string) { const db = SystemDatabase.db - const updated = await db.collection('Application').updateOne( + await db.collection('Application').updateOne( { appid: appid, }, diff --git a/server/src/region/bundle.service.ts b/server/src/region/bundle.service.ts index c2f1b5ab1f..6652b5c64f 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/region/bundle.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' +import { Bundle } from '@prisma/client' import { PrismaService } from 'src/prisma/prisma.service' @Injectable() @@ -35,4 +36,10 @@ export class BundleService { where: { appid }, }) } + + getSubscriptionOption(bundle: Bundle, duration: number) { + const options = bundle.subscriptionOptions + const found = options.find((option) => option.duration === duration) + return found ? found : null + } } diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index fe8e670ae7..1abaad144f 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -29,9 +29,9 @@ export class RegionService { return regions } - async findOneDesensitized(name: string) { + async findOneDesensitized(id: string) { const region = await this.prisma.region.findUnique({ - where: { name }, + where: { id }, select: { id: true, name: true, @@ -65,10 +65,11 @@ export class RegionService { id: true, name: true, displayName: true, - price: true, priority: true, state: true, resource: true, + limitCountPerUser: true, + subscriptionOptions: true, }, }, }, diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/subscription/dto/create-subscription.dto.ts similarity index 95% rename from server/src/application/dto/create-application.dto.ts rename to server/src/subscription/dto/create-subscription.dto.ts index 7a3eb7277f..6b25ae5e2a 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/subscription/dto/create-subscription.dto.ts @@ -7,7 +7,7 @@ enum CreateApplicationState { Stopped = 'Stopped', } -export class CreateApplicationDto { +export class CreateSubscriptionDto { @ApiProperty({ required: true }) @Length(1, 64) @IsNotEmpty() diff --git a/server/src/subscription/dto/renew-subscription.dto.ts b/server/src/subscription/dto/renew-subscription.dto.ts new file mode 100644 index 0000000000..f43bc305e5 --- /dev/null +++ b/server/src/subscription/dto/renew-subscription.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsInt, IsNotEmpty } from 'class-validator' + +export class RenewSubscriptionDto { + @ApiProperty() + @IsInt() + @IsNotEmpty() + duration: number +} diff --git a/server/src/subscription/dto/upgrade-subscription.dto.ts b/server/src/subscription/dto/upgrade-subscription.dto.ts new file mode 100644 index 0000000000..db7f0e27f4 --- /dev/null +++ b/server/src/subscription/dto/upgrade-subscription.dto.ts @@ -0,0 +1 @@ +export class UpgradeSubscriptionDto {} diff --git a/server/src/subscription/entities/subscription.entity.ts b/server/src/subscription/entities/subscription.entity.ts new file mode 100644 index 0000000000..c440a768f3 --- /dev/null +++ b/server/src/subscription/entities/subscription.entity.ts @@ -0,0 +1 @@ +export class Subscription {} diff --git a/server/src/subscription/renewal-task.service.ts b/server/src/subscription/renewal-task.service.ts new file mode 100644 index 0000000000..cca17a3946 --- /dev/null +++ b/server/src/subscription/renewal-task.service.ts @@ -0,0 +1,158 @@ +import { + Account, + SubscriptionRenewal, + SubscriptionRenewalPhase, +} 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 { PriceRound } from 'src/utils/number' + +@Injectable() +export class SubscriptionRenewalTaskService { + readonly lockTimeout = 30 // in second + readonly concurrency = 1 // concurrency count + + private readonly logger = new Logger(SubscriptionRenewalTaskService.name) + + constructor(private readonly accountService: AccountService) {} + + @Cron(CronExpression.EVERY_SECOND) + async tick() { + if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { + return + } + + // Phase `Pending` -> `Paid` + times(this.concurrency, () => this.handlePendingPhase()) + } + + /** + * Phase `Pending`: + * 1. Pay the subscription renewal order from account balance (Transaction) + * 2. Update subscription 'expiredAt' time (Transaction) (lock document) + * 3. Update subscription renewal order phase to ‘Paid’ (Transaction) + */ + async handlePendingPhase() { + const db = SystemDatabase.db + const client = SystemDatabase.client + + const doc = await db + .collection('SubscriptionRenewal') + .findOneAndUpdate( + { + phase: SubscriptionRenewalPhase.Pending, + lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, + }, + { $set: { lockedAt: new Date() } }, + ) + + if (!doc.value) { + return + } + + const renewal = doc.value + + // check account balance + const userid = renewal.createdBy + const account = await this.accountService.findOne(userid.toString()) + + if (account?.balance < renewal.amount) { + return + } + + const session = client.startSession() + await session + .withTransaction(async () => { + // Pay the subscription renewal order from account balance + const priceAmount = Math.round(renewal.amount * 100) + const r0 = await db.collection('Account').updateOne( + { + createdBy: userid, + balance: { + $gte: priceAmount, + }, + }, + { $inc: { balance: -priceAmount } }, + { session }, + ) + + if (r0.modifiedCount === 0) { + throw new Error('Insufficient balance') + } + + // Update subscription 'expiredAt' time + const r1 = await db.collection('Subscription').updateOne( + { _id: new ObjectId(renewal.subscriptionId) }, + [ + { + $set: { + expiredAt: { $add: ['$expiredAt', renewal.duration * 1000] }, + }, + }, + ], + { session }, + ) + + if (r1.modifiedCount === 0) { + throw new Error('Subscription not found') + } + + // Update subscription renewal order phase to ‘Paid’ + const r2 = await db + .collection('SubscriptionRenewal') + .updateOne( + { _id: renewal._id }, + { + $set: { + phase: SubscriptionRenewalPhase.Paid, + lockedAt: TASK_LOCK_INIT_TIME, + }, + }, + { session }, + ) + + if (r2.modifiedCount === 0) { + throw new Error('SubscriptionRenewal not found') + } + }) + .catch((err) => { + this.logger.debug(renewal._id, err.toString()) + }) + } + + @Cron(CronExpression.EVERY_MINUTE) + async handlePendingTimeout() { + const timeout = 30 * 60 * 1000 + + const db = SystemDatabase.db + await db.collection('SubscriptionRenewal').updateMany( + { + phase: SubscriptionRenewalPhase.Pending, + lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, + createdAt: { $lte: new Date(Date.now() - timeout) }, + }, + { + $set: { + phase: SubscriptionRenewalPhase.Failed, + message: `Timeout exceeded ${timeout / 1000} seconds`, + }, + }, + ) + } + + /** + * Unlock subscription + */ + async unlock(id: ObjectId) { + const db = SystemDatabase.db + await db + .collection('SubscriptionRenewal') + .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) + } +} diff --git a/server/src/subscription/subscription-task.service.ts b/server/src/subscription/subscription-task.service.ts new file mode 100644 index 0000000000..3690b38e88 --- /dev/null +++ b/server/src/subscription/subscription-task.service.ts @@ -0,0 +1,273 @@ +import { Application, Subscription, SubscriptionPhase } from '.prisma/client' +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import * as assert from 'node:assert' +import { ApplicationService } from 'src/application/application.service' +import { ApplicationState, SubscriptionState } from '@prisma/client' +import { ObjectId } from 'mongodb' +import { BundleService } from 'src/region/bundle.service' +import { CreateSubscriptionDto } from './dto/create-subscription.dto' + +@Injectable() +export class SubscriptionTaskService { + readonly lockTimeout = 30 // in second + + private readonly logger = new Logger(SubscriptionTaskService.name) + + constructor( + private readonly bundleService: BundleService, + private readonly applicationService: ApplicationService, + ) {} + + @Cron(CronExpression.EVERY_SECOND) + async tick() { + if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { + return + } + + // Phase `Pending` -> `Valid` + this.handlePendingPhaseAndNotExpired() + + // Phase `Valid` -> `Expired` + this.handleValidPhaseAndExpired() + + // Phase `Expired` -> `ExpiredAndStopped` + this.handleExpiredPhase() + + // Phase `ExpiredAndStopped` -> `Valid` + this.handleExpiredAndStoppedPhaseAndNotExpired() + + // Phase `ExpiredAndStopped` -> `Deleted` + this.handleExpiredAndStoppedPhase() + + // State `Deleted` + this.handleDeletedState() + } + + /** + * Phase `Pending` and not expired: + * - if appid is null, generate appid + * - if appid exists, but application is not found + * - create application + * - update subscription phase to `Valid` + */ + async handlePendingPhaseAndNotExpired() { + const db = SystemDatabase.db + + const res = await db + .collection('Subscription') + .findOneAndUpdate( + { + phase: SubscriptionPhase.Pending, + expiredAt: { $gt: new Date() }, + lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, + }, + { $set: { lockedAt: new Date() } }, + ) + if (!res.value) return + + // get region by appid + const doc = res.value + + // if application not found, create application + const application = await this.applicationService.findOne(doc.appid) + if (!application) { + const userid = doc.createdBy.toString() + const dto = new CreateSubscriptionDto() + dto.name = doc.input.name + dto.regionId = doc.input.regionId + dto.state = doc.input.state as ApplicationState + dto.runtimeId = doc.input.runtimeId + dto.bundleId = doc.bundleId + + await this.applicationService.create(userid, doc.appid, dto) + return await this.unlock(doc._id) + } + + // update subscription phase to `Valid` + await db.collection('Subscription').updateOne( + { _id: doc._id }, + { + $set: { phase: SubscriptionPhase.Valid, lockedAt: TASK_LOCK_INIT_TIME }, + }, + ) + } + + /** + * Phase ‘Valid’ with expiredAt < now + * - update subscription phase to ‘Expired’ + */ + async handleValidPhaseAndExpired() { + const db = SystemDatabase.db + + await db.collection('Subscription').updateMany( + { + phase: SubscriptionPhase.Valid, + expiredAt: { $lt: new Date() }, + }, + { $set: { phase: SubscriptionPhase.Expired } }, + ) + } + + /** + * Phase 'Expired': + * - update application state to 'Stopped' + * - update subscription phase to 'ExpiredAndStopped' + */ + async handleExpiredPhase() { + const db = SystemDatabase.db + + const res = await db + .collection('Subscription') + .findOneAndUpdate( + { + phase: SubscriptionPhase.Expired, + lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, + }, + { $set: { lockedAt: new Date() } }, + ) + if (!res.value) return + + const doc = res.value + + // update application state to 'Stopped' + await db + .collection('Application') + .updateOne( + { appid: doc.appid }, + { $set: { state: ApplicationState.Stopped } }, + ) + + // update subscription phase to 'ExpiredAndStopped' + await db.collection('Subscription').updateOne( + { _id: doc._id }, + { + $set: { + phase: SubscriptionPhase.ExpiredAndStopped, + lockedAt: TASK_LOCK_INIT_TIME, + }, + }, + ) + } + + /** + * Phase 'ExpiredAndStopped' but not expired (renewal case): + * - update subscription phase to ‘Valid’ + * (TODO) update application state to ‘Running’ + */ + async handleExpiredAndStoppedPhaseAndNotExpired() { + const db = SystemDatabase.db + + await db.collection('Subscription').updateMany( + { + phase: SubscriptionPhase.ExpiredAndStopped, + expiredAt: { $gt: new Date() }, + }, + { $set: { phase: SubscriptionPhase.Valid } }, + ) + } + + /** + * Phase 'ExpiredAndStopped': + * -if ‘Bundle.reservedTimeAfterExpired’ expired + * 1. Update application state to ‘Deleted’ + * 2. Update subscription phase to ‘ExpiredAndDeleted’ + */ + async handleExpiredAndStoppedPhase() { + const db = SystemDatabase.db + + const specialLockTimeout = 60 * 60 // 1 hour + + const res = await db + .collection('Subscription') + .findOneAndUpdate( + { + phase: SubscriptionPhase.ExpiredAndStopped, + lockedAt: { $lt: new Date(Date.now() - specialLockTimeout * 1000) }, + }, + { $set: { lockedAt: new Date() } }, + ) + if (!res.value) return + + const doc = res.value + + // if ‘Bundle.reservedTimeAfterExpired’ expired + const bundle = await this.bundleService.findApplicationBundle(doc.appid) + assert(bundle, 'bundle not found') + + const reservedTimeAfterExpired = + bundle.resource.reservedTimeAfterExpired * 1000 + const expiredTime = Date.now() - doc.expiredAt.getTime() + if (expiredTime < reservedTimeAfterExpired) { + return // return directly without unlocking it! + } + + // 2. Update subscription state to 'Deleted' + await db.collection('Subscription').updateOne( + { _id: doc._id }, + { + $set: { + state: SubscriptionState.Deleted, + lockedAt: TASK_LOCK_INIT_TIME, + }, + }, + ) + } + + /** + * State `Deleted` + */ + async handleDeletedState() { + const db = SystemDatabase.db + const res = await db + .collection('Subscription') + .findOneAndUpdate( + { + state: SubscriptionState.Deleted, + lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, + }, + { $set: { lockedAt: new Date() } }, + ) + if (!res.value) return + + const doc = res.value + + // Update application state to ‘Deleted’ + await this.applicationService.remove(doc.appid) + + // Update subscription phase to 'Deleted' + await db.collection('Subscription').updateOne( + { _id: doc._id }, + { + $set: { + phase: SubscriptionPhase.Deleted, + lockedAt: TASK_LOCK_INIT_TIME, + }, + }, + ) + } + + @Cron(CronExpression.EVERY_MINUTE) + async handlePendingTimeout() { + const timeout = 30 * 60 * 1000 + + const db = SystemDatabase.db + await db.collection('Subscription').deleteMany({ + phase: SubscriptionPhase.Pending, + lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, + createdAt: { $lte: new Date(Date.now() - timeout) }, + }) + } + + /** + * Unlock subscription + */ + async unlock(id: ObjectId) { + const db = SystemDatabase.db + await db + .collection('Subscription') + .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) + } +} diff --git a/server/src/subscription/subscription.controller.ts b/server/src/subscription/subscription.controller.ts new file mode 100644 index 0000000000..b0522d9857 --- /dev/null +++ b/server/src/subscription/subscription.controller.ts @@ -0,0 +1,222 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Logger, + UseGuards, + Req, + Query, +} from '@nestjs/common' +import { SubscriptionService } from './subscription.service' +import { CreateSubscriptionDto } from './dto/create-subscription.dto' +import { UpgradeSubscriptionDto } from './dto/upgrade-subscription.dto' +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' +import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' +import { IRequest } from 'src/utils/interface' +import { ResponseUtil } from 'src/utils/response' +import { BundleService } from 'src/region/bundle.service' +import { PrismaService } from 'src/prisma/prisma.service' +import { ApplicationService } from 'src/application/application.service' +import { RegionService } from 'src/region/region.service' +import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' +import { RenewSubscriptionDto } from './dto/renew-subscription.dto' +import { SubscriptionPhase } from '@prisma/client' +import * as assert from 'assert' + +@ApiTags('Subscription') +@Controller('subscriptions') +@ApiBearerAuth('Authorization') +export class SubscriptionController { + private readonly logger = new Logger(SubscriptionController.name) + + constructor( + private readonly subscriptService: SubscriptionService, + private readonly applicationService: ApplicationService, + private readonly bundleService: BundleService, + private readonly prisma: PrismaService, + private readonly regionService: RegionService, + ) {} + + /** + * Create a new subscription + */ + @ApiOperation({ summary: 'Create a new subscription' }) + @UseGuards(JwtAuthGuard) + @Post() + async create(@Body() dto: CreateSubscriptionDto, @Req() req: IRequest) { + const user = req.user + + // check if user has a pending subscription + const pendingCount = await this.prisma.subscription.count({ + where: { + phase: SubscriptionPhase.Pending, + createdBy: user.id, + }, + }) + if (pendingCount > 5) { + return ResponseUtil.error(`you have a pending subscription`) + } + + // check regionId exists + const region = await this.regionService.findOneDesensitized(dto.regionId) + if (!region) { + return ResponseUtil.error(`region ${dto.regionId} not found`) + } + + // check runtimeId exists + const runtime = await this.prisma.runtime.findUnique({ + where: { id: dto.runtimeId }, + }) + if (!runtime) { + return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) + } + + // check bundleId exists + const bundle = await this.bundleService.findOne(dto.bundleId) + if (!bundle) { + return ResponseUtil.error(`bundle ${dto.bundleId} not found`) + } + + // check app count limit + const LIMIT_COUNT = bundle.limitCountPerUser || 0 + const count = await this.prisma.application.count({ + where: { + createdBy: user.id, + bundle: { bundleId: dto.bundleId }, + }, + }) + if (count >= LIMIT_COUNT) { + return ResponseUtil.error( + `application count limit is ${LIMIT_COUNT} for bundle ${bundle.name}`, + ) + } + + // create subscription + const appid = await this.applicationService.tryGenerateUniqueAppid() + const subscription = await this.subscriptService.create(user.id, appid, dto) + + return ResponseUtil.ok(subscription) + } + + /** + * Get user's subscriptions + */ + @ApiOperation({ summary: "Get user's subscriptions" }) + @UseGuards(JwtAuthGuard) + @Get() + async findAll(@Req() req: IRequest) { + const user = req.user + const subscriptions = await this.subscriptService.findAll(user.id) + return ResponseUtil.ok(subscriptions) + } + + /** + * Get subscription by appid + */ + @ApiOperation({ summary: 'Get subscription by appid' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Get(':appid') + async findOne(@Param('appid') appid: string) { + const subscription = await this.subscriptService.findOneByAppid(appid) + if (!subscription) { + return ResponseUtil.error(`subscription ${appid} not found`) + } + + return ResponseUtil.ok(subscription) + } + + /** + * Calculate subscription renewal price + */ + @ApiOperation({ summary: 'Calculate subscription renewal price' }) + @UseGuards(JwtAuthGuard) + @Get('renewal/price') + async getRenewalPrice( + @Query('bundleId') bundleId: string, + @Query('duration') duration: number, + ) { + // get bundle + const bundle = await this.bundleService.findOne(bundleId) + if (!bundle) { + return ResponseUtil.error(`bundle ${bundleId} not found`) + } + + const option = this.bundleService.getSubscriptionOption(bundle, duration) + if (!option) { + return ResponseUtil.error(`duration not supported in bundle`) + } + + const result = await this.subscriptService.getRenewalPrice(option, duration) + return ResponseUtil.ok(result) + } + + /** + * Renew a subscription + */ + @ApiOperation({ summary: 'Renew a subscription' }) + @UseGuards(JwtAuthGuard) + @Post(':id/renewal') + async renew( + @Param('id') id: string, + @Body() dto: RenewSubscriptionDto, + @Req() req: IRequest, + ) { + const { duration } = 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`) + } + + const bundle = await this.bundleService.findOne(subscription.bundleId) + assert(bundle, `bundle ${subscription.bundleId} not found`) + + const option = this.bundleService.getSubscriptionOption(bundle, duration) + if (!option) { + return ResponseUtil.error(`duration not supported in bundle`) + } + + const priceAmount = await this.subscriptService.getRenewalPrice( + option, + duration, + ) + + // renew subscription + const res = await this.subscriptService.renew( + subscription, + duration, + priceAmount, + ) + return ResponseUtil.ok(res) + } + + /** + * TODO: Upgrade a subscription + */ + @ApiOperation({ summary: 'Upgrade a subscription (TODO)' }) + @UseGuards(JwtAuthGuard) + @Patch(':id/upgrade') + async upgrade(@Param('id') id: string, @Body() dto: UpgradeSubscriptionDto) { + return 'TODO' + } + + /** + * Delete a subscription + * @param id + * @returns + */ + @ApiOperation({ summary: 'Delete a subscription' }) + @UseGuards(JwtAuthGuard) + @Delete(':id') + async remove(@Param('id') id: string, @Req() req: IRequest) { + const userid = req.user.id + const res = await this.subscriptService.remove(userid, id) + return ResponseUtil.ok(res) + } +} diff --git a/server/src/subscription/subscription.module.ts b/server/src/subscription/subscription.module.ts new file mode 100644 index 0000000000..e5a1f6301a --- /dev/null +++ b/server/src/subscription/subscription.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { SubscriptionService } from './subscription.service' +import { SubscriptionController } from './subscription.controller' +import { SubscriptionTaskService } from './subscription-task.service' +import { ApplicationService } from 'src/application/application.service' +import { SubscriptionRenewalTaskService } from './renewal-task.service' +import { AccountModule } from 'src/account/account.module' + +@Module({ + imports: [AccountModule], + controllers: [SubscriptionController], + providers: [ + SubscriptionService, + SubscriptionTaskService, + ApplicationService, + SubscriptionRenewalTaskService, + ], +}) +export class SubscriptionModule {} diff --git a/server/src/subscription/subscription.service.ts b/server/src/subscription/subscription.service.ts new file mode 100644 index 0000000000..469a27fc9d --- /dev/null +++ b/server/src/subscription/subscription.service.ts @@ -0,0 +1,120 @@ +import { Injectable, Logger } from '@nestjs/common' +import { + Bundle, + BundleSubscriptionOption, + Subscription, + SubscriptionPhase, + SubscriptionRenewalPhase, + SubscriptionRenewalPlan, + SubscriptionState, +} from '@prisma/client' +import * as assert from 'assert' +import { ONE_MONTH_IN_SECONDS, TASK_LOCK_INIT_TIME } from 'src/constants' +import { PrismaService } from 'src/prisma/prisma.service' +import { BundleService } from 'src/region/bundle.service' +import { PriceRound } from 'src/utils/number' +import { CreateSubscriptionDto } from './dto/create-subscription.dto' +import { RenewSubscriptionDto } from './dto/renew-subscription.dto' + +@Injectable() +export class SubscriptionService { + private readonly logger = new Logger(SubscriptionService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly bundleService: BundleService, + ) {} + + async create(userid: string, appid: string, dto: CreateSubscriptionDto) { + const res = await this.prisma.subscription.create({ + data: { + input: { + name: dto.name, + state: dto.state, + regionId: dto.regionId, + runtimeId: dto.runtimeId, + }, + appid: appid, + bundleId: dto.bundleId, + phase: SubscriptionPhase.Pending, + renewalPlan: SubscriptionRenewalPlan.Manual, + expiredAt: new Date(), + lockedAt: TASK_LOCK_INIT_TIME, + createdBy: userid, + }, + }) + + return res + } + + async findAll(userid: string) { + const res = await this.prisma.subscription.findMany({ + where: { createdBy: userid }, + }) + + return res + } + + async findOne(userid: string, id: string) { + const res = await this.prisma.subscription.findUnique({ + where: { id }, + }) + + return res + } + + async findOneByAppid(appid: string) { + const res = await this.prisma.subscription.findUnique({ + where: { + appid, + }, + }) + + return res + } + + async remove(userid: string, id: string) { + const res = await this.prisma.subscription.updateMany({ + where: { id, createdBy: userid, state: SubscriptionState.Created }, + data: { state: SubscriptionState.Deleted }, + }) + + return res + } + + /** + * Calculate renewal price + * - calculate price amount based on bundle price and duration: + * - price per day = bundle price / 31 + * - price amount = price per day * (duration / 3600 / 24 ) + */ + async getRenewalPrice(option: BundleSubscriptionOption, duration: number) { + const price = Number(option.specialPrice || option.price) + const months = PriceRound(duration / ONE_MONTH_IN_SECONDS) + const priceAmount = PriceRound(price * months) + return priceAmount + } + + /** + * Renew a subscription by creating a subscription renewal + */ + async renew( + subscription: Subscription, + duration: number, + priceAmount: number, + ) { + // create subscription renewal + const res = await this.prisma.subscriptionRenewal.create({ + data: { + subscriptionId: subscription.id, + duration: duration, + amount: priceAmount, + phase: SubscriptionRenewalPhase.Pending, + lockedAt: TASK_LOCK_INIT_TIME, + createdBy: subscription.createdBy, + }, + }) + + return res + } +} diff --git a/server/src/utils/number.ts b/server/src/utils/number.ts new file mode 100644 index 0000000000..ecdca79275 --- /dev/null +++ b/server/src/utils/number.ts @@ -0,0 +1,39 @@ +/** + * PriceRound() + * - keep two decimals + * - 1.234 => 1.23 + * - 1.235 => 1.24 + * + * Special case: + * - capitalize the first letter of this function name to make it like a constructor + * @param price + * @returns + */ +export function PriceRound(price: number | string) { + const priceNum = Number(price) + return Math.round(priceNum * 100) / 100 +} + +export function PriceAdd(price1: number | string, price2: number | string) { + const price1Num = Number(price1) + const price2Num = Number(price2) + return PriceRound(price1Num + price2Num) +} + +export function PriceSub(price1: number | string, price2: number | string) { + const price1Num = Number(price1) + const price2Num = Number(price2) + return PriceRound(price1Num - price2Num) +} + +export function PriceMul(price1: number | string, price2: number | string) { + const price1Num = Number(price1) + const price2Num = Number(price2) + return PriceRound(price1Num * price2Num) +} + +export function PriceDiv(price1: number | string, price2: number | string) { + const price1Num = Number(price1) + const price2Num = Number(price2) + return PriceRound(price1Num / price2Num) +} diff --git a/server/src/utils/random.ts b/server/src/utils/random.ts index 28bfb5f84f..08c48d102b 100644 --- a/server/src/utils/random.ts +++ b/server/src/utils/random.ts @@ -1,4 +1,5 @@ import * as nanoid from 'nanoid' +import dayjs from 'dayjs' export function GenerateAlphaNumericPassword(length: number) { const nano = nanoid.customAlphabet( @@ -7,3 +8,18 @@ export function GenerateAlphaNumericPassword(length: number) { ) return nano() } + +export function GenerateRandomString(length: number) { + return GenerateAlphaNumericPassword(length) +} + +export function GenerateRandomNumericString(length: number) { + const nano = nanoid.customAlphabet('0123456789', length || 16) + return nano() +} + +export function GenerateOrderNumber() { + const dateStr = dayjs().format('YYYYMMDDHHMMSS') + const randomStr = GenerateRandomNumericString(6) + return `${dateStr}${randomStr}` +} From 3d817b24122ece3cf170b98f6b75b6c248d3040b Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 15 Mar 2023 14:22:14 +0800 Subject: [PATCH 2/3] chore(server): fix payment amount --- deploy/scripts/install-on-linux.sh | 2 +- server/prisma/schema.prisma | 6 ++++-- server/src/account/account.controller.ts | 9 +++++++++ .../src/account/dto/create-charge-order.dto.ts | 2 +- server/src/account/payment/wechat-pay.service.ts | 16 ++++++---------- server/src/initializer/initializer.service.ts | 3 +++ server/src/utils/random.ts | 2 +- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/deploy/scripts/install-on-linux.sh b/deploy/scripts/install-on-linux.sh index 5676368456..c2579b557a 100644 --- a/deploy/scripts/install-on-linux.sh +++ b/deploy/scripts/install-on-linux.sh @@ -34,7 +34,7 @@ gpgcheck=0 EOF # yum update yum clean all - yum install sealos=4.1.4 -y + yum install sealos -y fi ARCH=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index df61610ca9..fdf83b2b3c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -59,8 +59,9 @@ type RegionClusterConf { } type RegionDatabaseConf { - driver String // mongodb - connectionUri String + driver String // mongodb + connectionUri String + controlConnectionUri String } type RegionGatewayConf { @@ -79,6 +80,7 @@ type RegionStorageConf { internalEndpoint String accessKey String secretKey String + controlEndpoint String } model Region { diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index 9446f8b27b..fb094dc8d1 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -65,4 +65,13 @@ export class AccountController { result, }) } + + /** + * WeChat payment notify + */ + @ApiOperation({ summary: 'WeChat payment notify' }) + @Post('wechat-notify') + async wechatNotify() { + // todo + } } diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index e16c0a667b..65a001787a 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -8,7 +8,7 @@ export class CreateChargeOrderDto { @IsNumber() amount: number - @ApiProperty() + @ApiProperty({ example: PaymentChannelType.WeChat }) @IsString() @IsEnum(PaymentChannelType) paymentChannel: PaymentChannelType diff --git a/server/src/account/payment/wechat-pay.service.ts b/server/src/account/payment/wechat-pay.service.ts index 692e900172..a307098e8b 100644 --- a/server/src/account/payment/wechat-pay.service.ts +++ b/server/src/account/payment/wechat-pay.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common' import { GenerateOrderNumber, GenerateRandomString } from 'src/utils/random' import { WeChatPaymentChannelSpec, WeChatPaymentRequestBody } from './types' import * as crypto from 'crypto' -import { PaymentChannelService } from './payment-channel.service' import { HttpService } from '@nestjs/axios' import { ServerConfig } from 'src/constants' @@ -11,10 +10,7 @@ export class WeChatPaymentService { static readonly API_BASE_URL = 'https://api.mch.weixin.qq.com' static readonly API_NATIVE_PAY_URL = '/v3/pay/transactions/native' - constructor( - private readonly channelService: PaymentChannelService, - private readonly httpService: HttpService, - ) {} + constructor(private readonly httpService: HttpService) {} async pay( order: WeChatPaymentRequestBody, @@ -25,12 +21,12 @@ export class WeChatPaymentService { const signature = this.createSign(timestamp, nonceStr, order, channelSpec) const serialNo = channelSpec.certificateSerialNumber - const token = `WECHATPAY2-SHA256-RSA2048 mchid="xxxx",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}",serial_no="${serialNo}"` + const token = `WECHATPAY2-SHA256-RSA2048 mchid="${channelSpec.mchid}",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}",serial_no="${serialNo}"` const headers = { Authorization: token, } - const apiUrl = `${WeChatPaymentService.API_BASE_URL}${WeChatPaymentService.API_NATIVE_PAY_URL}}` + const apiUrl = `${WeChatPaymentService.API_BASE_URL}${WeChatPaymentService.API_NATIVE_PAY_URL}` const res = await this.httpService.axiosRef.post(apiUrl, order, { headers, }) @@ -47,7 +43,7 @@ export class WeChatPaymentService { out_trade_no: orderNumber, notify_url: this.getNotifyUrl(), amount: { - total: amount, + total: amount * 100, currency: 'CNY', }, } @@ -56,14 +52,14 @@ export class WeChatPaymentService { createSign( timestamp: number, - nonce_str: string, + nonceStr: string, order: WeChatPaymentRequestBody, channelSpec: WeChatPaymentChannelSpec, ) { const method = 'POST' const url = WeChatPaymentService.API_NATIVE_PAY_URL const orderStr = JSON.stringify(order) - const signStr = `${method}\n${url}\n${timestamp}\n${nonce_str}\n${orderStr}\n` + const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${orderStr}\n` const cert = channelSpec.privateKey const sign = crypto.createSign('RSA-SHA256') sign.update(signStr) diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index 8f41b64ad5..919e8cab63 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -34,6 +34,7 @@ export class InitializerService { set: { driver: 'mongodb', connectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, + controlConnectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, }, }, storageConf: { @@ -46,6 +47,8 @@ export class InitializerService { ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, accessKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_ACCESS_KEY, secretKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_SECRET_KEY, + controlEndpoint: + ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, }, }, gatewayConf: { diff --git a/server/src/utils/random.ts b/server/src/utils/random.ts index 08c48d102b..69baf08ca3 100644 --- a/server/src/utils/random.ts +++ b/server/src/utils/random.ts @@ -1,5 +1,5 @@ import * as nanoid from 'nanoid' -import dayjs from 'dayjs' +import * as dayjs from 'dayjs' export function GenerateAlphaNumericPassword(length: number) { const nano = nanoid.customAlphabet( From 732466cced06d7a70883c7a6f53947d4c2bdee6e Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 16 Mar 2023 00:10:33 +0800 Subject: [PATCH 3/3] feat(server): impl account charge, wechat pay --- .vscode/settings.json | 2 + server/package-lock.json | 574 ++++++++++++------ server/package.json | 5 +- server/prisma/schema.prisma | 34 +- server/src/account/account.controller.ts | 135 +++- server/src/account/account.module.ts | 6 +- server/src/account/account.service.ts | 51 +- .../account/dto/create-charge-order.dto.ts | 15 +- .../payment/payment-channel.service.ts | 8 +- server/src/account/payment/types.ts | 50 +- .../src/account/payment/wechat-pay.service.ts | 146 +++-- server/src/application/application.service.ts | 13 +- server/src/auth/application.auth.guard.ts | 1 - .../dto/create-subscription.dto.ts | 7 +- .../entities/subscription.entity.ts | 1 - .../src/subscription/renewal-task.service.ts | 60 +- .../subscription/subscription-task.service.ts | 22 +- .../subscription/subscription.controller.ts | 79 +-- .../src/subscription/subscription.service.ts | 76 +-- 19 files changed, 883 insertions(+), 402 deletions(-) delete mode 100644 server/src/subscription/entities/subscription.entity.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index eeff4d21d3..e09679ad5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,6 +38,7 @@ "chakra", "Chakra", "chatgpt", + "ciphertext", "clsx", "coll", "compat", @@ -99,6 +100,7 @@ "urlencode", "userid", "vitepress", + "webchat", "wechat", "WECHATPAY", "withs", diff --git a/server/package-lock.json b/server/package-lock.json index 8690c36d60..b070c6724f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -40,11 +40,12 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", - "typescript": "^4.9.3" + "typescript": "^4.9.3", + "wechatpay-node-v3": "^2.1.1" }, "devDependencies": { "@compodoc/compodoc": "^1.1.19", - "@nestjs/cli": "^9.0.0", + "@nestjs/cli": "^9.2.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.2.0", "@types/compression": "^1.7.2", @@ -94,19 +95,19 @@ } }, "node_modules/@angular-devkit/core": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-14.2.2.tgz", - "integrity": "sha512-ofDhTmJqoAkmkJP0duwUaCxDBMxPlc+AWYwgs3rKKZeJBb0d+tchEXHXevD5bYbbRfXtnwM+Vye2XYHhA4nWAA==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-15.1.4.tgz", + "integrity": "sha512-PW5MRmd9DHJR4FaXchwQtj9pXnsghSTnwRvfZeCRNYgU2sv0DKyTV+YTSJB+kNXnoPNG1Je6amDEkiXecpspXg==", "dev": true, "dependencies": { - "ajv": "8.11.0", + "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.1.0", + "jsonc-parser": "3.2.0", "rxjs": "6.6.7", "source-map": "0.7.4" }, "engines": { - "node": "^14.15.0 || >=16.10.0", + "node": "^14.20.0 || ^16.13.0 || >=18.10.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, @@ -119,6 +120,28 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/core/node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/@angular-devkit/core/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -138,31 +161,31 @@ "dev": true }, "node_modules/@angular-devkit/schematics": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-14.2.2.tgz", - "integrity": "sha512-90hseNg1yQ2AR+lVr/NByZRHnYAlzCL6hr9p9q1KPHxA3Owo04yX6n6dvR/xf27hCopXInXKPsasR59XCx5ZOQ==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-15.1.4.tgz", + "integrity": "sha512-jpddxo9Qd2yRQ1t9FLhAx5S+luz6HkyhDytq0LFKbxf9ikf1J4oy9riPBFl4pRmrNARWcHZ6GbD20/Ky8PjmXQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "14.2.2", - "jsonc-parser": "3.1.0", - "magic-string": "0.26.2", + "@angular-devkit/core": "15.1.4", + "jsonc-parser": "3.2.0", + "magic-string": "0.27.0", "ora": "5.4.1", "rxjs": "6.6.7" }, "engines": { - "node": "^14.15.0 || >=16.10.0", + "node": "^14.20.0 || ^16.13.0 || >=18.10.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-14.2.2.tgz", - "integrity": "sha512-timCty5tO1A5VOcy8nVJ+jL98i6+ct5/Hg+4rQxc3J6agmmNL9fALboJBEz1ckTt7MewlGtrpohMMy+YGhuWOg==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-15.1.4.tgz", + "integrity": "sha512-qkM5Mfs28jZzNcJnSM6RlyrKkYvzhQmWFTxBXnn15k5T4EnSs1gI6O054Xn7jo/senfwNNt7h2Mlz2OmBLo6+w==", "dev": true, "dependencies": { - "@angular-devkit/core": "14.2.2", - "@angular-devkit/schematics": "14.2.2", + "@angular-devkit/core": "15.1.4", + "@angular-devkit/schematics": "15.1.4", "ansi-colors": "4.1.3", "inquirer": "8.2.4", "symbol-observable": "4.0.0", @@ -172,7 +195,7 @@ "schematics": "bin/schematics.js" }, "engines": { - "node": "^14.15.0 || >=16.10.0", + "node": "^14.20.0 || ^16.13.0 || >=18.10.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } @@ -219,6 +242,24 @@ "node": ">=12.0.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "node_modules/@angular-devkit/schematics/node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -5470,12 +5511,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/@compodoc/compodoc/node_modules/jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true - }, "node_modules/@compodoc/compodoc/node_modules/magic-string": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", @@ -5675,6 +5710,31 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "dependencies": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, "node_modules/@foliojs-fork/fontkit": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.1.tgz", @@ -6374,32 +6434,32 @@ } }, "node_modules/@nestjs/cli": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.1.5.tgz", - "integrity": "sha512-rSp26+Nv7PFtYrRSP18Gv5ZK8rRSc2SCCF5wh4SdZaVGgkxShpNq9YEfI+ik/uziN3KC5o74ppYRXGj+aHGVsA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.2.0.tgz", + "integrity": "sha512-6B1IjDcJbrOu55oMF67L1x5lDUOZ3Zs9l7bKCBH9D78965m8wq/2rlEWl/gJto5TABLQWy3hVvV/s8VzUlRMxw==", "dev": true, "dependencies": { - "@angular-devkit/core": "14.2.2", - "@angular-devkit/schematics": "14.2.2", - "@angular-devkit/schematics-cli": "14.2.2", + "@angular-devkit/core": "15.1.4", + "@angular-devkit/schematics": "15.1.4", + "@angular-devkit/schematics-cli": "15.1.4", "@nestjs/schematics": "^9.0.0", "chalk": "3.0.0", "chokidar": "3.5.3", - "cli-table3": "0.6.2", + "cli-table3": "0.6.3", "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "7.2.13", + "fork-ts-checker-webpack-plugin": "7.3.0", "inquirer": "7.3.3", "node-emoji": "1.11.0", "ora": "5.4.1", "os-name": "4.0.1", - "rimraf": "3.0.2", + "rimraf": "4.1.2", "shelljs": "0.8.5", "source-map-support": "0.5.21", "tree-kill": "1.2.2", - "tsconfig-paths": "4.1.0", + "tsconfig-paths": "4.1.2", "tsconfig-paths-webpack-plugin": "4.0.0", - "typescript": "4.8.4", - "webpack": "5.74.0", + "typescript": "4.9.5", + "webpack": "5.75.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -6409,17 +6469,90 @@ "node": ">= 12.9.0" } }, - "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "node_modules/@nestjs/cli/node_modules/rimraf": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.1.2.tgz", + "integrity": "sha512-BlIbgFryTbw3Dz6hyoWFhKk+unCcHMSkZGrTFVAx2WmttdBSonsdtRlwiuTbDqTKr+UlXIUqJVS4QT5tUzGENQ==", + "deprecated": "Please upgrade to 4.3.1 or higher to fix a potentially damaging issue regarding symbolic link following. See https://github.com/isaacs/rimraf/issues/259 for details.", "dev": true, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "rimraf": "dist/cjs/src/bin.js" }, "engines": { - "node": ">=4.2.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nestjs/cli/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@nestjs/cli/node_modules/tsconfig-paths": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.2.tgz", + "integrity": "sha512-uhxiMgnXQp1IR622dUXI+9Ehnws7i/y6xvpZB9IbUVOPy0muvdvgXeZOn88UcGPiT98Vp3rJPTa8bFoalZ3Qhw==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@nestjs/cli/node_modules/webpack": { + "version": "5.75.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", + "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.10.0", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } } }, "node_modules/@nestjs/common": { @@ -7948,8 +8081,7 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "node_modules/asn1": { "version": "0.2.6", @@ -8765,9 +8897,9 @@ } }, "node_modules/cli-table3": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", - "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", + "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", "dev": true, "dependencies": { "string-width": "^4.2.0" @@ -8890,8 +9022,7 @@ "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "node_modules/compressible": { "version": "2.0.18", @@ -9081,8 +9212,7 @@ "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, "node_modules/core-js-compat": { "version": "3.26.0", @@ -9283,7 +9413,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -9299,8 +9428,7 @@ "node_modules/debug/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/decache": { "version": "4.6.1", @@ -9432,7 +9560,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -10609,9 +10736,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin": { - "version": "7.2.13", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz", - "integrity": "sha512-fR3WRkOb4bQdWB/y7ssDUlVdrclvwtyCUIHCfivAoYxq9dF7XfrDKbMdZIfwJ7hxIAqkYSGeU7lLJE6xrxIBdg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.3.0.tgz", + "integrity": "sha512-IN+XTzusCjR5VgntYFgxbxVx3WraPRnKehBFrf00cMSrtUuW9MsG9dhL6MWpY6MkjC3wVwoujfCDgZZCQwbswA==", "dev": true, "dependencies": { "@babel/code-frame": "^7.16.7", @@ -10672,10 +10799,9 @@ } }, "node_modules/formidable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", - "dev": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", "dependencies": { "dezalgo": "^1.0.4", "hexoid": "^1.0.0", @@ -11061,7 +11187,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true, "engines": { "node": ">=8" } @@ -12607,9 +12732,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", "dev": true }, "node_modules/jsonfile": { @@ -12955,9 +13080,9 @@ } }, "node_modules/memfs": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.10.tgz", - "integrity": "sha512-0bCUP+L79P4am30yP1msPzApwuMQG23TjwlwdHeEV5MxioDR1a0AgB0T9FfggU52eJuDCq8WVwb5ekznFyWiTQ==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.13.tgz", + "integrity": "sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg==", "dev": true, "dependencies": { "fs-monkey": "^1.0.3" @@ -13383,9 +13508,9 @@ "dev": true }, "node_modules/node-abort-controller": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz", - "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true }, "node_modules/node-emoji": { @@ -15522,17 +15647,16 @@ "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, "node_modules/superagent": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.3.tgz", - "integrity": "sha512-oBC+aNsCjzzjmO5AOPBPFS+Z7HPzlx+DQr/aHwM08kI+R24gsDmAS1LMfza1fK+P+SKlTAoNZpOvooE/pRO1HA==", - "dev": true, + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz", + "integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==", "dependencies": { "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", + "cookiejar": "^2.1.4", "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", - "formidable": "^2.0.1", + "formidable": "^2.1.2", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.11.0", @@ -15546,7 +15670,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, "bin": { "mime": "cli.js" }, @@ -16195,9 +16318,9 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typescript": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", - "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16473,10 +16596,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.74.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", - "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==", + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", + "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -16560,6 +16684,15 @@ "node": ">=0.8.0" } }, + "node_modules/wechatpay-node-v3": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/wechatpay-node-v3/-/wechatpay-node-v3-2.1.1.tgz", + "integrity": "sha512-pAWxzXd7xz4YonFDXvJTG4hc5o+3NPWDwKrC8wykQ0yCTltHFfrPwrEqvMFq28aqz69jp223gY6At3taDkpdCg==", + "dependencies": { + "@fidm/x509": "^1.2.1", + "superagent": "^8.0.6" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16839,18 +16972,36 @@ } }, "@angular-devkit/core": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-14.2.2.tgz", - "integrity": "sha512-ofDhTmJqoAkmkJP0duwUaCxDBMxPlc+AWYwgs3rKKZeJBb0d+tchEXHXevD5bYbbRfXtnwM+Vye2XYHhA4nWAA==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-15.1.4.tgz", + "integrity": "sha512-PW5MRmd9DHJR4FaXchwQtj9pXnsghSTnwRvfZeCRNYgU2sv0DKyTV+YTSJB+kNXnoPNG1Je6amDEkiXecpspXg==", "dev": true, "requires": { - "ajv": "8.11.0", + "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.1.0", + "jsonc-parser": "3.2.0", "rxjs": "6.6.7", "source-map": "0.7.4" }, "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -16869,18 +17020,33 @@ } }, "@angular-devkit/schematics": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-14.2.2.tgz", - "integrity": "sha512-90hseNg1yQ2AR+lVr/NByZRHnYAlzCL6hr9p9q1KPHxA3Owo04yX6n6dvR/xf27hCopXInXKPsasR59XCx5ZOQ==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-15.1.4.tgz", + "integrity": "sha512-jpddxo9Qd2yRQ1t9FLhAx5S+luz6HkyhDytq0LFKbxf9ikf1J4oy9riPBFl4pRmrNARWcHZ6GbD20/Ky8PjmXQ==", "dev": true, "requires": { - "@angular-devkit/core": "14.2.2", - "jsonc-parser": "3.1.0", - "magic-string": "0.26.2", + "@angular-devkit/core": "15.1.4", + "jsonc-parser": "3.2.0", + "magic-string": "0.27.0", "ora": "5.4.1", "rxjs": "6.6.7" }, "dependencies": { + "jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.13" + } + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -16899,13 +17065,13 @@ } }, "@angular-devkit/schematics-cli": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-14.2.2.tgz", - "integrity": "sha512-timCty5tO1A5VOcy8nVJ+jL98i6+ct5/Hg+4rQxc3J6agmmNL9fALboJBEz1ckTt7MewlGtrpohMMy+YGhuWOg==", + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-15.1.4.tgz", + "integrity": "sha512-qkM5Mfs28jZzNcJnSM6RlyrKkYvzhQmWFTxBXnn15k5T4EnSs1gI6O054Xn7jo/senfwNNt7h2Mlz2OmBLo6+w==", "dev": true, "requires": { - "@angular-devkit/core": "14.2.2", - "@angular-devkit/schematics": "14.2.2", + "@angular-devkit/core": "15.1.4", + "@angular-devkit/schematics": "15.1.4", "ansi-colors": "4.1.3", "inquirer": "8.2.4", "symbol-observable": "4.0.0", @@ -21072,12 +21238,6 @@ "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", "dev": true }, - "jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true - }, "magic-string": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", @@ -21247,6 +21407,27 @@ } } }, + "@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==" + }, + "@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "requires": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "dependencies": { + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } + }, "@foliojs-fork/fontkit": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.1.tgz", @@ -21816,40 +21997,89 @@ } }, "@nestjs/cli": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.1.5.tgz", - "integrity": "sha512-rSp26+Nv7PFtYrRSP18Gv5ZK8rRSc2SCCF5wh4SdZaVGgkxShpNq9YEfI+ik/uziN3KC5o74ppYRXGj+aHGVsA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.2.0.tgz", + "integrity": "sha512-6B1IjDcJbrOu55oMF67L1x5lDUOZ3Zs9l7bKCBH9D78965m8wq/2rlEWl/gJto5TABLQWy3hVvV/s8VzUlRMxw==", "dev": true, "requires": { - "@angular-devkit/core": "14.2.2", - "@angular-devkit/schematics": "14.2.2", - "@angular-devkit/schematics-cli": "14.2.2", + "@angular-devkit/core": "15.1.4", + "@angular-devkit/schematics": "15.1.4", + "@angular-devkit/schematics-cli": "15.1.4", "@nestjs/schematics": "^9.0.0", "chalk": "3.0.0", "chokidar": "3.5.3", - "cli-table3": "0.6.2", + "cli-table3": "0.6.3", "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "7.2.13", + "fork-ts-checker-webpack-plugin": "7.3.0", "inquirer": "7.3.3", "node-emoji": "1.11.0", "ora": "5.4.1", "os-name": "4.0.1", - "rimraf": "3.0.2", + "rimraf": "4.1.2", "shelljs": "0.8.5", "source-map-support": "0.5.21", "tree-kill": "1.2.2", - "tsconfig-paths": "4.1.0", + "tsconfig-paths": "4.1.2", "tsconfig-paths-webpack-plugin": "4.0.0", - "typescript": "4.8.4", - "webpack": "5.74.0", + "typescript": "4.9.5", + "webpack": "5.75.0", "webpack-node-externals": "3.0.0" }, "dependencies": { - "typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "rimraf": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.1.2.tgz", + "integrity": "sha512-BlIbgFryTbw3Dz6hyoWFhKk+unCcHMSkZGrTFVAx2WmttdBSonsdtRlwiuTbDqTKr+UlXIUqJVS4QT5tUzGENQ==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true + }, + "tsconfig-paths": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.2.tgz", + "integrity": "sha512-uhxiMgnXQp1IR622dUXI+9Ehnws7i/y6xvpZB9IbUVOPy0muvdvgXeZOn88UcGPiT98Vp3rJPTa8bFoalZ3Qhw==", + "dev": true, + "requires": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "webpack": { + "version": "5.75.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", + "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.10.0", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + } } } }, @@ -23025,8 +23255,7 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "asn1": { "version": "0.2.6", @@ -23651,9 +23880,9 @@ "dev": true }, "cli-table3": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", - "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", + "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", "dev": true, "requires": { "@colors/colors": "1.5.0", @@ -23743,8 +23972,7 @@ "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "compressible": { "version": "2.0.18", @@ -23905,8 +24133,7 @@ "cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, "core-js-compat": { "version": "3.26.0", @@ -24076,7 +24303,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" }, @@ -24084,8 +24310,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -24185,7 +24410,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, "requires": { "asap": "^2.0.0", "wrappy": "1" @@ -25097,9 +25321,9 @@ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, "fork-ts-checker-webpack-plugin": { - "version": "7.2.13", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz", - "integrity": "sha512-fR3WRkOb4bQdWB/y7ssDUlVdrclvwtyCUIHCfivAoYxq9dF7XfrDKbMdZIfwJ7hxIAqkYSGeU7lLJE6xrxIBdg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.3.0.tgz", + "integrity": "sha512-IN+XTzusCjR5VgntYFgxbxVx3WraPRnKehBFrf00cMSrtUuW9MsG9dhL6MWpY6MkjC3wVwoujfCDgZZCQwbswA==", "dev": true, "requires": { "@babel/code-frame": "^7.16.7", @@ -25139,10 +25363,9 @@ } }, "formidable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", - "dev": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", "requires": { "dezalgo": "^1.0.4", "hexoid": "^1.0.0", @@ -25424,8 +25647,7 @@ "hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" }, "hosted-git-info": { "version": "6.1.1", @@ -26573,9 +26795,9 @@ "dev": true }, "jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", "dev": true }, "jsonfile": { @@ -26847,9 +27069,9 @@ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, "memfs": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.10.tgz", - "integrity": "sha512-0bCUP+L79P4am30yP1msPzApwuMQG23TjwlwdHeEV5MxioDR1a0AgB0T9FfggU52eJuDCq8WVwb5ekznFyWiTQ==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.13.tgz", + "integrity": "sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg==", "dev": true, "requires": { "fs-monkey": "^1.0.3" @@ -27179,9 +27401,9 @@ "dev": true }, "node-abort-controller": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz", - "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true }, "node-emoji": { @@ -28794,17 +29016,16 @@ "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, "superagent": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.3.tgz", - "integrity": "sha512-oBC+aNsCjzzjmO5AOPBPFS+Z7HPzlx+DQr/aHwM08kI+R24gsDmAS1LMfza1fK+P+SKlTAoNZpOvooE/pRO1HA==", - "dev": true, + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz", + "integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==", "requires": { "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", + "cookiejar": "^2.1.4", "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", - "formidable": "^2.0.1", + "formidable": "^2.1.2", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.11.0", @@ -28814,8 +29035,7 @@ "mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" } } }, @@ -29273,9 +29493,9 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "typescript": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", - "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==" + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" }, "uglify-js": { "version": "3.17.4", @@ -29479,10 +29699,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.74.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", - "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==", + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", + "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -29539,6 +29760,15 @@ "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", "dev": true }, + "wechatpay-node-v3": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/wechatpay-node-v3/-/wechatpay-node-v3-2.1.1.tgz", + "integrity": "sha512-pAWxzXd7xz4YonFDXvJTG4hc5o+3NPWDwKrC8wykQ0yCTltHFfrPwrEqvMFq28aqz69jp223gY6At3taDkpdCg==", + "requires": { + "@fidm/x509": "^1.2.1", + "superagent": "^8.0.6" + } + }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/server/package.json b/server/package.json index e029ff757d..e1343aa585 100644 --- a/server/package.json +++ b/server/package.json @@ -56,11 +56,12 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", - "typescript": "^4.9.3" + "typescript": "^4.9.3", + "wechatpay-node-v3": "^2.1.1" }, "devDependencies": { "@compodoc/compodoc": "^1.1.19", - "@nestjs/cli": "^9.0.0", + "@nestjs/cli": "^9.2.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.2.0", "@types/compression": "^1.7.2", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index fdf83b2b3c..d7bfd01b8d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -126,8 +126,8 @@ type BundleSubscriptionOption { name String displayName String duration Int // in seconds - price Float - specialPrice Float + price Int + specialPrice Int } model Bundle { @@ -233,7 +233,7 @@ model SubscriptionRenewal { id String @id @default(auto()) @map("_id") @db.ObjectId subscriptionId String @db.ObjectId duration Int // in seconds - amount Float + amount Int phase SubscriptionRenewalPhase @default(Pending) message String? lockedAt DateTime @@ -259,18 +259,24 @@ enum AccountChargePhase { Failed } +enum Currency { + CNY + USD +} + model AccountChargeOrder { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Float - phase AccountChargePhase @default(Pending) - channel PaymentChannelType - channelData Json? - message String? - createdAt DateTime @default(now()) - lockedAt DateTime - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + currency Currency + phase AccountChargePhase @default(Pending) + channel PaymentChannelType + result Json? + message String? + createdAt DateTime @default(now()) + lockedAt DateTime + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId } enum PaymentChannelType { diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index fb094dc8d1..135430c779 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -2,18 +2,26 @@ import { Body, Controller, Get, + HttpCode, Logger, + Param, Post, Req, + Res, UseGuards, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' +import { AccountChargePhase } from '@prisma/client' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' +import { PrismaService } from 'src/prisma/prisma.service' import { IRequest } from 'src/utils/interface' -import { PriceRound } from 'src/utils/number' import { ResponseUtil } from 'src/utils/response' import { AccountService } from './account.service' import { CreateChargeOrderDto } from './dto/create-charge-order.dto' +import { PaymentChannelService } from './payment/payment-channel.service' +import { WeChatPayOrderResponse, WeChatPayTradeState } from './payment/types' +import { WeChatPayService } from './payment/wechat-pay.service' +import { Response } from 'express' @ApiTags('Account') @Controller('accounts') @@ -21,7 +29,12 @@ import { CreateChargeOrderDto } from './dto/create-charge-order.dto' export class AccountController { private readonly logger = new Logger(AccountController.name) - constructor(private readonly accountService: AccountService) {} + constructor( + private readonly accountService: AccountService, + private readonly paymentService: PaymentChannelService, + private readonly wechatPayService: WeChatPayService, + private readonly prisma: PrismaService, + ) {} /** * Get account info @@ -32,7 +45,18 @@ export class AccountController { async findOne(@Req() req: IRequest) { const user = req.user const data = await this.accountService.findOne(user.id) - data.balance = data.balance / 100 + return data + } + + /** + * Get charge order + */ + @ApiOperation({ summary: 'Get charge order' }) + @UseGuards(JwtAuthGuard) + @Get('charge-order/:id') + async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { + const user = req.user + const data = await this.accountService.findOneChargeOrder(user.id, id) return data } @@ -41,23 +65,26 @@ export class AccountController { */ @ApiOperation({ summary: 'Create charge order' }) @UseGuards(JwtAuthGuard) - @Post('charge') + @Post('charge-order') async charge(@Req() req: IRequest, @Body() dto: CreateChargeOrderDto) { const user = req.user - const amount = PriceRound(dto.amount) - - // invoke payment - const { result, channelData } = await this.accountService.pay( - dto.paymentChannel, - amount, - ) + const { amount, currency, channel } = dto // create charge order const order = await this.accountService.createChargeOrder( user.id, amount, - dto.paymentChannel, - channelData, + currency, + channel, + ) + + // invoke payment + const result = await this.accountService.pay( + channel, + order.id, + amount, + currency, + 'account charge', ) return ResponseUtil.ok({ @@ -69,9 +96,83 @@ export class AccountController { /** * WeChat payment notify */ - @ApiOperation({ summary: 'WeChat payment notify' }) - @Post('wechat-notify') - async wechatNotify() { - // todo + @Post('payment/wechat-notify') + async wechatNotify(@Req() req: IRequest, @Res() res: Response) { + try { + // get headers + const headers = req.headers + const nonce = headers['wechatpay-nonce'] as string + const timestamp = headers['wechatpay-timestamp'] as string + const signature = headers['wechatpay-signature'] as string + const serial = headers['wechatpay-serial'] as string + + // get body + const body = req.body as WeChatPayOrderResponse + + const spec = await this.paymentService.getWeChatPaySpec() + const result = await this.wechatPayService.getWeChatPayNotifyResult( + spec, + { + timestamp, + nonce, + body, + serial, + signature, + }, + ) + + this.logger.debug(result) + + const tradeOrderId = result.out_trade_no + if (result.trade_state !== WeChatPayTradeState.SUCCESS) { + await this.prisma.accountChargeOrder.update({ + where: { id: tradeOrderId }, + data: { + phase: AccountChargePhase.Failed, + result: result as any, + }, + }) + this.logger.log( + `wechatpay order failed: ${tradeOrderId} ${result.trade_state}`, + ) + return res.status(200).send() + } + + // start transaction + await this.prisma.$transaction(async (tx) => { + // get order + const order = await tx.accountChargeOrder.findFirst({ + where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, + }) + if (!order) { + this.logger.error(`wechatpay order not found: ${tradeOrderId}`) + return + } + + // update order to success + const res = await tx.accountChargeOrder.updateMany({ + where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, + data: { phase: AccountChargePhase.Paid, result: result as any }, + }) + + if (res.count === 0) { + this.logger.error(`wechatpay order not found: ${tradeOrderId}`) + return + } + + // update account balance + await tx.account.update({ + where: { id: order.accountId }, + data: { balance: { increment: order.amount } }, + }) + + this.logger.log(`wechatpay order success: ${tradeOrderId}`) + }) + } catch (err) { + this.logger.error(err) + return res.status(400).send({ code: 'FAIL', message: 'ERROR' }) + } + + return res.status(200).send() } } diff --git a/server/src/account/account.module.ts b/server/src/account/account.module.ts index 1201e0fcd5..ef3b3b4b78 100644 --- a/server/src/account/account.module.ts +++ b/server/src/account/account.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common' import { AccountService } from './account.service' import { AccountController } from './account.controller' -import { WeChatPaymentService } from './payment/wechat-pay.service' +import { WeChatPayService } from './payment/wechat-pay.service' import { PaymentChannelService } from './payment/payment-channel.service' import { HttpModule } from '@nestjs/axios' @Module({ imports: [HttpModule], - providers: [AccountService, WeChatPaymentService, PaymentChannelService], + providers: [AccountService, WeChatPayService, PaymentChannelService], controllers: [AccountController], - exports: [WeChatPaymentService, AccountService, PaymentChannelService], + exports: [WeChatPayService, AccountService, PaymentChannelService], }) export class AccountModule {} diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index 7b5ed50711..b8467b9bf3 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common' import { PrismaService } from 'src/prisma/prisma.service' import * as assert from 'assert' -import { AccountChargePhase, PaymentChannelType } from '@prisma/client' -import { WeChatPaymentService } from './payment/wechat-pay.service' +import { + AccountChargePhase, + Currency, + PaymentChannelType, +} from '@prisma/client' +import { WeChatPayService } from './payment/wechat-pay.service' import { PaymentChannelService } from './payment/payment-channel.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' @@ -12,7 +16,7 @@ export class AccountService { constructor( private readonly prisma: PrismaService, - private readonly wechatPayService: WeChatPaymentService, + private readonly wechatPayService: WeChatPayService, private readonly chanelService: PaymentChannelService, ) {} @@ -42,8 +46,8 @@ export class AccountService { async createChargeOrder( userid: string, amount: number, + currency: Currency, channel: PaymentChannelType, - channelData: any, ) { const account = await this.findOne(userid) assert(account, 'Account not found') @@ -53,9 +57,9 @@ export class AccountService { data: { accountId: account.id, amount, + currency: currency, phase: AccountChargePhase.Pending, channel: channel, - channelData: channelData, createdBy: userid, lockedAt: TASK_LOCK_INIT_TIME, }, @@ -64,15 +68,36 @@ export class AccountService { return order } - async pay(channel: PaymentChannelType, amount: number) { + async findOneChargeOrder(userid: string, id: string) { + const order = await this.prisma.accountChargeOrder.findFirst({ + where: { id, createdBy: userid }, + }) + + return order + } + + async pay( + channel: PaymentChannelType, + orderNumber: string, + amount: number, + currency: Currency, + description = 'Account charge', + ) { + // webchat pay if (channel === PaymentChannelType.WeChat) { - const channelSpec = await this.chanelService.getWeChatPaySpec() - const channelData = await this.wechatPayService.getChannelData( - amount, - channelSpec, - ) - const result = await this.wechatPayService.pay(channelData, channelSpec) - return { result, channelData } + const spec = await this.chanelService.getWeChatPaySpec() + const result = await this.wechatPayService.send(spec, { + mchid: spec.mchid, + appid: spec.appid, + description, + out_trade_no: orderNumber, + notify_url: this.wechatPayService.getNotifyUrl(), + amount: { + total: amount, + currency: currency, + }, + }) + return result } throw new Error('Unsupported payment channel') diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index 65a001787a..91be6e7c3f 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -1,15 +1,22 @@ import { ApiProperty } from '@nestjs/swagger' -import { PaymentChannelType } from '@prisma/client' -import { IsEnum, IsNumber, IsPositive, IsString } from 'class-validator' +import { Currency, PaymentChannelType } from '@prisma/client' +import { IsEnum, IsInt, IsPositive, IsString, Max, Min } from 'class-validator' export class CreateChargeOrderDto { @ApiProperty({ example: 1000 }) @IsPositive() - @IsNumber() + @IsInt() + @Min(1) + @Max(1000000000) amount: number @ApiProperty({ example: PaymentChannelType.WeChat }) @IsString() @IsEnum(PaymentChannelType) - paymentChannel: PaymentChannelType + channel: PaymentChannelType + + @ApiProperty({ example: Currency.CNY }) + @IsString() + @IsEnum(Currency) + currency: Currency } diff --git a/server/src/account/payment/payment-channel.service.ts b/server/src/account/payment/payment-channel.service.ts index d75e4d5c4c..d94eafd0f5 100644 --- a/server/src/account/payment/payment-channel.service.ts +++ b/server/src/account/payment/payment-channel.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { PaymentChannelType } from '@prisma/client' import { PrismaService } from 'src/prisma/prisma.service' -import { WeChatPaymentChannelSpec } from './types' +import { WeChatPaySpec } from './types' @Injectable() export class PaymentChannelService { @@ -33,11 +33,9 @@ export class PaymentChannelService { return res } - async getWeChatPaySpec(): Promise { + async getWeChatPaySpec(): Promise { const res = await this.prisma.paymentChannel.findFirst({ - where: { - type: PaymentChannelType.WeChat, - }, + where: { type: PaymentChannelType.WeChat }, }) if (!res) { diff --git a/server/src/account/payment/types.ts b/server/src/account/payment/types.ts index 7ac5d0b461..d3bc9aa6b8 100644 --- a/server/src/account/payment/types.ts +++ b/server/src/account/payment/types.ts @@ -1,12 +1,13 @@ -export interface WeChatPaymentChannelSpec { +export interface WeChatPaySpec { mchid: string appid: string apiV3Key: string certificateSerialNumber: string + publicKey: string privateKey: string } -export interface WeChatPaymentRequestBody { +export interface WeChatPayOrder { mchid: string appid: string description: string @@ -17,3 +18,48 @@ export interface WeChatPaymentRequestBody { currency: string } } + +export interface WeChatPayOrderResponse { + id: string + create_time: string + resource_type: string + event_type: string + summary: string + resource: { + original_type: string + algorithm: string + ciphertext: string + associated_data: string + nonce: string + } +} + +export enum WeChatPayTradeState { + SUCCESS = 'SUCCESS', + REFUND = 'REFUND', + NOTPAY = 'NOTPAY', + CLOSED = 'CLOSED', + REVOKED = 'REVOKED', + USERPAYING = 'USERPAYING', + PAYERROR = 'PAYERROR', +} + +export interface WeChatPayDecryptedResult { + mchid: string + appid: string + out_trade_no: string + transaction_id: string + trade_type: string + trade_state: WeChatPayTradeState + trade_state_desc: string + bank_type: string + attach: string + success_time: string + payer: { openid: string } + amount: { + total: number + payer_total: number + currency: string + payer_currency: string + } +} diff --git a/server/src/account/payment/wechat-pay.service.ts b/server/src/account/payment/wechat-pay.service.ts index a307098e8b..5a7afcbc10 100644 --- a/server/src/account/payment/wechat-pay.service.ts +++ b/server/src/account/payment/wechat-pay.service.ts @@ -1,73 +1,133 @@ import { Injectable } from '@nestjs/common' -import { GenerateOrderNumber, GenerateRandomString } from 'src/utils/random' -import { WeChatPaymentChannelSpec, WeChatPaymentRequestBody } from './types' +import { + WeChatPaySpec, + WeChatPayOrder, + WeChatPayOrderResponse, + WeChatPayDecryptedResult, +} from './types' import * as crypto from 'crypto' import { HttpService } from '@nestjs/axios' import { ServerConfig } from 'src/constants' +// import * as WxPay from 'wechatpay-node-v3' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const WxPay = require('wechatpay-node-v3') @Injectable() -export class WeChatPaymentService { +export class WeChatPayService { static readonly API_BASE_URL = 'https://api.mch.weixin.qq.com' - static readonly API_NATIVE_PAY_URL = '/v3/pay/transactions/native' constructor(private readonly httpService: HttpService) {} - async pay( - order: WeChatPaymentRequestBody, - channelSpec: WeChatPaymentChannelSpec, - ) { + async send(spec: WeChatPaySpec, order: WeChatPayOrder) { + // sign the order const timestamp = Math.floor(Date.now() / 1000) - const nonceStr = GenerateRandomString(32) - const signature = this.createSign(timestamp, nonceStr, order, channelSpec) - const serialNo = channelSpec.certificateSerialNumber - - const token = `WECHATPAY2-SHA256-RSA2048 mchid="${channelSpec.mchid}",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}",serial_no="${serialNo}"` - const headers = { - Authorization: token, - } + const nonceStr = crypto.randomUUID() + const method = 'POST' + const apiUrl = '/v3/pay/transactions/native' + const signature = this.createSign( + spec, + method, + apiUrl, + timestamp, + nonceStr, + order, + ) - const apiUrl = `${WeChatPaymentService.API_BASE_URL}${WeChatPaymentService.API_NATIVE_PAY_URL}` - const res = await this.httpService.axiosRef.post(apiUrl, order, { - headers, + // send the request + const serialNo = spec.certificateSerialNumber + const token = `WECHATPAY2-SHA256-RSA2048 mchid="${spec.mchid}",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}",serial_no="${serialNo}"` + const fullUrl = `${WeChatPayService.API_BASE_URL}${apiUrl}` + const res = await this.httpService.axiosRef.post(fullUrl, order, { + headers: { Authorization: token }, }) return res.data } - async getChannelData(amount: number, channelSpec: WeChatPaymentChannelSpec) { - const orderNumber = GenerateOrderNumber() - const data: WeChatPaymentRequestBody = { - mchid: channelSpec.mchid, - appid: channelSpec.appid, - description: 'Account charge', - out_trade_no: orderNumber, - notify_url: this.getNotifyUrl(), - amount: { - total: amount * 100, - currency: 'CNY', - }, - } - return data - } - - createSign( + private createSign( + spec: WeChatPaySpec, + method: string, + url: string, timestamp: number, nonceStr: string, - order: WeChatPaymentRequestBody, - channelSpec: WeChatPaymentChannelSpec, + order: WeChatPayOrder, ) { - const method = 'POST' - const url = WeChatPaymentService.API_NATIVE_PAY_URL - const orderStr = JSON.stringify(order) + let orderStr = '' + if (method === 'POST' && order) { + orderStr = JSON.stringify(order) + } const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${orderStr}\n` - const cert = channelSpec.privateKey + const cert = spec.privateKey const sign = crypto.createSign('RSA-SHA256') sign.update(signStr) return sign.sign(cert, 'base64') } + getClient(spec: WeChatPaySpec) { + const client = new WxPay({ + appid: spec.appid, + mchid: spec.mchid, + serial_no: spec.certificateSerialNumber, + key: spec.apiV3Key, + publicKey: Buffer.from(spec.publicKey, 'utf8'), + privateKey: Buffer.from(spec.privateKey, 'utf8'), + }) + + return client + } + + async getWeChatPayNotifyResult( + spec: WeChatPaySpec, + params: { + timestamp: string | number + nonce: string + body: WeChatPayOrderResponse + serial: string + signature: string + }, + ) { + const valid = await this.verifyNotify(spec, params) + if (!valid) { + throw new Error('Invalid wechat pay notify') + } + + const resource = params.body.resource + const result = this.decryptNotify( + spec, + resource.ciphertext, + resource.associated_data, + resource.nonce, + ) + + return result as WeChatPayDecryptedResult + } + + async verifyNotify( + spec: WeChatPaySpec, + params: { + timestamp: string | number + nonce: string + body: string | Record + serial: string + signature: string + }, + ) { + const client = this.getClient(spec) + return await client.verifySign(params) + } + + decryptNotify( + spec: WeChatPaySpec, + ciphertext: string, + associated_data: string, + nonce: string, + ) { + const client = this.getClient(spec) + return client.decipher_gcm(ciphertext, associated_data, nonce) + } + getNotifyUrl() { const apiUrl = ServerConfig.API_SERVER_URL - return `${apiUrl}/v1/accounts/payment/wechat/notify` + return `${apiUrl}/v1/accounts/payment/wechat-notify` } } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 32a235fa59..21590b5a3a 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -28,6 +28,8 @@ export class ApplicationService { }, }) + console.log(bundle, dto.bundleId) + // create app in db const appSecret = { name: APPLICATION_SECRET_KEY, @@ -149,15 +151,8 @@ export class ApplicationService { async remove(appid: string) { try { const res = await this.prisma.application.updateMany({ - where: { - appid, - phase: { - in: [ApplicationPhase.Started, ApplicationPhase.Stopped], - }, - }, - data: { - state: ApplicationState.Deleted, - }, + where: { appid }, + data: { state: ApplicationState.Deleted }, }) return res diff --git a/server/src/auth/application.auth.guard.ts b/server/src/auth/application.auth.guard.ts index 8d46e302b4..a184862158 100644 --- a/server/src/auth/application.auth.guard.ts +++ b/server/src/auth/application.auth.guard.ts @@ -16,7 +16,6 @@ export class ApplicationAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest() as IRequest const appid = request.params.appid const user = request.user as User - this.logger.debug(`check auth of: appid: ${appid}, user: ${user.id}`) const app = await this.appService.findOne(appid) if (!app) { diff --git a/server/src/subscription/dto/create-subscription.dto.ts b/server/src/subscription/dto/create-subscription.dto.ts index 6b25ae5e2a..082735a3a3 100644 --- a/server/src/subscription/dto/create-subscription.dto.ts +++ b/server/src/subscription/dto/create-subscription.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ApplicationState } from '@prisma/client' -import { IsEnum, IsNotEmpty, IsString, Length } from 'class-validator' +import { IsEnum, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' enum CreateApplicationState { Running = 'Running', @@ -36,6 +36,11 @@ export class CreateSubscriptionDto { @IsString() runtimeId: string + @ApiProperty() + @IsInt() + @IsNotEmpty() + duration: number + validate(): string | null { return null } diff --git a/server/src/subscription/entities/subscription.entity.ts b/server/src/subscription/entities/subscription.entity.ts deleted file mode 100644 index c440a768f3..0000000000 --- a/server/src/subscription/entities/subscription.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Subscription {} diff --git a/server/src/subscription/renewal-task.service.ts b/server/src/subscription/renewal-task.service.ts index cca17a3946..6a7d5908cd 100644 --- a/server/src/subscription/renewal-task.service.ts +++ b/server/src/subscription/renewal-task.service.ts @@ -11,7 +11,6 @@ import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' import { AccountService } from 'src/account/account.service' import { Subscription } from 'rxjs' -import { PriceRound } from 'src/utils/number' @Injectable() export class SubscriptionRenewalTaskService { @@ -60,34 +59,43 @@ export class SubscriptionRenewalTaskService { // check account balance const userid = renewal.createdBy - const account = await this.accountService.findOne(userid.toString()) - - if (account?.balance < renewal.amount) { - return - } - const session = client.startSession() await session .withTransaction(async () => { + const account = await db + .collection('Account') + .findOne({ createdBy: userid }, { session }) + + // if account balance is not enough, delete the subscription & renewal order + if (account?.balance < renewal.amount) { + await db + .collection('SubscriptionRenewal') + .deleteOne({ _id: renewal._id }, { session }) + + await db + .collection('Subscription') + .deleteOne( + { _id: new ObjectId(renewal.subscriptionId) }, + { session }, + ) + return + } + // Pay the subscription renewal order from account balance - const priceAmount = Math.round(renewal.amount * 100) - const r0 = await db.collection('Account').updateOne( - { - createdBy: userid, - balance: { - $gte: priceAmount, + const priceAmount = renewal.amount + if (priceAmount !== 0) { + await db.collection('Account').updateOne( + { + createdBy: userid, + balance: { $gte: priceAmount }, }, - }, - { $inc: { balance: -priceAmount } }, - { session }, - ) - - if (r0.modifiedCount === 0) { - throw new Error('Insufficient balance') + { $inc: { balance: -priceAmount } }, + { session }, + ) } // Update subscription 'expiredAt' time - const r1 = await db.collection('Subscription').updateOne( + await db.collection('Subscription').updateOne( { _id: new ObjectId(renewal.subscriptionId) }, [ { @@ -99,12 +107,8 @@ export class SubscriptionRenewalTaskService { { session }, ) - if (r1.modifiedCount === 0) { - throw new Error('Subscription not found') - } - // Update subscription renewal order phase to ‘Paid’ - const r2 = await db + await db .collection('SubscriptionRenewal') .updateOne( { _id: renewal._id }, @@ -116,10 +120,6 @@ export class SubscriptionRenewalTaskService { }, { session }, ) - - if (r2.modifiedCount === 0) { - throw new Error('SubscriptionRenewal not found') - } }) .catch((err) => { this.logger.debug(renewal._id, err.toString()) diff --git a/server/src/subscription/subscription-task.service.ts b/server/src/subscription/subscription-task.service.ts index 3690b38e88..5ab5b49bc3 100644 --- a/server/src/subscription/subscription-task.service.ts +++ b/server/src/subscription/subscription-task.service.ts @@ -80,7 +80,9 @@ export class SubscriptionTaskService { dto.regionId = doc.input.regionId dto.state = doc.input.state as ApplicationState dto.runtimeId = doc.input.runtimeId - dto.bundleId = doc.bundleId + // doc.bundleId is ObjectId, but prisma typed it as string, so we need to convert it + dto.bundleId = doc.bundleId.toString() + this.logger.debug(dto) await this.applicationService.create(userid, doc.appid, dto) return await this.unlock(doc._id) @@ -226,6 +228,7 @@ export class SubscriptionTaskService { .findOneAndUpdate( { state: SubscriptionState.Deleted, + phase: { $not: { $eq: SubscriptionPhase.Deleted } }, lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, }, { $set: { lockedAt: new Date() } }, @@ -234,8 +237,18 @@ export class SubscriptionTaskService { const doc = res.value - // Update application state to ‘Deleted’ - await this.applicationService.remove(doc.appid) + const app = await this.applicationService.findOne(doc.appid) + if (app && app.state !== ApplicationState.Deleted) { + // delete application, update application state to ‘Deleted’ + await this.applicationService.remove(doc.appid) + this.logger.debug(`deleting application: ${doc.appid}`) + } + + // wait for application to be deleted + if (app) { + this.logger.debug(`waiting for application to be deleted: ${doc.appid}`) + return // return directly without unlocking it + } // Update subscription phase to 'Deleted' await db.collection('Subscription').updateOne( @@ -247,11 +260,12 @@ export class SubscriptionTaskService { }, }, ) + this.logger.debug(`subscription phase updated to deleted: ${doc.appid}`) } @Cron(CronExpression.EVERY_MINUTE) async handlePendingTimeout() { - const timeout = 30 * 60 * 1000 + const timeout = 10 * 60 * 1000 const db = SystemDatabase.db await db.collection('Subscription').deleteMany({ diff --git a/server/src/subscription/subscription.controller.ts b/server/src/subscription/subscription.controller.ts index b0522d9857..a9d23ff413 100644 --- a/server/src/subscription/subscription.controller.ts +++ b/server/src/subscription/subscription.controller.ts @@ -9,7 +9,6 @@ import { Logger, UseGuards, Req, - Query, } from '@nestjs/common' import { SubscriptionService } from './subscription.service' import { CreateSubscriptionDto } from './dto/create-subscription.dto' @@ -24,8 +23,9 @@ import { ApplicationService } from 'src/application/application.service' import { RegionService } from 'src/region/region.service' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { RenewSubscriptionDto } from './dto/renew-subscription.dto' -import { SubscriptionPhase } from '@prisma/client' import * as assert from 'assert' +import { SubscriptionPhase } from '@prisma/client' +import { AccountService } from 'src/account/account.service' @ApiTags('Subscription') @Controller('subscriptions') @@ -39,6 +39,7 @@ export class SubscriptionController { private readonly bundleService: BundleService, private readonly prisma: PrismaService, private readonly regionService: RegionService, + private readonly accountService: AccountService, ) {} /** @@ -50,17 +51,6 @@ export class SubscriptionController { async create(@Body() dto: CreateSubscriptionDto, @Req() req: IRequest) { const user = req.user - // check if user has a pending subscription - const pendingCount = await this.prisma.subscription.count({ - where: { - phase: SubscriptionPhase.Pending, - createdBy: user.id, - }, - }) - if (pendingCount > 5) { - return ResponseUtil.error(`you have a pending subscription`) - } - // check regionId exists const region = await this.regionService.findOneDesensitized(dto.regionId) if (!region) { @@ -83,10 +73,11 @@ export class SubscriptionController { // check app count limit const LIMIT_COUNT = bundle.limitCountPerUser || 0 - const count = await this.prisma.application.count({ + const count = await this.prisma.subscription.count({ where: { createdBy: user.id, - bundle: { bundleId: dto.bundleId }, + bundleId: dto.bundleId, + phase: { not: SubscriptionPhase.Deleted }, }, }) if (count >= LIMIT_COUNT) { @@ -95,10 +86,33 @@ export class SubscriptionController { ) } + // check duration supported + const option = this.bundleService.getSubscriptionOption( + bundle, + dto.duration, + ) + if (!option) { + return ResponseUtil.error(`duration not supported in bundle`) + } + + // check account balance + const account = await this.accountService.findOne(user.id) + const balance = account?.balance || 0 + const priceAmount = option.specialPrice || option.price + if (balance < priceAmount) { + return ResponseUtil.error( + `account balance is not enough, need ${priceAmount} but only ${account.balance}`, + ) + } + // create subscription const appid = await this.applicationService.tryGenerateUniqueAppid() - const subscription = await this.subscriptService.create(user.id, appid, dto) - + const subscription = await this.subscriptService.create( + user.id, + appid, + dto, + option, + ) return ResponseUtil.ok(subscription) } @@ -129,31 +143,6 @@ export class SubscriptionController { return ResponseUtil.ok(subscription) } - /** - * Calculate subscription renewal price - */ - @ApiOperation({ summary: 'Calculate subscription renewal price' }) - @UseGuards(JwtAuthGuard) - @Get('renewal/price') - async getRenewalPrice( - @Query('bundleId') bundleId: string, - @Query('duration') duration: number, - ) { - // get bundle - const bundle = await this.bundleService.findOne(bundleId) - if (!bundle) { - return ResponseUtil.error(`bundle ${bundleId} not found`) - } - - const option = this.bundleService.getSubscriptionOption(bundle, duration) - if (!option) { - return ResponseUtil.error(`duration not supported in bundle`) - } - - const result = await this.subscriptService.getRenewalPrice(option, duration) - return ResponseUtil.ok(result) - } - /** * Renew a subscription */ @@ -181,11 +170,7 @@ export class SubscriptionController { if (!option) { return ResponseUtil.error(`duration not supported in bundle`) } - - const priceAmount = await this.subscriptService.getRenewalPrice( - option, - duration, - ) + const priceAmount = option.specialPrice || option.price // renew subscription const res = await this.subscriptService.renew( diff --git a/server/src/subscription/subscription.service.ts b/server/src/subscription/subscription.service.ts index 469a27fc9d..ac6532a661 100644 --- a/server/src/subscription/subscription.service.ts +++ b/server/src/subscription/subscription.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { - Bundle, BundleSubscriptionOption, Subscription, SubscriptionPhase, @@ -8,13 +7,10 @@ import { SubscriptionRenewalPlan, SubscriptionState, } from '@prisma/client' -import * as assert from 'assert' -import { ONE_MONTH_IN_SECONDS, TASK_LOCK_INIT_TIME } from 'src/constants' +import { TASK_LOCK_INIT_TIME } from 'src/constants' import { PrismaService } from 'src/prisma/prisma.service' import { BundleService } from 'src/region/bundle.service' -import { PriceRound } from 'src/utils/number' import { CreateSubscriptionDto } from './dto/create-subscription.dto' -import { RenewSubscriptionDto } from './dto/renew-subscription.dto' @Injectable() export class SubscriptionService { @@ -25,23 +21,46 @@ export class SubscriptionService { private readonly bundleService: BundleService, ) {} - async create(userid: string, appid: string, dto: CreateSubscriptionDto) { - const res = await this.prisma.subscription.create({ - data: { - input: { - name: dto.name, - state: dto.state, - regionId: dto.regionId, - runtimeId: dto.runtimeId, + async create( + userid: string, + appid: string, + dto: CreateSubscriptionDto, + option: BundleSubscriptionOption, + ) { + // start transaction + const res = await this.prisma.$transaction(async (tx) => { + // create subscription + const subscription = await tx.subscription.create({ + data: { + input: { + name: dto.name, + state: dto.state, + regionId: dto.regionId, + runtimeId: dto.runtimeId, + }, + appid: appid, + bundleId: dto.bundleId, + phase: SubscriptionPhase.Pending, + renewalPlan: SubscriptionRenewalPlan.Manual, + expiredAt: new Date(), + lockedAt: TASK_LOCK_INIT_TIME, + createdBy: userid, }, - appid: appid, - bundleId: dto.bundleId, - phase: SubscriptionPhase.Pending, - renewalPlan: SubscriptionRenewalPlan.Manual, - expiredAt: new Date(), - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: userid, - }, + }) + + // create subscription renewal + await tx.subscriptionRenewal.create({ + data: { + subscriptionId: subscription.id, + duration: option.duration, + amount: option.price, + phase: SubscriptionRenewalPhase.Pending, + lockedAt: TASK_LOCK_INIT_TIME, + createdBy: userid, + }, + }) + + return subscription }) return res @@ -50,6 +69,7 @@ export class SubscriptionService { async findAll(userid: string) { const res = await this.prisma.subscription.findMany({ where: { createdBy: userid }, + include: { application: true }, }) return res @@ -58,6 +78,7 @@ export class SubscriptionService { async findOne(userid: string, id: string) { const res = await this.prisma.subscription.findUnique({ where: { id }, + include: { application: true }, }) return res @@ -82,19 +103,6 @@ export class SubscriptionService { return res } - /** - * Calculate renewal price - * - calculate price amount based on bundle price and duration: - * - price per day = bundle price / 31 - * - price amount = price per day * (duration / 3600 / 24 ) - */ - async getRenewalPrice(option: BundleSubscriptionOption, duration: number) { - const price = Number(option.specialPrice || option.price) - const months = PriceRound(duration / ONE_MONTH_IN_SECONDS) - const priceAmount = PriceRound(price * months) - return priceAmount - } - /** * Renew a subscription by creating a subscription renewal */