|
@@ -0,0 +1,347 @@
|
|
|
|
|
+import { DataSource, Repository } from 'typeorm';
|
|
|
|
|
+import { WechatPayConfig } from './wechat-pay-config.entity';
|
|
|
|
|
+import { WechatCouponStock } from './wechat-coupon-stock.entity';
|
|
|
|
|
+import { WechatCoupon } from './wechat-coupon.entity';
|
|
|
|
|
+import crypto from 'crypto';
|
|
|
|
|
+import axios from 'axios';
|
|
|
|
|
+
|
|
|
|
|
+interface WechatPayConfigData {
|
|
|
|
|
+ merchantId: string;
|
|
|
|
|
+ appId: string;
|
|
|
|
|
+ privateKey: string;
|
|
|
|
|
+ certificateSerialNo: string;
|
|
|
|
|
+ apiV3Key: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface CreateStockData {
|
|
|
|
|
+ stockName: string;
|
|
|
|
|
+ stockCreatorMchid: string;
|
|
|
|
|
+ couponType: string;
|
|
|
|
|
+ couponUseRule: any;
|
|
|
|
|
+ stockSendRule: any;
|
|
|
|
|
+ couponAmount: number;
|
|
|
|
|
+ couponQuantity: number;
|
|
|
|
|
+ startTime: Date;
|
|
|
|
|
+ endTime: Date;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SendCouponData {
|
|
|
|
|
+ openid: string;
|
|
|
|
|
+ stockId: string;
|
|
|
|
|
+ outRequestNo: string;
|
|
|
|
|
+ stockCreatorMchid: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class WechatPayService {
|
|
|
|
|
+ private configRepository: Repository<WechatPayConfig>;
|
|
|
|
|
+ private stockRepository: Repository<WechatCouponStock>;
|
|
|
|
|
+ private couponRepository: Repository<WechatCoupon>;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private dataSource: DataSource) {
|
|
|
|
|
+ this.configRepository = dataSource.getRepository(WechatPayConfig);
|
|
|
|
|
+ this.stockRepository = dataSource.getRepository(WechatCouponStock);
|
|
|
|
|
+ this.couponRepository = dataSource.getRepository(WechatCoupon);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取微信支付配置
|
|
|
|
|
+ async getActiveConfig(): Promise<WechatPayConfig> {
|
|
|
|
|
+ const config = await this.configRepository.findOne({ where: { isActive: 1 } });
|
|
|
|
|
+ if (!config) {
|
|
|
|
|
+ throw new Error('未找到有效的微信支付配置');
|
|
|
|
|
+ }
|
|
|
|
|
+ return config;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 生成V3签名
|
|
|
|
|
+ private generateV3Signature(
|
|
|
|
|
+ httpMethod: string,
|
|
|
|
|
+ url: string,
|
|
|
|
|
+ body: string,
|
|
|
|
|
+ config: WechatPayConfigData
|
|
|
|
|
+ ): string {
|
|
|
|
|
+ const timestamp = Math.floor(Date.now() / 1000);
|
|
|
|
|
+ const nonceStr = this.generateNonceStr(32);
|
|
|
|
|
+
|
|
|
|
|
+ const urlParts = new URL(url);
|
|
|
|
|
+ const canonicalUrl = urlParts.pathname + (urlParts.search || '');
|
|
|
|
|
+
|
|
|
|
|
+ const message = `${httpMethod}\n${canonicalUrl}\n${timestamp}\n${nonceStr}\n${body}\n`;
|
|
|
|
|
+
|
|
|
|
|
+ const sign = crypto.createSign('RSA-SHA256');
|
|
|
|
|
+ sign.write(message);
|
|
|
|
|
+ sign.end();
|
|
|
|
|
+
|
|
|
|
|
+ const privateKey = `-----BEGIN PRIVATE KEY-----\n${config.privateKey}\n-----END PRIVATE KEY-----`;
|
|
|
|
|
+ const signature = sign.sign(privateKey, 'base64');
|
|
|
|
|
+
|
|
|
|
|
+ const token = `WECHATPAY2-SHA256-RSA2048 mchid="${config.merchantId}",nonce_str="${nonceStr}",timestamp="${timestamp}",serial_no="${config.certificateSerialNo}",signature="${signature}"`;
|
|
|
|
|
+
|
|
|
|
|
+ return token;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 生成随机字符串
|
|
|
|
|
+ private generateNonceStr(length: number = 32): string {
|
|
|
|
|
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
|
|
|
+ let result = '';
|
|
|
|
|
+ for (let i = 0; i < length; i++) {
|
|
|
|
|
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 发起微信支付API请求
|
|
|
|
|
+ private async wechatPayRequest(
|
|
|
|
|
+ method: string,
|
|
|
|
|
+ endpoint: string,
|
|
|
|
|
+ data?: any,
|
|
|
|
|
+ config?: WechatPayConfig
|
|
|
|
|
+ ): Promise<any> {
|
|
|
|
|
+ const activeConfig = config || await this.getActiveConfig();
|
|
|
|
|
+
|
|
|
|
|
+ const configData: WechatPayConfigData = {
|
|
|
|
|
+ merchantId: activeConfig.merchantId,
|
|
|
|
|
+ appId: activeConfig.appId,
|
|
|
|
|
+ privateKey: activeConfig.privateKey,
|
|
|
|
|
+ certificateSerialNo: activeConfig.certificateSerialNo,
|
|
|
|
|
+ apiV3Key: activeConfig.apiV3Key
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const url = `https://api.mch.weixin.qq.com${endpoint}`;
|
|
|
|
|
+ const body = data ? JSON.stringify(data) : '';
|
|
|
|
|
+ const signature = this.generateV3Signature(method, url, body, configData);
|
|
|
|
|
+
|
|
|
|
|
+ const response = await axios({
|
|
|
|
|
+ method,
|
|
|
|
|
+ url,
|
|
|
|
|
+ data,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Authorization': signature,
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ 'Accept': 'application/json',
|
|
|
|
|
+ 'User-Agent': 'WeChatPay/1.0'
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return response.data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建代金券批次
|
|
|
|
|
+ async createStock(data: CreateStockData, configId: number): Promise<WechatCouponStock> {
|
|
|
|
|
+ const config = await this.configRepository.findOne({ where: { id: configId } });
|
|
|
|
|
+ if (!config) {
|
|
|
|
|
+ throw new Error('微信支付配置不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const stockData = {
|
|
|
|
|
+ stock_name: data.stockName,
|
|
|
|
|
+ stock_creator_mchid: data.stockCreatorMchid,
|
|
|
|
|
+ coupon_use_rule: data.couponUseRule,
|
|
|
|
|
+ stock_send_rule: data.stockSendRule,
|
|
|
|
|
+ coupon_type: data.couponType,
|
|
|
|
|
+ coupon_amount: data.couponAmount,
|
|
|
|
|
+ coupon_quantity: data.couponQuantity,
|
|
|
|
|
+ available_begin_time: data.startTime.toISOString(),
|
|
|
|
|
+ available_end_time: data.endTime.toISOString()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const response = await this.wechatPayRequest('POST', '/v3/marketing/favor/coupon-stocks', stockData, config);
|
|
|
|
|
+
|
|
|
|
|
+ const stock = this.stockRepository.create({
|
|
|
|
|
+ stockId: response.stock_id,
|
|
|
|
|
+ stockName: data.stockName,
|
|
|
|
|
+ stockCreatorMchid: data.stockCreatorMchid,
|
|
|
|
|
+ couponType: data.couponType,
|
|
|
|
|
+ couponUseRule: data.couponUseRule,
|
|
|
|
|
+ stockSendRule: data.stockSendRule,
|
|
|
|
|
+ couponAmount: data.couponAmount,
|
|
|
|
|
+ couponQuantity: data.couponQuantity,
|
|
|
|
|
+ availableQuantity: data.couponQuantity,
|
|
|
|
|
+ distributedQuantity: 0,
|
|
|
|
|
+ status: 'CREATED',
|
|
|
|
|
+ startTime: data.startTime,
|
|
|
|
|
+ endTime: data.endTime,
|
|
|
|
|
+ configId
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return this.stockRepository.save(stock);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 激活代金券批次
|
|
|
|
|
+ async activateStock(stockId: string): Promise<void> {
|
|
|
|
|
+ const stock = await this.stockRepository.findOne({ where: { stockId } });
|
|
|
|
|
+ if (!stock) {
|
|
|
|
|
+ throw new Error('代金券批次不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const config = await this.configRepository.findOne({ where: { id: stock.configId } });
|
|
|
|
|
+ if (!config) {
|
|
|
|
|
+ throw new Error('微信支付配置不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await this.wechatPayRequest('POST', `/v3/marketing/favor/stocks/${stockId}/start`, {
|
|
|
|
|
+ stock_creator_mchid: stock.stockCreatorMchid
|
|
|
|
|
+ }, config);
|
|
|
|
|
+
|
|
|
|
|
+ stock.status = 'RUNNING';
|
|
|
|
|
+ await this.stockRepository.save(stock);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 发放代金券
|
|
|
|
|
+ async sendCoupon(data: SendCouponData, configId: number): Promise<WechatCoupon> {
|
|
|
|
|
+ const stock = await this.stockRepository.findOne({
|
|
|
|
|
+ where: { stockId: data.stockId, stockCreatorMchid: data.stockCreatorMchid }
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!stock) {
|
|
|
|
|
+ throw new Error('代金券批次不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (stock.availableQuantity <= 0) {
|
|
|
|
|
+ throw new Error('代金券库存不足');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const config = await this.configRepository.findOne({ where: { id: configId } });
|
|
|
|
|
+ if (!config) {
|
|
|
|
|
+ throw new Error('微信支付配置不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const couponData = {
|
|
|
|
|
+ stock_id: data.stockId,
|
|
|
|
|
+ out_request_no: data.outRequestNo,
|
|
|
|
|
+ appid: config.appId,
|
|
|
|
|
+ stock_creator_mchid: data.stockCreatorMchid
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const response = await this.wechatPayRequest('POST', `/v3/marketing/favor/users/${data.openid}/coupons`, couponData, config);
|
|
|
|
|
+
|
|
|
|
|
+ const coupon = this.couponRepository.create({
|
|
|
|
|
+ couponId: response.coupon_id,
|
|
|
|
|
+ stockId: data.stockId,
|
|
|
|
|
+ openid: data.openid,
|
|
|
|
|
+ outRequestNo: data.outRequestNo,
|
|
|
|
|
+ couponStatus: 'SENDED',
|
|
|
|
|
+ amount: stock.couponAmount,
|
|
|
|
|
+ availableStartTime: new Date(response.available_start_time),
|
|
|
|
|
+ availableEndTime: new Date(response.available_end_time),
|
|
|
|
|
+ usedTime: null,
|
|
|
|
|
+ transactionId: null,
|
|
|
|
|
+ stockIdRef: stock.id
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 更新批次可用数量
|
|
|
|
|
+ stock.availableQuantity--;
|
|
|
|
|
+ stock.distributedQuantity++;
|
|
|
|
|
+ await this.stockRepository.save(stock);
|
|
|
|
|
+
|
|
|
|
|
+ return this.couponRepository.save(coupon);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询代金券详情
|
|
|
|
|
+ async getCouponDetail(couponId: string, openid: string): Promise<any> {
|
|
|
|
|
+ const coupon = await this.couponRepository.findOne({
|
|
|
|
|
+ where: { couponId, openid },
|
|
|
|
|
+ relations: ['stock', 'stock.config']
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!coupon) {
|
|
|
|
|
+ throw new Error('代金券不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const response = await this.wechatPayRequest(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ `/v3/marketing/favor/users/${openid}/coupons/${couponId}?appid=${coupon.stock.config.appId}`
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return response;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询代金券批次列表
|
|
|
|
|
+ async getStockList(
|
|
|
|
|
+ offset: number,
|
|
|
|
|
+ limit: number,
|
|
|
|
|
+ stockCreatorMchid: string,
|
|
|
|
|
+ status?: string
|
|
|
|
|
+ ): Promise<any> {
|
|
|
|
|
+ const config = await this.getActiveConfig();
|
|
|
|
|
+
|
|
|
|
|
+ let url = `/v3/marketing/favor/stocks?offset=${offset}&limit=${limit}&stock_creator_mchid=${stockCreatorMchid}`;
|
|
|
|
|
+ if (status) {
|
|
|
|
|
+ url += `&status=${status}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const response = await this.wechatPayRequest('GET', url, undefined, config);
|
|
|
|
|
+ return response;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询代金券批次详情
|
|
|
|
|
+ async getStockDetail(stockId: string, stockCreatorMchid: string): Promise<any> {
|
|
|
|
|
+ const response = await this.wechatPayRequest(
|
|
|
|
|
+ 'GET',
|
|
|
|
|
+ `/v3/marketing/favor/stocks/${stockId}?stock_creator_mchid=${stockCreatorMchid}`
|
|
|
|
|
+ );
|
|
|
|
|
+ return response;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询本地代金券批次列表
|
|
|
|
|
+ async getLocalStockList(
|
|
|
|
|
+ page: number = 1,
|
|
|
|
|
+ pageSize: number = 10,
|
|
|
|
|
+ keyword?: string,
|
|
|
|
|
+ status?: string
|
|
|
|
|
+ ): Promise<[WechatCouponStock[], number]> {
|
|
|
|
|
+ const query = this.stockRepository.createQueryBuilder('stock')
|
|
|
|
|
+ .leftJoinAndSelect('stock.config', 'config')
|
|
|
|
|
+ .orderBy('stock.createdAt', 'DESC');
|
|
|
|
|
+
|
|
|
|
|
+ if (keyword) {
|
|
|
|
|
+ query.andWhere('stock.stockName LIKE :keyword', { keyword: `%${keyword}%` });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (status) {
|
|
|
|
|
+ query.andWhere('stock.status = :status', { status });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return query.skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 查询本地代金券列表
|
|
|
|
|
+ async getLocalCouponList(
|
|
|
|
|
+ page: number = 1,
|
|
|
|
|
+ pageSize: number = 10,
|
|
|
|
|
+ openid?: string,
|
|
|
|
|
+ stockId?: string,
|
|
|
|
|
+ status?: string
|
|
|
|
|
+ ): Promise<[WechatCoupon[], number]> {
|
|
|
|
|
+ const query = this.couponRepository.createQueryBuilder('coupon')
|
|
|
|
|
+ .leftJoinAndSelect('coupon.stock', 'stock')
|
|
|
|
|
+ .orderBy('coupon.createdAt', 'DESC');
|
|
|
|
|
+
|
|
|
|
|
+ if (openid) {
|
|
|
|
|
+ query.andWhere('coupon.openid = :openid', { openid });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (stockId) {
|
|
|
|
|
+ query.andWhere('coupon.stockId = :stockId', { stockId });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (status) {
|
|
|
|
|
+ query.andWhere('coupon.couponStatus = :status', { status });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return query.skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 同步批次状态
|
|
|
|
|
+ async syncStockStatus(stockId: string): Promise<void> {
|
|
|
|
|
+ const stock = await this.stockRepository.findOne({ where: { stockId } });
|
|
|
|
|
+ if (!stock) {
|
|
|
|
|
+ throw new Error('代金券批次不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const response = await this.getStockDetail(stockId, stock.stockCreatorMchid);
|
|
|
|
|
+
|
|
|
|
|
+ stock.status = response.status;
|
|
|
|
|
+ stock.availableQuantity = response.available_quantity;
|
|
|
|
|
+ stock.distributedQuantity = response.distributed_quantity;
|
|
|
|
|
+
|
|
|
|
|
+ await this.stockRepository.save(stock);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|