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; @Injectable() export class XiaofeixiaClient { constructor( private readonly courierConfig: CourierConfigService, private readonly prisma: PrismaService, ) {} async request(cmd: string, bizParams: RequestParams): Promise { 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; try { payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse) : (null as unknown as XiaofeixiaApiResponse); } 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, 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, 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; } }