feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
export type WechatActorRef = {
|
||||
refType: string;
|
||||
refId: bigint;
|
||||
};
|
||||
|
||||
export function wechatActorRefFromAuth(actorType?: string, actorId?: bigint): WechatActorRef | undefined {
|
||||
if (!actorType || actorId == null) return undefined;
|
||||
return { refType: actorType, refId: actorId };
|
||||
}
|
||||
|
||||
type LogWechatAuthInput = {
|
||||
scene: string;
|
||||
requestUrl?: string;
|
||||
requestBody?: Record<string, unknown>;
|
||||
responseBody?: Record<string, unknown>;
|
||||
externalNo?: string;
|
||||
status: 'SUCCESS' | 'FAILED';
|
||||
errorMessage?: string;
|
||||
actorRef?: WechatActorRef;
|
||||
};
|
||||
|
||||
export async function logWechatAuth(prisma: PrismaService, input: LogWechatAuthInput) {
|
||||
const row = await prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_AUTH',
|
||||
scene: input.scene,
|
||||
refType: input.actorRef?.refType,
|
||||
refId: input.actorRef?.refId,
|
||||
requestUrl: input.requestUrl?.slice(0, 512),
|
||||
requestBody: input.requestBody as never,
|
||||
responseBody: input.responseBody as never,
|
||||
externalNo: input.externalNo,
|
||||
status: input.status,
|
||||
errorMessage: input.errorMessage?.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||
|
||||
/** 微信消息推送:Token/timestamp/nonce[/Encrypt] 字典序拼接后 SHA1 */
|
||||
export function wechatMsgSha1(...parts: string[]): string {
|
||||
return createHash('sha1').update([...parts].sort().join('')).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyWechatUrlSignature(
|
||||
token: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
signature: string,
|
||||
): boolean {
|
||||
if (!token || !timestamp || !nonce || !signature) return false;
|
||||
return wechatMsgSha1(token, timestamp, nonce) === signature;
|
||||
}
|
||||
|
||||
export function verifyWechatMsgSignature(
|
||||
token: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
encrypt: string,
|
||||
msgSignature: string,
|
||||
): boolean {
|
||||
if (!token || !timestamp || !nonce || !encrypt || !msgSignature) return false;
|
||||
return wechatMsgSha1(token, timestamp, nonce, encrypt) === msgSignature;
|
||||
}
|
||||
|
||||
function decodeAesKey(encodingAESKey: string): Buffer {
|
||||
const key = Buffer.from(`${encodingAESKey.trim()}=`, 'base64');
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`EncodingAESKey 无效(解码后应为 32 字节,实际 ${key.length})`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密微信安全模式 Encrypt 字段。
|
||||
* FullStr = random(16) + msg_len(4 BE) + msg + appid
|
||||
*/
|
||||
export function decryptWechatEncrypt(
|
||||
encryptBase64: string,
|
||||
encodingAESKey: string,
|
||||
expectedAppId?: string,
|
||||
): string {
|
||||
const aesKey = decodeAesKey(encodingAESKey);
|
||||
const iv = aesKey.subarray(0, 16);
|
||||
const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptBase64, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
if (decrypted.length < 20) {
|
||||
throw new Error('解密结果过短');
|
||||
}
|
||||
const msgLen = decrypted.readUInt32BE(16);
|
||||
const msgStart = 20;
|
||||
const msgEnd = msgStart + msgLen;
|
||||
if (msgEnd > decrypted.length) {
|
||||
throw new Error('解密消息长度非法');
|
||||
}
|
||||
const msg = decrypted.subarray(msgStart, msgEnd).toString('utf8');
|
||||
const appId = decrypted.subarray(msgEnd).toString('utf8');
|
||||
if (expectedAppId && appId && appId !== expectedAppId) {
|
||||
throw new Error(`appid 不匹配: got=${appId}`);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 加密回包(一般回复 success 明文即可,此函数供需要加密回包时使用) */
|
||||
export function encryptWechatReply(
|
||||
plain: string,
|
||||
encodingAESKey: string,
|
||||
appId: string,
|
||||
): string {
|
||||
const aesKey = decodeAesKey(encodingAESKey);
|
||||
const iv = aesKey.subarray(0, 16);
|
||||
const random = randomBytes(16);
|
||||
const msg = Buffer.from(plain, 'utf8');
|
||||
const msgLen = Buffer.alloc(4);
|
||||
msgLen.writeUInt32BE(msg.length, 0);
|
||||
const full = Buffer.concat([random, msgLen, msg, Buffer.from(appId, 'utf8')]);
|
||||
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
|
||||
return Buffer.concat([cipher.update(full), cipher.final()]).toString('base64');
|
||||
}
|
||||
|
||||
/** 简易 XML 标签提取(微信推送字段无嵌套结构) */
|
||||
export function parseSimpleXml(xml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const re = /<([A-Za-z0-9_]+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml))) {
|
||||
out[m[1]] = (m[2] ?? m[3] ?? '').trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseWechatPushBody(raw: string): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
if (trimmed.startsWith('{')) {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
}
|
||||
return parseSimpleXml(trimmed);
|
||||
}
|
||||
|
||||
export type WechatTradeManageEvent = {
|
||||
event: string;
|
||||
toUserName?: string;
|
||||
fromUserName?: string;
|
||||
createTime?: number;
|
||||
transactionId?: string;
|
||||
merchantId?: string;
|
||||
subMerchantId?: string;
|
||||
merchantTradeNo?: string;
|
||||
payTime?: number;
|
||||
shippedTime?: number;
|
||||
estimatedSettlementTime?: number;
|
||||
/** 1 手动确认;2 自动确认(结算推送才有) */
|
||||
confirmReceiveMethod?: number;
|
||||
confirmReceiveTime?: number;
|
||||
settlementTime?: number;
|
||||
msg?: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (v == null || v === '') return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
if (v == null) return undefined;
|
||||
const s = String(v).trim();
|
||||
return s || undefined;
|
||||
}
|
||||
|
||||
export function normalizeTradeManageEvent(body: Record<string, unknown>): WechatTradeManageEvent {
|
||||
return {
|
||||
event: str(body.Event ?? body.event) || '',
|
||||
toUserName: str(body.ToUserName),
|
||||
fromUserName: str(body.FromUserName),
|
||||
createTime: num(body.CreateTime),
|
||||
transactionId: str(body.transaction_id),
|
||||
merchantId: str(body.merchant_id),
|
||||
subMerchantId: str(body.sub_merchant_id),
|
||||
merchantTradeNo: str(body.merchant_trade_no),
|
||||
payTime: num(body.pay_time),
|
||||
shippedTime: num(body.shipped_time),
|
||||
estimatedSettlementTime: num(body.estimated_settlement_time),
|
||||
confirmReceiveMethod: num(body.confirm_receive_method),
|
||||
confirmReceiveTime: num(body.confirm_receive_time),
|
||||
settlementTime: num(body.settlement_time),
|
||||
msg: str(body.msg),
|
||||
raw: body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Inject, Injectable, Logger, BadRequestException } 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.interface';
|
||||
import {
|
||||
formatWechatUploadTime,
|
||||
maskReceiverPhone,
|
||||
resolveExpressCompanyId,
|
||||
} from './wechat-order-shipping.util';
|
||||
|
||||
/** 微信要求支付成功后约 1 分钟才入库,过早调用会返回 10060001 */
|
||||
const MIN_PAID_AGE_MS = 65_000;
|
||||
/** 10060001 / 系统繁忙时的重试间隔 */
|
||||
const RETRY_DELAY_MS = 60_000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
|
||||
/** 微信 order_state:3 确认收货;4 交易完成 */
|
||||
const WECHAT_CONFIRMED_STATES = new Set([3, 4]);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序发货信息管理:订单发货/自提后向微信录入发货信息,解冻交易资金。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
|
||||
*/
|
||||
@Injectable()
|
||||
export class WechatOrderShippingService {
|
||||
private readonly logger = new Logger(WechatOrderShippingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
/** 供 C 端拉起微信确认收货组件 */
|
||||
buildConfirmPayload(order: {
|
||||
orderNo: string;
|
||||
payStatus: string;
|
||||
payExternalNo: string | null;
|
||||
}): {
|
||||
merchantId?: string;
|
||||
merchantTradeNo: string;
|
||||
transactionId?: string;
|
||||
} | null {
|
||||
if (order.payStatus !== 'PAID') return null;
|
||||
const mchId = this.wechat.getMchId()?.trim() || undefined;
|
||||
const transactionId = order.payExternalNo?.trim() || undefined;
|
||||
if (!transactionId && !mchId) return null;
|
||||
return {
|
||||
merchantId: mchId,
|
||||
merchantTradeNo: order.orderNo,
|
||||
transactionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户已通过微信确认收货组件完成确认(或订单已结算)。
|
||||
* Mock 支付环境跳过。
|
||||
*/
|
||||
async assertWechatUserConfirmed(orderId: bigint): Promise<void> {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockPay) return;
|
||||
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) return;
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { orderNo: true, payExternalNo: true },
|
||||
});
|
||||
if (!order) throw new BadRequestException('订单不存在');
|
||||
|
||||
const transactionId = order.payExternalNo?.trim();
|
||||
const mchId = this.wechat.getMchId()?.trim();
|
||||
if (!transactionId && !(mchId && order.orderNo)) {
|
||||
throw new BadRequestException('缺少微信支付单号,无法校验微信确认收货');
|
||||
}
|
||||
|
||||
const result = await this.wechat.getOrderShippingInfo({
|
||||
transactionId: transactionId || undefined,
|
||||
mchId: transactionId ? undefined : mchId,
|
||||
outTradeNo: transactionId ? undefined : order.orderNo,
|
||||
});
|
||||
if (result.errcode && result.errcode !== 0) {
|
||||
this.logger.warn(
|
||||
`get_order for confirm failed order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
result.errmsg || '查询微信订单状态失败,请稍后重试',
|
||||
);
|
||||
}
|
||||
if (result.orderState == null || !WECHAT_CONFIRMED_STATES.has(result.orderState)) {
|
||||
throw new BadRequestException(
|
||||
'请先在微信确认收货组件中完成确认(勿仅点服务通知外的按钮)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
|
||||
uploadForOrderSafe(orderId: bigint) {
|
||||
void this.scheduleAndUpload(orderId).catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`upload shipping info failed order=${orderId}: ${msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async scheduleAndUpload(orderId: bigint) {
|
||||
const paidAtRow = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { paidAt: true, orderNo: true },
|
||||
});
|
||||
if (!paidAtRow?.paidAt) {
|
||||
await this.uploadForOrder(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - paidAtRow.paidAt.getTime();
|
||||
const waitMs = Math.max(0, MIN_PAID_AGE_MS - ageMs);
|
||||
if (waitMs > 0) {
|
||||
this.logger.log(
|
||||
`WeChat upload_shipping_info wait ${waitMs}ms for pay入库 order=${paidAtRow.orderNo}`,
|
||||
);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const result = await this.uploadForOrder(orderId);
|
||||
if (!result || result.skipped || result.ok) return;
|
||||
|
||||
const retryable = result.errcode != null && RETRYABLE_ERRCODES.has(result.errcode);
|
||||
if (!retryable || attempt >= MAX_ATTEMPTS) return;
|
||||
|
||||
this.logger.warn(
|
||||
`WeChat upload_shipping_info retry ${attempt}/${MAX_ATTEMPTS} ` +
|
||||
`order=${paidAtRow.orderNo} errcode=${result.errcode} in ${RETRY_DELAY_MS}ms`,
|
||||
);
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadForOrder(
|
||||
orderId: bigint,
|
||||
): Promise<{ skipped?: string; ok?: boolean; errcode?: number } | void> {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockPay) {
|
||||
return { skipped: 'MOCK_PAY' };
|
||||
}
|
||||
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) {
|
||||
return { skipped: 'MOCK_WECHAT' };
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
delivery: true,
|
||||
user: { select: { wxOpenId: true } },
|
||||
},
|
||||
});
|
||||
if (!order) return { skipped: 'ORDER_NOT_FOUND' };
|
||||
if (order.payStatus !== 'PAID') return { skipped: 'NOT_PAID' };
|
||||
|
||||
const openId = order.user?.wxOpenId?.trim();
|
||||
if (!openId) return { skipped: 'NO_OPENID' };
|
||||
|
||||
const transactionId = order.payExternalNo?.trim();
|
||||
const mchId = this.wechat.getMchId()?.trim();
|
||||
if (!transactionId && !(mchId && order.orderNo)) {
|
||||
return { skipped: 'NO_PAY_REF' };
|
||||
}
|
||||
|
||||
// 已成功上报则跳过(幂等)
|
||||
const existed = await this.prisma.logThirdParty.findFirst({
|
||||
where: {
|
||||
provider: 'WECHAT_PAY',
|
||||
scene: 'UPLOAD_SHIPPING',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
if (existed) return { skipped: 'ALREADY_UPLOADED' };
|
||||
|
||||
const logisticsType = this.resolveLogisticsType(order.deliveryType, order.delivery);
|
||||
const trackingNo = order.delivery?.trackingNo?.trim() || undefined;
|
||||
let expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany);
|
||||
|
||||
if (logisticsType === 1 && trackingNo && !expressCompany) {
|
||||
try {
|
||||
const list = await this.wechat.getDeliveryList();
|
||||
expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany, list);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`get_delivery_list failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 快递模式缺运力 ID 时降级为同城,避免接口硬失败
|
||||
let finalLogisticsType = logisticsType;
|
||||
if (finalLogisticsType === 1 && (!trackingNo || !expressCompany)) {
|
||||
finalLogisticsType = 2;
|
||||
}
|
||||
|
||||
const itemDesc = `${order.productName}${order.productSpec ? `(${order.productSpec})` : ''}*${order.quantity}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 120);
|
||||
|
||||
const shippingItem: {
|
||||
trackingNo?: string;
|
||||
expressCompany?: string;
|
||||
itemDesc: string;
|
||||
contact?: { receiverContact: string };
|
||||
} = { itemDesc };
|
||||
|
||||
if (finalLogisticsType === 1) {
|
||||
shippingItem.trackingNo = trackingNo;
|
||||
shippingItem.expressCompany = expressCompany;
|
||||
if (expressCompany === 'SF') {
|
||||
shippingItem.contact = {
|
||||
receiverContact: maskReceiverPhone(order.receiverPhone),
|
||||
};
|
||||
}
|
||||
} else if (trackingNo) {
|
||||
// 同城也可附带运单信息
|
||||
shippingItem.trackingNo = trackingNo;
|
||||
if (expressCompany) shippingItem.expressCompany = expressCompany;
|
||||
}
|
||||
|
||||
const input = {
|
||||
orderNumberType: (transactionId ? 2 : 1) as 1 | 2,
|
||||
transactionId: transactionId || undefined,
|
||||
mchId: transactionId ? undefined : mchId,
|
||||
outTradeNo: transactionId ? undefined : order.orderNo,
|
||||
logisticsType: finalLogisticsType,
|
||||
deliveryMode: 1 as const,
|
||||
shippingList: [shippingItem],
|
||||
uploadTime: formatWechatUploadTime(),
|
||||
payerOpenId: openId,
|
||||
};
|
||||
|
||||
const requestUrl = 'https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info';
|
||||
try {
|
||||
const result = await this.wechat.uploadShippingInfo(input);
|
||||
const ok = result.errcode === 0;
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_PAY',
|
||||
scene: 'UPLOAD_SHIPPING',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
requestUrl,
|
||||
requestBody: {
|
||||
orderNo: order.orderNo,
|
||||
logisticsType: finalLogisticsType,
|
||||
trackingNo: shippingItem.trackingNo,
|
||||
expressCompany: shippingItem.expressCompany,
|
||||
orderNumberType: input.orderNumberType,
|
||||
transactionId: transactionId || undefined,
|
||||
outTradeNo: input.outTradeNo,
|
||||
payerOpenId: openId,
|
||||
},
|
||||
responseBody: { errcode: result.errcode, errmsg: result.errmsg },
|
||||
externalNo: transactionId || order.orderNo,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : `${result.errcode}:${result.errmsg}`.slice(0, 512),
|
||||
},
|
||||
});
|
||||
if (!ok) {
|
||||
this.logger.warn(
|
||||
`WeChat upload_shipping_info order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
|
||||
);
|
||||
return { ok: false, errcode: result.errcode };
|
||||
}
|
||||
this.logger.log(`WeChat upload_shipping_info ok order=${order.orderNo}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_PAY',
|
||||
scene: 'UPLOAD_SHIPPING',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
requestUrl,
|
||||
requestBody: {
|
||||
orderNo: order.orderNo,
|
||||
orderNumberType: input.orderNumberType,
|
||||
transactionId: transactionId || undefined,
|
||||
},
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
externalNo: transactionId || order.orderNo,
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveLogisticsType(
|
||||
deliveryType: string,
|
||||
delivery: { trackingNo: string | null; logisticsCompany: string | null; provider: string } | null,
|
||||
): 1 | 2 | 4 {
|
||||
if (deliveryType === 'ON_SITE_PICKUP') return 4;
|
||||
if (deliveryType === 'LOCAL') return 2;
|
||||
// CROSS_CITY:有运单走快递,否则同城兜底
|
||||
if (delivery?.trackingNo?.trim()) return 1;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 常见快递公司名称 → 微信运力 ID(get_delivery_list 的 delivery_id) */
|
||||
const STATIC_EXPRESS_MAP: Array<{ id: string; aliases: string[] }> = [
|
||||
{ id: 'SF', aliases: ['sf', '顺丰', '顺丰速运', '顺丰快递'] },
|
||||
{ id: 'STO', aliases: ['sto', '申通', '申通快递'] },
|
||||
{ id: 'YTO', aliases: ['yto', '圆通', '圆通速递', '圆通快递'] },
|
||||
{ id: 'ZTO', aliases: ['zto', '中通', '中通快递'] },
|
||||
{ id: 'YD', aliases: ['yd', '韵达', '韵达速递', '韵达快递'] },
|
||||
{ id: 'HTKY', aliases: ['htky', '百世', '百世快递', '汇通'] },
|
||||
{ id: 'EMS', aliases: ['ems', '邮政', '中国邮政', '邮政快递'] },
|
||||
{ id: 'JD', aliases: ['jd', '京东', '京东快递', '京东物流'] },
|
||||
{ id: 'JTSD', aliases: ['jtsd', 'jt', '极兔', '极兔速递', '极兔快递'] },
|
||||
{ id: 'UC', aliases: ['uc', '优速', '优速快递'] },
|
||||
{ id: 'DBL', aliases: ['dbl', '德邦', '德邦快递', '德邦物流'] },
|
||||
{ id: 'ANE', aliases: ['ane', '安能', '安能物流'] },
|
||||
];
|
||||
|
||||
export function maskReceiverPhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.length >= 7) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
}
|
||||
return phone.trim();
|
||||
}
|
||||
|
||||
/** RFC3339 +08:00,微信发货录入 upload_time 要求 */
|
||||
export function formatWechatUploadTime(date = new Date()): string {
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}+08:00`;
|
||||
}
|
||||
|
||||
export function resolveExpressCompanyId(
|
||||
logisticsCompany: string | null | undefined,
|
||||
deliveryList?: Array<{ deliveryId: string; deliveryName: string }>,
|
||||
): string | undefined {
|
||||
const raw = (logisticsCompany || '').trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
// 已是运力 ID
|
||||
if (/^[A-Z0-9_()-]{2,32}$/.test(upper) && !/[\u4e00-\u9fff]/.test(raw)) {
|
||||
return upper;
|
||||
}
|
||||
|
||||
const lower = raw.toLowerCase();
|
||||
for (const row of STATIC_EXPRESS_MAP) {
|
||||
if (row.aliases.some((a) => lower.includes(a.toLowerCase()) || a === raw)) {
|
||||
return row.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryList?.length) {
|
||||
const hit = deliveryList.find(
|
||||
(d) =>
|
||||
d.deliveryId.toUpperCase() === upper ||
|
||||
d.deliveryName === raw ||
|
||||
d.deliveryName.includes(raw) ||
|
||||
raw.includes(d.deliveryName),
|
||||
);
|
||||
if (hit) return hit.deliveryId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createDecipheriv, createVerify, timingSafeEqual } from 'crypto';
|
||||
|
||||
export type WechatPayNotifyResource = {
|
||||
transaction_id: string;
|
||||
out_trade_no: string;
|
||||
trade_state: string;
|
||||
trade_state_desc?: string;
|
||||
amount?: { total?: number; payer_total?: number };
|
||||
};
|
||||
|
||||
export type WechatRefundNotifyResource = {
|
||||
refund_id: string;
|
||||
out_refund_no: string;
|
||||
transaction_id?: string;
|
||||
out_trade_no?: string;
|
||||
refund_status: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||
amount?: { refund?: number; total?: number; payer_refund?: number; payer_total?: number };
|
||||
};
|
||||
|
||||
export type WechatPayNotifyEnvelope = {
|
||||
id: string;
|
||||
create_time: string;
|
||||
event_type: string;
|
||||
resource_type: string;
|
||||
summary: string;
|
||||
resource: {
|
||||
algorithm: string;
|
||||
ciphertext: string;
|
||||
associated_data?: string;
|
||||
nonce: string;
|
||||
original_type?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function decryptPayResource(
|
||||
apiV3Key: string,
|
||||
associatedData: string,
|
||||
nonce: string,
|
||||
ciphertext: string,
|
||||
): WechatPayNotifyResource {
|
||||
const key = Buffer.from(apiV3Key, 'utf8');
|
||||
const buf = Buffer.from(ciphertext, 'base64');
|
||||
const authTag = buf.subarray(buf.length - 16);
|
||||
const data = buf.subarray(0, buf.length - 16);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'));
|
||||
if (associatedData) {
|
||||
decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
||||
}
|
||||
decipher.setAuthTag(authTag);
|
||||
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
return JSON.parse(decoded.toString('utf8')) as WechatPayNotifyResource;
|
||||
}
|
||||
|
||||
export function verifyPaySignature(params: {
|
||||
platformPublicKeyPem: string;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
body: string;
|
||||
signature: string;
|
||||
}): boolean {
|
||||
const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
|
||||
const verifier = createVerify('RSA-SHA256');
|
||||
verifier.update(message);
|
||||
verifier.end();
|
||||
const ok = verifier.verify(params.platformPublicKeyPem, params.signature, 'base64');
|
||||
if (!ok) return false;
|
||||
const ts = Number(params.timestamp);
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
const skewMs = Math.abs(Date.now() - ts * 1000);
|
||||
return skewMs <= 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 .env / system_config 中的 PEM:
|
||||
* - 去掉外层引号(DB/表单常把整段含引号写入)
|
||||
* - 把字面量 \\n 转成真实换行
|
||||
* OpenSSL 报 1E08010C DECODER unsupported 时多半是这两类污染。
|
||||
*/
|
||||
export function normalizePemEnv(raw: string | undefined | null): string {
|
||||
if (!raw) return '';
|
||||
let value = String(raw).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
|
||||
return value.trim();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { buildOrderStatusEvent } from '../../common/event/event.helpers';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { WechatOrderShippingService } from './wechat-order-shipping.service';
|
||||
import type { WechatTradeManageEvent } from './wechat-msg-crypto.util';
|
||||
|
||||
/**
|
||||
* 小程序发货信息管理相关消息推送处理。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
|
||||
*/
|
||||
@Injectable()
|
||||
export class WechatTradeManageService {
|
||||
private readonly logger = new Logger(WechatTradeManageService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wechatOrderShipping: WechatOrderShippingService,
|
||||
) {}
|
||||
|
||||
async handleEvent(evt: WechatTradeManageEvent): Promise<{ handled: string }> {
|
||||
switch (evt.event) {
|
||||
case 'trade_manage_order_settlement':
|
||||
return this.handleOrderSettlement(evt);
|
||||
case 'trade_manage_remind_shipping':
|
||||
return this.handleRemindShipping(evt);
|
||||
case 'trade_manage_remind_access_api':
|
||||
case 'wxa_trade_controlled':
|
||||
await this.logEvent(evt, null, 'SUCCESS', evt.msg || evt.event);
|
||||
return { handled: evt.event };
|
||||
default:
|
||||
await this.logEvent(evt, null, 'SUCCESS', `ignored:${evt.event || 'empty'}`);
|
||||
return { handled: 'ignored' };
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOrderSettlement(evt: WechatTradeManageEvent) {
|
||||
const order = await this.findOrder(evt);
|
||||
const isConfirmOrSettle =
|
||||
evt.confirmReceiveTime != null || evt.settlementTime != null || evt.confirmReceiveMethod != null;
|
||||
|
||||
if (!isConfirmOrSettle) {
|
||||
// 发货时推送:仅有 shipped_time / estimated_settlement_time
|
||||
await this.logEvent(evt, order?.id ?? null, 'SUCCESS', 'shipped_notify');
|
||||
return { handled: 'settlement_shipped_notify' };
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
await this.logEvent(evt, null, 'FAILED', 'ORDER_NOT_FOUND');
|
||||
this.logger.warn(
|
||||
`trade_manage_order_settlement order not found tradeNo=${evt.merchantTradeNo} tx=${evt.transactionId}`,
|
||||
);
|
||||
return { handled: 'settlement_order_missing' };
|
||||
}
|
||||
|
||||
if (order.status === 'COMPLETED') {
|
||||
await this.logEvent(evt, order.id, 'SUCCESS', 'already_completed');
|
||||
return { handled: 'settlement_already_completed' };
|
||||
}
|
||||
|
||||
if (order.payStatus !== 'PAID') {
|
||||
await this.logEvent(evt, order.id, 'FAILED', 'NOT_PAID');
|
||||
return { handled: 'settlement_not_paid' };
|
||||
}
|
||||
|
||||
const methodLabel =
|
||||
evt.confirmReceiveMethod === 2
|
||||
? '微信自动确认收货'
|
||||
: evt.confirmReceiveMethod === 1
|
||||
? '微信手动确认收货'
|
||||
: '微信订单结算';
|
||||
|
||||
const now = new Date();
|
||||
const remark = [
|
||||
methodLabel,
|
||||
evt.confirmReceiveTime ? `confirmAt=${evt.confirmReceiveTime}` : null,
|
||||
evt.settlementTime ? `settleAt=${evt.settlementTime}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
.slice(0, 512);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: order.completedAt ?? now,
|
||||
},
|
||||
});
|
||||
await tx.orderDelivery.updateMany({
|
||||
where: { orderId: order.id, deliveredAt: null },
|
||||
data: { deliveredAt: now },
|
||||
});
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: order.id,
|
||||
fromStatus: order.status,
|
||||
toStatus: 'COMPLETED',
|
||||
operator: 'WECHAT_TRADE_MANAGE',
|
||||
remark,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await this.logEvent(evt, order.id, 'SUCCESS', methodLabel);
|
||||
this.logger.log(
|
||||
`WeChat confirm/settle → COMPLETED order=${order.orderNo} method=${evt.confirmReceiveMethod ?? '-'}`,
|
||||
);
|
||||
return { handled: 'settlement_completed' };
|
||||
}
|
||||
|
||||
private async handleRemindShipping(evt: WechatTradeManageEvent) {
|
||||
const order = await this.findOrder(evt);
|
||||
await this.logEvent(evt, order?.id ?? null, order ? 'SUCCESS' : 'FAILED', evt.msg || 'remind_shipping');
|
||||
if (order) {
|
||||
this.wechatOrderShipping.uploadForOrderSafe(order.id);
|
||||
}
|
||||
return { handled: 'remind_shipping' };
|
||||
}
|
||||
|
||||
private async findOrder(evt: WechatTradeManageEvent) {
|
||||
if (evt.merchantTradeNo) {
|
||||
const byNo = await this.prisma.order.findUnique({ where: { orderNo: evt.merchantTradeNo } });
|
||||
if (byNo) return byNo;
|
||||
}
|
||||
if (evt.transactionId) {
|
||||
return this.prisma.order.findFirst({
|
||||
where: { payExternalNo: evt.transactionId },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async logEvent(
|
||||
evt: WechatTradeManageEvent,
|
||||
orderId: bigint | null,
|
||||
status: 'SUCCESS' | 'FAILED',
|
||||
note: string,
|
||||
) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_PAY',
|
||||
scene: 'TRADE_MANAGE_PUSH',
|
||||
refType: orderId ? 'ORDER' : 'SYSTEM',
|
||||
refId: orderId ?? undefined,
|
||||
requestUrl: 'callbacks/wechat/message',
|
||||
requestBody: {
|
||||
event: evt.event,
|
||||
merchantTradeNo: evt.merchantTradeNo,
|
||||
transactionId: evt.transactionId,
|
||||
confirmReceiveMethod: evt.confirmReceiveMethod,
|
||||
confirmReceiveTime: evt.confirmReceiveTime,
|
||||
settlementTime: evt.settlementTime,
|
||||
shippedTime: evt.shippedTime,
|
||||
estimatedSettlementTime: evt.estimatedSettlementTime,
|
||||
msg: evt.msg,
|
||||
},
|
||||
responseBody: { note },
|
||||
externalNo: evt.transactionId || evt.merchantTradeNo,
|
||||
status,
|
||||
errorMessage: status === 'FAILED' ? note.slice(0, 512) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type {
|
||||
IWechatProvider,
|
||||
WechatCodeSession,
|
||||
WechatOAuthSession,
|
||||
WechatWxaCodeUnlimitedInput,
|
||||
WechatUploadShippingInfoInput,
|
||||
WechatUploadShippingInfoResult,
|
||||
WechatOrderShippingQueryResult,
|
||||
WechatDeliveryCompany,
|
||||
} from './wechat.interface';
|
||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||
import {
|
||||
decryptPayResource,
|
||||
normalizePemEnv,
|
||||
verifyPaySignature,
|
||||
type WechatPayNotifyEnvelope,
|
||||
type WechatRefundNotifyResource,
|
||||
} from './wechat-pay.util';
|
||||
|
||||
type TokenCache = { accessToken: string; expiresAt: number };
|
||||
type TicketCache = { ticket: string; expiresAt: number };
|
||||
|
||||
/** 使用 getStableAccessToken,与旧 cgi-bin/token 隔离,避免多端抢刷新导致 40001 */
|
||||
const ACCESS_TOKEN_KEY = 'wechat:stable_access_token';
|
||||
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_stable_access_token';
|
||||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
||||
|
||||
@Injectable()
|
||||
export class WechatApiProvider implements IWechatProvider {
|
||||
private readonly logger = new Logger(WechatApiProvider.name);
|
||||
private readonly appId = process.env.WX_APP_ID ?? '';
|
||||
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
||||
/** 小程序独立凭证;未配置时回退公众号/H5 的 WX_APP_ID(须与开发者工具 appid 一致) */
|
||||
private readonly miniAppId = (process.env.WX_MINI_APP_ID ?? this.appId).trim();
|
||||
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
||||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||||
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||
private readonly refundNotifyUrl = process.env.WX_REFUND_NOTIFY_URL ?? '';
|
||||
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
isEnabled() {
|
||||
const cfg = loadAppConfig();
|
||||
return !cfg.mockWechat && !!this.appId && !!this.appSecret;
|
||||
}
|
||||
|
||||
isMock() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
const cfg = loadAppConfig();
|
||||
return (
|
||||
!cfg.mockPay &&
|
||||
!!(this.miniAppId || this.appId) &&
|
||||
!!this.mchId &&
|
||||
!!this.mchSerialNo &&
|
||||
!!this.mchPrivateKey &&
|
||||
!!this.apiV3Key
|
||||
);
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return this.mchId;
|
||||
}
|
||||
|
||||
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, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
|
||||
const appId = this.miniAppId;
|
||||
const appSecret = this.miniAppSecret;
|
||||
if (!appId || !appSecret) {
|
||||
throw new InternalServerErrorException(
|
||||
'小程序微信登录未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET(或与 project.config.json appid 一致)',
|
||||
);
|
||||
}
|
||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
url.searchParams.set('appid', appId);
|
||||
url.searchParams.set('secret', '***');
|
||||
url.searchParams.set('js_code', code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
apiUrl.searchParams.set('appid', appId);
|
||||
apiUrl.searchParams.set('secret', appSecret);
|
||||
apiUrl.searchParams.set('js_code', code);
|
||||
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||||
const data = await this.fetchJson<{
|
||||
openid?: string;
|
||||
unionid?: string;
|
||||
session_key?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}>(apiUrl.toString());
|
||||
const ok = !!data.openid;
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'LOGIN',
|
||||
requestUrl: url.toString(),
|
||||
requestBody: { grant_type: 'authorization_code', platform: 'mini', appId },
|
||||
responseBody: ok
|
||||
? { openid: data.openid, unionid: data.unionid }
|
||||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||
externalNo: data.openid,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.errmsg || '微信 code2session 失败',
|
||||
actorRef,
|
||||
});
|
||||
if (!data.openid) {
|
||||
const invalidCode = data.errcode === 40029 || /invalid code/i.test(data.errmsg ?? '');
|
||||
const hint = invalidCode
|
||||
? `(后端 appid=${appId},请确认 WX_MINI_APP_ID/SECRET 与小程序 project.config.json 一致;本地可 MOCK_WECHAT=true)`
|
||||
: '';
|
||||
throw new InternalServerErrorException((data.errmsg || '微信 code2session 失败') + hint);
|
||||
}
|
||||
return {
|
||||
openId: data.openid,
|
||||
unionId: data.unionid,
|
||||
sessionKey: data.session_key,
|
||||
};
|
||||
}
|
||||
|
||||
async oauth2AccessToken(code: string, actorRef?: WechatActorRef): Promise<WechatOAuthSession> {
|
||||
const maskedUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||
maskedUrl.searchParams.set('appid', this.appId);
|
||||
maskedUrl.searchParams.set('secret', '***');
|
||||
maskedUrl.searchParams.set('code', code);
|
||||
maskedUrl.searchParams.set('grant_type', 'authorization_code');
|
||||
const apiUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||
apiUrl.searchParams.set('appid', this.appId);
|
||||
apiUrl.searchParams.set('secret', this.appSecret);
|
||||
apiUrl.searchParams.set('code', code);
|
||||
apiUrl.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;
|
||||
}>(apiUrl.toString());
|
||||
const ok = !!data.openid;
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'LOGIN',
|
||||
requestUrl: maskedUrl.toString(),
|
||||
requestBody: { grant_type: 'authorization_code', platform: 'h5' },
|
||||
responseBody: ok
|
||||
? { openid: data.openid, unionid: data.unionid }
|
||||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||
externalNo: data.openid,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.errmsg || '微信 OAuth 失败',
|
||||
actorRef,
|
||||
});
|
||||
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 fetchOAuthUserInfo(
|
||||
accessToken: string,
|
||||
openId: string,
|
||||
actorRef?: WechatActorRef,
|
||||
): Promise<import('./wechat.interface').WechatOAuthUserInfo> {
|
||||
const maskedUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
|
||||
maskedUrl.searchParams.set('access_token', '***');
|
||||
maskedUrl.searchParams.set('openid', openId);
|
||||
maskedUrl.searchParams.set('lang', 'zh_CN');
|
||||
const apiUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
|
||||
apiUrl.searchParams.set('access_token', accessToken);
|
||||
apiUrl.searchParams.set('openid', openId);
|
||||
apiUrl.searchParams.set('lang', 'zh_CN');
|
||||
const data = await this.fetchJson<{
|
||||
openid?: string;
|
||||
nickname?: string;
|
||||
headimgurl?: string;
|
||||
unionid?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}>(apiUrl.toString());
|
||||
const ok = !!data.openid;
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'USERINFO',
|
||||
requestUrl: maskedUrl.toString(),
|
||||
requestBody: { lang: 'zh_CN' },
|
||||
responseBody: ok
|
||||
? { openid: data.openid, nickname: data.nickname, unionid: data.unionid }
|
||||
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||
externalNo: data.openid ?? openId,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.errmsg || '微信用户信息获取失败',
|
||||
actorRef,
|
||||
});
|
||||
if (!data.openid) {
|
||||
throw new InternalServerErrorException(data.errmsg || '微信用户信息获取失败');
|
||||
}
|
||||
return {
|
||||
openId: data.openid,
|
||||
nickname: data.nickname,
|
||||
headImgUrl: data.headimgurl,
|
||||
unionId: data.unionid,
|
||||
};
|
||||
}
|
||||
|
||||
async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
|
||||
try {
|
||||
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');
|
||||
const config = {
|
||||
appId: this.appId,
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature,
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'JSSDK_CONFIG',
|
||||
requestUrl: url.split('#')[0],
|
||||
requestBody: { appId: this.appId },
|
||||
responseBody: { appId: this.appId, timestamp, nonceStr },
|
||||
status: 'SUCCESS',
|
||||
actorRef,
|
||||
});
|
||||
return config;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'JSSDK_CONFIG',
|
||||
requestUrl: url.split('#')[0],
|
||||
requestBody: { appId: this.appId },
|
||||
status: 'FAILED',
|
||||
errorMessage: message,
|
||||
actorRef,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
const scene = (input.scene ?? '').trim();
|
||||
if (!scene || scene.length > 32) {
|
||||
throw new BadRequestException('小程序码 scene 须为 1~32 个可见字符');
|
||||
}
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const page = (input.page ?? process.env.WX_MINI_PROMO_PAGE ?? 'pages/home/index').replace(
|
||||
/^\//,
|
||||
'',
|
||||
);
|
||||
const envFromCfg = process.env.WX_MINI_ENV_VERSION;
|
||||
const envVersion: 'release' | 'trial' | 'develop' =
|
||||
input.envVersion ??
|
||||
(envFromCfg === 'trial' || envFromCfg === 'develop' || envFromCfg === 'release'
|
||||
? envFromCfg
|
||||
: 'release');
|
||||
const body = {
|
||||
scene,
|
||||
page,
|
||||
width: input.width ?? 430,
|
||||
check_path: input.checkPath ?? false,
|
||||
env_version: envVersion,
|
||||
is_hyaline: input.isHyaline ?? false,
|
||||
};
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`;
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// 失败时微信返回 JSON(以 { 开头),成功为 PNG 二进制
|
||||
if (buf.length >= 1 && buf[0] === 0x7b /* '{' */) {
|
||||
let errMsg = '生成小程序码失败';
|
||||
try {
|
||||
const err = JSON.parse(buf.toString('utf8')) as { errcode?: number; errmsg?: string };
|
||||
errMsg = err.errmsg || errMsg;
|
||||
this.logger.error(`getwxacodeunlimit failed: ${err.errcode} ${err.errmsg}`);
|
||||
} catch {
|
||||
this.logger.error(`getwxacodeunlimit non-image response: ${buf.toString('utf8').slice(0, 200)}`);
|
||||
}
|
||||
throw new InternalServerErrorException(errMsg);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult> {
|
||||
const orderKey: Record<string, string | number> = {
|
||||
order_number_type: input.orderNumberType,
|
||||
};
|
||||
if (input.orderNumberType === 2) {
|
||||
if (!input.transactionId) {
|
||||
throw new BadRequestException('微信支付单号不能为空');
|
||||
}
|
||||
orderKey.transaction_id = input.transactionId;
|
||||
} else {
|
||||
if (!input.mchId || !input.outTradeNo) {
|
||||
throw new BadRequestException('商户号与商户单号不能为空');
|
||||
}
|
||||
orderKey.mchid = input.mchId;
|
||||
orderKey.out_trade_no = input.outTradeNo;
|
||||
}
|
||||
|
||||
const body = {
|
||||
order_key: orderKey,
|
||||
logistics_type: input.logisticsType,
|
||||
delivery_mode: input.deliveryMode ?? 1,
|
||||
shipping_list: input.shippingList.map((row) => {
|
||||
const item: Record<string, unknown> = {
|
||||
item_desc: row.itemDesc.slice(0, 120),
|
||||
};
|
||||
if (row.trackingNo) item.tracking_no = row.trackingNo;
|
||||
if (row.expressCompany) item.express_company = row.expressCompany;
|
||||
if (row.contact?.consignorContact || row.contact?.receiverContact) {
|
||||
item.contact = {
|
||||
...(row.contact.consignorContact
|
||||
? { consignor_contact: row.contact.consignorContact }
|
||||
: {}),
|
||||
...(row.contact.receiverContact
|
||||
? { receiver_contact: row.contact.receiverContact }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
upload_time: input.uploadTime,
|
||||
payer: { openid: input.payerOpenId },
|
||||
};
|
||||
|
||||
const callOnce = async (accessToken: string) => {
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`;
|
||||
return this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
let accessToken = await this.getMiniAccessToken();
|
||||
let data = await callOnce(accessToken);
|
||||
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
|
||||
this.logger.warn(
|
||||
`upload_shipping_info token invalid ${data.errcode}, refresh stable token and retry`,
|
||||
);
|
||||
accessToken = await this.getMiniAccessToken(true);
|
||||
data = await callOnce(accessToken);
|
||||
}
|
||||
return {
|
||||
errcode: data.errcode ?? -1,
|
||||
errmsg: data.errmsg ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async getOrderShippingInfo(input: {
|
||||
transactionId?: string;
|
||||
mchId?: string;
|
||||
outTradeNo?: string;
|
||||
}): Promise<WechatOrderShippingQueryResult> {
|
||||
const callOnce = async (accessToken: string) => {
|
||||
const body: Record<string, string> = {};
|
||||
if (input.transactionId) {
|
||||
body.transaction_id = input.transactionId;
|
||||
} else {
|
||||
if (!input.mchId || !input.outTradeNo) {
|
||||
throw new BadRequestException('请提供 transaction_id 或 mchid+out_trade_no');
|
||||
}
|
||||
body.merchant_id = input.mchId;
|
||||
body.merchant_trade_no = input.outTradeNo;
|
||||
}
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/get_order?access_token=${accessToken}`;
|
||||
return this.fetchJson<{
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
order?: {
|
||||
transaction_id?: string;
|
||||
merchant_trade_no?: string;
|
||||
order_state?: number;
|
||||
shipping?: { finish_shipping?: boolean };
|
||||
};
|
||||
}>(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
let accessToken = await this.getMiniAccessToken();
|
||||
let data = await callOnce(accessToken);
|
||||
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
|
||||
accessToken = await this.getMiniAccessToken(true);
|
||||
data = await callOnce(accessToken);
|
||||
}
|
||||
return {
|
||||
errcode: data.errcode ?? 0,
|
||||
errmsg: data.errmsg ?? 'ok',
|
||||
orderState: data.order?.order_state,
|
||||
transactionId: data.order?.transaction_id,
|
||||
merchantTradeNo: data.order?.merchant_trade_no,
|
||||
finishShipping: data.order?.shipping?.finish_shipping,
|
||||
};
|
||||
}
|
||||
|
||||
async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`;
|
||||
const data = await this.fetchJson<{
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
delivery_list?: Array<{ delivery_id?: string; delivery_name?: string }>;
|
||||
}>(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
this.logger.warn(`get_delivery_list failed: ${data.errcode} ${data.errmsg}`);
|
||||
return [];
|
||||
}
|
||||
return (data.delivery_list ?? [])
|
||||
.filter((row) => row.delivery_id && row.delivery_name)
|
||||
.map((row) => ({
|
||||
deliveryId: row.delivery_id!,
|
||||
deliveryName: row.delivery_name!,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
||||
if (platform === 'h5') {
|
||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||
}
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const apiUrl = `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 };
|
||||
}>(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
||||
const ok = !!phone;
|
||||
await logWechatAuth(this.prisma, {
|
||||
scene: 'BIND_PHONE',
|
||||
requestUrl: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber',
|
||||
requestBody: { platform },
|
||||
responseBody: ok ? { phone: `${phone!.slice(0, 3)}****${phone!.slice(-4)}` } : { errcode: data.errcode, errmsg: data.errmsg },
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.errmsg || '获取手机号失败',
|
||||
actorRef,
|
||||
});
|
||||
if (!phone) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
async createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
platform?: 'h5' | 'mini';
|
||||
}) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new InternalServerErrorException(
|
||||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
);
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||||
if (!notifyUrl) {
|
||||
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
||||
}
|
||||
const platform = params.platform ?? 'h5';
|
||||
const payAppId = platform === 'mini' ? this.miniAppId || this.appId : this.appId || this.miniAppId;
|
||||
if (!payAppId) {
|
||||
throw new InternalServerErrorException(
|
||||
platform === 'mini'
|
||||
? '小程序支付未配置:请设置 WX_MINI_APP_ID'
|
||||
: '微信支付未配置:请设置 WX_APP_ID',
|
||||
);
|
||||
}
|
||||
const body = {
|
||||
appid: payAppId,
|
||||
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.fetchPayJson<{ 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('微信预支付下单失败');
|
||||
}
|
||||
this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
|
||||
const timeStamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonceStr = randomUUID().replace(/-/g, '');
|
||||
const packageStr = `prepay_id=${res.prepay_id}`;
|
||||
const message = `${payAppId}\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: payAppId,
|
||||
timeStamp,
|
||||
nonceStr,
|
||||
package: packageStr,
|
||||
signType: 'RSA' as const,
|
||||
paySign,
|
||||
};
|
||||
}
|
||||
|
||||
async createNativePrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new InternalServerErrorException(
|
||||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
);
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||||
if (!notifyUrl) {
|
||||
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
||||
}
|
||||
const payAppId = this.appId || this.miniAppId;
|
||||
if (!payAppId) {
|
||||
throw new InternalServerErrorException('微信支付未配置:请设置 WX_APP_ID');
|
||||
}
|
||||
const body = {
|
||||
appid: payAppId,
|
||||
mchid: this.mchId,
|
||||
description: params.description,
|
||||
out_trade_no: params.orderNo,
|
||||
notify_url: notifyUrl,
|
||||
amount: { total: params.amountFen, currency: 'CNY' },
|
||||
};
|
||||
const path = '/v3/pay/transactions/native';
|
||||
const payload = JSON.stringify(body);
|
||||
const auth = this.signPayRequest('POST', path, payload);
|
||||
const res = await this.fetchPayJson<{ code_url?: string }>(
|
||||
`https://api.mch.weixin.qq.com${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: auth,
|
||||
},
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
if (!res.code_url) {
|
||||
throw new InternalServerErrorException('微信 Native 下单失败');
|
||||
}
|
||||
this.logger.log(`NATIVE prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
|
||||
return { codeUrl: res.code_url };
|
||||
}
|
||||
|
||||
async parsePayNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new BadRequestException('微信支付未启用');
|
||||
}
|
||||
const signature = this.headerValue(headers, 'wechatpay-signature');
|
||||
const timestamp = this.headerValue(headers, 'wechatpay-timestamp');
|
||||
const nonce = this.headerValue(headers, 'wechatpay-nonce');
|
||||
if (!signature || !timestamp || !nonce) {
|
||||
throw new BadRequestException('微信回调签名头缺失');
|
||||
}
|
||||
if (this.platformCert) {
|
||||
const valid = verifyPaySignature({
|
||||
platformPublicKeyPem: this.platformCert,
|
||||
timestamp,
|
||||
nonce,
|
||||
body: rawBody,
|
||||
signature,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new BadRequestException('微信回调验签失败');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)');
|
||||
}
|
||||
|
||||
const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope;
|
||||
if (envelope.event_type !== 'TRANSACTION.SUCCESS') {
|
||||
throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
|
||||
}
|
||||
const resource = decryptPayResource(
|
||||
this.apiV3Key,
|
||||
envelope.resource.associated_data ?? '',
|
||||
envelope.resource.nonce,
|
||||
envelope.resource.ciphertext,
|
||||
);
|
||||
if (resource.trade_state !== 'SUCCESS') {
|
||||
throw new BadRequestException(`交易未成功: ${resource.trade_state}`);
|
||||
}
|
||||
return {
|
||||
transactionId: resource.transaction_id,
|
||||
outTradeNo: resource.out_trade_no,
|
||||
tradeState: resource.trade_state,
|
||||
amountFen: resource.amount?.total ?? resource.amount?.payer_total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async createDomesticRefund(params: {
|
||||
orderNo: string;
|
||||
transactionId?: string;
|
||||
outRefundNo: string;
|
||||
amountFen: number;
|
||||
totalFen: number;
|
||||
reason?: string;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new InternalServerErrorException(
|
||||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
);
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.refundNotifyUrl;
|
||||
if (!notifyUrl) {
|
||||
throw new InternalServerErrorException('请配置 WX_REFUND_NOTIFY_URL');
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
out_refund_no: params.outRefundNo,
|
||||
reason: params.reason ?? '用户申请退款',
|
||||
notify_url: notifyUrl,
|
||||
amount: {
|
||||
refund: params.amountFen,
|
||||
total: params.totalFen,
|
||||
currency: 'CNY',
|
||||
},
|
||||
};
|
||||
if (params.transactionId) {
|
||||
body.transaction_id = params.transactionId;
|
||||
} else {
|
||||
body.out_trade_no = params.orderNo;
|
||||
}
|
||||
const path = '/v3/refund/domestic/refunds';
|
||||
const payload = JSON.stringify(body);
|
||||
const auth = this.signPayRequest('POST', path, payload);
|
||||
const res = await this.fetchPayJson<{
|
||||
refund_id?: string;
|
||||
out_refund_no?: string;
|
||||
status?: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||
}>(`https://api.mch.weixin.qq.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: auth,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
if (!res.refund_id || !res.out_refund_no) {
|
||||
throw new InternalServerErrorException('微信退款申请失败');
|
||||
}
|
||||
this.logger.log(
|
||||
`refund ok mchid=${this.mchId} orderNo=${params.orderNo} outRefundNo=${params.outRefundNo} status=${res.status}`,
|
||||
);
|
||||
return {
|
||||
refundId: res.refund_id,
|
||||
outRefundNo: res.out_refund_no,
|
||||
status: res.status ?? 'PROCESSING',
|
||||
};
|
||||
}
|
||||
|
||||
async parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new BadRequestException('微信支付未启用');
|
||||
}
|
||||
const signature = this.headerValue(headers, 'wechatpay-signature');
|
||||
const timestamp = this.headerValue(headers, 'wechatpay-timestamp');
|
||||
const nonce = this.headerValue(headers, 'wechatpay-nonce');
|
||||
if (!signature || !timestamp || !nonce) {
|
||||
throw new BadRequestException('微信回调签名头缺失');
|
||||
}
|
||||
if (this.platformCert) {
|
||||
const valid = verifyPaySignature({
|
||||
platformPublicKeyPem: this.platformCert,
|
||||
timestamp,
|
||||
nonce,
|
||||
body: rawBody,
|
||||
signature,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new BadRequestException('微信回调验签失败');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)');
|
||||
}
|
||||
|
||||
const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope;
|
||||
if (envelope.event_type !== 'REFUND.SUCCESS') {
|
||||
throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
|
||||
}
|
||||
const resource = decryptPayResource(
|
||||
this.apiV3Key,
|
||||
envelope.resource.associated_data ?? '',
|
||||
envelope.resource.nonce,
|
||||
envelope.resource.ciphertext,
|
||||
) as unknown as WechatRefundNotifyResource;
|
||||
if (resource.refund_status !== 'SUCCESS') {
|
||||
throw new BadRequestException(`退款未成功: ${resource.refund_status}`);
|
||||
}
|
||||
return {
|
||||
outRefundNo: resource.out_refund_no,
|
||||
refundId: resource.refund_id,
|
||||
status: resource.refund_status,
|
||||
amountFen: resource.amount?.refund ?? resource.amount?.payer_refund ?? 0,
|
||||
outTradeNo: resource.out_trade_no,
|
||||
transactionId: resource.transaction_id,
|
||||
};
|
||||
}
|
||||
|
||||
private headerValue(headers: Record<string, string | string[] | undefined>, key: string) {
|
||||
const raw = headers[key] ?? headers[key.toLowerCase()];
|
||||
if (Array.isArray(raw)) return raw[0];
|
||||
return raw;
|
||||
}
|
||||
|
||||
private async getAccessToken(forceRefresh = false): Promise<string> {
|
||||
return this.fetchStableAccessToken({
|
||||
cacheKey: ACCESS_TOKEN_KEY,
|
||||
appId: this.appId,
|
||||
appSecret: this.appSecret,
|
||||
forceRefresh,
|
||||
label: '服务号',
|
||||
});
|
||||
}
|
||||
|
||||
/** 小程序 access_token(getPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(forceRefresh = false): Promise<string> {
|
||||
const appId = this.miniAppId;
|
||||
const appSecret = this.miniAppSecret;
|
||||
if (!appId || !appSecret) {
|
||||
throw new InternalServerErrorException(
|
||||
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||||
);
|
||||
}
|
||||
return this.fetchStableAccessToken({
|
||||
cacheKey: MINI_ACCESS_TOKEN_KEY,
|
||||
appId,
|
||||
appSecret,
|
||||
forceRefresh,
|
||||
label: '小程序',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/mp-access-token/api_getstableaccesstoken.html
|
||||
*/
|
||||
private async fetchStableAccessToken(opts: {
|
||||
cacheKey: string;
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
forceRefresh: boolean;
|
||||
label: string;
|
||||
}): Promise<string> {
|
||||
if (!opts.appId || !opts.appSecret) {
|
||||
throw new InternalServerErrorException(`${opts.label}未配置 AppID/Secret`);
|
||||
}
|
||||
if (!opts.forceRefresh) {
|
||||
const cached = await this.redis.getJson<TokenCache>(opts.cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||
} else {
|
||||
await this.redis.del(opts.cacheKey);
|
||||
}
|
||||
|
||||
const data = await this.fetchJson<{
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}>('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid: opts.appId,
|
||||
secret: opts.appSecret,
|
||||
force_refresh: !!opts.forceRefresh,
|
||||
}),
|
||||
});
|
||||
if (!data.access_token) {
|
||||
this.logger.error(
|
||||
`getStableAccessToken ${opts.label} failed: ${data.errcode} ${data.errmsg}`,
|
||||
);
|
||||
throw new InternalServerErrorException(data.errmsg || `获取${opts.label} access_token 失败`);
|
||||
}
|
||||
// 稳定版会提前约 5 分钟轮换;本地再提前 5 分钟过期,避免踩边
|
||||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||
await this.redis.setJson(
|
||||
opts.cacheKey,
|
||||
{ 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 fetchPayJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
let data: T & { code?: string; message?: string };
|
||||
try {
|
||||
data = JSON.parse(text) as T & { code?: string; message?: string };
|
||||
} catch {
|
||||
this.logger.error(`WeChat Pay invalid JSON (${res.status}): ${text.slice(0, 300)}`);
|
||||
throw new InternalServerErrorException('微信支付接口响应异常');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = data.message || data.code || text.slice(0, 200);
|
||||
this.logger.error(`WeChat Pay API ${res.status}: ${detail}`);
|
||||
throw new InternalServerErrorException(`微信支付下单失败: ${detail}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
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,85 @@
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import type { IWechatProvider, WechatWxaCodeUnlimitedInput } from './wechat.interface';
|
||||
|
||||
@Injectable()
|
||||
export class WechatDisabledProvider implements IWechatProvider {
|
||||
isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isMock() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
private disabled(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
code2Session() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
oauth2AccessToken() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
fetchOAuthUserInfo() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createJssdkConfig() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
buildOAuthUrl() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getPhoneNumberByCode() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createJsapiPrepay() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createNativePrepay() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
parsePayNotification() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createDomesticRefund() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
parseRefundNotification() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
uploadShippingInfo() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getOrderShippingInfo() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getDeliveryList() {
|
||||
return this.disabled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
WechatJssdkConfig,
|
||||
WechatJsapiPrepayParams,
|
||||
WechatRefundNotifyResult,
|
||||
} 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 type WechatOAuthUserInfo = {
|
||||
openId: string;
|
||||
nickname?: string;
|
||||
headImgUrl?: string;
|
||||
unionId?: string;
|
||||
};
|
||||
|
||||
export type WechatPayNotifyResult = {
|
||||
transactionId: string;
|
||||
outTradeNo: string;
|
||||
tradeState: string;
|
||||
amountFen: number;
|
||||
};
|
||||
|
||||
export type WechatWxaCodeUnlimitedInput = {
|
||||
/** 最大 32 可见字符,扫码后小程序 onLaunch.options.scene */
|
||||
scene: string;
|
||||
/** 小程序页面路径,如 pages/home/index(不要前导 /) */
|
||||
page?: string;
|
||||
width?: number;
|
||||
checkPath?: boolean;
|
||||
envVersion?: 'release' | 'trial' | 'develop';
|
||||
isHyaline?: boolean;
|
||||
};
|
||||
|
||||
/** 小程序发货信息管理 — 发货信息录入 */
|
||||
export type WechatUploadShippingInfoInput = {
|
||||
/** 1=商户单号;2=微信支付单号 */
|
||||
orderNumberType: 1 | 2;
|
||||
transactionId?: string;
|
||||
mchId?: string;
|
||||
outTradeNo?: string;
|
||||
/** 1 快递 2 同城 3 虚拟 4 自提 */
|
||||
logisticsType: 1 | 2 | 3 | 4;
|
||||
/** 1 统一发货 2 分拆发货 */
|
||||
deliveryMode?: 1 | 2;
|
||||
shippingList: Array<{
|
||||
trackingNo?: string;
|
||||
/** 微信运力 ID,如 SF / STO */
|
||||
expressCompany?: string;
|
||||
itemDesc: string;
|
||||
contact?: {
|
||||
consignorContact?: string;
|
||||
receiverContact?: string;
|
||||
};
|
||||
}>;
|
||||
uploadTime: string;
|
||||
payerOpenId: string;
|
||||
};
|
||||
|
||||
export type WechatUploadShippingInfoResult = {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
};
|
||||
|
||||
export type WechatOrderShippingQueryResult = {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
/** 1待发货 2已发货 3确认收货 4交易完成 5已退款 */
|
||||
orderState?: number;
|
||||
transactionId?: string;
|
||||
merchantTradeNo?: string;
|
||||
finishShipping?: boolean;
|
||||
};
|
||||
|
||||
export type WechatDeliveryCompany = {
|
||||
deliveryId: string;
|
||||
deliveryName: string;
|
||||
};
|
||||
|
||||
export interface IWechatProvider {
|
||||
isEnabled(): boolean;
|
||||
|
||||
/** 是否为 preV1 Mock 实现(登录时可回落到演示账号) */
|
||||
isMock(): boolean;
|
||||
|
||||
/** 微信支付是否已配置(商户号 + 证书) */
|
||||
isPayEnabled(): boolean;
|
||||
|
||||
/** 当前商户号(用于日志/排查) */
|
||||
getMchId(): string;
|
||||
|
||||
/** 小程序 code2session */
|
||||
code2Session(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatCodeSession>;
|
||||
|
||||
/** 公众号 H5 OAuth code 换 openId */
|
||||
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
|
||||
|
||||
/** 公众号 OAuth access_token 拉取用户昵称头像(snsapi_userinfo) */
|
||||
fetchOAuthUserInfo(
|
||||
accessToken: string,
|
||||
openId: string,
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
): Promise<WechatOAuthUserInfo>;
|
||||
|
||||
/** JSSDK 签名配置 */
|
||||
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
|
||||
|
||||
/** 构建公众号 OAuth 授权 URL */
|
||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
||||
|
||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||
getPhoneNumberByCode(
|
||||
code: string,
|
||||
platform: 'mini' | 'h5',
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
): Promise<string>;
|
||||
|
||||
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
||||
createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
platform?: 'h5' | 'mini';
|
||||
}): Promise<WechatJsapiPrepayParams>;
|
||||
|
||||
/** 创建 Native 扫码支付 code_url */
|
||||
createNativePrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
notifyUrl: string;
|
||||
}): Promise<{ codeUrl: string }>;
|
||||
|
||||
/** 解析并验签支付回调通知 */
|
||||
parsePayNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
): Promise<WechatPayNotifyResult>;
|
||||
|
||||
/** 发起国内退款(API v3) */
|
||||
createDomesticRefund(params: {
|
||||
orderNo: string;
|
||||
transactionId?: string;
|
||||
outRefundNo: string;
|
||||
amountFen: number;
|
||||
totalFen: number;
|
||||
reason?: string;
|
||||
notifyUrl: string;
|
||||
}): Promise<{ refundId: string; outRefundNo: string; status: 'PROCESSING' | 'SUCCESS' | 'ABNORMAL' | 'CLOSED' }>;
|
||||
|
||||
/** 解析并验签退款回调通知 */
|
||||
parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
): Promise<WechatRefundNotifyResult>;
|
||||
|
||||
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
|
||||
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* 小程序发货信息录入(交易资金解冻前置)
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/order_shipping/api_uploadshippinginfo.html
|
||||
*/
|
||||
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
|
||||
|
||||
/** 查询支付单发货/确认收货状态 */
|
||||
getOrderShippingInfo(input: {
|
||||
transactionId?: string;
|
||||
mchId?: string;
|
||||
outTradeNo?: string;
|
||||
}): Promise<WechatOrderShippingQueryResult>;
|
||||
|
||||
/** 获取运力公司列表(快递公司 delivery_id) */
|
||||
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import * as QRCode from 'qrcode';
|
||||
import type {
|
||||
IWechatProvider,
|
||||
WechatCodeSession,
|
||||
WechatOAuthSession,
|
||||
WechatWxaCodeUnlimitedInput,
|
||||
} from './wechat.interface';
|
||||
|
||||
/**
|
||||
* preV1 Mock 微信 Provider。
|
||||
*
|
||||
* 目的:让「微信授权登录」按钮在不接真实微信的情况下走通。前端仍按真实 OAuth 流程
|
||||
* (跳转 oauth-url → 回调携带 code),Mock 端将授权 URL 直接回跳并返回稳定 openId。
|
||||
* 后续填入 WX_APP_ID/WX_APP_SECRET 并置 MOCK_WECHAT=false 即切换到真实实现。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WechatMockProvider implements IWechatProvider {
|
||||
isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isMock() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 由 code 派生稳定 openId,保证同一 code 多次授权指向同一账号 */
|
||||
private openIdFromCode(code: string): string {
|
||||
return `mockwx_${createHash('md5').update(code).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
async code2Session(code: string): Promise<WechatCodeSession> {
|
||||
return { openId: this.openIdFromCode(code), sessionKey: 'mock-session-key' };
|
||||
}
|
||||
|
||||
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
|
||||
return { openId: this.openIdFromCode(code), accessToken: 'mock-access-token' };
|
||||
}
|
||||
|
||||
async fetchOAuthUserInfo(accessToken: string, openId: string) {
|
||||
return {
|
||||
openId,
|
||||
nickname: 'Mock微信用户',
|
||||
headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async createJssdkConfig(url: string) {
|
||||
return {
|
||||
appId: 'mock-appid',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
nonceStr: 'mocknonce',
|
||||
signature: 'mocksignature',
|
||||
url,
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseImage'],
|
||||
} as unknown as Awaited<ReturnType<IWechatProvider['createJssdkConfig']>>;
|
||||
}
|
||||
|
||||
/** 直接把授权链接回跳到 redirectUri 并附带 mock code,模拟微信授权完成 */
|
||||
buildOAuthUrl(redirectUri: string, state: string): string {
|
||||
const sep = redirectUri.includes('?') ? '&' : '?';
|
||||
const code = `mockcode_${state || 'default'}`;
|
||||
return `${redirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
}
|
||||
|
||||
async getPhoneNumberByCode(): Promise<string> {
|
||||
throw new NotImplementedException('Mock 微信不支持获取手机号,请用短信绑定');
|
||||
}
|
||||
|
||||
createJsapiPrepay(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
createNativePrepay(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
parsePayNotification(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
createDomesticRefund(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
parseRefundNotification(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
const scene = (input.scene ?? '').trim() || 'mock';
|
||||
return QRCode.toBuffer(`mock-wxa://promo?scene=${encodeURIComponent(scene)}`, {
|
||||
width: input.width ?? 430,
|
||||
margin: 1,
|
||||
type: 'png',
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
|
||||
async uploadShippingInfo() {
|
||||
return { errcode: 0, errmsg: 'ok' };
|
||||
}
|
||||
|
||||
async getOrderShippingInfo() {
|
||||
return {
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
orderState: 3,
|
||||
finishShipping: true,
|
||||
};
|
||||
}
|
||||
|
||||
async getDeliveryList() {
|
||||
return [
|
||||
{ deliveryId: 'SF', deliveryName: '顺丰速运' },
|
||||
{ deliveryId: 'STO', deliveryName: '申通快递' },
|
||||
{ deliveryId: 'YTO', deliveryName: '圆通速递' },
|
||||
{ deliveryId: 'ZTO', deliveryName: '中通快递' },
|
||||
{ deliveryId: 'YD', deliveryName: '韵达速递' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig, needsRealWechatApi } from '@dukang/shared-types';
|
||||
import type { IWechatProvider } from './wechat.interface';
|
||||
import { WechatApiProvider } from './wechat.api.provider';
|
||||
import { WechatDisabledProvider } from './wechat.disabled.provider';
|
||||
import { WechatMockProvider } from './wechat.mock.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 真实微信 / 禁用 */
|
||||
@Injectable()
|
||||
export class WechatRouterProvider implements IWechatProvider {
|
||||
constructor(
|
||||
private readonly api: WechatApiProvider,
|
||||
private readonly disabled: WechatDisabledProvider,
|
||||
private readonly mock: WechatMockProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): IWechatProvider {
|
||||
const cfg = loadAppConfig();
|
||||
if (needsRealWechatApi(cfg)) return this.api;
|
||||
if (cfg.mockWechat) return this.mock;
|
||||
return this.disabled;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.resolve().isEnabled();
|
||||
}
|
||||
|
||||
isMock() {
|
||||
return this.resolve().isMock();
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
return this.resolve().isPayEnabled();
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return this.resolve().getMchId();
|
||||
}
|
||||
|
||||
code2Session(code: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().code2Session(code, actorRef);
|
||||
}
|
||||
|
||||
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().oauth2AccessToken(code, actorRef);
|
||||
}
|
||||
|
||||
fetchOAuthUserInfo(
|
||||
accessToken: string,
|
||||
openId: string,
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
) {
|
||||
return this.resolve().fetchOAuthUserInfo(accessToken, openId, actorRef);
|
||||
}
|
||||
|
||||
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().createJssdkConfig(url, actorRef);
|
||||
}
|
||||
|
||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string) {
|
||||
return this.resolve().buildOAuthUrl(redirectUri, state, scope);
|
||||
}
|
||||
|
||||
getPhoneNumberByCode(
|
||||
code: string,
|
||||
platform: 'mini' | 'h5',
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
) {
|
||||
return this.resolve().getPhoneNumberByCode(code, platform, actorRef);
|
||||
}
|
||||
|
||||
createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
platform?: 'h5' | 'mini';
|
||||
}) {
|
||||
return this.resolve().createJsapiPrepay(params);
|
||||
}
|
||||
|
||||
createNativePrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
return this.resolve().createNativePrepay(params);
|
||||
}
|
||||
|
||||
parsePayNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
return this.resolve().parsePayNotification(headers, rawBody);
|
||||
}
|
||||
|
||||
createDomesticRefund(params: Parameters<IWechatProvider['createDomesticRefund']>[0]) {
|
||||
return this.resolve().createDomesticRefund(params);
|
||||
}
|
||||
|
||||
parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
return this.resolve().parseRefundNotification(headers, rawBody);
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
|
||||
return this.resolve().getWxaCodeUnlimited(input);
|
||||
}
|
||||
|
||||
uploadShippingInfo(input: Parameters<IWechatProvider['uploadShippingInfo']>[0]) {
|
||||
return this.resolve().uploadShippingInfo(input);
|
||||
}
|
||||
|
||||
getOrderShippingInfo(input: Parameters<IWechatProvider['getOrderShippingInfo']>[0]) {
|
||||
return this.resolve().getOrderShippingInfo(input);
|
||||
}
|
||||
|
||||
getDeliveryList() {
|
||||
return this.resolve().getDeliveryList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user