xiongzhu 2 سال پیش
والد
کامیت
3b75e4ed9e

+ 2 - 1
.env

@@ -67,4 +67,5 @@ WX_APP_SECRET=33097584ed2af75d5fd04ce15ad1eda8
 WX_MCH_ID=1642294106
 WX_MCH_KEY=4DYXfhBFSq7PIik4QyuqyiFpAoSvDzxO
 WX_MCH_CERT_SERIAL=49CCEA09EA571001147A905BDA24B3B8C804A884
-WX_MCH_CERT_PATH=src/cert/
+WX_MCH_CERT_PATH=src/cert/
+WX_NOTIFY_URL=https://chillgpt.raexmeta.com/api/notify/weixin

+ 2 - 1
.env.production

@@ -67,4 +67,5 @@ WX_APP_SECRET=33097584ed2af75d5fd04ce15ad1eda8
 WX_MCH_ID=1642294106
 WX_MCH_KEY=4DYXfhBFSq7PIik4QyuqyiFpAoSvDzxO
 WX_MCH_CERT_SERIAL=49CCEA09EA571001147A905BDA24B3B8C804A884
-WX_MCH_CERT_PATH=/root/cert/
+WX_MCH_CERT_PATH=/root/cert/
+WX_NOTIFY_URL=https://chillgpt.raexmeta.com/api/notify/weixin

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 245 - 244
graph.json


+ 3 - 2
package.json

@@ -37,7 +37,6 @@
     "@nestjs/swagger": "^6.2.1",
     "@nestjs/throttler": "^4.0.0",
     "@nestjs/typeorm": "^9.0.1",
-    "@types/multer": "^1.4.7",
     "ali-oss": "^6.17.1",
     "axios": "^1.3.5",
     "bcrypt": "^5.1.0",
@@ -66,7 +65,7 @@
     "rxjs": "^7.8.0",
     "tnwx": "^2.5.6",
     "typeorm": "^0.3.12",
-    "wechatpay-node-v3": "^2.1.1",
+    "uuid": "^9.0.0",
     "yup": "^1.0.0"
   },
   "devDependencies": {
@@ -75,10 +74,12 @@
     "@nestjs/testing": "^9.3.3",
     "@types/express": "^4.17.17",
     "@types/jest": "^29.4.0",
+    "@types/multer": "^1.4.7",
     "@types/node": "^18.14.1",
     "@types/nodemailer": "^6.4.7",
     "@types/nodemailer-express-handlebars": "^4.0.2",
     "@types/supertest": "^2.0.12",
+    "@types/uuid": "^9.0.1",
     "@typescript-eslint/eslint-plugin": "^5.53.0",
     "@typescript-eslint/parser": "^5.53.0",
     "eslint": "^8.34.0",

+ 3 - 1
src/app.module.ts

@@ -14,6 +14,7 @@ import { FileModule } from './file/file.module'
 import { ChatModule } from './chat/chat.module'
 import { MembershipModule } from './membership/membership.module'
 import { WeixinModule } from './weixin/weixin.module'
+import { NotifyModule } from './notify/notify.module';
 
 @Module({
     imports: [
@@ -66,7 +67,8 @@ import { WeixinModule } from './weixin/weixin.module'
         FileModule,
         ChatModule,
         MembershipModule,
-        WeixinModule
+        WeixinModule,
+        NotifyModule
     ],
     controllers: [AppController],
     providers: [AppService]

+ 10 - 0
src/membership/dto/renew.dto.ts

@@ -1,3 +1,13 @@
+import { IsNotEmpty } from "class-validator"
+
 export class RenewDto {
+    userId?: number
+
+    @IsNotEmpty()
     planId: number
+
+    @IsNotEmpty()
+    type: 'JSAPI' | 'WEB'
+    
+    openid?: string
 }

+ 34 - 0
src/membership/entities/member-order.entity.ts

@@ -0,0 +1,34 @@
+import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
+
+export enum MemberOrderStatus {
+    NOT_PAID = 'NOT_PAID',
+    FINISH = 'FINISH',
+    CANCEL = 'CANCEL'
+}
+
+@Entity()
+export class MemberOrder {
+    @PrimaryGeneratedColumn()
+    id: number
+
+    @Column()
+    userId: number
+
+    @Column()
+    planId: number
+
+    @Column()
+    price: number
+
+    @Column()
+    name: string
+
+    @CreateDateColumn()
+    createdAt: Date
+
+    @Column({ type: 'enum', enum: MemberOrderStatus })
+    status: MemberOrderStatus
+
+    @Column({ nullable: true })
+    finishTime: Date
+}

+ 9 - 2
src/membership/membership.controller.ts

@@ -1,7 +1,7 @@
 import { ApiTags } from '@nestjs/swagger'
 import { Public } from '../auth/public.decorator'
 import { MembershipService } from './membership.service'
-import { Body, Controller, Get, Post, Req } from '@nestjs/common'
+import { BadRequestException, Body, Controller, Get, NotImplementedException, Post, Req } from '@nestjs/common'
 import { RenewDto } from './dto/renew.dto'
 
 @ApiTags('membership')
@@ -16,7 +16,14 @@ export class MembershipController {
 
     @Post('/renew')
     async renewMembership(@Req() req, @Body() body: RenewDto) {
-        return this.membershipService.renewMembership(req.user.id, body.planId)
+        if (!body.openid) {
+            throw new BadRequestException('openid is required')
+        }
+        if (body.type === 'JSAPI') {
+            return await this.membershipService.combinedJsapi(req.user.id, body.planId, body.openid)
+        } else {
+            new NotImplementedException()
+        }
     }
 
     @Public()

+ 5 - 2
src/membership/membership.module.ts

@@ -5,10 +5,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'
 import { Membership } from './entities/membership.entity'
 import { MemberPlan } from './entities/memberPlan.entity'
 import { MembershipAdminController } from './membership.admin.controller'
+import { MemberOrder } from './entities/member-order.entity'
+import { WeixinModule } from '../weixin/weixin.module'
 
 @Module({
-    imports: [TypeOrmModule.forFeature([Membership, MemberPlan]), TypeOrmModule.forFeature([MemberPlan])],
+    imports: [TypeOrmModule.forFeature([Membership, MemberPlan, MemberOrder]), WeixinModule],
     providers: [MembershipService],
-    controllers: [MembershipController, MembershipAdminController]
+    controllers: [MembershipController, MembershipAdminController],
+    exports: [MembershipService]
 })
 export class MembershipModule {}

+ 51 - 1
src/membership/membership.service.ts

@@ -5,11 +5,16 @@ import { Repository } from 'typeorm'
 import { MemberPlan } from './entities/memberPlan.entity'
 import { addDays } from 'date-fns'
 import { MemberPlanDto } from './dto/memberPlan.dto'
+import { MemberOrder, MemberOrderStatus } from './entities/member-order.entity'
+import { WeixinService } from 'src/weixin/weixin.service'
+import { Attach, AttachType } from 'src/weixin/dto/attach.dto'
 @Injectable()
 export class MembershipService {
     constructor(
         @InjectRepository(Membership) private readonly memberShipRepository: Repository<Membership>,
-        @InjectRepository(MemberPlan) private readonly memberPlanRepository: Repository<MemberPlan>
+        @InjectRepository(MemberPlan) private readonly memberPlanRepository: Repository<MemberPlan>,
+        @InjectRepository(MemberPlan) private readonly memberOrderRepository: Repository<MemberOrder>,
+        private readonly weixinService: WeixinService
     ) {}
 
     async renewMembership(userId: number, planId: number) {
@@ -57,4 +62,49 @@ export class MembershipService {
     async save(memberPlanDto: MemberPlanDto) {
         return await this.memberPlanRepository.save(memberPlanDto)
     }
+
+    async createOrder(userId: number, planId: number) {
+        const plan = await this.memberPlanRepository.findOneBy({
+            id: planId
+        })
+        if (!plan) {
+            throw new NotFoundException(`Plan #${planId} not found`)
+        }
+        const order: MemberOrder = await this.memberOrderRepository.save({
+            name: plan.name,
+            userId: userId,
+            planId: planId,
+            price: plan.price
+        })
+        return order
+    }
+
+    async combinedJsapi(userId: number, planId: number, openid: string) {
+        const order: MemberOrder = await this.createOrder(userId, planId)
+        return await this.weixinService.jsapiPay(
+            '会员-' + order.name,
+            order.price,
+            openid,
+            new Attach(AttachType.MEMBER_ORDER, order.id)
+        )
+    }
+
+    async orderNotify(orderId: number) {
+        const order: MemberOrder = await this.memberOrderRepository.findOneBy({
+            id: orderId
+        })
+        if (!order) {
+            throw new NotFoundException(`Order #${orderId} not found`)
+        }
+        if (order.status === MemberOrderStatus.CANCEL) {
+            throw new NotFoundException(`Order #${orderId} has been canceled`)
+        }
+        if (order.status === MemberOrderStatus.FINISH) {
+            return
+        }
+        order.status = MemberOrderStatus.FINISH
+        order.finishTime = new Date()
+        await this.memberOrderRepository.save(order)
+        await this.renewMembership(order.userId, order.planId)
+    }
 }

+ 15 - 0
src/notify/notify.controller.ts

@@ -0,0 +1,15 @@
+import { Body, Controller, Headers, HttpCode, Post } from '@nestjs/common'
+import { NotifyService } from './notify.service'
+import { Public } from 'src/auth/public.decorator'
+
+@Controller('/notify')
+export class NotifyController {
+    constructor(private readonly notifyService: NotifyService) {}
+
+    @Public()
+    @Post('/weixin')
+    @HttpCode(200)
+    public async weixin(@Headers() headers, @Body() body: WechatPay.NotifyEvent) {
+        await this.notifyService.handleWeixinNotify(headers, body)
+    }
+}

+ 12 - 0
src/notify/notify.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common'
+import { NotifyService } from './notify.service'
+import { NotifyController } from './notify.controller'
+import { WeixinModule } from 'src/weixin/weixin.module'
+import { MembershipModule } from 'src/membership/membership.module'
+
+@Module({
+    imports: [WeixinModule, MembershipModule],
+    providers: [NotifyService],
+    controllers: [NotifyController]
+})
+export class NotifyModule {}

+ 20 - 0
src/notify/notify.service.ts

@@ -0,0 +1,20 @@
+import { MembershipService } from './../membership/membership.service'
+import { Injectable } from '@nestjs/common'
+import { Attach, AttachType } from '../weixin/dto/attach.dto'
+import { WeixinService } from '../weixin/weixin.service'
+
+@Injectable()
+export class NotifyService {
+    constructor(private readonly weixinService: WeixinService, private readonly membershipService: MembershipService) {}
+
+    public async handleWeixinNotify(headers, body: WechatPay.NotifyEvent) {
+        const data: WechatPay.NotifyData = await this.weixinService.getNotifyData(headers, body)
+        if (data.trade_state === 'SUCCESS') {
+            if (!data.attach) return
+            const attach: Attach = JSON.parse(data.attach)
+            if (attach.type === AttachType.MEMBER_ORDER) {
+                await this.membershipService.orderNotify(attach.id)
+            }
+        }
+    }
+}

+ 15 - 0
src/weixin/dto/attach.dto.ts

@@ -0,0 +1,15 @@
+export enum AttachType {
+    MEMBER_ORDER = 'MEMBER_ORDER'
+}
+
+export class Attach {
+    type: AttachType
+    id?: number
+    extra?: any
+
+    constructor(type: AttachType, id?: number, extra?: any) {
+        this.type = type
+        this.id = id
+        this.extra = extra
+    }
+}

+ 0 - 14
src/weixin/dto/notify.dto.ts

@@ -1,14 +0,0 @@
-export class WeixinNotifyDto {
-    id: string
-    create_time?: Date
-    resource_type?: string
-    event_type?: string
-    summary?: string
-    resource?: {
-        original_type?: string
-        algorithm?: string
-        ciphertext?: string
-        associated_data?: string
-        nonce?: string
-    }
-}

+ 73 - 0
src/weixin/types.ts

@@ -0,0 +1,73 @@
+declare namespace WechatPay {
+    type TradeState = 'SUCCESS' | 'REFUND' | 'NOTPAY' | 'CLOSED' | 'REVOKED' | 'USERPAYING' | 'PAYERROR'
+    type TradeType = 'JSAPI' | 'NATIVE' | 'APP' | 'MICROPAY' | 'MWEB' | 'FACEPAY'
+
+    interface Amount {
+        total: number
+        payer_total?: number
+        currency: string
+        payer_currency?: string
+    }
+
+    interface GoodsDetail {
+        goods_remark: string
+        quantity: number
+        discount_amount: number
+        goods_id: string
+        unit_price: number
+    }
+
+    interface PromotionDetail {
+        amount: number
+        wechatpay_contribute: number
+        coupon_id: string
+        scope: string
+        merchant_contribute: number
+        name: string
+        other_contribute: number
+        currency: string
+        stock_id: string
+        goods_detail: GoodsDetail[]
+    }
+
+    interface Payer {
+        openid: string
+    }
+
+    interface SceneInfo {
+        device_id: string
+    }
+
+    interface NotifyData {
+        transaction_id: string
+        amount: Amount
+        mchid: string
+        trade_state: TradeState
+        bank_type: string
+        success_time: Date
+        payer: Payer
+        out_trade_no: string
+        appid: string
+        trade_state_desc: string
+        trade_type: TradeType
+        attach?: string
+        scene_info?: any
+        promotion_detail?: any
+    }
+
+    interface Resource {
+        original_type?: string
+        algorithm: string
+        ciphertext: string
+        associated_data: string
+        nonce: string
+    }
+    interface NotifyEvent {
+        id: string
+        create_time: Date
+        resource_type: string
+        event_type: string
+        summary: string
+        resource: Resource
+    }
+}

+ 1 - 0
src/weixin/weixin.config.ts

@@ -13,5 +13,6 @@ export default registerAs('weixin', () => {
         mchKey: configService.get<string>('WX_MCH_KEY'),
         certSerial: configService.get<string>('WX_MCH_CERT_SERIAL'),
         certPath: configService.get<string>('WX_MCH_CERT_PATH'),
+        notifyUrl: configService.get<string>('WX_NOTIFY_URL'),
     }
 })

+ 3 - 17
src/weixin/weixin.controller.ts

@@ -1,9 +1,7 @@
-import { Body, Controller, Get, HttpCode, Post, Query, Headers } from '@nestjs/common'
+import { Controller, Get, Query } from '@nestjs/common'
 import { WeixinService } from './weixin.service'
 import { Public } from 'src/auth/public.decorator'
-import { WeixinNotifyDto } from './dto/notify.dto'
 import { ApiTags } from '@nestjs/swagger'
-import { Request } from 'express'
 
 @ApiTags('weixin')
 @Controller('/weixin')
@@ -17,8 +15,8 @@ export class WeixinController {
 
     @Public()
     @Get('/redirectUrl')
-    public getRedirectUrl(url: string) {
-        return this.weixinService.getRedirectUrl()
+    public getRedirectUrl(@Query() { url, state }) {
+        return this.weixinService.getRedirectUrl(url, state)
     }
 
     @Public()
@@ -27,11 +25,6 @@ export class WeixinController {
         return await this.weixinService.code2oenId(code)
     }
 
-    @Get('/getCert')
-    public async getCert() {
-        return await this.weixinService.getCert()
-    }
-
     @Public()
     @Get('/pay')
     public async pay(@Query() { openid }) {
@@ -43,11 +36,4 @@ export class WeixinController {
     public async jsapiSign(@Query() { url }) {
         return await this.weixinService.jsapiSign(url)
     }
-
-    @Public()
-    @Post('/notify')
-    @HttpCode(200)
-    public async notify(@Headers() headers, @Body() body: WeixinNotifyDto) {
-        await this.weixinService.notify(headers, body)
-    }
 }

+ 2 - 1
src/weixin/weixin.module.ts

@@ -10,6 +10,7 @@ import { JsapiTicketCache } from './entities/jsapi-ticket-cache.entity'
 @Module({
     imports: [ConfigModule.forFeature(weixinConfig), TypeOrmModule.forFeature([AccessTokenCache, JsapiTicketCache])],
     controllers: [WeixinController],
-    providers: [WeixinService]
+    providers: [WeixinService],
+    exports: [WeixinService]
 })
 export class WeixinModule {}

+ 64 - 14
src/weixin/weixin.service.ts

@@ -22,7 +22,8 @@ import { LessThan, MoreThan, Not, Repository } from 'typeorm'
 import { addSeconds } from 'date-fns'
 import * as fs from 'node:fs'
 import { JsapiTicketCache } from './entities/jsapi-ticket-cache.entity'
-import { WeixinNotifyDto } from './dto/notify.dto'
+import { Attach } from './dto/attach.dto'
+import { v4 as uuid } from 'uuid'
 
 @Injectable()
 export class WeixinService {
@@ -85,12 +86,8 @@ export class WeixinService {
         return res
     }
 
-    getRedirectUrl() {
-        return SnsAccessTokenApi.getAuthorizeUrl(
-            'https://chillgpt.raexmeta.com/ui/#/home',
-            ScopeEnum.SNSAPI_BASE,
-            'pay'
-        )
+    getRedirectUrl(url: string, state?: string) {
+        return SnsAccessTokenApi.getAuthorizeUrl(url, ScopeEnum.SNSAPI_BASE, state)
     }
 
     async code2oenId(code: string) {
@@ -151,6 +148,53 @@ export class WeixinService {
         }
     }
 
+    async jsapiPay(description: string, amount: number, openid: string, attach: Attach) {
+        let data = {
+            appid: this.weixinConfiguration.appId,
+            mchid: this.weixinConfiguration.mchId,
+            description: description,
+            out_trade_no: uuid(),
+            notify_url: this.weixinConfiguration.notifyUrl,
+            amount: {
+                total: amount * 100,
+                currency: 'CNY'
+            },
+            payer: {
+                openid
+            },
+            attach: JSON.stringify(attach)
+        }
+        let result = await PayKit.exePost(
+            WX_DOMAIN.CHINA, //
+            WX_API_TYPE.JS_API_PAY,
+            this.weixinConfiguration.mchId,
+            this.weixinConfiguration.certSerial,
+            this.privateKey,
+            JSON.stringify(data)
+        )
+        Logger.log(JSON.stringify(result.data, null, 2), 'jsapi下单')
+        if (result.status === 200) {
+            const timeStamp = parseInt((new Date().getTime() / 1000).toString()).toString()
+            const nonceStr = Kits.generateStr()
+            const packageStr = `prepay_id=${result.data.prepay_id}`
+            const signType = 'RSA'
+            const signStr =
+                [this.weixinConfiguration.appId, timeStamp, nonceStr, packageStr].join(String.fromCharCode(10)) +
+                String.fromCharCode(10)
+            const paySign = Kits.sha256WithRsa(signStr, this.privateKey)
+            return {
+                appId: this.weixinConfiguration.appId,
+                timestamp: timeStamp,
+                nonceStr,
+                package: packageStr,
+                signType,
+                paySign
+            }
+        } else {
+            throw new InternalServerErrorException(result.data.message)
+        }
+    }
+
     async getCert() {
         try {
             let result = await PayKit.exeGet(
@@ -206,17 +250,23 @@ export class WeixinService {
         }
     }
 
-    async notify(headers, data: WeixinNotifyDto) {
+    async getNotifyData(headers, data: WechatPay.NotifyEvent) {
         Logger.log(JSON.stringify(headers, null, 2), '微信回调Header')
         Logger.log(JSON.stringify(data, null, 2), '微信回调Body')
         let verifySignature: boolean = PayKit.verifySign(headers, JSON.stringify(data), this.platformPlublicKey)
         Logger.log(verifySignature, '验证签名')
-        let decrypt = PayKit.aes256gcmDecrypt(
-            this.weixinConfiguration.mchKey,
-            data.resource.nonce,
-            data.resource.associated_data,
-            data.resource.ciphertext
+        if (!verifySignature) {
+            throw new InternalServerErrorException('签名验证失败')
+        }
+        let notifyData: WechatPay.NotifyData = JSON.parse(
+            PayKit.aes256gcmDecrypt(
+                this.weixinConfiguration.mchKey,
+                data.resource.nonce,
+                data.resource.associated_data,
+                data.resource.ciphertext
+            )
         )
-        Logger.log(decrypt, '解密数据')
+        Logger.log(JSON.stringify(notifyData, null, 2), '解密数据')
+        return notifyData
     }
 }

+ 6 - 9
yarn.lock

@@ -1332,6 +1332,11 @@
   dependencies:
     "@types/superagent" "*"
 
+"@types/uuid@^9.0.1":
+  version "9.0.1"
+  resolved "https://registry.npmmirror.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6"
+  integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==
+
 "@types/webidl-conversions@*":
   version "7.0.0"
   resolved "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz#2b8e60e33906459219aa587e9d1a612ae994cfe7"
@@ -5963,7 +5968,7 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
   resolved "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
-superagent@^8.0.5, superagent@^8.0.6:
+superagent@^8.0.5:
   version "8.0.9"
   resolved "https://registry.npmmirror.com/superagent/-/superagent-8.0.9.tgz#2c6fda6fadb40516515f93e9098c0eb1602e0535"
   integrity sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==
@@ -6581,14 +6586,6 @@ webpack@5.76.2:
     watchpack "^2.4.0"
     webpack-sources "^3.2.3"
 
-wechatpay-node-v3@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.npmmirror.com/wechatpay-node-v3/-/wechatpay-node-v3-2.1.1.tgz#d84f8463cb44b811b5990740991c8c5d1ae472b4"
-  integrity sha512-pAWxzXd7xz4YonFDXvJTG4hc5o+3NPWDwKrC8wykQ0yCTltHFfrPwrEqvMFq28aqz69jp223gY6At3taDkpdCg==
-  dependencies:
-    "@fidm/x509" "^1.2.1"
-    superagent "^8.0.6"
-
 whatwg-fetch@^3.4.1:
   version "3.6.2"
   resolved "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است