微信SDK接通
This commit is contained in:
@@ -10,4 +10,15 @@ MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
# TRUST_PROXY=true
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 微信SDK(WECHAT_AUTH_ENABLED=true 时生效)
|
||||
WX_APP_ID=
|
||||
WX_APP_SECRET=
|
||||
WECHAT_AUTH_ENABLED=false
|
||||
WECHAT_PAY_ENABLED=false
|
||||
WX_MCH_ID=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
|
||||
@@ -1,3 +1,4 @@
|
||||
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
||||
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
||||
|
||||
@@ -1,21 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
|
||||
import { WechatApiProvider } from './wechat/wechat.api.provider';
|
||||
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||
import {
|
||||
SMS_PROVIDER,
|
||||
PAY_PROVIDER,
|
||||
DELIVERY_PROVIDER,
|
||||
WECHAT_PROVIDER,
|
||||
} from './integrations.constants';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||
import type { IPayProvider } from './pay/pay.interface';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
|
||||
WechatApiProvider,
|
||||
WechatDisabledProvider,
|
||||
{
|
||||
provide: WECHAT_PROVIDER,
|
||||
useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
return cfg.wechatAuthEnabled && cfg.wxAppId ? api : disabled;
|
||||
},
|
||||
inject: [WechatApiProvider, WechatDisabledProvider],
|
||||
},
|
||||
PayMockProvider,
|
||||
PayWechatProvider,
|
||||
{
|
||||
provide: PAY_PROVIDER,
|
||||
useFactory: (mock: PayMockProvider, wechat: PayWechatProvider): IPayProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
return cfg.mockPay ? mock : wechat;
|
||||
},
|
||||
inject: [PayMockProvider, PayWechatProvider],
|
||||
},
|
||||
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||
SmsMockProvider,
|
||||
PayMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
|
||||
export type PayOrderResult =
|
||||
| { mode: 'mock'; externalNo: string }
|
||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams };
|
||||
|
||||
export interface IPayProvider {
|
||||
payOrder(orderId: bigint): Promise<{ externalNo: string }>;
|
||||
payOrder(orderId: bigint, openId?: string): Promise<PayOrderResult>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IPayProvider } from './pay.interface';
|
||||
import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async payOrder(_orderId: bigint): Promise<{ externalNo: string }> {
|
||||
async payOrder(_orderId: bigint, _openId?: string): Promise<PayOrderResult> {
|
||||
if (!this.config.mockPay) {
|
||||
throw new Error('Real WeChat pay not implemented in preV1');
|
||||
throw new Error('Real WeChat pay requires PayWechatProvider');
|
||||
}
|
||||
return { externalNo: `MOCK-${Date.now()}` };
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { WECHAT_PROVIDER } from '../integrations.constants';
|
||||
import type { IWechatProvider } from '../wechat/wechat.interface';
|
||||
import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayWechatProvider implements IPayProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
async payOrder(orderId: bigint, openId?: string): Promise<PayOrderResult> {
|
||||
if (this.config.mockPay) {
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
if (!openId) {
|
||||
throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
|
||||
}
|
||||
if (!this.wechat.isEnabled()) {
|
||||
throw new Error('微信能力未启用,请配置 WECHAT_AUTH_ENABLED 与 WX_APP_ID/SECRET');
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new Error('订单不存在');
|
||||
|
||||
const amountFen = Math.round(Number(order.payAmount) * 100);
|
||||
const prepay = await this.wechat.createJsapiPrepay({
|
||||
orderNo: order.orderNo,
|
||||
description: `杜康好客订单 ${order.orderNo}`,
|
||||
amountFen,
|
||||
openId,
|
||||
notifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '',
|
||||
});
|
||||
return { mode: 'jsapi', prepay };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
||||
|
||||
type TokenCache = { accessToken: string; expiresAt: number };
|
||||
type TicketCache = { ticket: string; expiresAt: number };
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
||||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||
|
||||
@Injectable()
|
||||
export class WechatApiProvider implements IWechatProvider {
|
||||
private readonly logger = new Logger(WechatApiProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly appId = process.env.WX_APP_ID ?? '';
|
||||
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
||||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||||
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
isEnabled() {
|
||||
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
||||
}
|
||||
|
||||
buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') {
|
||||
const qs = new URLSearchParams({
|
||||
appid: this.appId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope,
|
||||
state,
|
||||
});
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async code2Session(code: string): Promise<WechatCodeSession> {
|
||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
url.searchParams.set('appid', this.appId);
|
||||
url.searchParams.set('secret', this.appSecret);
|
||||
url.searchParams.set('js_code', code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
const data = await this.fetchJson<{
|
||||
openid?: string;
|
||||
unionid?: string;
|
||||
session_key?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}>(url.toString());
|
||||
if (!data.openid) {
|
||||
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
||||
}
|
||||
return {
|
||||
openId: data.openid,
|
||||
unionId: data.unionid,
|
||||
sessionKey: data.session_key,
|
||||
};
|
||||
}
|
||||
|
||||
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
|
||||
const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||
url.searchParams.set('appid', this.appId);
|
||||
url.searchParams.set('secret', this.appSecret);
|
||||
url.searchParams.set('code', code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
const data = await this.fetchJson<{
|
||||
openid?: string;
|
||||
unionid?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}>(url.toString());
|
||||
if (!data.openid) {
|
||||
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
|
||||
}
|
||||
return {
|
||||
openId: data.openid,
|
||||
unionId: data.unionid,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
async createJssdkConfig(url: string) {
|
||||
const ticket = await this.getJsapiTicket();
|
||||
const nonceStr = randomBytes(8).toString('hex');
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
||||
const signature = createHash('sha1').update(raw).digest('hex');
|
||||
return {
|
||||
appId: this.appId,
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature,
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
|
||||
};
|
||||
}
|
||||
|
||||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string> {
|
||||
if (platform === 'h5') {
|
||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||
}
|
||||
const accessToken = await this.getAccessToken();
|
||||
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||
const data = await this.fetchJson<{
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
||||
}>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
||||
if (!phone) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
async createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) {
|
||||
throw new InternalServerErrorException('微信支付商户配置不完整');
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||||
const body = {
|
||||
appid: this.appId,
|
||||
mchid: this.mchId,
|
||||
description: params.description,
|
||||
out_trade_no: params.orderNo,
|
||||
notify_url: notifyUrl,
|
||||
amount: { total: params.amountFen, currency: 'CNY' },
|
||||
payer: { openid: params.openId },
|
||||
};
|
||||
const path = '/v3/pay/transactions/jsapi';
|
||||
const payload = JSON.stringify(body);
|
||||
const auth = this.signPayRequest('POST', path, payload);
|
||||
const res = await this.fetchJson<{ prepay_id?: string }>(`https://api.mch.weixin.qq.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: auth,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
if (!res.prepay_id) {
|
||||
throw new InternalServerErrorException('微信预支付下单失败');
|
||||
}
|
||||
const timeStamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonceStr = randomUUID().replace(/-/g, '');
|
||||
const packageStr = `prepay_id=${res.prepay_id}`;
|
||||
const message = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
|
||||
const sign = createSign('RSA-SHA256');
|
||||
sign.update(message);
|
||||
sign.end();
|
||||
const paySign = sign.sign(this.mchPrivateKey, 'base64');
|
||||
return {
|
||||
appId: this.appId,
|
||||
timeStamp,
|
||||
nonceStr,
|
||||
package: packageStr,
|
||||
signType: 'RSA' as const,
|
||||
paySign,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
||||
url.searchParams.set('grant_type', 'client_credential');
|
||||
url.searchParams.set('appid', this.appId);
|
||||
url.searchParams.set('secret', this.appSecret);
|
||||
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||||
url.toString(),
|
||||
);
|
||||
if (!data.access_token) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败');
|
||||
}
|
||||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||
await this.redis.setJson(
|
||||
ACCESS_TOKEN_KEY,
|
||||
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||||
ttl,
|
||||
);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async getJsapiTicket(): Promise<string> {
|
||||
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
||||
|
||||
const accessToken = await this.getAccessToken();
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket');
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
url.searchParams.set('type', 'jsapi');
|
||||
const data = await this.fetchJson<{ ticket?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||||
url.toString(),
|
||||
);
|
||||
if (!data.ticket) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取 jsapi_ticket 失败');
|
||||
}
|
||||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||
await this.redis.setJson(
|
||||
JSAPI_TICKET_KEY,
|
||||
{ ticket: data.ticket, expiresAt: Date.now() + ttl * 1000 },
|
||||
ttl,
|
||||
);
|
||||
return data.ticket;
|
||||
}
|
||||
|
||||
private signPayRequest(method: string, path: string, body: string) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonce = randomUUID();
|
||||
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
|
||||
const sign = createSign('RSA-SHA256');
|
||||
sign.update(message);
|
||||
sign.end();
|
||||
const signature = sign.sign(this.mchPrivateKey, 'base64');
|
||||
return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`;
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
this.logger.error(`WeChat API invalid JSON: ${text.slice(0, 200)}`);
|
||||
throw new InternalServerErrorException('微信接口响应异常');
|
||||
}
|
||||
}
|
||||
|
||||
/** 解密小程序敏感数据(备用) */
|
||||
decryptData(sessionKey: string, encryptedData: string, iv: string): Record<string, unknown> {
|
||||
const key = Buffer.from(sessionKey, 'base64');
|
||||
const decipher = createDecipheriv('aes-128-cbc', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAutoPadding(true);
|
||||
const decoded = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedData, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return JSON.parse(decoded.toString('utf8')) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import type { IWechatProvider } from './wechat.interface';
|
||||
|
||||
@Injectable()
|
||||
export class WechatDisabledProvider implements IWechatProvider {
|
||||
isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
private disabled(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
code2Session() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
oauth2AccessToken() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createJssdkConfig() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
buildOAuthUrl() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getPhoneNumberByCode() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createJsapiPrepay() {
|
||||
return this.disabled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WechatJssdkConfig, WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
|
||||
export type WechatCodeSession = {
|
||||
openId: string;
|
||||
unionId?: string;
|
||||
sessionKey?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
|
||||
export type WechatOAuthSession = {
|
||||
openId: string;
|
||||
unionId?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export interface IWechatProvider {
|
||||
isEnabled(): boolean;
|
||||
|
||||
/** 小程序 code2session */
|
||||
code2Session(code: string): Promise<WechatCodeSession>;
|
||||
|
||||
/** 公众号 H5 OAuth code 换 openId */
|
||||
oauth2AccessToken(code: string): Promise<WechatOAuthSession>;
|
||||
|
||||
/** JSSDK 签名配置 */
|
||||
createJssdkConfig(url: string): Promise<WechatJssdkConfig>;
|
||||
|
||||
/** 构建公众号 OAuth 授权 URL */
|
||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
||||
|
||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
||||
|
||||
/** 创建 JSAPI 预支付参数 */
|
||||
createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
}): Promise<WechatJsapiPrepayParams>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { EventService } from './event.service';
|
||||
import { TicketService } from './ticket.service';
|
||||
@@ -8,10 +9,11 @@ import { ResourceController } from './resource.controller';
|
||||
import { EventController } from './event.controller';
|
||||
import { TicketController } from './ticket.controller';
|
||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||
import { WechatController } from './wechat.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController],
|
||||
imports: [IamModule, IntegrationsModule],
|
||||
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController, WechatController],
|
||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
||||
exports: [ResourceService, EventService, TicketService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BadRequestException, Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
|
||||
class PhoneNumberDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['mini', 'h5'])
|
||||
@IsOptional()
|
||||
platform?: 'mini' | 'h5';
|
||||
}
|
||||
|
||||
@Controller('common/wechat')
|
||||
export class WechatController {
|
||||
constructor(@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider) {}
|
||||
|
||||
@Get('jssdk-config')
|
||||
async jssdkConfig(@Query('url') url: string) {
|
||||
if (!url) throw new BadRequestException('url 参数必填');
|
||||
return this.wechat.createJssdkConfig(url);
|
||||
}
|
||||
|
||||
@Get('oauth-url')
|
||||
oauthUrl(
|
||||
@Query('redirectUri') redirectUri: string,
|
||||
@Query('state') state: string,
|
||||
@Query('scope') scope?: string,
|
||||
) {
|
||||
if (!redirectUri || !state) throw new BadRequestException('redirectUri 与 state 必填');
|
||||
return { url: this.wechat.buildOAuthUrl(redirectUri, state, scope) };
|
||||
}
|
||||
|
||||
@Post('phone-number')
|
||||
phoneNumber(@Body() dto: PhoneNumberDto) {
|
||||
return this.wechat
|
||||
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
||||
.then((phone) => ({ phone }));
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import type { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
BindPhoneDto,
|
||||
BindWechatPhoneDto,
|
||||
BootstrapSessionDto,
|
||||
LoginSmsDto,
|
||||
LoginWechatDto,
|
||||
RefreshTokenDto,
|
||||
SendSmsDto,
|
||||
} from './dto/auth.dto';
|
||||
@@ -48,13 +50,26 @@ export class UserAuthController {
|
||||
}
|
||||
|
||||
@Post('auth/login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
|
||||
const guest = (req as Request & { user?: AuthUser }).user;
|
||||
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||
return this.authService.loginUserWechat(
|
||||
dto.code,
|
||||
ClientApp.USER_H5,
|
||||
dto.platform ?? 'h5',
|
||||
guestId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('auth/wechat/bind-phone')
|
||||
bindPhoneLegacy() {
|
||||
return this.authService.wechatDisabled();
|
||||
bindPhoneLegacy(@Body() dto: BindWechatPhoneDto) {
|
||||
return this.authService.bindWechatPhone(
|
||||
dto.wxSessionKey,
|
||||
dto.phone,
|
||||
dto.code,
|
||||
ClientApp.USER_H5,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('auth/me')
|
||||
@@ -79,8 +94,8 @@ export class ShopAuthController {
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
wechatLogin(@Body() dto: LoginWechatDto) {
|
||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +114,8 @@ export class PartnerAuthController {
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
wechatLogin(@Body() dto: LoginWechatDto) {
|
||||
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,23 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { generateUserNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
type WxSessionPayload = {
|
||||
openId: string;
|
||||
unionId?: string;
|
||||
sessionKey?: string;
|
||||
clientApp: ClientApp;
|
||||
guestId?: string;
|
||||
};
|
||||
|
||||
type UserRow = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
@@ -29,6 +39,7 @@ type UserRow = Pick<
|
||||
| 'nickname'
|
||||
| 'avatarResourceId'
|
||||
| 'wxOpenId'
|
||||
| 'wxUnionId'
|
||||
| 'mergedIntoUserId'
|
||||
| 'status'
|
||||
> & {
|
||||
@@ -40,7 +51,9 @@ export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly redis: RedisService,
|
||||
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
|
||||
) {}
|
||||
|
||||
async sendSms(phone: string, scene: string) {
|
||||
@@ -286,6 +299,242 @@ export class AuthService {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
assertWechatEnabled() {
|
||||
if (!this.wechatProvider.isEnabled()) {
|
||||
this.wechatDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
async loginUserWechat(
|
||||
code: string,
|
||||
clientApp: ClientApp,
|
||||
platform: 'h5' | 'mini' = 'h5',
|
||||
guestId?: bigint,
|
||||
) {
|
||||
this.assertWechatEnabled();
|
||||
const session =
|
||||
platform === 'mini'
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let user = await this.prisma.user.findFirst({
|
||||
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
if (user?.phone && user.phoneVerifiedAt) {
|
||||
let activeUser: UserRow =
|
||||
guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow);
|
||||
activeUser = await this.prisma.user.update({
|
||||
where: { id: activeUser.id },
|
||||
data: {
|
||||
wxUnionId: session.unionId ?? activeUser.wxUnionId,
|
||||
},
|
||||
include: { avatar: true },
|
||||
});
|
||||
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
|
||||
}
|
||||
|
||||
const wxSessionKey = randomUUID();
|
||||
await this.redis.setJson(
|
||||
`wx:session:${wxSessionKey}`,
|
||||
{
|
||||
openId: session.openId,
|
||||
unionId: session.unionId,
|
||||
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
|
||||
clientApp,
|
||||
guestId: guestId?.toString(),
|
||||
} satisfies WxSessionPayload,
|
||||
1800,
|
||||
);
|
||||
|
||||
if (user && !user.phoneVerifiedAt) {
|
||||
return {
|
||||
needBindPhone: true,
|
||||
wxSessionKey,
|
||||
actorType: 'USER',
|
||||
actorId: user.id.toString(),
|
||||
phoneVerified: false,
|
||||
user: this.formatUserProfile(user),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
needBindPhone: true,
|
||||
wxSessionKey,
|
||||
phoneVerified: false,
|
||||
};
|
||||
}
|
||||
|
||||
async bindWechatPhone(
|
||||
wxSessionKey: string,
|
||||
phone: string,
|
||||
code: string,
|
||||
clientApp: ClientApp,
|
||||
) {
|
||||
this.assertWechatEnabled();
|
||||
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
|
||||
|
||||
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
|
||||
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
||||
|
||||
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
|
||||
const wxUser = await this.prisma.user.findFirst({
|
||||
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
const existingPhone = await this.prisma.user.findUnique({
|
||||
where: { phone },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
let targetUserId: bigint | null = wxUser?.id ?? null;
|
||||
|
||||
if (!wxUser && !existingPhone) {
|
||||
if (guestId) {
|
||||
try {
|
||||
const guest = await this.assertActiveUser(guestId);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: guest.id },
|
||||
data: {
|
||||
phone,
|
||||
phoneVerifiedAt: new Date(),
|
||||
wxOpenId: wxSession.openId,
|
||||
wxUnionId: wxSession.unionId,
|
||||
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
|
||||
},
|
||||
});
|
||||
targetUserId = updated.id;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
if (!targetUserId) {
|
||||
const created = await this.prisma.user.create({
|
||||
data: {
|
||||
phone,
|
||||
phoneVerifiedAt: new Date(),
|
||||
wxOpenId: wxSession.openId,
|
||||
wxUnionId: wxSession.unionId,
|
||||
userNo: generateUserNo(),
|
||||
nickname: `用户${phone.slice(-4)}`,
|
||||
cityPreference: {
|
||||
create: {
|
||||
selectedCityCode: '410100',
|
||||
selectedDistrict: '郑州市',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetUserId = created.id;
|
||||
}
|
||||
} else if (existingPhone) {
|
||||
await this.assertActiveUser(existingPhone.id);
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: existingPhone.id },
|
||||
data: {
|
||||
wxOpenId: wxSession.openId,
|
||||
wxUnionId: wxSession.unionId,
|
||||
phoneVerifiedAt: existingPhone.phoneVerifiedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
targetUserId = updated.id;
|
||||
if (guestId && guestId !== updated.id) {
|
||||
targetUserId = (await this.mergeUsers(guestId, updated.id)).id;
|
||||
}
|
||||
} else if (wxUser) {
|
||||
if (wxUser.phone && wxUser.phone !== phone) {
|
||||
throw new BadRequestException('手机号已被其他账号占用');
|
||||
}
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: wxUser.id },
|
||||
data: {
|
||||
phone,
|
||||
phoneVerifiedAt: new Date(),
|
||||
wxUnionId: wxSession.unionId ?? wxUser.wxUnionId,
|
||||
},
|
||||
});
|
||||
targetUserId = updated.id;
|
||||
}
|
||||
|
||||
if (!targetUserId) throw new BadRequestException('绑定失败');
|
||||
const user = await this.assertActiveUser(targetUserId);
|
||||
await this.redis.del(`wx:session:${wxSessionKey}`);
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
}
|
||||
|
||||
async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
|
||||
this.assertWechatEnabled();
|
||||
const session =
|
||||
platform === 'mini'
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let account = await this.prisma.storeAccount.findFirst({
|
||||
where: { wxOpenId: session.openId },
|
||||
include: { store: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
|
||||
}
|
||||
|
||||
account = await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
wxOpenId: session.openId,
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { store: true },
|
||||
});
|
||||
|
||||
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||
id: account.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
storeName: account.store.name,
|
||||
});
|
||||
}
|
||||
|
||||
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
|
||||
this.assertWechatEnabled();
|
||||
const session =
|
||||
platform === 'mini'
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let account = await this.prisma.partnerAccount.findFirst({
|
||||
where: { wxOpenId: session.openId },
|
||||
include: { partner: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
|
||||
}
|
||||
|
||||
account = await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
wxOpenId: session.openId,
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { partner: true },
|
||||
});
|
||||
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
}
|
||||
|
||||
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
|
||||
if (guestId === primaryId) {
|
||||
return this.assertActiveUser(primaryId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SendSmsDto {
|
||||
@IsString()
|
||||
@@ -41,3 +41,28 @@ export class BindPhoneDto {
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
}
|
||||
|
||||
export class LoginWechatDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['h5', 'mini'])
|
||||
@IsOptional()
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
export class BindWechatPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
wxSessionKey: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,19 @@ export class TradeService {
|
||||
throw new BadRequestException('订单状态不可支付');
|
||||
}
|
||||
|
||||
const { externalNo } = await this.payProvider.payOrder(orderId);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const openId = user?.wxOpenId ?? undefined;
|
||||
const payResult = await this.payProvider.payOrder(orderId, openId);
|
||||
|
||||
if (payResult.mode === 'jsapi') {
|
||||
return {
|
||||
mode: 'jsapi' as const,
|
||||
orderId: order.id.toString(),
|
||||
prepay: payResult.prepay,
|
||||
};
|
||||
}
|
||||
|
||||
const { externalNo } = payResult;
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
Reference in New Issue
Block a user