feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants';
|
||||
|
||||
const CMD_SCENE: Record<string, string> = {
|
||||
[XIAOFEIXIA_CMD.CREATE_ORDER]: 'CREATE_SHIPMENT',
|
||||
[XIAOFEIXIA_CMD.TRACK_ROUTE]: 'GET_TRACK',
|
||||
[XIAOFEIXIA_CMD.CANCEL_ORDER]: 'CANCEL_SHIPMENT',
|
||||
[XIAOFEIXIA_CMD.GET_ORDER]: 'GET_SHIPMENT',
|
||||
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
|
||||
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
|
||||
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
|
||||
};
|
||||
|
||||
export type CourierLogRef = {
|
||||
refType?: string;
|
||||
refId?: bigint;
|
||||
};
|
||||
|
||||
export type LogCourierCallInput = {
|
||||
scene: string;
|
||||
requestUrl: string;
|
||||
requestBody?: Record<string, unknown>;
|
||||
responseBody?: unknown;
|
||||
externalNo?: string;
|
||||
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
||||
errorMessage?: string;
|
||||
ref?: CourierLogRef;
|
||||
};
|
||||
|
||||
function maskMchId(mchId?: string) {
|
||||
if (!mchId) return mchId;
|
||||
if (mchId.length <= 4) return '****';
|
||||
return `${mchId.slice(0, 4)}****`;
|
||||
}
|
||||
|
||||
/** 请求体入库前脱敏(去掉 sign,商户号打码) */
|
||||
export function sanitizeXfxRequestBody(body: Record<string, unknown>) {
|
||||
const { sign: _sign, mchId, ...rest } = body;
|
||||
return {
|
||||
...rest,
|
||||
...(mchId != null ? { mchId: maskMchId(String(mchId)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneForXfxCmd(cmd: string) {
|
||||
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
||||
}
|
||||
|
||||
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
||||
const responseBody =
|
||||
input.responseBody === undefined
|
||||
? undefined
|
||||
: typeof input.responseBody === 'object' && input.responseBody !== null
|
||||
? (input.responseBody as Record<string, unknown>)
|
||||
: { value: input.responseBody };
|
||||
|
||||
const row = await prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: input.scene,
|
||||
refType: input.ref?.refType,
|
||||
refId: input.ref?.refId,
|
||||
requestUrl: input.requestUrl.slice(0, 512),
|
||||
requestBody: input.requestBody as never,
|
||||
responseBody: responseBody as never,
|
||||
externalNo: input.externalNo?.slice(0, 128),
|
||||
status: input.status,
|
||||
errorMessage: input.errorMessage?.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export async function resolveOrderRefByOutNumber(
|
||||
prisma: PrismaService,
|
||||
outNumber?: string,
|
||||
): Promise<CourierLogRef | undefined> {
|
||||
if (!outNumber?.trim()) return undefined;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { orderNo: outNumber.trim() },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) return undefined;
|
||||
return { refType: 'ORDER', refId: order.id };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CourierProviderCode } from './courier.constants';
|
||||
|
||||
export type XiaofeixiaSignType = 'MD5' | 'HMAC-SHA256';
|
||||
|
||||
export interface XiaofeixiaConfig {
|
||||
apiUrl: string;
|
||||
appId?: string;
|
||||
mchId: string;
|
||||
apiKey: string;
|
||||
signType: XiaofeixiaSignType;
|
||||
}
|
||||
|
||||
export interface CourierIntegrationConfig {
|
||||
provider: CourierProviderCode;
|
||||
xiaofeixia: XiaofeixiaConfig;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CourierConfigService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
load(): CourierIntegrationConfig {
|
||||
const providerRaw = (this.config.get<string>('COURIER_PROVIDER') ?? 'xiaofeixia').toLowerCase();
|
||||
const provider = this.resolveProvider(providerRaw);
|
||||
|
||||
return {
|
||||
provider,
|
||||
xiaofeixia: {
|
||||
apiUrl:
|
||||
this.config.get<string>('XIAOFEIXIA_API_URL')?.trim() ||
|
||||
'https://beta.51xiaoju.cn/app/api/interface.do',
|
||||
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
|
||||
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
|
||||
apiKey: this.config.get<string>('XIAOFEIXIA_API_KEY') ?? '',
|
||||
signType: this.resolveSignType(this.config.get<string>('XIAOFEIXIA_SIGN_TYPE')),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveProvider(raw: string): CourierProviderCode {
|
||||
switch (raw) {
|
||||
case 'xiaofeixia':
|
||||
return CourierProviderCode.XIAOFEIXIA;
|
||||
case 'sf':
|
||||
return CourierProviderCode.SF;
|
||||
case 'jd':
|
||||
return CourierProviderCode.JD;
|
||||
default:
|
||||
throw new Error(`Unsupported COURIER_PROVIDER: ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSignType(raw?: string): XiaofeixiaSignType {
|
||||
if (raw?.toUpperCase() === 'HMAC-SHA256') {
|
||||
return 'HMAC-SHA256';
|
||||
}
|
||||
return 'MD5';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const COURIER_PROVIDER = 'COURIER_PROVIDER';
|
||||
|
||||
export enum CourierProviderCode {
|
||||
XIAOFEIXIA = 'XIAOFEIXIA',
|
||||
SF = 'SF',
|
||||
JD = 'JD',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class CourierApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
readonly providerCode?: string,
|
||||
readonly raw?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CourierApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { COURIER_PROVIDER, CourierProviderCode } from './courier.constants';
|
||||
import { CourierConfigService } from './courier.config';
|
||||
import { CourierService } from './courier.service';
|
||||
import { XiaofeixiaClient } from './xiaofeixia/xiaofeixia.client';
|
||||
import { XiaofeixiaProvider } from './xiaofeixia/xiaofeixia.provider';
|
||||
import type { ICourierProvider } from './courier.types';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
CourierConfigService,
|
||||
XiaofeixiaClient,
|
||||
XiaofeixiaProvider,
|
||||
{
|
||||
provide: COURIER_PROVIDER,
|
||||
useFactory: (
|
||||
configService: CourierConfigService,
|
||||
xiaofeixia: XiaofeixiaProvider,
|
||||
): ICourierProvider => {
|
||||
const { provider } = configService.load();
|
||||
switch (provider) {
|
||||
case CourierProviderCode.XIAOFEIXIA:
|
||||
return xiaofeixia;
|
||||
default:
|
||||
throw new Error(`Courier provider not implemented: ${provider}`);
|
||||
}
|
||||
},
|
||||
inject: [CourierConfigService, XiaofeixiaProvider],
|
||||
},
|
||||
CourierService,
|
||||
],
|
||||
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
|
||||
})
|
||||
export class CourierModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COURIER_PROVIDER } from './courier.constants';
|
||||
import type {
|
||||
BatchShipmentQuery,
|
||||
CourierCallOptions,
|
||||
CreateShipmentInput,
|
||||
CreateShipmentResult,
|
||||
DeliveryCoverageResult,
|
||||
FreightEstimateResult,
|
||||
ICourierProvider,
|
||||
ShipmentDetail,
|
||||
ShipmentQuery,
|
||||
TrackCallbackResponse,
|
||||
TrackNode,
|
||||
} from './courier.types';
|
||||
|
||||
/**
|
||||
* 快递统一门面:业务层只依赖本 Service,不感知具体快递商实现。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourierService {
|
||||
constructor(@Inject(COURIER_PROVIDER) private readonly provider: ICourierProvider) {}
|
||||
|
||||
get activeProvider() {
|
||||
return this.provider.code;
|
||||
}
|
||||
|
||||
createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
|
||||
return this.provider.createShipment(input, options);
|
||||
}
|
||||
|
||||
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
|
||||
return this.provider.cancelShipment(query, options);
|
||||
}
|
||||
|
||||
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
|
||||
return this.provider.getShipment(query, options);
|
||||
}
|
||||
|
||||
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
|
||||
return this.provider.batchGetShipments(query, options);
|
||||
}
|
||||
|
||||
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
|
||||
return this.provider.getTrack(query, options);
|
||||
}
|
||||
|
||||
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
|
||||
return this.provider.checkDeliveryCoverage(toAddress, options);
|
||||
}
|
||||
|
||||
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
|
||||
return this.provider.estimateFreight(weight, options);
|
||||
}
|
||||
|
||||
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
|
||||
return this.provider.buildTrackCallbackResponse(success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { CourierProviderCode } from './courier.constants';
|
||||
import type { XiaofeixiaConfig } from './courier.config';
|
||||
|
||||
/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */
|
||||
export interface CourierCallOptions {
|
||||
xiaofeixia?: XiaofeixiaConfig;
|
||||
}
|
||||
|
||||
export interface CourierCoordinate {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
export interface CourierContact {
|
||||
name: string;
|
||||
mobile: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
coordinate?: CourierCoordinate;
|
||||
}
|
||||
|
||||
/** 付费对象:寄付 / 到付 */
|
||||
export enum CourierPayMode {
|
||||
SENDER = '1',
|
||||
RECEIVER = '2',
|
||||
}
|
||||
|
||||
export interface CreateShipmentInput {
|
||||
outNumber: string;
|
||||
customerId?: string;
|
||||
from: CourierContact;
|
||||
to: CourierContact;
|
||||
goodsName?: string;
|
||||
goodsNum?: number;
|
||||
weight?: number;
|
||||
insuredSumPrice?: number;
|
||||
collectionPrice?: number;
|
||||
payMode: CourierPayMode;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface CreateShipmentResult {
|
||||
providerShipmentId: number;
|
||||
trackingNumber: string;
|
||||
}
|
||||
|
||||
export interface ShipmentQuery {
|
||||
trackingNumber?: string;
|
||||
outNumber?: string;
|
||||
}
|
||||
|
||||
export interface BatchShipmentQuery {
|
||||
trackingNumbers?: string[];
|
||||
outNumbers?: string[];
|
||||
}
|
||||
|
||||
export interface TrackNode {
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
statusName: string;
|
||||
}
|
||||
|
||||
export interface ShipmentDetail {
|
||||
trackingNumber: string;
|
||||
toCarrierName?: string;
|
||||
toSiteName?: string;
|
||||
toName: string;
|
||||
toMobile: string;
|
||||
toAddress: string;
|
||||
toAddressDetail?: string;
|
||||
fromCarrierName?: string;
|
||||
fromSiteName?: string;
|
||||
fromName: string;
|
||||
fromMobile: string;
|
||||
fromAddress: string;
|
||||
fromAddressDetail?: string;
|
||||
weight?: number;
|
||||
payModeName?: string;
|
||||
freightPrice?: string;
|
||||
insuredPrice?: number;
|
||||
collectionPrice?: number;
|
||||
sumPrice?: number;
|
||||
goodsName?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryCoverageResult {
|
||||
arriveTime: string;
|
||||
siteName: string;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export interface FreightEstimateResult {
|
||||
freightPrice: number;
|
||||
}
|
||||
|
||||
/** 路由变化回调(各快递商 POST 到业务方) */
|
||||
export interface TrackCallbackPayload {
|
||||
outNumber: string;
|
||||
trackingNumber: string;
|
||||
status: string;
|
||||
statusName: string;
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface TrackCallbackResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ICourierProvider {
|
||||
readonly code: CourierProviderCode;
|
||||
|
||||
createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult>;
|
||||
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void>;
|
||||
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
|
||||
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
|
||||
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
|
||||
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
|
||||
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
|
||||
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './courier.constants';
|
||||
export * from './courier.types';
|
||||
export * from './courier.error';
|
||||
export * from './courier.service';
|
||||
export * from './courier.module';
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CourierApiError } from '../courier.error';
|
||||
import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config';
|
||||
import { PrismaService } from '../../../common/prisma/prisma.module';
|
||||
import {
|
||||
logCourierCall,
|
||||
resolveOrderRefByOutNumber,
|
||||
sanitizeXfxRequestBody,
|
||||
sceneForXfxCmd,
|
||||
} from '../courier-log.util';
|
||||
import { buildXiaofeixiaSign } from './xiaofeixia.sign';
|
||||
import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants';
|
||||
import type { XiaofeixiaApiResponse } from './xiaofeixia.types';
|
||||
|
||||
type RequestParams = Record<string, string | number | undefined>;
|
||||
|
||||
@Injectable()
|
||||
export class XiaofeixiaClient {
|
||||
constructor(
|
||||
private readonly courierConfig: CourierConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async request<T>(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise<T> {
|
||||
const cfg = override ?? this.courierConfig.load().xiaofeixia;
|
||||
const scene = sceneForXfxCmd(cmd);
|
||||
const externalNo = this.pickExternalNo(bizParams);
|
||||
|
||||
if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) {
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl || '(未配置)',
|
||||
requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }),
|
||||
status: 'FAILED',
|
||||
errorMessage: '小飞侠商户配置不完整',
|
||||
externalNo,
|
||||
});
|
||||
throw new CourierApiError(
|
||||
'小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key',
|
||||
'CONFIG_ERROR',
|
||||
'XIAOFEIXIA',
|
||||
);
|
||||
}
|
||||
|
||||
const baseParams: RequestParams = {
|
||||
mchId: cfg.mchId,
|
||||
cmd,
|
||||
signType: cfg.signType,
|
||||
...bizParams,
|
||||
};
|
||||
|
||||
if (cfg.appId) {
|
||||
baseParams.appId = cfg.appId;
|
||||
}
|
||||
|
||||
const sign = buildXiaofeixiaSign(baseParams, cfg.apiKey, cfg.signType);
|
||||
const body = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries({ ...baseParams, sign })) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
body.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const logRequestBody = sanitizeXfxRequestBody({ ...baseParams, sign: '[REDACTED]' });
|
||||
const orderRef = await resolveOrderRefByOutNumber(
|
||||
this.prisma,
|
||||
typeof bizParams.outNumber === 'string' ? bizParams.outNumber : undefined,
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(cfg.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
status: 'FAILED',
|
||||
errorMessage: `网络异常: ${message}`,
|
||||
externalNo,
|
||||
ref: orderRef,
|
||||
});
|
||||
throw new CourierApiError('小飞侠接口网络异常', '200000', 'XIAOFEIXIA', error);
|
||||
}
|
||||
|
||||
const rawText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
responseBody: { httpStatus: response.status, body: rawText.slice(0, 500) },
|
||||
status: 'FAILED',
|
||||
errorMessage: `HTTP ${response.status}`,
|
||||
externalNo,
|
||||
ref: orderRef,
|
||||
});
|
||||
throw new CourierApiError(
|
||||
`小飞侠 HTTP 请求失败: ${response.status}`,
|
||||
'200000',
|
||||
'XIAOFEIXIA',
|
||||
);
|
||||
}
|
||||
|
||||
let payload: XiaofeixiaApiResponse<T>;
|
||||
try {
|
||||
payload = rawText
|
||||
? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>)
|
||||
: (null as unknown as XiaofeixiaApiResponse<T>);
|
||||
} catch {
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
responseBody: { raw: rawText.slice(0, 500) },
|
||||
status: 'FAILED',
|
||||
errorMessage: '响应非 JSON',
|
||||
externalNo,
|
||||
ref: orderRef,
|
||||
});
|
||||
throw new CourierApiError(
|
||||
`小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`,
|
||||
'200000',
|
||||
'XIAOFEIXIA',
|
||||
rawText,
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
status: 'FAILED',
|
||||
errorMessage: '小飞侠返回空响应',
|
||||
externalNo,
|
||||
ref: orderRef,
|
||||
});
|
||||
throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
|
||||
}
|
||||
|
||||
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
responseBody: payload as unknown as Record<string, unknown>,
|
||||
status: 'FAILED',
|
||||
errorMessage: payload.message || '业务失败',
|
||||
externalNo: externalNo || payload.data?.toString(),
|
||||
ref: orderRef,
|
||||
});
|
||||
throw new CourierApiError(
|
||||
payload.message || '小飞侠接口业务失败',
|
||||
payload.code,
|
||||
'XIAOFEIXIA',
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
await logCourierCall(this.prisma, {
|
||||
scene,
|
||||
requestUrl: cfg.apiUrl,
|
||||
requestBody: logRequestBody,
|
||||
responseBody: payload as unknown as Record<string, unknown>,
|
||||
status: 'SUCCESS',
|
||||
externalNo: externalNo || this.pickExternalNoFromData(payload.data),
|
||||
ref: orderRef,
|
||||
});
|
||||
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
private pickExternalNo(params: RequestParams) {
|
||||
const outNumber = params.outNumber != null ? String(params.outNumber) : undefined;
|
||||
const number = params.number != null ? String(params.number) : undefined;
|
||||
return outNumber || number;
|
||||
}
|
||||
|
||||
private pickExternalNoFromData(data: unknown) {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const row = data as { number?: string; outNumber?: string; id?: string };
|
||||
return row.number || row.outNumber || row.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 小飞侠接口 cmd 编号 */
|
||||
export const XIAOFEIXIA_CMD = {
|
||||
CREATE_ORDER: '100101',
|
||||
TRACK_ROUTE: '100102',
|
||||
CANCEL_ORDER: '100103',
|
||||
GET_ORDER: '100104',
|
||||
ESTIMATE_FREIGHT: '100105',
|
||||
BATCH_GET_ORDER: '100106',
|
||||
DELIVERY_COVERAGE: '100301',
|
||||
} as const;
|
||||
|
||||
export const XIAOFEIXIA_SUCCESS_CODE = '100000';
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CourierProviderCode } from '../courier.constants';
|
||||
import { CourierApiError } from '../courier.error';
|
||||
import type {
|
||||
BatchShipmentQuery,
|
||||
CourierCallOptions,
|
||||
CreateShipmentInput,
|
||||
CreateShipmentResult,
|
||||
DeliveryCoverageResult,
|
||||
FreightEstimateResult,
|
||||
ICourierProvider,
|
||||
ShipmentDetail,
|
||||
ShipmentQuery,
|
||||
TrackCallbackResponse,
|
||||
TrackNode,
|
||||
} from '../courier.types';
|
||||
import { XiaofeixiaClient } from './xiaofeixia.client';
|
||||
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
|
||||
import type {
|
||||
XiaofeixiaCreateOrderData,
|
||||
XiaofeixiaDeliveryCoverageData,
|
||||
XiaofeixiaFreightEstimateData,
|
||||
XiaofeixiaOrderDetail,
|
||||
XiaofeixiaTrackNode,
|
||||
} from './xiaofeixia.types';
|
||||
|
||||
@Injectable()
|
||||
export class XiaofeixiaProvider implements ICourierProvider {
|
||||
readonly code = CourierProviderCode.XIAOFEIXIA;
|
||||
|
||||
constructor(private readonly client: XiaofeixiaClient) {}
|
||||
|
||||
async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
|
||||
const data = await this.client.request<XiaofeixiaCreateOrderData>(
|
||||
XIAOFEIXIA_CMD.CREATE_ORDER,
|
||||
{
|
||||
customerId: input.customerId,
|
||||
outNumber: input.outNumber,
|
||||
fromAddress: input.from.address,
|
||||
fromAddressDetail: input.from.addressDetail,
|
||||
fromCoordinate: this.formatCoordinate(input.from.coordinate),
|
||||
fromMobile: input.from.mobile,
|
||||
fromName: input.from.name,
|
||||
toAddress: input.to.address,
|
||||
toAddressDetail: input.to.addressDetail,
|
||||
toCoordinate: this.formatCoordinate(input.to.coordinate),
|
||||
toMobile: input.to.mobile,
|
||||
toName: input.to.name,
|
||||
goodsName: input.goodsName,
|
||||
goodsNum: input.goodsNum,
|
||||
weight: input.weight,
|
||||
insuredSumPrice: input.insuredSumPrice,
|
||||
collectionPrice: input.collectionPrice,
|
||||
payMode: input.payMode,
|
||||
remark: input.remark,
|
||||
},
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
|
||||
return {
|
||||
providerShipmentId: data.id,
|
||||
trackingNumber: data.number,
|
||||
};
|
||||
}
|
||||
|
||||
async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
|
||||
this.assertShipmentQuery(query);
|
||||
await this.client.request(
|
||||
XIAOFEIXIA_CMD.CANCEL_ORDER,
|
||||
{
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
},
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
}
|
||||
|
||||
async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
|
||||
this.assertShipmentQuery(query);
|
||||
const data = await this.client.request<XiaofeixiaOrderDetail>(
|
||||
XIAOFEIXIA_CMD.GET_ORDER,
|
||||
{
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
},
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
return this.mapOrderDetail(data);
|
||||
}
|
||||
|
||||
async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
|
||||
const number = query.trackingNumbers?.join(',');
|
||||
const outNumber = query.outNumbers?.join(',');
|
||||
|
||||
if (!number && !outNumber) {
|
||||
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
||||
}
|
||||
|
||||
const data = await this.client.request<XiaofeixiaOrderDetail[]>(
|
||||
XIAOFEIXIA_CMD.BATCH_GET_ORDER,
|
||||
{
|
||||
number,
|
||||
outNumber,
|
||||
},
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
|
||||
return (data ?? []).map((item) => this.mapOrderDetail(item));
|
||||
}
|
||||
|
||||
async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
|
||||
this.assertShipmentQuery(query);
|
||||
const data = await this.client.request<XiaofeixiaTrackNode[]>(
|
||||
XIAOFEIXIA_CMD.TRACK_ROUTE,
|
||||
{
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
},
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
|
||||
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
|
||||
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
|
||||
{ toAddress },
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
|
||||
return {
|
||||
arriveTime: data.arriveTime,
|
||||
siteName: data.name,
|
||||
siteId: data.id,
|
||||
};
|
||||
}
|
||||
|
||||
async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
|
||||
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
|
||||
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
|
||||
{ weight },
|
||||
options?.xiaofeixia,
|
||||
);
|
||||
|
||||
return { freightPrice: data.freightPrice };
|
||||
}
|
||||
|
||||
buildTrackCallbackResponse(success = true): TrackCallbackResponse {
|
||||
return {
|
||||
code: success ? '100000' : '300000',
|
||||
message: success ? 'success' : 'fail',
|
||||
};
|
||||
}
|
||||
|
||||
private assertShipmentQuery(query: ShipmentQuery): void {
|
||||
if (!query.trackingNumber && !query.outNumber) {
|
||||
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
||||
}
|
||||
}
|
||||
|
||||
private formatCoordinate(coordinate?: { lng: number; lat: number }): string | undefined {
|
||||
if (!coordinate) return undefined;
|
||||
return JSON.stringify({ lng: coordinate.lng, lat: coordinate.lat });
|
||||
}
|
||||
|
||||
private mapOrderDetail(data: XiaofeixiaOrderDetail): ShipmentDetail {
|
||||
return {
|
||||
trackingNumber: data.number,
|
||||
toCarrierName: data.toCarrierName,
|
||||
toSiteName: data.toSiteName,
|
||||
toName: data.toName,
|
||||
toMobile: data.toMobile,
|
||||
toAddress: data.toAddress,
|
||||
toAddressDetail: data.toAddressDetail,
|
||||
fromCarrierName: data.fromCarrierName,
|
||||
fromSiteName: data.fromSiteName,
|
||||
fromName: data.fromName,
|
||||
fromMobile: data.fromMobile,
|
||||
fromAddress: data.fromAddress,
|
||||
fromAddressDetail: data.fromAddressDetail,
|
||||
weight: data.weight,
|
||||
payModeName: data.payModeName,
|
||||
freightPrice: data.freightPrice,
|
||||
insuredPrice: data.insuredPrice,
|
||||
collectionPrice: data.collectionPrice,
|
||||
sumPrice: data.sumPrice,
|
||||
goodsName: data.goodsName,
|
||||
remark: data.remark,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import type { XiaofeixiaSignType } from '../courier.config';
|
||||
|
||||
type SignParams = Record<string, string | number | undefined | null>;
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/** 按 ASCII 字典序拼接并生成签名 */
|
||||
export function buildXiaofeixiaSign(
|
||||
params: SignParams,
|
||||
apiKey: string,
|
||||
signType: XiaofeixiaSignType = 'MD5',
|
||||
): string {
|
||||
const sortedKeys = Object.keys(params)
|
||||
.filter((key) => key !== 'sign' && !isEmpty(params[key]))
|
||||
.sort();
|
||||
|
||||
const stringA = sortedKeys.map((key) => `${key}=${params[key]}`).join('&');
|
||||
const stringSignTemp = `${stringA}&key=${apiKey}`;
|
||||
|
||||
if (signType === 'HMAC-SHA256') {
|
||||
return createHmac('sha256', apiKey).update(stringSignTemp).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
return createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface XiaofeixiaApiResponse<T> {
|
||||
cmd: string;
|
||||
code: string;
|
||||
message: string;
|
||||
bizCode?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaCreateOrderData {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaTrackNode {
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
statusName: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaOrderDetail {
|
||||
number: string;
|
||||
toCarrierName?: string;
|
||||
toSiteName?: string;
|
||||
toName: string;
|
||||
toMobile: string;
|
||||
toAddress: string;
|
||||
toAddressDetail?: string;
|
||||
fromCarrierName?: string;
|
||||
fromSiteName?: string;
|
||||
fromName: string;
|
||||
fromMobile: string;
|
||||
fromAddress: string;
|
||||
fromAddressDetail?: string;
|
||||
weight?: number;
|
||||
payModeName?: string;
|
||||
freightPrice?: string;
|
||||
insuredPrice?: number;
|
||||
collectionPrice?: number;
|
||||
sumPrice?: number;
|
||||
goodsName?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaDeliveryCoverageData {
|
||||
arriveTime: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaFreightEstimateData {
|
||||
freightPrice: number;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaCancelOrderData {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IDeliveryProvider {
|
||||
scheduleAutoAdvance(orderId: bigint): Promise<void>;
|
||||
advanceTo(orderId: bigint, targetStatus: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IDeliveryProvider } from './delivery.interface';
|
||||
import { DELIVERY_QUEUE } from '../../jobs/jobs.constants';
|
||||
|
||||
@Injectable()
|
||||
export class DeliveryMockProvider implements IDeliveryProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(@InjectQueue(DELIVERY_QUEUE) private readonly queue: Queue) {}
|
||||
|
||||
async scheduleAutoAdvance(orderId: bigint): Promise<void> {
|
||||
if (!this.config.mockDeliveryAuto) return;
|
||||
const steps = [
|
||||
{ delay: 0, status: 'OUT_WAREHOUSE' },
|
||||
{ delay: 10000, status: 'SHIPPING' },
|
||||
{ delay: 30000, status: 'PENDING_RECEIVE' },
|
||||
{ delay: 60000, status: 'COMPLETED' },
|
||||
];
|
||||
for (const step of steps) {
|
||||
await this.queue.add(
|
||||
'advance-status',
|
||||
{ orderId: orderId.toString(), targetStatus: step.status },
|
||||
{ delay: step.delay, jobId: `${orderId}-${step.status}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTo(orderId: bigint, targetStatus: string): Promise<void> {
|
||||
await this.queue.add('advance-status', {
|
||||
orderId: orderId.toString(),
|
||||
targetStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
||||
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
||||
export const OSS_PROVIDER = 'OSS_PROVIDER';
|
||||
export const MAP_PROVIDER = 'MAP_PROVIDER';
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { SmsCodeStore } from './sms/sms-code.store';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
|
||||
import { SmsRouterProvider } from './sms/sms.router.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||
import { PayRouterProvider } from './pay/pay.router.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
import { WechatApiProvider } from './wechat/wechat.api.provider';
|
||||
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||
import { WechatMockProvider } from './wechat/wechat.mock.provider';
|
||||
import { WechatRouterProvider } from './wechat/wechat.router.provider';
|
||||
import { WechatOrderShippingService } from './wechat/wechat-order-shipping.service';
|
||||
import { WechatTradeManageService } from './wechat/wechat-trade-manage.service';
|
||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||
import {
|
||||
SMS_PROVIDER,
|
||||
PAY_PROVIDER,
|
||||
DELIVERY_PROVIDER,
|
||||
WECHAT_PROVIDER,
|
||||
OSS_PROVIDER,
|
||||
MAP_PROVIDER,
|
||||
} from './integrations.constants';
|
||||
import { CourierModule } from './courier/courier.module';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||
providers: [
|
||||
SmsCodeStore,
|
||||
SmsMockProvider,
|
||||
SmsAliyunProvider,
|
||||
SmsRouterProvider,
|
||||
{ provide: SMS_PROVIDER, useExisting: SmsRouterProvider },
|
||||
WechatApiProvider,
|
||||
WechatDisabledProvider,
|
||||
WechatMockProvider,
|
||||
WechatRouterProvider,
|
||||
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
|
||||
WechatOrderShippingService,
|
||||
WechatTradeManageService,
|
||||
PayMockProvider,
|
||||
PayWechatProvider,
|
||||
PayRouterProvider,
|
||||
{ provide: PAY_PROVIDER, useExisting: PayRouterProvider },
|
||||
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||
OssAliyunProvider,
|
||||
{ provide: OSS_PROVIDER, useExisting: OssAliyunProvider },
|
||||
DeliveryMockProvider,
|
||||
TencentLbsProvider,
|
||||
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
|
||||
],
|
||||
exports: [
|
||||
SMS_PROVIDER,
|
||||
SmsCodeStore,
|
||||
PAY_PROVIDER,
|
||||
DELIVERY_PROVIDER,
|
||||
WECHAT_PROVIDER,
|
||||
WechatOrderShippingService,
|
||||
WechatTradeManageService,
|
||||
OSS_PROVIDER,
|
||||
MAP_PROVIDER,
|
||||
TencentLbsProvider,
|
||||
CourierModule,
|
||||
],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
const MAX_CONTEXT_CHARS = 6000;
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeRetrievalService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 简易关键词命中:按文档正文包含查询词打分,拼进上下文 */
|
||||
async buildContext(knowledgeBaseId: bigint, query: string): Promise<string> {
|
||||
const kb = await this.prisma.knowledgeBase.findUnique({
|
||||
where: { id: knowledgeBaseId },
|
||||
select: { id: true, enabled: true, name: true },
|
||||
});
|
||||
if (!kb?.enabled) return '';
|
||||
|
||||
const docs = await this.prisma.knowledgeDocument.findMany({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
status: 'READY',
|
||||
contentText: { not: null },
|
||||
},
|
||||
select: { title: true, contentText: true },
|
||||
take: 50,
|
||||
});
|
||||
if (!docs.length) return '';
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const scored = docs
|
||||
.map((d) => {
|
||||
const body = d.contentText || '';
|
||||
let score = 0;
|
||||
for (const t of tokens) {
|
||||
if (body.includes(t) || d.title.includes(t)) score += 1;
|
||||
}
|
||||
if (!tokens.length) score = 1;
|
||||
return { title: d.title, body, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const picked = scored.length ? scored.slice(0, 5) : docs.slice(0, 3).map((d) => ({
|
||||
title: d.title,
|
||||
body: d.contentText || '',
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
let out = `【知识库:${kb.name}】\n`;
|
||||
for (const p of picked) {
|
||||
const chunk = `### ${p.title}\n${p.body}\n\n`;
|
||||
if (out.length + chunk.length > MAX_CONTEXT_CHARS) {
|
||||
out += chunk.slice(0, Math.max(0, MAX_CONTEXT_CHARS - out.length));
|
||||
break;
|
||||
}
|
||||
out += chunk;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(q: string): string[] {
|
||||
return q
|
||||
.split(/[\s,,。;;、!?!?\-_/\\]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length >= 2)
|
||||
.slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export type LlmChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
|
||||
|
||||
export type LlmChatParams = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: LlmChatMessage[];
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
};
|
||||
|
||||
/** 规范化 OpenAI 兼容根地址:去掉末尾 / 与重复的 /v1 */
|
||||
export function normalizeLlmBaseUrl(raw: string): string {
|
||||
let base = String(raw || '').trim().replace(/\/+$/, '');
|
||||
// 用户常填 https://api.deepseek.com/v1 ,避免拼成 /v1/v1/chat/completions
|
||||
if (/\/v1$/i.test(base)) {
|
||||
base = base.replace(/\/v1$/i, '');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildLlmChatCompletionsUrl(baseUrl: string): string {
|
||||
return `${normalizeLlmBaseUrl(baseUrl)}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmChatClient {
|
||||
private readonly logger = new Logger(LlmChatClient.name);
|
||||
|
||||
async chat(params: LlmChatParams): Promise<string> {
|
||||
const url = buildLlmChatCompletionsUrl(params.baseUrl);
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (params.temperature != null && !Number.isNaN(params.temperature)) {
|
||||
body.temperature = params.temperature;
|
||||
}
|
||||
if (params.maxTokens != null && params.maxTokens > 0) {
|
||||
body.max_tokens = params.maxTokens;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch((err: unknown) => {
|
||||
const cause =
|
||||
err && typeof err === 'object' && 'cause' in err
|
||||
? (err as { cause?: { code?: string; message?: string } }).cause
|
||||
: undefined;
|
||||
const detail = cause?.code || cause?.message || (err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(`llm chat network error ${url}: ${detail}`);
|
||||
throw new Error(`无法连接语言模型服务(${detail})。请检查 Base URL 是否可从服务器访问,或更换可达的模型网关`);
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`llm chat failed ${res.status} ${url}: ${text.slice(0, 400)}`);
|
||||
throw new Error(`语言模型调用失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
let json: {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(text) as typeof json;
|
||||
} catch {
|
||||
throw new Error('语言模型返回非 JSON');
|
||||
}
|
||||
if (json.error?.message) throw new Error(json.error.message);
|
||||
const content = json.choices?.[0]?.message?.content?.trim();
|
||||
if (!content) throw new Error('语言模型未返回内容');
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LlmChatClient } from './llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from './knowledge-retrieval.service';
|
||||
|
||||
@Module({
|
||||
providers: [LlmChatClient, KnowledgeRetrievalService],
|
||||
exports: [LlmChatClient, KnowledgeRetrievalService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type { WechatActorRef } from '../wechat/wechat-log.util';
|
||||
import { buildTencentLbsRequestUrl } from './tencent-lbs.sign';
|
||||
|
||||
export type ReverseGeocodeResult = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
export type GeocodeAddressResult = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
export type PlaceSuggestItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
export type ReverseGeocodeDetailResult = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
name?: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
function normalizeCityName(name: string) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
|
||||
type TencentPlaceRow = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
location?: { lat?: number; lng?: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TencentLbsProvider {
|
||||
private readonly logger = new Logger(TencentLbsProvider.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key/SK */
|
||||
private getLbsKey() {
|
||||
return (loadAppConfig().tencentLbsKey || '').trim();
|
||||
}
|
||||
|
||||
private getLbsSecretKey() {
|
||||
return (loadAppConfig().tencentLbsSecretKey || '').trim();
|
||||
}
|
||||
|
||||
private lbsUrl(path: string, params: Record<string, string>) {
|
||||
return buildTencentLbsRequestUrl(path, params, {
|
||||
key: this.getLbsKey(),
|
||||
secretKey: this.getLbsSecretKey(),
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return !!this.getLbsKey();
|
||||
}
|
||||
|
||||
/** 地址 → 坐标(正向地理编码) */
|
||||
async geocodeAddress(
|
||||
address: string,
|
||||
actorRef?: WechatActorRef,
|
||||
): Promise<GeocodeAddressResult | null> {
|
||||
const trimmed = address.replace(/\s+/g, '').trim();
|
||||
const baseLog = {
|
||||
provider: 'WECHAT_MAP' as const,
|
||||
scene: 'GEOCODE',
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
requestBody: { address: trimmed.slice(0, 200) },
|
||||
};
|
||||
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'TENCENT_LBS_KEY 未配置',
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = this.lbsUrl('/ws/geocoder/v1', { address: trimmed });
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
result?: { location?: { lat?: number; lng?: number } };
|
||||
};
|
||||
const loc = data.result?.location;
|
||||
const ok =
|
||||
data.status === 0 &&
|
||||
typeof loc?.lat === 'number' &&
|
||||
typeof loc?.lng === 'number' &&
|
||||
Number.isFinite(loc.lat) &&
|
||||
Number.isFinite(loc.lng);
|
||||
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
responseBody: {
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
lat: loc?.lat,
|
||||
lng: loc?.lng,
|
||||
},
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.message ?? '地理编码失败',
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok || !loc) return null;
|
||||
return { latitude: loc.lat!, longitude: loc.lng!, logId: log.id };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS geocode failed: ${message}`);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
actorRef?: WechatActorRef,
|
||||
): Promise<ReverseGeocodeResult | null> {
|
||||
const baseLog = {
|
||||
provider: 'WECHAT_MAP' as const,
|
||||
scene: 'REVERSE_GEOCODE',
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
requestBody: {
|
||||
latitude: Number(latitude.toFixed(6)),
|
||||
longitude: Number(longitude.toFixed(6)),
|
||||
},
|
||||
};
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'TENCENT_LBS_KEY 未配置',
|
||||
},
|
||||
});
|
||||
this.logger.warn('Tencent LBS key missing, skip reverse geocode');
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = this.lbsUrl('/ws/geocoder/v1', {
|
||||
location: `${latitude},${longitude}`,
|
||||
get_poi: '0',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
result?: {
|
||||
ad_info?: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const ad = data.result?.ad_info;
|
||||
const ok = data.status === 0 && !!ad?.city;
|
||||
const responseBody = {
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
province: ad?.province,
|
||||
city: ad?.city,
|
||||
district: ad?.district,
|
||||
};
|
||||
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
responseBody,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.message ?? '逆地理编码失败',
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok || !ad?.province || !ad?.city) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
province: ad.province,
|
||||
city: normalizeCityName(ad.city),
|
||||
district: ad.district ?? '',
|
||||
logId: log.id,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS reverse geocode failed: ${message}`);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private mapPlaceRows(rows: TencentPlaceRow[] | undefined): PlaceSuggestItem[] {
|
||||
if (!rows?.length) return [];
|
||||
const out: PlaceSuggestItem[] = [];
|
||||
for (const row of rows) {
|
||||
const lat = Number(row.location?.lat);
|
||||
const lng = Number(row.location?.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
const title = (row.title || '').trim();
|
||||
const address = (row.address || '').trim();
|
||||
if (!title && !address) continue;
|
||||
out.push({
|
||||
id: String(row.id || `${lat},${lng}`),
|
||||
title: title || address,
|
||||
address: address || title,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
city: row.city?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 关键词输入提示(地点搜索) */
|
||||
async suggestPlaces(
|
||||
keyword: string,
|
||||
options?: { region?: string; latitude?: number; longitude?: number },
|
||||
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return { items: [] };
|
||||
if (!this.isEnabled()) {
|
||||
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
keyword: trimmed.slice(0, 64),
|
||||
policy: '1',
|
||||
page_index: '1',
|
||||
page_size: '20',
|
||||
};
|
||||
const region = options?.region?.trim();
|
||||
if (region) params.region = region;
|
||||
if (
|
||||
options?.latitude != null &&
|
||||
options?.longitude != null &&
|
||||
Number.isFinite(options.latitude) &&
|
||||
Number.isFinite(options.longitude)
|
||||
) {
|
||||
params.location = `${options.latitude},${options.longitude}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(this.lbsUrl('/ws/place/v1/suggestion', params));
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
data?: TencentPlaceRow[];
|
||||
};
|
||||
if (data.status !== 0) {
|
||||
this.logger.warn(`Tencent LBS suggest failed: ${data.message ?? data.status}`);
|
||||
return { items: [], error: data.message || '地点搜索失败' };
|
||||
}
|
||||
return { items: this.mapPlaceRows(data.data) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS suggest failed: ${message}`);
|
||||
return { items: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 周边地点(打开选点时预填附近列表) */
|
||||
async exploreNearby(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusMeters = 1000,
|
||||
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return { items: [], error: '经纬度无效' };
|
||||
}
|
||||
if (!this.isEnabled()) {
|
||||
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
|
||||
}
|
||||
|
||||
const radius = Math.min(5000, Math.max(200, Math.round(radiusMeters)));
|
||||
const url = this.lbsUrl('/ws/place/v1/explore', {
|
||||
boundary: `nearby(${latitude},${longitude},${radius})`,
|
||||
policy: '1',
|
||||
page_size: '20',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
data?: TencentPlaceRow[];
|
||||
};
|
||||
if (data.status !== 0) {
|
||||
this.logger.warn(`Tencent LBS explore failed: ${data.message ?? data.status}`);
|
||||
return { items: [], error: data.message || '周边检索失败' };
|
||||
}
|
||||
return { items: this.mapPlaceRows(data.data) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS explore failed: ${message}`);
|
||||
return { items: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 逆地理(含地址文案,供选点回填) */
|
||||
async reverseGeocodeDetail(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): Promise<{ item: ReverseGeocodeDetailResult | null; error?: string }> {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return { item: null, error: '经纬度无效' };
|
||||
}
|
||||
if (!this.isEnabled()) {
|
||||
return { item: null, error: 'TENCENT_LBS_KEY 未配置' };
|
||||
}
|
||||
|
||||
const url = this.lbsUrl('/ws/geocoder/v1', {
|
||||
location: `${latitude},${longitude}`,
|
||||
get_poi: '1',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
result?: {
|
||||
address?: string;
|
||||
formatted_addresses?: { recommend?: string; rough?: string };
|
||||
address_component?: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
street?: string;
|
||||
street_number?: string;
|
||||
};
|
||||
ad_info?: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
};
|
||||
pois?: Array<{ title?: string; address?: string }>;
|
||||
};
|
||||
};
|
||||
if (data.status !== 0 || !data.result) {
|
||||
return { item: null, error: data.message || '逆地理编码失败' };
|
||||
}
|
||||
const result = data.result;
|
||||
const ad = result.ad_info ?? result.address_component;
|
||||
const province = ad?.province ?? '';
|
||||
const city = normalizeCityName(ad?.city ?? '');
|
||||
const district = ad?.district ?? '';
|
||||
const recommend =
|
||||
result.formatted_addresses?.recommend?.trim() ||
|
||||
result.formatted_addresses?.rough?.trim() ||
|
||||
result.address?.trim() ||
|
||||
'';
|
||||
const poiTitle = result.pois?.[0]?.title?.trim();
|
||||
if (!recommend && !poiTitle) {
|
||||
return { item: null, error: '未解析到地址' };
|
||||
}
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_MAP',
|
||||
scene: 'REVERSE_GEOCODE',
|
||||
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
requestBody: { latitude, longitude, detail: true },
|
||||
responseBody: {
|
||||
status: data.status,
|
||||
address: recommend,
|
||||
city,
|
||||
},
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
return {
|
||||
item: {
|
||||
latitude,
|
||||
longitude,
|
||||
address: recommend || poiTitle || '',
|
||||
name: poiTitle || recommend || undefined,
|
||||
province,
|
||||
city,
|
||||
district,
|
||||
logId: log.id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS reverse detail failed: ${message}`);
|
||||
return { item: null, error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const LBS_HOST = 'https://apis.map.qq.com';
|
||||
|
||||
/**
|
||||
* 腾讯位置服务 WebServiceAPI(GET)签名 URL。
|
||||
* 控制台开启 SN/签名校验后须附带 sig;SecretKey 仅服务端使用。
|
||||
*
|
||||
* sig = md5(请求路径 + "?" + 按参数名升序的原始 query + SK)
|
||||
* @see https://lbs.qq.com/FAQ/server_faq.html
|
||||
*/
|
||||
export function buildTencentLbsRequestUrl(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
options: { key: string; secretKey?: string },
|
||||
): string {
|
||||
const key = options.key.trim();
|
||||
if (!key) {
|
||||
throw new Error('TENCENT_LBS_KEY 未配置');
|
||||
}
|
||||
|
||||
const pathname = (path.startsWith('/') ? path : `/${path}`).replace(/\/+$/, '') || '/';
|
||||
const all: Record<string, string> = { ...params, key };
|
||||
const sortedKeys = Object.keys(all).sort();
|
||||
const rawQuery = sortedKeys.map((k) => `${k}=${all[k]}`).join('&');
|
||||
|
||||
const search = new URLSearchParams();
|
||||
for (const k of sortedKeys) {
|
||||
search.set(k, all[k]);
|
||||
}
|
||||
|
||||
const sk = options.secretKey?.trim();
|
||||
if (sk) {
|
||||
const sig = createHash('md5').update(`${pathname}?${rawQuery}${sk}`).digest('hex');
|
||||
search.set('sig', sig);
|
||||
}
|
||||
|
||||
return `${LBS_HOST}${pathname}?${search.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
export type OssActorRef = {
|
||||
refType: string;
|
||||
refId: bigint;
|
||||
};
|
||||
|
||||
export function ossActorRefFromAuth(
|
||||
actorType?: string,
|
||||
actorId?: bigint,
|
||||
): OssActorRef | undefined {
|
||||
if (!actorType || actorId == null) return undefined;
|
||||
return { refType: actorType, refId: actorId };
|
||||
}
|
||||
|
||||
type LogOssUploadInput = {
|
||||
scene: 'UPLOAD_PUT_OBJECT' | 'UPLOAD_TOKEN';
|
||||
requestBody?: Record<string, unknown>;
|
||||
responseBody?: Record<string, unknown>;
|
||||
externalNo?: string;
|
||||
status: 'SUCCESS' | 'FAILED';
|
||||
errorMessage?: string;
|
||||
actorRef?: OssActorRef;
|
||||
};
|
||||
|
||||
export async function logOssUpload(prisma: PrismaService, input: LogOssUploadInput) {
|
||||
try {
|
||||
const row = await prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'ALIYUN_OSS',
|
||||
scene: input.scene,
|
||||
refType: input.actorRef?.refType,
|
||||
refId: input.actorRef?.refId,
|
||||
requestBody: input.requestBody as never,
|
||||
responseBody: input.responseBody as never,
|
||||
externalNo: input.externalNo?.slice(0, 128),
|
||||
status: input.status,
|
||||
errorMessage: input.errorMessage?.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return row.id;
|
||||
} catch (err) {
|
||||
// 日志表/枚举未就绪时不阻断上传主流程
|
||||
console.error('[oss-upload-log] write failed:', err instanceof Error ? err.message : err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import OSS = require('ali-oss');
|
||||
|
||||
export type AliyunOssClientConfig = {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
authorizationV4?: boolean;
|
||||
};
|
||||
|
||||
export function createAliyunOssClient(config: AliyunOssClientConfig): OSS {
|
||||
return new OSS({
|
||||
region: config.region,
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
bucket: config.bucket,
|
||||
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
||||
...(config.authorizationV4 ? { authorizationV4: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOssUploadHost(bucket: string, region: string): string {
|
||||
return `https://${bucket}.${region}.aliyuncs.com`;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import type {
|
||||
IOssProvider,
|
||||
OssPutObjectInput,
|
||||
OssPutObjectResult,
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey, resolveOssUploadDir } from './oss.key.util';
|
||||
import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
|
||||
|
||||
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class OssAliyunProvider implements IOssProvider {
|
||||
private readonly accessKeyId = process.env.OSS_ACCESS_KEY_ID ?? '';
|
||||
private readonly accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET ?? '';
|
||||
private readonly bucket = process.env.OSS_BUCKET ?? '';
|
||||
private readonly region = process.env.OSS_REGION ?? 'oss-cn-hangzhou';
|
||||
private readonly cdnBase = process.env.OSS_CDN_BASE ?? '';
|
||||
private readonly endpoint = process.env.OSS_ENDPOINT ?? '';
|
||||
private readonly uploadPrefix = (process.env.OSS_UPLOAD_PREFIX ?? 'uploads').replace(/\/$/, '');
|
||||
private readonly expireSeconds = Number(process.env.OSS_UPLOAD_EXPIRE_SECONDS ?? DEFAULT_EXPIRE_SECONDS);
|
||||
private readonly maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
|
||||
private readonly authorizationV4 = process.env.OSS_AUTHORIZATION_V4 === 'true';
|
||||
private client: ReturnType<typeof createAliyunOssClient> | null = null;
|
||||
|
||||
isEnabled() {
|
||||
return !!(this.accessKeyId && this.accessKeySecret && this.bucket);
|
||||
}
|
||||
|
||||
private assertConfigured() {
|
||||
if (this.isEnabled()) return;
|
||||
throw new InternalServerErrorException(
|
||||
'OSS 未配置:请设置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET(及 OSS_REGION)',
|
||||
);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
this.assertConfigured();
|
||||
if (!this.client) {
|
||||
this.client = createAliyunOssClient({
|
||||
accessKeyId: this.accessKeyId,
|
||||
accessKeySecret: this.accessKeySecret,
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
endpoint: this.endpoint || undefined,
|
||||
authorizationV4: this.authorizationV4,
|
||||
});
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
buildPublicUrl(ossKey: string) {
|
||||
const key = ossKey.replace(/^\//, '');
|
||||
const client = this.getClient();
|
||||
return client.generateObjectUrl(key, this.cdnBase || undefined);
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
const client = this.getClient();
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
|
||||
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
|
||||
const host = resolveOssUploadHost(this.bucket, this.region);
|
||||
const keyPrefix = resolveOssUploadDir(this.uploadPrefix, dto.bizType);
|
||||
|
||||
const policy = {
|
||||
expiration: expireAt.toISOString(),
|
||||
conditions: [
|
||||
['content-length-range', 0, this.maxUploadBytes],
|
||||
['eq', '$bucket', this.bucket],
|
||||
['starts-with', '$key', keyPrefix],
|
||||
],
|
||||
};
|
||||
|
||||
const signed = client.calculatePostSignature(policy);
|
||||
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey,
|
||||
url: this.buildPublicUrl(ossKey),
|
||||
mock: false,
|
||||
expireAt: expireAt.toISOString(),
|
||||
mediaType: dto.mediaType,
|
||||
bizType: dto.bizType,
|
||||
host,
|
||||
policy: signed.policy,
|
||||
signature: signed.Signature,
|
||||
accessKeyId: signed.OSSAccessKeyId,
|
||||
};
|
||||
}
|
||||
|
||||
async putObject(input: OssPutObjectInput): Promise<OssPutObjectResult> {
|
||||
const client = this.getClient();
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, input.bizType, input.fileName);
|
||||
await client.put(ossKey, input.buffer, {
|
||||
mime: input.mimeType || 'application/octet-stream',
|
||||
headers: {
|
||||
'Content-Disposition': 'inline',
|
||||
},
|
||||
});
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey,
|
||||
url: this.buildPublicUrl(ossKey),
|
||||
mock: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface OssUploadTokenInput {
|
||||
bizType: string;
|
||||
mediaType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface OssUploadTokenResult {
|
||||
bucket: string;
|
||||
region: string;
|
||||
ossKey: string;
|
||||
url: string;
|
||||
mock: boolean;
|
||||
expireAt: string;
|
||||
mediaType: string;
|
||||
bizType: string;
|
||||
/** OSS PostObject 直传 host,mock 时为空 */
|
||||
host?: string;
|
||||
/** Base64 policy,mock 时为空 */
|
||||
policy?: string;
|
||||
/** HMAC-SHA1 签名,mock 时为空 */
|
||||
signature?: string;
|
||||
/** RAM AccessKeyId,mock 时为空 */
|
||||
accessKeyId?: string;
|
||||
}
|
||||
|
||||
export interface OssPutObjectInput {
|
||||
bizType: string;
|
||||
mediaType: string;
|
||||
fileName: string;
|
||||
buffer: Buffer;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface OssPutObjectResult {
|
||||
bucket: string;
|
||||
region: string;
|
||||
ossKey: string;
|
||||
url: string;
|
||||
mock: boolean;
|
||||
}
|
||||
|
||||
export interface IOssProvider {
|
||||
isEnabled(): boolean;
|
||||
getUploadToken(input: OssUploadTokenInput): OssUploadTokenResult;
|
||||
putObject(input: OssPutObjectInput): Promise<OssPutObjectResult>;
|
||||
buildPublicUrl(ossKey: string): string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/** 门店资源上传目录(OSS object key 前缀) */
|
||||
const STORE_UPLOAD_DIRS: Record<string, string> = {
|
||||
STORE_TITLE: 'store/title',
|
||||
STORE_ENV: 'store/env',
|
||||
STORE_CONTRACT: 'store/contract',
|
||||
};
|
||||
|
||||
export function resolveOssUploadDir(uploadPrefix: string, bizType: string): string {
|
||||
const storeDir = STORE_UPLOAD_DIRS[bizType];
|
||||
if (storeDir) return `${storeDir}/`;
|
||||
return `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
}
|
||||
|
||||
export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||
const dir = resolveOssUploadDir(uploadPrefix, bizType);
|
||||
return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
IOssProvider,
|
||||
OssPutObjectInput,
|
||||
OssPutObjectResult,
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey } from './oss.key.util';
|
||||
|
||||
@Injectable()
|
||||
export class OssMockProvider implements IOssProvider {
|
||||
private readonly bucket = process.env.OSS_BUCKET || 'mock-dukang';
|
||||
private readonly region = process.env.OSS_REGION || 'oss-cn-hangzhou';
|
||||
private readonly cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local';
|
||||
|
||||
isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
buildPublicUrl(ossKey: string) {
|
||||
return `${this.cdnBase.replace(/\/$/, '')}/${ossKey.replace(/^\//, '')}`;
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
const key = buildOssObjectKey('uploads', dto.bizType, dto.fileName);
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey: key,
|
||||
url: this.buildPublicUrl(key),
|
||||
mock: true,
|
||||
expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
mediaType: dto.mediaType,
|
||||
bizType: dto.bizType,
|
||||
};
|
||||
}
|
||||
|
||||
async putObject(input: OssPutObjectInput): Promise<OssPutObjectResult> {
|
||||
const ossKey = buildOssObjectKey('uploads', input.bizType, input.fileName);
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey,
|
||||
url: this.buildPublicUrl(ossKey),
|
||||
mock: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
|
||||
export type PayMethod = 'JSAPI' | 'NATIVE';
|
||||
|
||||
export type PayOrderResult =
|
||||
| { mode: 'mock'; externalNo: string }
|
||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }
|
||||
| { mode: 'native'; codeUrl: string; externalNo: string };
|
||||
|
||||
export type RefundOrderResult =
|
||||
| { mode: 'mock'; outRefundNo: string }
|
||||
| { mode: 'wechat'; outRefundNo: string; refundId?: string; status: 'PROCESSING' | 'SUCCESS' };
|
||||
|
||||
export interface IPayProvider {
|
||||
payOrder(
|
||||
orderId: bigint,
|
||||
openId?: string,
|
||||
platform?: 'h5' | 'mini',
|
||||
payMethod?: PayMethod,
|
||||
): Promise<PayOrderResult>;
|
||||
refundOrder(orderId: bigint, outRefundNo: string, reason?: string): Promise<RefundOrderResult>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult, RefundOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async payOrder(
|
||||
orderId: bigint,
|
||||
_openId?: string,
|
||||
_platform?: 'h5' | 'mini',
|
||||
payMethod: PayMethod = 'JSAPI',
|
||||
): Promise<PayOrderResult> {
|
||||
if (!loadAppConfig().mockPay) {
|
||||
throw new Error('Real WeChat pay requires PayWechatProvider');
|
||||
}
|
||||
if (payMethod === 'NATIVE') {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
const orderNo = order?.orderNo ?? orderId.toString();
|
||||
return {
|
||||
mode: 'native',
|
||||
codeUrl: `mock://wechat-pay/native?orderNo=${encodeURIComponent(orderNo)}`,
|
||||
externalNo: `MOCK-NATIVE-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
|
||||
async refundOrder(
|
||||
_orderId: bigint,
|
||||
outRefundNo: string,
|
||||
_reason?: string,
|
||||
): Promise<RefundOrderResult> {
|
||||
if (!loadAppConfig().mockPay) {
|
||||
throw new Error('Real WeChat refund requires PayWechatProvider');
|
||||
}
|
||||
return { mode: 'mock', outRefundNo };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult, RefundOrderResult } from './pay.interface';
|
||||
import { PayMockProvider } from './pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay.wechat.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 微信支付 */
|
||||
@Injectable()
|
||||
export class PayRouterProvider implements IPayProvider {
|
||||
constructor(
|
||||
private readonly mock: PayMockProvider,
|
||||
private readonly wechat: PayWechatProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): IPayProvider {
|
||||
return loadAppConfig().mockPay ? this.mock : this.wechat;
|
||||
}
|
||||
|
||||
payOrder(
|
||||
orderId: bigint,
|
||||
openId?: string,
|
||||
platform?: 'h5' | 'mini',
|
||||
payMethod?: PayMethod,
|
||||
): Promise<PayOrderResult> {
|
||||
return this.resolve().payOrder(orderId, openId, platform, payMethod);
|
||||
}
|
||||
|
||||
refundOrder(
|
||||
orderId: bigint,
|
||||
outRefundNo: string,
|
||||
reason?: string,
|
||||
): Promise<RefundOrderResult> {
|
||||
return this.resolve().refundOrder(orderId, outRefundNo, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { WECHAT_PROVIDER } from '../integrations.constants';
|
||||
import type { IWechatProvider } from '../wechat/wechat.interface';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayWechatProvider implements IPayProvider {
|
||||
private readonly logger = new Logger(PayWechatProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
async payOrder(
|
||||
orderId: bigint,
|
||||
openId?: string,
|
||||
platform: 'h5' | 'mini' = 'h5',
|
||||
payMethod: PayMethod = 'JSAPI',
|
||||
): Promise<PayOrderResult> {
|
||||
if (loadAppConfig().mockPay) {
|
||||
if (payMethod === 'NATIVE') {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
const orderNo = order?.orderNo ?? orderId.toString();
|
||||
return {
|
||||
mode: 'native',
|
||||
codeUrl: `mock://wechat-pay/native?orderNo=${encodeURIComponent(orderNo)}`,
|
||||
externalNo: `MOCK-NATIVE-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
if (!this.wechat.isPayEnabled()) {
|
||||
throw new Error('微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID 等商户参数');
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new Error('订单不存在');
|
||||
|
||||
const amountFen = Math.round(Number(order.payAmount) * 100);
|
||||
const notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||
|
||||
if (payMethod === 'NATIVE') {
|
||||
this.logger.log(`create NATIVE prepay order=${order.orderNo} mchid=${this.wechat.getMchId()}`);
|
||||
const { codeUrl } = await this.wechat.createNativePrepay({
|
||||
orderNo: order.orderNo,
|
||||
description: `杜康好客订单 ${order.orderNo}`,
|
||||
amountFen,
|
||||
notifyUrl,
|
||||
});
|
||||
return {
|
||||
mode: 'native',
|
||||
codeUrl,
|
||||
externalNo: `NATIVE-${order.orderNo}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!openId) {
|
||||
throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
|
||||
}
|
||||
|
||||
this.logger.log(`create JSAPI prepay order=${order.orderNo} mchid=${this.wechat.getMchId()} platform=${platform}`);
|
||||
const prepay = await this.wechat.createJsapiPrepay({
|
||||
orderNo: order.orderNo,
|
||||
description: `杜康好客订单 ${order.orderNo}`,
|
||||
amountFen,
|
||||
openId,
|
||||
notifyUrl,
|
||||
platform,
|
||||
});
|
||||
return { mode: 'jsapi', prepay };
|
||||
}
|
||||
|
||||
async refundOrder(
|
||||
orderId: bigint,
|
||||
outRefundNo: string,
|
||||
reason?: string,
|
||||
) {
|
||||
if (loadAppConfig().mockPay) {
|
||||
return { mode: 'mock' as const, outRefundNo };
|
||||
}
|
||||
if (!this.wechat.isPayEnabled()) {
|
||||
throw new Error('微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID 等商户参数');
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new Error('订单不存在');
|
||||
|
||||
const amountFen = Math.round(Number(order.payAmount) * 100);
|
||||
const notifyUrl = process.env.WX_REFUND_NOTIFY_URL ?? '';
|
||||
|
||||
this.logger.log(`create refund order=${order.orderNo} outRefundNo=${outRefundNo}`);
|
||||
const result = await this.wechat.createDomesticRefund({
|
||||
orderNo: order.orderNo,
|
||||
transactionId: order.payExternalNo ?? undefined,
|
||||
outRefundNo,
|
||||
amountFen,
|
||||
totalFen: amountFen,
|
||||
reason,
|
||||
notifyUrl,
|
||||
});
|
||||
return {
|
||||
mode: 'wechat' as const,
|
||||
outRefundNo: result.outRefundNo,
|
||||
refundId: result.refundId,
|
||||
status: result.status === 'SUCCESS' ? 'SUCCESS' as const : 'PROCESSING' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
/** Optional Sentry bootstrap — reads SENTRY_DSN from system_config (preloaded) or .env. */
|
||||
export function initSentryIfConfigured() {
|
||||
const dsn = process.env.SENTRY_DSN?.trim();
|
||||
if (!dsn) return;
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0.2,
|
||||
});
|
||||
console.log('[sentry] initialized');
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { MOCK_SMS_FIXED_CODE, SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
export { MOCK_SMS_FIXED_CODE };
|
||||
|
||||
const RATE_TTL_SECONDS = 60;
|
||||
|
||||
function codeKey(phone: string, scene: string) {
|
||||
return `dukang:sms:${scene}:${phone}`;
|
||||
}
|
||||
|
||||
function rateKey(phone: string) {
|
||||
return `dukang:sms:rate:${phone}`;
|
||||
}
|
||||
|
||||
function randomSixDigitCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsCodeStore {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async assertSendCooldown(phone: string) {
|
||||
const ttl = await this.redis.ttl(rateKey(phone));
|
||||
if (ttl > 0) {
|
||||
throw new BadRequestException('发送过于频繁,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
async setSendCooldown(phone: string) {
|
||||
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
async storeCode(phone: string, scene: string, code: string): Promise<string> {
|
||||
await this.redis.client.set(codeKey(phone, scene), code, 'EX', SMS_CODE_TTL_SECONDS);
|
||||
return code;
|
||||
}
|
||||
|
||||
async generateAndStore(phone: string, scene: string): Promise<string> {
|
||||
return this.storeCode(phone, scene, randomSixDigitCode());
|
||||
}
|
||||
|
||||
async verifyAndConsume(phone: string, scene: string, code: string) {
|
||||
const key = codeKey(phone, scene);
|
||||
const stored = await this.redis.client.get(key);
|
||||
if (!stored || stored !== code.trim()) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
|
||||
import * as OpenApi from '@alicloud/openapi-client';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
|
||||
if (!body || typeof body !== 'object') return undefined;
|
||||
const src = body as Record<string, unknown>;
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ['code', 'message', 'requestId', 'bizId'] as const) {
|
||||
const value = src[key];
|
||||
if (value != null && typeof value !== 'function') {
|
||||
out[key] = String(value);
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsAliyunProvider implements ISmsProvider {
|
||||
private readonly logger = new Logger(SmsAliyunProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
isEnabled() {
|
||||
return !!(
|
||||
this.config.aliyunSmsAccessKeyId &&
|
||||
this.config.aliyunSmsAccessKeySecret &&
|
||||
this.config.aliyunSmsSignName &&
|
||||
this.config.aliyunSmsTemplateCode
|
||||
);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
if (!this.isEnabled()) {
|
||||
throw new Error('Aliyun SMS is not configured');
|
||||
}
|
||||
if (!this.client) {
|
||||
const openApiConfig = new OpenApi.Config({
|
||||
accessKeyId: this.config.aliyunSmsAccessKeyId,
|
||||
accessKeySecret: this.config.aliyunSmsAccessKeySecret,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
});
|
||||
this.client = new Dysmsapi20170525(openApiConfig);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private getTemplateCode(scene: string): string {
|
||||
if (
|
||||
scene === 'REDEEM_PHONE_CONFIRM' &&
|
||||
this.config.aliyunSmsRedeemConfirmTemplateCode
|
||||
) {
|
||||
return this.config.aliyunSmsRedeemConfirmTemplateCode;
|
||||
}
|
||||
if (
|
||||
(scene === 'PARTNER_PROXY_ORDER' || scene === 'PARTNER_PROXY_CUSTOMER') &&
|
||||
this.config.aliyunSmsProxyOrderTemplateCode
|
||||
) {
|
||||
return this.config.aliyunSmsProxyOrderTemplateCode;
|
||||
}
|
||||
return this.config.aliyunSmsTemplateCode;
|
||||
}
|
||||
|
||||
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
const masked = maskPhone(phone);
|
||||
const templateCode = this.getTemplateCode(scene);
|
||||
const request = new SendSmsRequest({
|
||||
phoneNumbers: phone,
|
||||
signName: this.config.aliyunSmsSignName,
|
||||
templateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
const baseLog = {
|
||||
provider: 'SMS' as const,
|
||||
scene,
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestBody: {
|
||||
phone: masked,
|
||||
templateCode,
|
||||
signName: this.config.aliyunSmsSignName,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.getClient().sendSms(request);
|
||||
const bizId = response.body?.bizId ?? undefined;
|
||||
const ok = response.body?.code === 'OK';
|
||||
const responseBody = serializeSmsResponseBody(response.body);
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
responseBody,
|
||||
externalNo: bizId,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
|
||||
},
|
||||
});
|
||||
if (ok) {
|
||||
this.logger.log(`Aliyun SMS sent to ${masked} scene=${scene} bizId=${bizId ?? '-'}`);
|
||||
} else {
|
||||
this.logger.warn(`Aliyun SMS failed for ${masked} scene=${scene}: ${response.body?.message}`);
|
||||
}
|
||||
return {
|
||||
logId: log.id,
|
||||
ok,
|
||||
errorMessage: ok ? undefined : response.body?.message ?? '短信发送失败',
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Aliyun SMS send failed: ${message}`);
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return { logId: log.id, ok: false, errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type SmsActorRef = {
|
||||
refType: string;
|
||||
refId: bigint;
|
||||
};
|
||||
|
||||
export type SmsSendResult = {
|
||||
logId: bigint;
|
||||
ok: boolean;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export interface ISmsProvider {
|
||||
send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult>;
|
||||
verify(phone: string, code: string, scene: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { MOCK_SMS_FIXED_CODE, SmsCodeStore } from './sms-code.store';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsMockProvider implements ISmsProvider {
|
||||
private readonly logger = new Logger(SmsMockProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mockSmsCodeService: MockSmsCodeService,
|
||||
) {}
|
||||
|
||||
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
const code = await this.smsCodeStore.storeCode(phone, scene, MOCK_SMS_FIXED_CODE);
|
||||
await this.mockSmsCodeService.record(phone, scene, code);
|
||||
const masked = maskPhone(phone);
|
||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||
responseBody: { mock: true, hint: 'see HQ system settings mock SMS list' },
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
return { logId: log.id, ok: true };
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { SmsMockProvider } from './sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms.aliyun.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 阿里云短信 */
|
||||
@Injectable()
|
||||
export class SmsRouterProvider implements ISmsProvider {
|
||||
constructor(
|
||||
private readonly mock: SmsMockProvider,
|
||||
private readonly aliyun: SmsAliyunProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): ISmsProvider {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockSms) return this.mock;
|
||||
if (!this.aliyun.isEnabled()) {
|
||||
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
|
||||
}
|
||||
return this.aliyun;
|
||||
}
|
||||
|
||||
send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
return this.resolve().send(phone, scene, actorRef);
|
||||
}
|
||||
|
||||
verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
return this.resolve().verify(phone, code, scene);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { WSClient, generateReqId, type WsFrame } from '@wecom/aibot-node-sdk';
|
||||
import {
|
||||
normalizeWecomBotRole,
|
||||
parseWecomUserIdList,
|
||||
resolveWecomBotPermissions,
|
||||
type WecomBotRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_WELCOMES: Record<WecomBotRole, string> = {
|
||||
CUSTOMER_SERVICE:
|
||||
'您好,我是杜康好客【客服助手】。发送「帮助」查看:订单、配送、售后工单等指令。',
|
||||
FINANCE:
|
||||
'您好,我是杜康好客【财务助手】。发送「帮助」查看:门店/合伙人/酒厂/物流账单与打款提现查询。',
|
||||
OPERATIONS:
|
||||
'您好,我是杜康好客【运营助手】。发送「帮助」查看:订单、门店、用户、核销等只读查询。',
|
||||
TECH_SUPPORT:
|
||||
'您好,我是杜康好客【技术支持】。发送「帮助」查看:技术支持工单、开发计划与审批指令。',
|
||||
CUSTOM: '您好!发送「帮助」查看可用指令。',
|
||||
};
|
||||
|
||||
export type WecomAibotSlotStatus = {
|
||||
id: string;
|
||||
role: string;
|
||||
key: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
botIdMasked: string | null;
|
||||
avatarUrl: string | null;
|
||||
permissions: string[];
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type WecomAibotStatus = {
|
||||
masterEnabled: boolean;
|
||||
bots: WecomAibotSlotStatus[];
|
||||
};
|
||||
|
||||
type BotRuntime = {
|
||||
config: WecomBotRuntimeConfig;
|
||||
client: WSClient | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WecomAibotService.name);
|
||||
private runtimes = new Map<string, BotRuntime>();
|
||||
private starting = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly actions: WecomBotActionsService,
|
||||
private readonly ai: WecomBotAiService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.reload('boot');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.stopAll('shutdown');
|
||||
}
|
||||
|
||||
getStatus(): WecomAibotStatus {
|
||||
const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true';
|
||||
const bots: WecomAibotSlotStatus[] = [];
|
||||
for (const rt of this.runtimes.values()) {
|
||||
const cfg = rt.config;
|
||||
bots.push({
|
||||
id: cfg.id,
|
||||
role: cfg.role,
|
||||
key: cfg.key,
|
||||
name: cfg.name,
|
||||
enabled: cfg.enabled,
|
||||
configured: !!(cfg.botId && cfg.secret),
|
||||
connected: !!rt.client?.isConnected,
|
||||
botIdMasked: cfg.botId ? maskId(cfg.botId) : null,
|
||||
avatarUrl: cfg.avatarUrl,
|
||||
permissions: cfg.permissions,
|
||||
lastError: rt.lastError,
|
||||
});
|
||||
}
|
||||
return { masterEnabled, bots };
|
||||
}
|
||||
|
||||
async reload(reason = 'config'): Promise<WecomAibotStatus> {
|
||||
if (this.starting) {
|
||||
this.logger.warn(`wecom aibot reload skipped (busy): ${reason}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
this.starting = true;
|
||||
try {
|
||||
this.stopAll(reason);
|
||||
const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true';
|
||||
const rows = await this.prisma.wecomBot.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
if (!masterEnabled) {
|
||||
this.logger.log(`wecom aibot master disabled (${reason})`);
|
||||
for (const row of rows) {
|
||||
const cfg = this.rowToConfig(row);
|
||||
this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null });
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const cfg = this.rowToConfig(row);
|
||||
if (!cfg.enabled || !cfg.botId || !cfg.secret) {
|
||||
this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.startBot(cfg);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`wecom bot ${cfg.key} start failed: ${msg}`);
|
||||
this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: msg });
|
||||
}
|
||||
}
|
||||
return this.getStatus();
|
||||
} catch (e) {
|
||||
// 表未创建时不阻断启动
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`wecom aibot reload failed (${reason}): ${msg}`);
|
||||
return this.getStatus();
|
||||
} finally {
|
||||
this.starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private rowToConfig(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
role: string;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
reviewSuperAdminWecomUserIds: string | null;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
enabled: boolean;
|
||||
}): WecomBotRuntimeConfig {
|
||||
const role = normalizeWecomBotRole(row.role);
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
key: `db_${row.id.toString()}`,
|
||||
role,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
botId: row.botId,
|
||||
secret: row.secret,
|
||||
avatarUrl: row.avatarUrl,
|
||||
welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role],
|
||||
permissions: resolveWecomBotPermissions(role, row.permissions),
|
||||
reviewSuperAdminWecomUserIds: parseWecomUserIdList(row.reviewSuperAdminWecomUserIds),
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private stopAll(reason: string) {
|
||||
for (const [key, rt] of this.runtimes) {
|
||||
if (!rt.client) continue;
|
||||
try {
|
||||
rt.client.removeAllListeners();
|
||||
rt.client.disconnect();
|
||||
this.logger.log(`wecom bot ${key} disconnected (${reason})`);
|
||||
} catch (e) {
|
||||
this.logger.warn(`wecom bot ${key} disconnect error: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
this.runtimes.clear();
|
||||
}
|
||||
|
||||
private async startBot(cfg: WecomBotRuntimeConfig) {
|
||||
const client = new WSClient({
|
||||
botId: cfg.botId,
|
||||
secret: cfg.secret,
|
||||
maxReconnectAttempts: -1,
|
||||
maxAuthFailureAttempts: 5,
|
||||
heartbeatInterval: 30_000,
|
||||
logger: {
|
||||
debug: (msg, ...args) => this.logger.debug(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
|
||||
info: (msg, ...args) => this.logger.log(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
|
||||
warn: (msg, ...args) => this.logger.warn(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
|
||||
error: (msg, ...args) => this.logger.error(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
|
||||
},
|
||||
});
|
||||
|
||||
const rt: BotRuntime = { config: cfg, client, lastError: null };
|
||||
this.runtimes.set(cfg.key, rt);
|
||||
|
||||
client.on('authenticated', () => {
|
||||
rt.lastError = null;
|
||||
this.logger.log(`wecom bot ${cfg.key} authenticated bot=${maskId(cfg.botId)}`);
|
||||
});
|
||||
client.on('disconnected', (reason) => {
|
||||
this.logger.warn(`wecom bot ${cfg.key} disconnected: ${reason || 'unknown'}`);
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
rt.lastError = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`wecom bot ${cfg.key} error: ${rt.lastError}`);
|
||||
});
|
||||
client.on('event.enter_chat', (frame: WsFrame) => {
|
||||
void this.handleEnterChat(cfg, client, frame);
|
||||
});
|
||||
client.on('message.text', (frame: WsFrame) => {
|
||||
void this.handleText(cfg, client, frame);
|
||||
});
|
||||
for (const evt of [
|
||||
'message.image',
|
||||
'message.file',
|
||||
'message.voice',
|
||||
'message.video',
|
||||
'message.mixed',
|
||||
] as const) {
|
||||
client.on(evt, (frame: WsFrame) => {
|
||||
void this.replyText(client, frame, '暂仅支持文本消息,请发送「帮助」。');
|
||||
});
|
||||
}
|
||||
|
||||
client.connect();
|
||||
this.logger.log(`wecom bot ${cfg.key} connecting bot=${maskId(cfg.botId)}`);
|
||||
}
|
||||
|
||||
private async handleEnterChat(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) {
|
||||
try {
|
||||
await client.replyWelcome(frame, {
|
||||
msgtype: 'text',
|
||||
text: { content: cfg.welcome },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(`[${cfg.key}] welcome failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleText(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) {
|
||||
const raw = String(frame.body?.text?.content ?? '').trim();
|
||||
const content = raw.replace(/@[^\s]+\s*/g, '').trim();
|
||||
const wecomUserId = String(frame.body?.from?.userid ?? 'unknown');
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
this.logger.log(`[${cfg.key}] inbound user=${wecomUserId} text=${content.slice(0, 120)}`);
|
||||
|
||||
try {
|
||||
if (!content || lower === '帮助' || lower === 'help' || content === '?' || content === '?') {
|
||||
const reply = this.actions.buildHelp(cfg);
|
||||
this.logger.log(`[${cfg.key}] route=help`);
|
||||
await this.replyText(client, frame, reply);
|
||||
return;
|
||||
}
|
||||
if (lower === '状态' || lower === 'status' || lower === 'ping') {
|
||||
const reply = this.formatStatusMarkdown();
|
||||
this.logger.log(`[${cfg.key}] route=status`);
|
||||
await this.replyText(client, frame, reply);
|
||||
return;
|
||||
}
|
||||
const useAi = cfg.aiEnabled && !!cfg.llmConfigId;
|
||||
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
|
||||
skipNaturalFallback: useAi,
|
||||
});
|
||||
if (reply != null) {
|
||||
this.logger.log(`[${cfg.key}] route=command len=${reply.length}`);
|
||||
await this.replyText(client, frame, reply);
|
||||
return;
|
||||
}
|
||||
if (useAi) {
|
||||
const aiReply = await this.ai.replyIfConfigured(cfg, content, wecomUserId);
|
||||
this.logger.log(`[${cfg.key}] route=ai len=${aiReply?.length ?? 0}`);
|
||||
await this.replyText(
|
||||
client,
|
||||
frame,
|
||||
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.log(`[${cfg.key}] route=fallback`);
|
||||
await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`[${cfg.key}] handle text failed: ${msg}`);
|
||||
await this.replyText(client, frame, `处理失败:${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatStatusMarkdown(): string {
|
||||
const st = this.getStatus();
|
||||
const lines = [
|
||||
'**企微机器人状态**',
|
||||
`- 总开关:${st.masterEnabled ? '开' : '关'}(系统设置 → 功能开关)`,
|
||||
'',
|
||||
];
|
||||
for (const b of st.bots) {
|
||||
lines.push(
|
||||
`**${b.name}**`,
|
||||
`- 角色:${b.role}`,
|
||||
`- 启用/配置/连接:${b.enabled ? '是' : '否'} / ${b.configured ? '是' : '否'} / ${b.connected ? '是' : '否'}`,
|
||||
`- BotID:${b.botIdMasked ?? '—'}`,
|
||||
`- 权限:${b.permissions.join(', ') || '—'}`,
|
||||
b.lastError ? `- 错误:${b.lastError}` : '',
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.filter((l, i, arr) => l !== '' || arr[i - 1] !== '').join('\n');
|
||||
}
|
||||
|
||||
private async replyText(client: WSClient, frame: WsFrame, content: string) {
|
||||
const streamId = generateReqId('stream');
|
||||
try {
|
||||
await client.replyStream(frame, streamId, content, true);
|
||||
} catch (e) {
|
||||
this.logger.error(`reply failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maskId(id: string): string {
|
||||
if (id.length <= 8) return `${id.slice(0, 2)}***`;
|
||||
return `${id.slice(0, 4)}…${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatSdkLog(message: string, args: unknown[]): string {
|
||||
if (!args.length) return message;
|
||||
try {
|
||||
return `${message} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||
|
||||
/** 兼容层:AI / Aibot 仍通过 Actions 入口调用 Capability */
|
||||
@Injectable()
|
||||
export class WecomBotActionsService {
|
||||
constructor(private readonly capability: WecomBotCapabilityService) {}
|
||||
|
||||
buildHelp(bot: WecomBotRuntimeConfig): string {
|
||||
return this.capability.buildHelp(bot);
|
||||
}
|
||||
|
||||
handleCommand(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
text: string,
|
||||
opts?: { skipNaturalFallback?: boolean },
|
||||
): Promise<string | null> {
|
||||
return this.capability.dispatch(bot, wecomUserId, text, opts);
|
||||
}
|
||||
|
||||
runTool(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
toolName: string,
|
||||
args: string,
|
||||
): Promise<string> {
|
||||
return this.capability.runTool(bot, wecomUserId, toolName, args);
|
||||
}
|
||||
|
||||
tryNaturalLanguageQuery(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
content: string,
|
||||
): Promise<string | null> {
|
||||
return this.capability.tryNaturalLanguageQuery(bot, wecomUserId, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { LlmChatClient } from '../llm/llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { parseWecomToolLine, sanitizeWecomUserReply } from './wecom-bot-reply.util';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
import { wecomBotHasPermission } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_SYSTEM = [
|
||||
'你是杜康好客企业内部助手。',
|
||||
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
|
||||
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
|
||||
'回答简洁,使用中文。',
|
||||
].join('');
|
||||
|
||||
const TOOL_INSTRUCTION = [
|
||||
'当用户需要查询业务数据时,你必须在回复的第一行输出工具指令(仅一行,用户不可见后续会过滤):',
|
||||
'格式:`TOOL <工具名> [参数]`',
|
||||
'可用工具(按权限):',
|
||||
'- order_read <订单号>',
|
||||
'- delivery_read <单号>',
|
||||
'- store_read <关键词> · redeem_read <核销单号或门店>',
|
||||
'- user_read <用户号>',
|
||||
'- support_tickets_open · support_ticket_read [工单号]',
|
||||
'- support_ticket_approve <工单号> · support_ticket_reject <工单号> <理由>',
|
||||
'- finance_store_bill [门店] · finance_partner_bill · finance_winery_bill · finance_logistics_bill',
|
||||
'- finance_payout · finance_withdrawal',
|
||||
'- dev_plan_tasks [状态] · dev_plan_versions [版本号]',
|
||||
'- dev_plan_task_create <BUG|REQUIREMENT|OPTIMIZATION> <描述>',
|
||||
'- dev_plan_task_update_status <任务编号> <TODO|DEVELOPED|RELEASED>',
|
||||
'- dev_plan_version_create <版本号>',
|
||||
'- dev_plan_version_update_status <版本号> <PENDING|IN_PROGRESS|TESTING|RELEASED>',
|
||||
'- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>',
|
||||
'- server_logs [关键词] · handbook_read [关键词]',
|
||||
'禁止输出 JSON、api 字段、「请稍等正在检索」等占位话术。',
|
||||
'若仅需解释概念、无需查库,第一行写 `ANSWER` 后直接回答。',
|
||||
].join('\n');
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAiService {
|
||||
private readonly logger = new Logger(WecomBotAiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
private readonly kb: KnowledgeRetrievalService,
|
||||
private readonly actions: WecomBotActionsService,
|
||||
) {}
|
||||
|
||||
async replyIfConfigured(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
userText: string,
|
||||
wecomUserId: string,
|
||||
): Promise<string | null> {
|
||||
if (!bot.aiEnabled || !bot.llmConfigId) return null;
|
||||
|
||||
const cfg = await this.prisma.llmApiConfig.findUnique({
|
||||
where: { id: BigInt(bot.llmConfigId) },
|
||||
});
|
||||
if (!cfg?.enabled) {
|
||||
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
|
||||
}
|
||||
|
||||
let kbBlock = '';
|
||||
if (bot.knowledgeBaseId) {
|
||||
try {
|
||||
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
|
||||
} catch (e) {
|
||||
this.logger.warn(`kb retrieve failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts = [
|
||||
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
|
||||
this.buildToolHint(bot),
|
||||
TOOL_INSTRUCTION,
|
||||
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
|
||||
];
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`wecom ai request bot=${bot.key} user=${wecomUserId} text=${userText.slice(0, 80)}`,
|
||||
);
|
||||
const raw = await this.llm.chat({
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.modelName,
|
||||
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
|
||||
maxTokens: cfg.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{ role: 'system', content: systemParts.join('') },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
});
|
||||
|
||||
const tool = parseWecomToolLine(raw);
|
||||
if (tool) {
|
||||
this.logger.log(
|
||||
`wecom ai tool=${tool.name} bot=${bot.key} user=${wecomUserId} args=${tool.args.slice(0, 80)}`,
|
||||
);
|
||||
try {
|
||||
const result = await this.actions.runTool(bot, wecomUserId, tool.name, tool.args);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`wecom ai tool failed ${tool.name}: ${msg}`);
|
||||
return `查询失败:${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
const answerMatch = raw.match(/^ANSWER\s+([\s\S]*)/i);
|
||||
if (answerMatch) {
|
||||
return sanitizeWecomUserReply(answerMatch[1]);
|
||||
}
|
||||
|
||||
return sanitizeWecomUserReply(raw);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`wecom ai reply failed: ${msg}`);
|
||||
return `AI 回复失败:${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
private buildToolHint(bot: WecomBotRuntimeConfig): string {
|
||||
const tools: string[] = [];
|
||||
if (wecomBotHasPermission(bot, 'order.read')) tools.push('order_read');
|
||||
if (wecomBotHasPermission(bot, 'delivery.read')) tools.push('delivery_read');
|
||||
if (wecomBotHasPermission(bot, 'store.read')) tools.push('store_read');
|
||||
if (wecomBotHasPermission(bot, 'redeem.read')) tools.push('redeem_read');
|
||||
if (wecomBotHasPermission(bot, 'user.read')) tools.push('user_read');
|
||||
if (wecomBotHasPermission(bot, 'support_ticket.read')) {
|
||||
tools.push('support_tickets_open', 'support_ticket_read');
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'finance.store_bill.read')) tools.push('finance_store_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.partner_bill.read')) tools.push('finance_partner_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.winery_bill.read')) tools.push('finance_winery_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.logistics_bill.read')) tools.push('finance_logistics_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.payout.read')) tools.push('finance_payout');
|
||||
if (wecomBotHasPermission(bot, 'finance.withdrawal.read')) tools.push('finance_withdrawal');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.task.read')) tools.push('dev_plan_tasks');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.version.read')) tools.push('dev_plan_versions');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.version.link_tasks')) tools.push('dev_plan_version_link_tasks');
|
||||
if (wecomBotHasPermission(bot, 'server_log.read')) tools.push('server_logs');
|
||||
if (wecomBotHasPermission(bot, 'handbook.read')) tools.push('handbook_read');
|
||||
if (!tools.length) return '';
|
||||
return `\n\n当前机器人可用工具:${tools.join(', ')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { WecomBotPermission } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
export type WecomBotAuditContext = {
|
||||
bot: WecomBotRuntimeConfig;
|
||||
wecomUserId: string;
|
||||
action: string;
|
||||
permission?: WecomBotPermission | null;
|
||||
inputSummary?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAuditService {
|
||||
private readonly logger = new Logger(WecomBotAuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run<T>(ctx: WecomBotAuditContext, fn: () => Promise<T>): Promise<T> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
await this.write({ ...ctx, success: true, latencyMs: Date.now() - started });
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await this.write({
|
||||
...ctx,
|
||||
success: false,
|
||||
errorMessage: msg.slice(0, 512),
|
||||
latencyMs: Date.now() - started,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async write(
|
||||
ctx: WecomBotAuditContext & {
|
||||
success: boolean;
|
||||
errorMessage?: string | null;
|
||||
latencyMs?: number | null;
|
||||
},
|
||||
) {
|
||||
const botId = ctx.bot.id ? BigInt(ctx.bot.id) : null;
|
||||
this.logger.log(
|
||||
`wecom bot audit action=${ctx.action} bot=${ctx.bot.key} user=${ctx.wecomUserId} success=${ctx.success}${ctx.inputSummary ? ` input=${ctx.inputSummary.slice(0, 80)}` : ''}`,
|
||||
);
|
||||
try {
|
||||
await this.prisma.logWecomBot.create({
|
||||
data: {
|
||||
botId,
|
||||
botKey: ctx.bot.key,
|
||||
wecomUserId: ctx.wecomUserId,
|
||||
action: ctx.action,
|
||||
permission: ctx.permission ?? null,
|
||||
inputSummary: ctx.inputSummary?.slice(0, 512) ?? null,
|
||||
success: ctx.success,
|
||||
errorMessage: ctx.errorMessage ?? null,
|
||||
latencyMs: ctx.latencyMs ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(`wecom bot audit write failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
botId?: string;
|
||||
wecomUserId?: string;
|
||||
action?: string;
|
||||
success?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.LogWecomBotWhereInput = {};
|
||||
|
||||
if (query.botId) where.botId = BigInt(query.botId);
|
||||
if (query.wecomUserId?.trim()) where.wecomUserId = { contains: query.wecomUserId.trim() };
|
||||
if (query.action?.trim()) where.action = { contains: query.action.trim() };
|
||||
if (query.success === 'true' || query.success === 'false') {
|
||||
where.success = query.success === 'true';
|
||||
}
|
||||
if (query.from || query.to) {
|
||||
where.createdAt = {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.logWecomBot.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { bot: { select: { id: true, name: true } } },
|
||||
}),
|
||||
this.prisma.logWecomBot.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id.toString(),
|
||||
botId: row.botId?.toString() ?? null,
|
||||
botName: row.bot?.name ?? null,
|
||||
botKey: row.botKey,
|
||||
wecomUserId: row.wecomUserId,
|
||||
action: row.action,
|
||||
permission: row.permission,
|
||||
inputSummary: row.inputSummary,
|
||||
success: row.success,
|
||||
errorMessage: row.errorMessage,
|
||||
latencyMs: row.latencyMs,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
/** 去掉 LLM 误输出的 JSON / API 占位,避免展示给用户 */
|
||||
export function sanitizeWecomUserReply(text: string): string {
|
||||
let s = text.trim();
|
||||
s = s.replace(/```(?:json)?\s*[\s\S]*?```/gi, '').trim();
|
||||
s = s.replace(/\{\s*"api"\s*:[\s\S]*?\}/gi, '').trim();
|
||||
s = s.replace(/请稍等[,,]?系统正在检索[^\n]*/gi, '').trim();
|
||||
s = s.replace(/^我来帮您[^\n]*\n+/i, '').trim();
|
||||
if (!s) {
|
||||
return '未能生成有效回复。请使用「帮助」中的指令,或换一种问法。';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 解析 LLM 工具行:TOOL support_tickets_open 或 TOOL order_lookup DK123 */
|
||||
export function parseWecomToolLine(raw: string): { name: string; args: string } | null {
|
||||
const line = raw
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /^TOOL\s+\S+/i.test(l));
|
||||
if (!line) return null;
|
||||
const m = line.match(/^TOOL\s+(\S+)(?:\s+(.*))?$/i);
|
||||
if (!m) return null;
|
||||
return { name: m[1].toLowerCase(), args: (m[2] ?? '').trim() };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
const TTL_SECONDS = 30 * 60;
|
||||
|
||||
export type WecomUserVerifySession = {
|
||||
phone: string;
|
||||
/** 验证通过后可查看 */
|
||||
verified: boolean;
|
||||
pendingUserId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotSessionService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
private key(botKey: string, wecomUserId: string) {
|
||||
return `dukang:wecom:session:${botKey}:${wecomUserId}`;
|
||||
}
|
||||
|
||||
async get(botKey: string, wecomUserId: string): Promise<WecomUserVerifySession | null> {
|
||||
return this.redis.getJson<WecomUserVerifySession>(this.key(botKey, wecomUserId));
|
||||
}
|
||||
|
||||
async setPendingPhone(botKey: string, wecomUserId: string, phone: string) {
|
||||
await this.redis.setJson(
|
||||
this.key(botKey, wecomUserId),
|
||||
{ phone, verified: false } satisfies WecomUserVerifySession,
|
||||
TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
async markVerified(botKey: string, wecomUserId: string, phone: string, userId: string) {
|
||||
await this.redis.setJson(
|
||||
this.key(botKey, wecomUserId),
|
||||
{ phone, verified: true, pendingUserId: userId } satisfies WecomUserVerifySession,
|
||||
TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
async clear(botKey: string, wecomUserId: string) {
|
||||
await this.redis.del(this.key(botKey, wecomUserId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { WecomBotPermission, WecomBotRole } from '@dukang/shared-types';
|
||||
import { normalizeWecomBotRole } from '@dukang/shared-types';
|
||||
|
||||
export type WecomBotRuntimeConfig = {
|
||||
id: string;
|
||||
key: string;
|
||||
role: WecomBotRole;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
botId: string;
|
||||
secret: string;
|
||||
welcome: string;
|
||||
avatarUrl: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
reviewSuperAdminWecomUserIds: string[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
};
|
||||
|
||||
export function wecomBotHasPermission(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
permission: WecomBotPermission,
|
||||
): boolean {
|
||||
return bot.permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function wecomBotNormalizeRole(raw: string): WecomBotRole {
|
||||
return normalizeWecomBotRole(raw);
|
||||
}
|
||||
|
||||
export function wecomBotCanReview(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
): boolean {
|
||||
if (
|
||||
!wecomBotHasPermission(bot, 'support_ticket.review') &&
|
||||
!wecomBotHasPermission(bot, 'support_ticket.approve') &&
|
||||
!wecomBotHasPermission(bot, 'support_ticket.reject')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const ids = bot.reviewSuperAdminWecomUserIds.map((s) => s.trim()).filter(Boolean);
|
||||
if (!ids.length) return false;
|
||||
return ids.includes(wecomUserId.trim());
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 团队助手手册条目(知识库摘要,供关键词检索) */
|
||||
export type HandbookEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
keywords: string[];
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
title: '系统整体概述',
|
||||
keywords: ['概述', '四端', '整体', '是什么', '介绍'],
|
||||
body: [
|
||||
'杜康好客:购酒 → 1:1 好客权益 → 门店核销。',
|
||||
'四端:用户小程序 / 门店 H5 / 合伙人 H5 / HQ 后台。',
|
||||
'核心:门店结算=核销额×60%;权益永久;核销码 3 分钟。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'order',
|
||||
title: '订单与履约',
|
||||
keywords: ['订单', '同城', '跨城', '提货', '履约', '小飞侠'],
|
||||
body: [
|
||||
'状态:待付款 → 已付款 → 已完成(30 分钟未付取消)。',
|
||||
'同城≥2瓶:仓配/小飞侠;跨城≥1箱:总部物流到付。',
|
||||
'现场提货:支付后直接已完成并发权益。',
|
||||
'HQ「订单」可查看详情、填运单;「配送单」维护运单号。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'redeem',
|
||||
title: '好客权益与核销',
|
||||
keywords: ['权益', '核销', '出码', '扫码', '余额'],
|
||||
body: [
|
||||
'支付成功发放实付 1:1 权益,永久有效。',
|
||||
'用户出码 3 分钟;门店可扫码或手机号+验证码核销。',
|
||||
'直接核销:0 < 金额 ≤ 全部 ACTIVE 余额。',
|
||||
'门店账本按核销额×60% 入账,T+1 出账。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
title: '开城流程',
|
||||
keywords: ['开城', '城市', '合伙人', '仓库', '仓配'],
|
||||
body: [
|
||||
'HQ「开城」:①新增城市 ②配置合伙人(全城/区域+佣金)③仓库 ④仓配承运商。',
|
||||
'已开城走同城规则;未开城走跨城到付。',
|
||||
'订单佣金按收货区县解析区域/全城合伙人;跨城归总部。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
title: '开店/拓店流程',
|
||||
keywords: ['开店', '拓店', '入驻', '审核', '试核销'],
|
||||
body: [
|
||||
'合伙人三步录入 → 负责人复核 → HQ 审核 → 试核销 100 元 → 正式入驻。',
|
||||
'营业中门店才对 C 端可见。',
|
||||
'AUTO_APPROVE_STORE 开启时可自动审核(试点)。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '财务结算',
|
||||
keywords: ['财务', '账单', '提现', '打款', '佣金'],
|
||||
body: [
|
||||
'门店账单:核销×60%,T+1 出账;HQ 确认打款。',
|
||||
'合伙人月账独立确认打款。',
|
||||
'门店可对未出账核销主动提现(受单日上限);申请后锁定明细不进入次日出账,HQ 审后打款并企微提醒。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'ticket',
|
||||
title: '工单与发票',
|
||||
keywords: ['工单', '售后', '退款', '补发', '发票', '技术支持'],
|
||||
body: [
|
||||
'售后四类型:仅退款 / 破损补发 / 破损退货 / 退货退款 → HQ 工单中心。',
|
||||
'技术支持:BUG/建议/其他,待评审→开发→测试→通过。',
|
||||
'发票:个人/企业 × 普票/专票;2 工作日 SLA。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'hq-roles',
|
||||
title: 'HQ 角色分工',
|
||||
keywords: ['运营', '财务', '客服', '权限', '角色', 'hq'],
|
||||
body: [
|
||||
'运营:商品/开城/门店/订单/配送/权益。',
|
||||
'财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。',
|
||||
'客服:用户/订单、售后工单、发票协助。',
|
||||
'超管:权限分配、技术支持评审、系统设置。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: '系统设置',
|
||||
keywords: ['系统设置', 'mock', '短信', '微信', 'oss', '企微'],
|
||||
body: [
|
||||
'HQ「系统设置」:功能开关、短信、微信、OSS、应用链接、部署、酒厂账户。',
|
||||
'企微机器人在独立菜单「企微机器人」维护;总开关在功能开关「启用企微机器人长连接」。',
|
||||
'角色:客服 / 技术支持 / 团队助手;指令式,不依赖大模型。发「帮助」看命令。',
|
||||
'改完可「同步到 env」;密钥类变更后注意重启标识。',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
export function searchHandbook(query: string, limit = 3): HandbookEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return WECOM_HANDBOOK_ENTRIES.slice(0, limit);
|
||||
|
||||
const scored = WECOM_HANDBOOK_ENTRIES.map((e) => {
|
||||
let score = 0;
|
||||
const title = e.title.toLowerCase();
|
||||
if (title.includes(q)) score += 10;
|
||||
for (const kw of e.keywords) {
|
||||
const k = kw.toLowerCase();
|
||||
if (q.includes(k) || k.includes(q)) score += 5;
|
||||
}
|
||||
if (e.body.toLowerCase().includes(q)) score += 1;
|
||||
return { e, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (!scored.length) return [];
|
||||
return scored.slice(0, limit).map((x) => x.e);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
WECOM_PUSH_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
||||
maskWecomWebhookUrl,
|
||||
parseWecomPushConditions,
|
||||
type WecomMessagePushDto,
|
||||
type WecomPushCondition,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||
|
||||
type PushRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
mentionWecomUserId: string | null;
|
||||
pushConditions: string;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomMessagePushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(WecomMessagePushService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.ensureDefaults();
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom message push ensureDefaults failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11) */
|
||||
async ensureDefaults(): Promise<void> {
|
||||
const count = await this.prisma.wecomMessagePush.count();
|
||||
if (count > 0) return;
|
||||
|
||||
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
||||
if (alertUrl) {
|
||||
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '运营告警',
|
||||
webhookUrl: alertUrl,
|
||||
enabled: alertEnabled,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 运营告警');
|
||||
}
|
||||
|
||||
let devWebhook: string | null = null;
|
||||
let devUserId: string | null = null;
|
||||
let devEnabled = false;
|
||||
try {
|
||||
const rows = await this.prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
task_dispatch_webhook_url: string | null;
|
||||
task_dispatch_wecom_user_id: string | null;
|
||||
task_dispatch_enabled: number | boolean | null;
|
||||
}>
|
||||
>(
|
||||
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
devWebhook = row.task_dispatch_webhook_url;
|
||||
devUserId = row.task_dispatch_wecom_user_id;
|
||||
devEnabled = !!row.task_dispatch_enabled;
|
||||
}
|
||||
} catch {
|
||||
// 列已迁移删除,跳过
|
||||
}
|
||||
|
||||
if (devWebhook?.trim()) {
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '开发任务派发',
|
||||
webhookUrl: devWebhook.trim(),
|
||||
enabled: devEnabled,
|
||||
mentionWecomUserId: devUserId?.trim() || null,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
|
||||
sortOrder: 10,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 开发任务派发');
|
||||
}
|
||||
}
|
||||
|
||||
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
|
||||
const rows = await this.prisma.wecomMessagePush.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.filter((r) => parseWecomPushConditions(r.pushConditions).includes(eventKey));
|
||||
}
|
||||
|
||||
async hasEnabledPushes(eventKey: WecomPushCondition): Promise<boolean> {
|
||||
const pushes = await this.listMatchingPushes(eventKey);
|
||||
return pushes.length > 0;
|
||||
}
|
||||
|
||||
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
|
||||
async dispatchMarkdown(
|
||||
eventKey: WecomPushCondition,
|
||||
content: string,
|
||||
options?: { applyMention?: boolean },
|
||||
): Promise<number> {
|
||||
const pushes = await this.listMatchingPushes(eventKey);
|
||||
if (!pushes.length) return 0;
|
||||
|
||||
const applyMention = options?.applyMention !== false;
|
||||
let sent = 0;
|
||||
for (const push of pushes) {
|
||||
let text = content.trim();
|
||||
if (applyMention && push.mentionWecomUserId) {
|
||||
text = applyWecomAtMentionInContent(text, push.mentionWecomUserId);
|
||||
}
|
||||
const ok = await this.sendMarkdownToWebhook(push.webhookUrl, text);
|
||||
if (ok) sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
async dispatchMarkdownOrThrow(
|
||||
eventKey: WecomPushCondition,
|
||||
content: string,
|
||||
options?: { applyMention?: boolean },
|
||||
): Promise<number> {
|
||||
const sent = await this.dispatchMarkdown(eventKey, content, options);
|
||||
if (sent === 0) {
|
||||
throw new BadRequestException(
|
||||
`没有已启用且勾选「${eventKey}」条件的消息推送,请在 HQ「企微机器人 → 消息推送」中配置`,
|
||||
);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
|
||||
const url = (webhookUrl || '').trim();
|
||||
if (!url) return false;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: { content: content.slice(0, 4000) },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
};
|
||||
if (!res.ok || (data.errcode != null && data.errcode !== 0)) {
|
||||
this.logger.warn(
|
||||
`wecom message push failed: HTTP ${res.status} errcode=${data.errcode} ${data.errmsg ?? ''}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom message push network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTest(id: bigint): Promise<{ ok: boolean; message: string }> {
|
||||
const row = await this.prisma.wecomMessagePush.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('消息推送不存在');
|
||||
if (!row.webhookUrl.trim()) {
|
||||
return { ok: false, message: 'Webhook URL 未配置' };
|
||||
}
|
||||
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
let content = `**消息推送测试 · ${row.name}**\n时间:${now}`;
|
||||
if (row.mentionWecomUserId) {
|
||||
content = applyWecomAtMentionInContent(content, row.mentionWecomUserId);
|
||||
}
|
||||
const ok = await this.sendMarkdownToWebhook(row.webhookUrl, content);
|
||||
return ok
|
||||
? { ok: true, message: '已发送测试消息,请查看企微群' }
|
||||
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
|
||||
}
|
||||
|
||||
toDto(row: PushRow): WecomMessagePushDto {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
avatarUrl: row.avatarUrl,
|
||||
webhookUrl: row.webhookUrl,
|
||||
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
||||
enabled: row.enabled,
|
||||
mentionWecomUserId: row.mentionWecomUserId,
|
||||
pushConditions: parseWecomPushConditions(row.pushConditions),
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
validatePushConditions(conditions: string[]): WecomPushCondition[] {
|
||||
const parsed = parseWecomPushConditions(conditions);
|
||||
if (!parsed.length) {
|
||||
throw new BadRequestException('请至少勾选一项推送条件');
|
||||
}
|
||||
const valid = new Set<string>(WECOM_PUSH_CONDITIONS);
|
||||
for (const c of conditions) {
|
||||
if (!valid.has(c)) throw new BadRequestException(`无效推送条件:${c}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../modules/common/common.module';
|
||||
|
||||
import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module';
|
||||
|
||||
import { SettlementModule } from '../../modules/settlement/settlement.module';
|
||||
|
||||
import { IntegrationsModule } from '../integrations.module';
|
||||
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
|
||||
import { WecomAibotService } from './wecom-aibot.service';
|
||||
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
|
||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||
|
||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
|
||||
|
||||
|
||||
/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Integrations(短信)+ Llm */
|
||||
|
||||
@Module({
|
||||
|
||||
imports: [
|
||||
|
||||
forwardRef(() => CommonModule),
|
||||
Reference in New Issue
Block a user