193 lines
5.9 KiB
TypeScript
193 lines
5.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { CourierApiError } from '../courier.error';
|
||
import { CourierConfigService } 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): Promise<T> {
|
||
const cfg = this.courierConfig.load().xiaofeixia;
|
||
const scene = sceneForXfxCmd(cmd);
|
||
const externalNo = this.pickExternalNo(bizParams);
|
||
|
||
if (!cfg.mchId || !cfg.apiKey) {
|
||
await logCourierCall(this.prisma, {
|
||
scene,
|
||
requestUrl: cfg.apiUrl || '(未配置)',
|
||
requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }),
|
||
status: 'FAILED',
|
||
errorMessage: '小飞侠商户配置不完整',
|
||
externalNo,
|
||
});
|
||
throw new CourierApiError(
|
||
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_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;
|
||
}
|
||
}
|