feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-66
View File
@@ -1,66 +0,0 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from './common/prisma/prisma.module';
import { GeoModule } from './common/geo/geo.module';
import { RedisModule } from './common/redis/redis.module';
import { AlertModule } from './common/alert/alert.module';
import { HealthModule } from './modules/health/health.module';
import { IamModule } from './modules/iam/iam.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { TradeModule } from './modules/trade/trade.module';
import { BenefitModule } from './modules/benefit/benefit.module';
import { StoreModule } from './modules/store/store.module';
import { RedeemModule } from './modules/redeem/redeem.module';
import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { PromoModule } from './modules/promo/promo.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
import { LoggingModule } from './common/logging/logging.module';
import { RequestIdMiddleware } from './common/logging/request-id.middleware';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
}),
PrismaModule,
SystemConfigModule,
GeoModule,
RedisModule,
AlertModule,
HealthModule,
IamModule,
CatalogModule,
TradeModule,
BenefitModule,
StoreModule,
RedeemModule,
SettlementModule,
AnalyticsModule,
JobsModule,
OpsModule,
PromoModule,
CityScopeModule,
CommonModule,
HqOperationModule,
LoggingModule,
CallbacksModule,
WecomModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { IntegrationsModule } from '../integrations/integrations.module';
import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [
WechatPayCallbackController,
WechatRefundCallbackController,
WechatMessageCallbackController,
DeliveryCallbackController,
],
})
export class CallbacksModule {}
@@ -1,71 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TradeService } from '../modules/trade/trade.service';
import { CourierService } from '../integrations/courier/courier.service';
import { PrismaService } from '../common/prisma/prisma.module';
import { logCourierCall } from '../integrations/courier/courier-log.util';
@Controller('callbacks/delivery')
export class DeliveryCallbackController {
constructor(
private readonly tradeService: TradeService,
private readonly courier: CourierService,
private readonly prisma: PrismaService,
) {}
@Post('track')
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
const baseLog = {
scene: 'TRACK_CALLBACK',
requestUrl: '/api/v1/callbacks/delivery/track',
requestBody: body as Record<string, unknown>,
};
if (!body.orderId && !body.orderNo) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '缺少 orderId / orderNo',
});
return this.courier.buildTrackCallbackResponse(false);
}
const order = body.orderId
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
if (!order) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '订单不存在',
externalNo: body.orderNo,
});
return this.courier.buildTrackCallbackResponse(false);
}
const statusMap: Record<string, string> = {
SHIPPED: 'SHIPPING',
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
DELIVERED: 'COMPLETED',
COMPLETED: 'COMPLETED',
};
const target = statusMap[body.status ?? ''] ?? body.status;
let applied = false;
if (target && target !== order.status) {
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
applied = true;
}
const response = this.courier.buildTrackCallbackResponse(true);
await logCourierCall(this.prisma, {
...baseLog,
responseBody: response,
status: 'SUCCESS',
externalNo: order.orderNo,
ref: { refType: 'ORDER', refId: order.id },
errorMessage: applied ? undefined : '状态未变更',
});
return response;
}
}
@@ -1,118 +0,0 @@
import {
Controller,
Get,
HttpStatus,
Logger,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
decryptWechatEncrypt,
normalizeTradeManageEvent,
parseWechatPushBody,
verifyWechatMsgSignature,
verifyWechatUrlSignature,
} from '../integrations/wechat/wechat-msg-crypto.util';
import { WechatTradeManageService } from '../integrations/wechat/wechat-trade-manage.service';
/**
* 小程序消息推送(发货信息管理事件)。
* 后台配置 URLhttps://{host}/api/v1/callbacks/wechat/message
* 需配置 WX_MINI_MSG_TOKEN;安全模式另需 WX_MINI_MSG_AES_KEY。
*/
@Controller('callbacks/wechat')
export class WechatMessageCallbackController {
private readonly logger = new Logger(WechatMessageCallbackController.name);
constructor(private readonly tradeManage: WechatTradeManageService) {}
@Get('message')
verify(
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('echostr') echostr: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
this.logger.error('WX_MINI_MSG_TOKEN 未配置,无法完成消息推送 URL 验证');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
this.logger.warn('wechat message URL verify failed');
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
return res.status(HttpStatus.OK).send(echostr ?? '');
}
@Post('message')
async receive(
@Req() req: Request & { rawBody?: Buffer },
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('msg_signature') msgSignature: string,
@Query('encrypt_type') encryptType: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
try {
const raw =
req.rawBody?.toString('utf8') ??
(typeof req.body === 'string'
? req.body
: Buffer.isBuffer(req.body)
? req.body.toString('utf8')
: JSON.stringify(req.body ?? {}));
let body = parseWechatPushBody(raw);
const encrypt = typeof body.Encrypt === 'string' ? body.Encrypt : undefined;
const secure = encryptType === 'aes' || !!encrypt;
if (secure) {
if (!encrypt) {
return res.status(HttpStatus.BAD_REQUEST).send('missing Encrypt');
}
const aesKey = (process.env.WX_MINI_MSG_AES_KEY || '').trim();
if (!aesKey) {
this.logger.error('安全模式推送但未配置 WX_MINI_MSG_AES_KEY');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('aes key not configured');
}
if (
!verifyWechatMsgSignature(token, timestamp, nonce, encrypt, msgSignature || String(body.MsgSignature || ''))
) {
return res.status(HttpStatus.FORBIDDEN).send('invalid msg_signature');
}
const appId = (process.env.WX_MINI_APP_ID || process.env.WX_APP_ID || '').trim();
const plain = decryptWechatEncrypt(encrypt, aesKey, appId || undefined);
body = parseWechatPushBody(plain);
} else if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
// 云托管探活
if (String(body.action || '') === 'CheckContainerPath') {
return res.status(HttpStatus.OK).send('success');
}
const evt = normalizeTradeManageEvent(body);
if (evt.event) {
await this.tradeManage.handleEvent(evt);
}
return res.status(HttpStatus.OK).send('success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`wechat message push failed: ${message}`);
// 仍回 success,避免微信疯狂重试;错误已记日志
return res.status(HttpStatus.OK).send('success');
}
}
}
@@ -1,53 +0,0 @@
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TradeService } from '../modules/trade/trade.service';
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
import { PayRedeemAnomalyService } from '../common/alert/pay-redeem-anomaly.service';
import { AlertService } from '../common/alert/alert.service';
type RawBodyRequest = Request & { body: Buffer };
@Controller('callbacks/wechat')
export class WechatPayCallbackController {
constructor(
private readonly tradeService: TradeService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
) {}
@Post('pay')
async payNotify(
@Req() req: RawBodyRequest,
@Headers() headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
let orderNo: string | undefined;
try {
const rawBody =
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const notify = await this.wechat.parsePayNotification(headers, rawBody);
orderNo = notify.outTradeNo;
await this.tradeService.handlePaySuccess({
orderNo: notify.outTradeNo,
transactionId: notify.transactionId,
amountFen: notify.amountFen,
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
this.payRedeemAnomaly.onPayFail(message, { orderNo });
this.alert.notify({
level: 'P0',
category: 'pay',
title: '支付回调处理失败',
detail: `${orderNo ? `订单 ${orderNo}\n` : ''}${message}`,
dedupeKey: `pay_callback_fail|${orderNo ?? message.slice(0, 40)}`,
dedupeTtlSec: 120,
});
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -1,52 +0,0 @@
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TradeService } from '../modules/trade/trade.service';
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
import { AlertService } from '../common/alert/alert.service';
type RawBodyRequest = Request & { body: Buffer };
@Controller('callbacks/wechat')
export class WechatRefundCallbackController {
constructor(
private readonly tradeService: TradeService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly alert: AlertService,
) {}
@Post('refund')
async refundNotify(
@Req() req: RawBodyRequest,
@Headers() headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
let outRefundNo: string | undefined;
try {
const rawBody =
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const notify = await this.wechat.parseRefundNotification(headers, rawBody);
outRefundNo = notify.outRefundNo;
await this.tradeService.handleRefundSuccess({
outRefundNo: notify.outRefundNo,
refundId: notify.refundId,
amountFen: notify.amountFen,
remark: '微信退款回调确认',
actorType: 'WECHAT_REFUND',
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
this.alert.notify({
level: 'P0',
category: 'pay',
title: '退款回调处理失败',
detail: `${outRefundNo ? `退款单 ${outRefundNo}\n` : ''}${message}`,
dedupeKey: `refund_callback_fail|${outRefundNo ?? message.slice(0, 40)}`,
dedupeTtlSec: 120,
});
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -1,30 +0,0 @@
/** 告警级别 */
export type AlertLevel = 'P0' | 'P1' | 'P2';
/** 支付 / 核销行为异常阈值(金额单位:元) */
export const ALERT_THRESHOLDS = {
payAmountMinYuan: 1,
payAmountMaxYuan: 5000,
payAttemptPerMinute: 3,
payFailPerMinute: 5,
redeemAmountMinYuan: 1,
redeemAmountMaxYuan: 1000,
redeemAttemptPerMinute: 3,
redeemFailPerMinute: 5,
} as const;
/** Redis 分钟桶计数 TTL(秒) */
export const ALERT_RATE_BUCKET_TTL_SEC = 120;
/** 默认去重 TTL */
export const ALERT_DEDUPE_TTL_SEC = 600;
/** 频次类告警去重 TTL(同一分钟桶只推一次) */
export const ALERT_RATE_DEDUPE_TTL_SEC = 90;
/** 卡住订单扫描 */
export const ALERT_STUCK_ORDER = {
pendingShipHours: 24,
inDeliveryHours: 48,
sampleLimit: 5,
} as const;
@@ -1,17 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '../redis/redis.module';
import { AlertService } from './alert.service';
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
import { WecomWebhookAlertService } from '../../integrations/wecom/wecom-webhook-alert.service';
/**
* 运营告警(企微 Webhook)。Global 以便 Filter / 各业务 Module 注入。
* Webhook 发送器在本 Module 注册,避免与 WecomModule↔Common 循环依赖。
*/
@Global()
@Module({
imports: [RedisModule],
providers: [WecomWebhookAlertService, AlertService, PayRedeemAnomalyService],
exports: [WecomWebhookAlertService, AlertService, PayRedeemAnomalyService],
})
export class AlertModule {}
@@ -1,95 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { WecomWebhookAlertService } from '../../integrations/wecom/wecom-webhook-alert.service';
import { ALERT_DEDUPE_TTL_SEC, type AlertLevel } from './alert.constants';
export type AlertNotifyInput = {
level: AlertLevel;
category: string;
title: string;
detail: string;
dedupeKey: string;
dedupeTtlSec?: number;
};
@Injectable()
export class AlertService {
private readonly logger = new Logger(AlertService.name);
constructor(
private readonly redis: RedisService,
private readonly wecomWebhook: WecomWebhookAlertService,
) {}
/** 异步告警,不阻塞调用方 */
notify(input: AlertNotifyInput): void {
void this.notifyAsync(input).catch((e) => {
this.logger.warn(`alert notify failed: ${e instanceof Error ? e.message : String(e)}`);
});
}
async notifyAsync(input: AlertNotifyInput): Promise<boolean> {
if (!this.wecomWebhook.isEnabled()) return false;
const ttl = input.dedupeTtlSec ?? ALERT_DEDUPE_TTL_SEC;
const dedupeRedisKey = `alert:dedupe:${input.dedupeKey}`;
try {
const ok = await this.redis.client.set(dedupeRedisKey, '1', 'EX', ttl, 'NX');
if (ok !== 'OK') return false;
} catch (e) {
this.logger.warn(
`alert dedupe redis error, send anyway: ${e instanceof Error ? e.message : String(e)}`,
);
}
return this.sendMarkdownNow(input);
}
/** HQ 测试推送:不去重,返回明确结果 */
async sendTestAlert(): Promise<{ ok: boolean; message: string }> {
const url = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (!url) {
return {
ok: false,
message: '未配置 WECOM_ALERT_WEBHOOK_URL,请在服务器 .env 中填写群机器人 Webhook',
};
}
if (process.env.WECOM_ALERT_ENABLED !== 'true') {
return {
ok: false,
message: '请先开启「启用企微运营告警 Webhook」并保存后再测试',
};
}
const sent = await this.sendMarkdownNow({
level: 'P2',
category: 'ops',
title: '告警测试',
detail: '你好,这是一条告警测试消息',
dedupeKey: `test|${Date.now()}`,
});
return sent
? { ok: true, message: '已发送测试告警,请查看企微群' }
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
}
private async sendMarkdownNow(input: AlertNotifyInput): Promise<boolean> {
const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim();
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const content = [
`## [${input.level}] ${escapeMd(input.title)}`,
`> 环境:<font color="comment">${escapeMd(envLabel)}</font>`,
`> 类别:<font color="comment">${escapeMd(input.category)}</font>`,
`> 时间:${escapeMd(now)}`,
'',
escapeMd(input.detail).slice(0, 3500),
].join('\n');
return this.wecomWebhook.sendMarkdown(content);
}
}
/** 轻量转义,保留企微 markdown 的 <font> 标签可用 */
function escapeMd(s: string): string {
return s.replace(/([\\`*_[\]])/g, '\\$1');
}
@@ -1,213 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { AlertService } from './alert.service';
import {
ALERT_RATE_BUCKET_TTL_SEC,
ALERT_RATE_DEDUPE_TTL_SEC,
ALERT_THRESHOLDS,
} from './alert.constants';
export type PayAnomalyMeta = {
orderNo?: string;
userId?: string | number | bigint;
reason?: string;
};
export type RedeemAnomalyMeta = {
storeId?: string | number | bigint;
userId?: string | number | bigint;
recordId?: string | number | bigint;
reason?: string;
};
@Injectable()
export class PayRedeemAnomalyService {
private readonly logger = new Logger(PayRedeemAnomalyService.name);
constructor(
private readonly redis: RedisService,
private readonly alert: AlertService,
) {}
onPayAttempt(amountYuan: number, meta: PayAnomalyMeta = {}): void {
this.checkPayAmount(amountYuan, meta);
void this.bumpAndMaybeAlert(
'pay:attempt',
ALERT_THRESHOLDS.payAttemptPerMinute,
'P0',
'pay',
'支付频率异常',
`1 分钟内支付尝试超过 ${ALERT_THRESHOLDS.payAttemptPerMinute}`,
meta,
);
}
onPaySuccess(amountYuan: number, meta: PayAnomalyMeta = {}): void {
this.checkPayAmount(amountYuan, meta);
}
onPayFail(reason: string, meta: PayAnomalyMeta = {}): void {
void this.bumpAndMaybeAlert(
'pay:fail',
ALERT_THRESHOLDS.payFailPerMinute,
'P0',
'pay',
'支付失败频率异常',
`1 分钟内支付失败超过 ${ALERT_THRESHOLDS.payFailPerMinute} 次;最近原因:${reason}`,
{ ...meta, reason },
);
}
onRedeemAttempt(amountYuan: number, meta: RedeemAnomalyMeta = {}): void {
this.checkRedeemAmount(amountYuan, meta);
void this.bumpAndMaybeAlert(
'redeem:attempt',
ALERT_THRESHOLDS.redeemAttemptPerMinute,
'P0',
'redeem',
'核销频率异常',
`1 分钟内核销尝试超过 ${ALERT_THRESHOLDS.redeemAttemptPerMinute}`,
meta,
);
}
onRedeemFail(reason: string, meta: RedeemAnomalyMeta = {}): void {
void this.bumpAndMaybeAlert(
'redeem:fail',
ALERT_THRESHOLDS.redeemFailPerMinute,
'P0',
'redeem',
'核销失败频率异常',
`1 分钟内核销失败超过 ${ALERT_THRESHOLDS.redeemFailPerMinute} 次;最近原因:${reason}`,
{ ...meta, reason },
);
}
/** 弱网阈值 / 补核销待办等业务信号 */
notifyRedeemOps(title: string, detail: string, dedupeKey: string): void {
this.alert.notify({
level: 'P1',
category: 'redeem',
title,
detail,
dedupeKey,
});
}
private checkPayAmount(amountYuan: number, meta: PayAnomalyMeta): void {
if (!Number.isFinite(amountYuan)) return;
if (amountYuan < ALERT_THRESHOLDS.payAmountMinYuan) {
this.alert.notify({
level: 'P1',
category: 'pay',
title: '支付金额过低',
detail: formatPayDetail(
`支付金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.payAmountMinYuan}`,
meta,
),
dedupeKey: `pay_amount_low|${meta.orderNo ?? amountYuan}`,
});
} else if (amountYuan > ALERT_THRESHOLDS.payAmountMaxYuan) {
this.alert.notify({
level: 'P1',
category: 'pay',
title: '支付金额过高',
detail: formatPayDetail(
`支付金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.payAmountMaxYuan}`,
meta,
),
dedupeKey: `pay_amount_high|${meta.orderNo ?? amountYuan}`,
});
}
}
private checkRedeemAmount(amountYuan: number, meta: RedeemAnomalyMeta): void {
if (!Number.isFinite(amountYuan)) return;
if (amountYuan < ALERT_THRESHOLDS.redeemAmountMinYuan) {
this.alert.notify({
level: 'P1',
category: 'redeem',
title: '核销金额过低',
detail: formatRedeemDetail(
`核销金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.redeemAmountMinYuan}`,
meta,
),
dedupeKey: `redeem_amount_low|${meta.recordId ?? meta.storeId ?? amountYuan}`,
});
} else if (amountYuan > ALERT_THRESHOLDS.redeemAmountMaxYuan) {
this.alert.notify({
level: 'P1',
category: 'redeem',
title: '核销金额过高',
detail: formatRedeemDetail(
`核销金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.redeemAmountMaxYuan}`,
meta,
),
dedupeKey: `redeem_amount_high|${meta.recordId ?? meta.storeId ?? amountYuan}`,
});
}
}
private async bumpAndMaybeAlert(
counterKind: string,
threshold: number,
level: 'P0' | 'P1' | 'P2',
category: string,
title: string,
baseDetail: string,
meta: PayAnomalyMeta & RedeemAnomalyMeta,
): Promise<void> {
try {
const bucket = Math.floor(Date.now() / 60_000);
const key = `alert:cnt:${counterKind}:${bucket}`;
const count = await this.redis.incr(key, ALERT_RATE_BUCKET_TTL_SEC);
if (count <= threshold) return;
const extra = [
`当前分钟计数:${count}`,
meta.orderNo ? `订单:${meta.orderNo}` : null,
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
this.alert.notify({
level,
category,
title,
detail: `${baseDetail}\n${extra}`,
dedupeKey: `${counterKind}|${bucket}`,
dedupeTtlSec: ALERT_RATE_DEDUPE_TTL_SEC,
});
} catch (e) {
this.logger.warn(
`anomaly counter failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
function formatPayDetail(line: string, meta: PayAnomalyMeta): string {
return [
line,
meta.orderNo ? `订单:${meta.orderNo}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatRedeemDetail(line: string, meta: RedeemAnomalyMeta): string {
return [
line,
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.recordId != null ? `记录:${String(meta.recordId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
}
@@ -1,81 +0,0 @@
import type { CommonEvent } from '@prisma/client';
type OrderLike = {
productName?: string;
productSpec?: string;
quantity?: number;
listUnitPrice?: unknown;
payAmount?: unknown;
payStatus?: string;
payExternalNo?: string | null;
paidAt?: Date | string | null;
imageResource?: { url?: string } | null;
};
export function mapOrderItemCompat(order: OrderLike) {
return {
productName: order.productName ?? '',
productSpec: order.productSpec ?? '',
productImage: order.imageResource?.url ?? '',
unitPrice: Number(order.listUnitPrice ?? 0),
quantity: order.quantity ?? 1,
};
}
export function mapOrderCompat<T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
}>(order: T) {
const payStatus = order.payStatus ?? 'UNPAID';
const isProxyOrder = order.orderType === 'PROXY';
return {
...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)],
payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
externalNo: order.payExternalNo ?? null,
paidAt: order.paidAt ?? null,
},
};
}
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
return {
...store,
coverUrl: store.coverResource?.url ?? null,
};
}
export function mapStatusLogCompat(events: CommonEvent[]) {
return events.map((e) => ({
fromStatus: e.param1,
toStatus: e.param2 ?? '',
operator: e.param3,
remark: e.remark,
createdAt: e.createdAt,
}));
}
export function mapBenefitLedgerCompat(
event: CommonEvent,
user?: { userNo?: string | null } | null,
coupon?: { couponNo?: string } | null,
) {
return {
id: event.id,
type: event.param1,
amount: event.amount1 != null ? Number(event.amount1) : 0,
balanceAfter: event.amount2 != null ? Number(event.amount2) : 0,
remark: event.remark,
createdAt: event.createdAt,
userId: event.actorId,
couponId: event.param2 ? BigInt(event.param2) : null,
user: user ? { userNo: user.userNo } : undefined,
coupon: coupon ? { couponNo: coupon.couponNo } : undefined,
};
}
@@ -1,20 +0,0 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
const SALT_LEN = 16;
const KEY_LEN = 64;
export function hashPassword(password: string): string {
const salt = randomBytes(SALT_LEN);
const hash = scryptSync(password, salt, KEY_LEN);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = scryptSync(password, salt, expected.length);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
@@ -1,14 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from '../guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest().user;
},
);
export function serializeBigInt<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
);
}
@@ -1,7 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import type { PartnerPermissionKey } from '@dukang/shared-types';
export const PARTNER_PERMISSIONS_KEY = 'partner_permissions';
export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) =>
SetMetadata(PARTNER_PERMISSIONS_KEY, permissions);
@@ -1,117 +0,0 @@
import type { Prisma } from '@prisma/client';
type BenefitLedgerType = 'GRANT' | 'REDEEM' | 'REFUND_VOID' | 'ADJUST';
export function buildBenefitLedgerEvent(data: {
userId: bigint;
couponId: bigint;
type: BenefitLedgerType;
amount: number;
balanceAfter: number;
refType: string;
refId?: bigint;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'BENEFIT_LEDGER',
refType: data.refType,
refId: data.refId ?? data.couponId,
actorType: 'USER',
actorId: data.userId,
param1: data.type,
param1Desc: 'ledger_type',
param2: data.couponId.toString(),
param2Desc: 'coupon_id',
amount1: data.amount,
amount2: data.balanceAfter,
remark: data.remark,
};
}
export function buildOrderStatusEvent(data: {
orderId: bigint;
fromStatus: string;
toStatus: string;
operator: string;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: data.orderId,
actorType: 'SYSTEM',
param1: data.fromStatus,
param1Desc: 'from_status',
param2: data.toStatus,
param2Desc: 'to_status',
param3: data.operator,
param3Desc: 'operator',
remark: data.remark,
};
}
export function benefitLedgerWhere(userId?: bigint, couponId?: bigint): Prisma.CommonEventWhereInput {
return {
eventType: 'BENEFIT_LEDGER',
...(userId ? { actorType: 'USER', actorId: userId } : {}),
...(couponId ? { param2: couponId.toString() } : {}),
};
}
export function orderStatusLogWhere(orderId: bigint): Prisma.CommonEventWhereInput {
return {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: orderId,
};
}
export function buildHqOperationEvent(data: {
hqAccountId: bigint;
action: string;
refType: string;
refId: bigint;
status?: string;
detail?: Record<string, unknown>;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'HQ_OPERATION',
refType: data.refType,
refId: data.refId,
actorType: 'HQ',
actorId: data.hqAccountId,
status: data.status,
param1: data.action,
param1Desc: 'action',
param2: data.refType,
param2Desc: 'target_type',
param3: data.refId.toString(),
param3Desc: 'target_id',
remark: data.remark,
extraJson: data.detail as Prisma.InputJsonValue,
};
}
export function hqOperationLogWhere(filters?: {
hqAccountId?: bigint;
action?: string;
refType?: string;
from?: Date;
to?: Date;
}): Prisma.CommonEventWhereInput {
return {
eventType: 'HQ_OPERATION',
...(filters?.hqAccountId ? { actorType: 'HQ' as const, actorId: filters.hqAccountId } : {}),
...(filters?.action ? { param1: filters.action } : {}),
...(filters?.refType ? { param2: filters.refType } : {}),
...(filters?.from || filters?.to
? {
createdAt: {
...(filters.from ? { gte: filters.from } : {}),
...(filters.to ? { lte: filters.to } : {}),
},
}
: {}),
};
}
@@ -1,109 +0,0 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AlertService } from '../alert/alert.service';
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
switch (exception.code) {
case 'P2002':
return '数据冲突,请刷新后重试';
case 'P2003':
return '关联数据不存在,请检查门店/权益配置';
case 'P2021':
case 'P2022':
return '数据库表结构未同步,请在服务器执行 prisma db push';
case 'P2025':
return '记录不存在或已被删除';
default:
return exception.message;
}
}
@Catch()
@Injectable()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly alert: AlertService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest<{ method?: string; url?: string }>();
const method = request?.method ?? '?';
const path = request?.url ?? '?';
if (exception instanceof HttpException) {
const status = exception.getStatus();
const res = exception.getResponse();
const message =
typeof res === 'string'
? res
: (res as { message?: string | string[] }).message || exception.message;
const msgText = Array.isArray(message) ? message.join(', ') : message;
if (status >= 500) {
this.alert.notify({
level: 'P0',
category: 'api_error',
title: `API ${status}`,
detail: `${method} ${path}\n${msgText}`,
dedupeKey: `http_${status}|${method}|${path}|${String(msgText).slice(0, 80)}`,
dedupeTtlSec: 120,
});
}
response.status(status).json({
code: status,
message: msgText,
data: null,
});
return;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
console.error(exception);
this.alert.notify({
level: 'P0',
category: 'api_error',
title: `Prisma ${exception.code}`,
detail: `${method} ${path}\n${exception.message}`,
dedupeKey: `prisma|${exception.code}|${method}|${path}`,
dedupeTtlSec: 120,
});
response.status(HttpStatus.BAD_REQUEST).json({
code: 400,
message: prismaErrorMessage(exception),
data: null,
});
return;
}
if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) {
response.status(HttpStatus.BAD_REQUEST).json({
code: 400,
message: 'ID 格式无效',
data: null,
});
return;
}
console.error(exception);
const errMsg = exception instanceof Error ? exception.message : 'Internal server error';
this.alert.notify({
level: 'P0',
category: 'api_error',
title: '未捕获异常 500',
detail: `${method} ${path}\n${errMsg}`,
dedupeKey: `uncaught|${method}|${path}|${errMsg.slice(0, 80)}`,
dedupeTtlSec: 120,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: 500,
message: errMsg,
data: null,
});
}
}
@@ -1,24 +0,0 @@
import type { Request } from 'express';
function normalizeIp(raw?: string | null): string | null {
if (!raw) return null;
const ip = raw.replace(/^::ffff:/, '').trim();
if (!ip || ip === '::1') return null;
return ip;
}
/** 从反向代理 / Socket 提取客户端 IP */
export function extractClientIp(req: Request): string | null {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return normalizeIp(forwarded.split(',')[0]);
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return normalizeIp(forwarded[0]?.split(',')[0]);
}
const realIp = req.headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.length > 0) {
return normalizeIp(realIp);
}
return normalizeIp(req.ip ?? req.socket?.remoteAddress ?? null);
}
@@ -1,44 +0,0 @@
import type { ClientGpsLocation, OrderClientLocationSnapshot } from './geo.types';
import type { IpRegion } from './geo.types';
import { extractClientIp } from './client-ip.util';
import type { Request } from 'express';
function parseGpsLocation(raw: unknown): ClientGpsLocation | null {
if (!raw || typeof raw !== 'object') return null;
const input = raw as Record<string, unknown>;
const latitude = Number(input.latitude);
const longitude = Number(input.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
return {
province: input.province != null ? String(input.province).trim() || null : null,
city: input.city != null ? String(input.city).trim() || null : null,
district: input.district != null ? String(input.district).trim() || null : null,
latitude,
longitude,
address: input.address != null ? String(input.address).trim() || null : null,
};
}
export function buildOrderClientLocationSnapshot(
req: Request,
ipGeo: IpRegion,
clientLocationRaw?: unknown,
): OrderClientLocationSnapshot {
const clientIp = extractClientIp(req);
const gps = parseGpsLocation(clientLocationRaw);
return {
clientIp,
ipProvince: ipGeo.province,
ipCity: ipGeo.city,
ipDistrict: ipGeo.district,
gpsProvince: gps?.province ?? null,
gpsCity: gps?.city ?? null,
gpsDistrict: gps?.district ?? null,
gpsLatitude: gps?.latitude ?? null,
gpsLongitude: gps?.longitude ?? null,
gpsAddress: gps?.address ?? null,
};
}
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { IpGeoService } from './ip-geo.service';
@Global()
@Module({
providers: [IpGeoService],
exports: [IpGeoService],
})
export class GeoModule {}
@@ -1,27 +0,0 @@
export type IpRegion = {
province: string | null;
city: string | null;
district: string | null;
};
export type ClientGpsLocation = {
province?: string | null;
city?: string | null;
district?: string | null;
latitude: number;
longitude: number;
address?: string | null;
};
export type OrderClientLocationSnapshot = {
clientIp: string | null;
ipProvince: string | null;
ipCity: string | null;
ipDistrict: string | null;
gpsProvince: string | null;
gpsCity: string | null;
gpsDistrict: string | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
gpsAddress: string | null;
};
@@ -1,49 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import IP2Region from 'ip2region';
import type { IpRegion } from './geo.types';
function cleanRegionName(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed || trimmed === '0' || trimmed === '内网IP') return null;
return trimmed;
}
@Injectable()
export class IpGeoService implements OnModuleInit {
private readonly logger = new Logger(IpGeoService.name);
private query: IP2Region | null = null;
onModuleInit() {
try {
this.query = new IP2Region();
} catch (error) {
this.logger.warn(`ip2region 初始化失败: ${error instanceof Error ? error.message : error}`);
}
}
resolve(ip: string | null): IpRegion {
if (!ip || !this.query) {
return { province: null, city: null, district: null };
}
if (ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return { province: null, city: null, district: null };
}
try {
const result = this.query.search(ip) as {
province?: string;
city?: string;
region?: string;
};
return {
province: cleanRegionName(result?.province),
city: cleanRegionName(result?.city),
district: cleanRegionName(result?.region),
};
} catch (error) {
this.logger.debug(`IP 解析失败 ${ip}: ${error instanceof Error ? error.message : error}`);
return { province: null, city: null, district: null };
}
}
}
@@ -1,26 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ClientApp } from '@dukang/shared-types';
import { JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ok = super.canActivate(context);
if (!ok) return false;
const req = context.switchToHttp().getRequest();
const clientApp = req.headers['x-client-app'] as ClientApp;
if (clientApp !== ClientApp.HQ_WEB) {
throw new UnauthorizedException('Invalid client app for admin');
}
if (req.user?.actorType !== 'HQ') {
throw new UnauthorizedException('HQ access required');
}
return true;
}
}
@@ -1,103 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
export const HQ_PERMISSIONS_KEY = 'hq:permissions';
export const RequireHqPermissions = (...keys: string[]) =>
SetMetadata(HQ_PERMISSIONS_KEY, keys);
/** 任意一项系统设置分组权限即可访问系统设置接口 */
export const RequireAnySystemSettings = () =>
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
@Injectable()
export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用');
}
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
});
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
...userKeys,
]);
}
const roleRows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: account.adminRole },
select: { permissionKey: true },
});
const roleKeys =
roleRows.length > 0
? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
}
}
@Injectable()
export class HqPermissionGuard implements CanActivate {
constructor(
private readonly resolver: HqPermissionsResolver,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
req.hqPermissionKeys = keys;
const required =
this.reflector.getAllAndOverride<string[]>(HQ_PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]) ?? [];
if (!required.length) return true;
if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限');
}
return true;
}
if (!required.some((k) => keys.includes(k as HqPermissionKey))) {
throw new ForbiddenException('权限不足');
}
return true;
}
}
@@ -1,56 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
export interface AuthUser {
actorType: string;
actorId: bigint;
clientApp: ClientApp;
sub: string;
phoneVerified: boolean;
/** Selected store after POST /shop/auth/select-store */
storeId?: bigint;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(protected readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const auth = req.headers.authorization as string | undefined;
if (!auth?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing token');
}
try {
const payload = this.jwtService.verify(auth.slice(7));
const clientApp = req.headers['x-client-app'] as ClientApp;
if (!clientApp || payload.clientApp !== clientApp) {
throw new UnauthorizedException('Invalid client app');
}
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
if (payload.actorType !== expectedActor) {
throw new UnauthorizedException('Actor mismatch');
}
req.user = {
actorType: payload.actorType,
actorId: BigInt(payload.actorId),
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
return true;
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid token');
}
}
}
@@ -1,50 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
import type { AuthUser } from './jwt-auth.guard';
/** C 端 H5 / 小程序同属 USER,目录白名单只认 actorId→手机号,允许端间 token 兼容 */
const USER_CLIENT_APPS = new Set<ClientApp>([ClientApp.USER_MINI, ClientApp.USER_H5]);
function clientAppCompatible(headerApp: ClientApp | undefined, payloadApp: unknown): boolean {
if (!headerApp || payloadApp == null) return false;
if (headerApp === payloadApp) return true;
return USER_CLIENT_APPS.has(headerApp) && USER_CLIENT_APPS.has(payloadApp as ClientApp);
}
@Injectable()
export class OptionalJwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const auth = req.headers.authorization as string | undefined;
if (!auth?.startsWith('Bearer ')) {
return true;
}
try {
const payload = this.jwtService.verify(auth.slice(7));
const clientApp = req.headers['x-client-app'] as ClientApp;
if (!clientAppCompatible(clientApp, payload.clientApp)) return true;
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
if (payload.actorType !== expectedActor) return true;
req.user = {
actorType: payload.actorType,
actorId: BigInt(payload.actorId),
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
} catch {
/* ignore invalid token */
}
return true;
}
}
@@ -1,45 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { PartnerPermissionKey } from '@dukang/shared-types';
import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPermissionGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary === 1) return true;
const required = this.reflector.getAllAndOverride<PartnerPermissionKey[]>(
PARTNER_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const perms = Array.isArray(account.permissions)
? (account.permissions as string[])
: [];
if (required.some((p) => perms.includes(p))) return true;
throw new ForbiddenException('当前子账号无此操作权限');
}
}
@@ -1,32 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPrimaryGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人主账号可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary !== 1) {
throw new ForbiddenException('仅主账号可操作');
}
return true;
}
}
@@ -1,33 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
@Injectable()
export class PhoneVerifiedGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'USER') {
throw new ForbiddenException('请先验证手机号');
}
const row = await this.prisma.user.findUnique({
where: { id: user.actorId },
select: { phoneVerifiedAt: true, mergedIntoUserId: true, status: true },
});
if (!row || row.status !== 1 || row.mergedIntoUserId) {
throw new ForbiddenException('账号状态异常,请重新进入');
}
if (!row.phoneVerifiedAt) {
throw new ForbiddenException('请先验证手机号');
}
return true;
}
}
@@ -1,30 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
/** 门店主账号专用(提现等资金操作) */
@Injectable()
export class ShopPrimaryGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'STORE') {
throw new ForbiddenException('仅门店主账号可操作');
}
const account = await this.prisma.storeAccount.findUnique({
where: { id: user.actorId },
select: { isPrimary: true },
});
if (!account || account.isPrimary !== 1) {
throw new ForbiddenException('仅门店主账号可申请提现');
}
return true;
}
}
@@ -1,15 +0,0 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { AuthUser } from './jwt-auth.guard';
/** Shop business APIs require JWT claim storeId (after select-store). */
@Injectable()
export class ShopStoreGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user?.storeId) {
throw new ForbiddenException('请先选择门店');
}
return true;
}
}
@@ -1,27 +0,0 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from '../guards/jwt-auth.guard';
@Injectable()
export class StoreMembershipService {
constructor(private readonly prisma: PrismaService) {}
async assertStoreMembership(accountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUnique({
where: {
storeAccountId_storeId: { storeAccountId: accountId, storeId },
},
});
if (!binding) {
throw new ForbiddenException('无权访问该门店');
}
return binding;
}
requireShopStoreId(user: AuthUser): bigint {
if (!user.storeId) {
throw new ForbiddenException('请先选择门店');
}
return user.storeId;
}
}
@@ -1,29 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const account = await this.prisma.hqAccount.findUnique({
where: { id: user.actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE' || account.adminRole !== 'SUPER_ADMIN') {
throw new ForbiddenException('需要超级管理员权限');
}
return true;
}
}
@@ -1,30 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import { buildHqOperationEvent } from '../event/event.helpers';
export type LogHqOperationInput = {
hqAccountId: bigint;
action: string;
refType: string;
refId: bigint;
status?: string;
detail?: Record<string, unknown>;
remark?: string;
};
@Injectable()
export class HqOperationLogService {
constructor(private readonly prisma: PrismaService) {}
async log(input: LogHqOperationInput): Promise<void> {
await this.prisma.commonEvent.create({
data: buildHqOperationEvent(input),
});
}
logSafe(input: LogHqOperationInput): void {
void this.log(input).catch((err) => {
console.error('[HqOperationLog] write failed', err);
});
}
}
@@ -1,219 +0,0 @@
/** HQ 后台写操作审计 action(写入 common_event.param1 */
export const HqOperationAction = {
CITY_CREATE: 'CITY_CREATE',
CITY_UPDATE: 'CITY_UPDATE',
CITY_DELETE: 'CITY_DELETE',
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
USER_DELETE: 'USER_DELETE',
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
ORDER_SHIP: 'ORDER_SHIP',
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
ORDER_DELETE: 'ORDER_DELETE',
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_STATUS: 'STORE_STATUS',
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
PRODUCT_CREATE: 'PRODUCT_CREATE',
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
PRODUCT_DELETE: 'PRODUCT_DELETE',
PRODUCT_TEMPLATE_CREATE: 'PRODUCT_TEMPLATE_CREATE',
PRODUCT_TEMPLATE_UPDATE: 'PRODUCT_TEMPLATE_UPDATE',
BENEFIT_COUPON_VOID: 'BENEFIT_COUPON_VOID',
BENEFIT_COUPON_GRANT: 'BENEFIT_COUPON_GRANT',
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
TICKET_APPROVE: 'TICKET_APPROVE',
TICKET_REJECT: 'TICKET_REJECT',
TICKET_CREATE: 'TICKET_CREATE',
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
INVOICE_CREATE: 'INVOICE_CREATE',
INVOICE_ISSUE: 'INVOICE_ISSUE',
INVOICE_REJECT: 'INVOICE_REJECT',
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE',
STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT',
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
PARTNER_BILL_BATCH_MARK_PAID: 'PARTNER_BILL_BATCH_MARK_PAID',
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
WECOM_BOT_CREATE: 'WECOM_BOT_CREATE',
WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.CITY_CREATE]: '新增开城城市',
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
[HqOperationAction.CITY_DELETE]: '删除开城城市',
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
[HqOperationAction.USER_DELETE]: '删除用户',
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
[HqOperationAction.ORDER_SHIP]: '订单发货',
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
[HqOperationAction.ORDER_DELETE]: '删除订单',
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
[HqOperationAction.PRODUCT_TEMPLATE_CREATE]: '新增详情模板',
[HqOperationAction.PRODUCT_TEMPLATE_UPDATE]: '编辑详情模板',
[HqOperationAction.BENEFIT_COUPON_VOID]: '作废权益券',
[HqOperationAction.BENEFIT_COUPON_GRANT]: '手动发放权益',
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
[HqOperationAction.TICKET_APPROVE]: '工单通过',
[HqOperationAction.TICKET_REJECT]: '工单驳回',
[HqOperationAction.TICKET_CREATE]: '创建工单',
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
[HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过',
[HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回',
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID]: '批量合伙人账单结算',
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
[HqOperationAction.WECOM_BOT_CREATE]: '创建企微机器人',
[HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
[HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
[HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
STORE_PAYOUT: '门店打款确认',
};
export function resolveHqOperationLabel(action: string | null | undefined, refType?: string | null): string {
if (action && HQ_OPERATION_ACTION_LABELS[action]) {
return HQ_OPERATION_ACTION_LABELS[action];
}
if (refType && HQ_OPERATION_ACTION_LABELS[refType]) {
return HQ_OPERATION_ACTION_LABELS[refType];
}
return action || refType || 'HQ 操作';
}
@@ -1,21 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import type { HqOperationActionCode } from './hq-operation.constants';
export const HQ_OPERATION_KEY = 'hq_operation';
export type HqOperationMeta = {
action: HqOperationActionCode | string;
refType: string;
/** 从路由 params 取 refId */
refIdParam?: string;
/** 从响应体字段取 refId,默认 id */
refIdField?: string;
/** 批量操作无单一 refId 时用 0 */
batch?: boolean;
/** 记录请求体到 extraJson */
includeBody?: boolean;
/** 记录响应体到 extraJson(截断) */
includeResponse?: boolean;
};
export const HqOperation = (meta: HqOperationMeta) => SetMetadata(HQ_OPERATION_KEY, meta);
@@ -1,113 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, catchError, tap, throwError } from 'rxjs';
import type { AuthUser } from '../guards/jwt-auth.guard';
import type { RequestWithId } from '../logging/request-id.middleware';
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
import { HqOperationLogService } from './hq-operation-log.service';
function pickRefId(value: unknown): bigint | null {
if (value == null || value === '') return null;
try {
return BigInt(String(value));
} catch {
return null;
}
}
function sanitizeBody(body: unknown): unknown {
if (!body || typeof body !== 'object') return body;
const copy = { ...(body as Record<string, unknown>) };
for (const key of Object.keys(copy)) {
if (/password|secret|token/i.test(key)) {
copy[key] = '***';
}
}
return copy;
}
function summarizeResponse(data: unknown): unknown {
if (data == null) return null;
if (typeof data !== 'object') return data;
const obj = data as Record<string, unknown>;
const summary: Record<string, unknown> = {};
for (const key of ['id', 'ok', 'deleted', 'orderNo', 'userNo', 'redeemNo', 'message', 'status']) {
if (obj[key] !== undefined) summary[key] = obj[key];
}
if (Array.isArray(obj.items)) {
summary.itemCount = obj.items.length;
}
if (Array.isArray(obj.orderNos)) {
summary.orderNos = obj.orderNos;
}
return Object.keys(summary).length ? summary : obj;
}
@Injectable()
export class HqOperationInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly logService: HqOperationLogService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<HqOperationMeta | undefined>(
HQ_OPERATION_KEY,
context.getHandler(),
);
if (!meta) return next.handle();
const req = context.switchToHttp().getRequest<RequestWithId & { user?: AuthUser }>();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
return next.handle();
}
const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
const refId = meta.batch
? 0n
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
?? pickRefId(
meta.refIdField
? (data as Record<string, unknown> | null)?.[meta.refIdField]
: (data as Record<string, unknown> | null)?.id,
)
?? 0n;
const detail: Record<string, unknown> = {
method: req.method,
path: req.originalUrl ?? req.url,
requestId: req.requestId,
};
if (meta.includeBody && req.body) {
detail.requestBody = sanitizeBody(req.body);
}
if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
detail.response = summarizeResponse(data);
}
if (errorMessage) detail.error = errorMessage;
this.logService.logSafe({
hqAccountId: user.actorId,
action: meta.action,
refType: meta.refType,
refId,
status,
detail,
});
};
return next.handle().pipe(
tap((data) => writeLog('SUCCESS', data)),
catchError((err: { message?: string }) => {
writeLog('FAILED', undefined, err?.message ?? '操作失败');
return throwError(() => err);
}),
);
}
}
@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { HqOperationLogService } from './hq-operation-log.service';
import { HqOperationInterceptor } from './hq-operation.interceptor';
@Module({
providers: [
HqOperationLogService,
HqOperationInterceptor,
{
provide: APP_INTERCEPTOR,
useClass: HqOperationInterceptor,
},
],
exports: [HqOperationLogService],
})
export class HqOperationModule {}
@@ -1,20 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data) => ({
code: 0,
message: 'ok',
data: data ?? null,
})),
);
}
}
@@ -1,55 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Response } from 'express';
import type { AuthUser } from '../guards/jwt-auth.guard';
import type { RequestWithId } from './request-id.middleware';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = Date.now();
const http = context.switchToHttp();
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
const res = http.getResponse<Response>();
return next.handle().pipe(
tap({
next: () => this.logLine(req, res.statusCode, Date.now() - started),
error: (err: { status?: number; message?: string }) => {
const status = err?.status ?? res.statusCode ?? 500;
this.logLine(req, status, Date.now() - started, err?.message);
},
}),
);
}
private logLine(
req: RequestWithId & { user?: AuthUser },
status: number,
latencyMs: number,
error?: string,
) {
const line = {
requestId: req.requestId,
method: req.method,
path: req.originalUrl || req.url,
status,
latencyMs,
clientApp: req.headers['x-client-app'],
actorType: req.user?.actorType,
actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
error: error?.slice(0, 200),
};
if (status >= 500) this.logger.error(JSON.stringify(line));
else if (status >= 400) this.logger.warn(JSON.stringify(line));
else this.logger.log(JSON.stringify(line));
}
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { RequestIdMiddleware } from './request-id.middleware';
import { LoggingInterceptor } from './logging.interceptor';
@Module({
providers: [RequestIdMiddleware, LoggingInterceptor],
exports: [RequestIdMiddleware, LoggingInterceptor],
})
export class LoggingModule {}
@@ -1,21 +0,0 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
export const REQUEST_ID_HEADER = 'x-request-id';
export type RequestWithId = Request & { requestId?: string };
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: RequestWithId, res: Response, next: NextFunction) {
const incoming = req.headers[REQUEST_ID_HEADER];
const requestId =
typeof incoming === 'string' && incoming.trim()
? incoming.trim().slice(0, 64)
: randomUUID();
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
next();
}
}
@@ -1,30 +0,0 @@
import { Injectable } from '@nestjs/common';
import type { MockSmsCodeItem } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
const LIST_LIMIT = 50;
@Injectable()
export class MockSmsCodeService {
constructor(private readonly prisma: PrismaService) {}
async record(phone: string, scene: string, code: string) {
await this.prisma.mockSmsCode.create({
data: { phone, scene, code },
});
}
async listRecent(limit = LIST_LIMIT): Promise<MockSmsCodeItem[]> {
const rows = await this.prisma.mockSmsCode.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
});
return rows.map((row) => ({
id: String(row.id),
phone: row.phone,
scene: row.scene,
code: row.code,
createdAt: row.createdAt.toISOString(),
}));
}
}
@@ -1,13 +0,0 @@
import { BadRequestException } from '@nestjs/common';
export function parseBigIntParam(value: unknown, label = 'ID'): bigint {
const text = String(value ?? '').trim();
if (!/^\d+$/.test(text)) {
throw new BadRequestException(`${label}格式无效`);
}
try {
return BigInt(text);
} catch {
throw new BadRequestException(`${label}格式无效`);
}
}
@@ -1,16 +0,0 @@
import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@@ -1,16 +0,0 @@
import { Global, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: () => new Redis(process.env.REDIS_URL || 'redis://localhost:6379'),
},
RedisService,
],
exports: ['REDIS_CLIENT', RedisService],
})
export class RedisModule {}
@@ -1,45 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService {
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
get client() {
return this.redis;
}
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const payload = JSON.stringify(value);
if (ttlSeconds) {
await this.redis.set(key, payload, 'EX', ttlSeconds);
} else {
await this.redis.set(key, payload);
}
}
async getJson<T>(key: string): Promise<T | null> {
const raw = await this.redis.get(key);
return raw ? (JSON.parse(raw) as T) : null;
}
async del(key: string) {
await this.redis.del(key);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
const count = await this.redis.incr(key);
if (ttlSeconds && count === 1) {
await this.redis.expire(key, ttlSeconds);
}
return count;
}
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
}
@@ -1,55 +0,0 @@
import { resolve } from 'path';
import { PrismaClient } from '@prisma/client';
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
function apiRoot() {
return resolve(__dirname, '..', '..');
}
export function resolveEnvFilePath() {
const isProduction = (process.env.NODE_ENV ?? 'development') === 'production';
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
}
function normalizeConfigValue(key: string, value: string): string {
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
return value;
}
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
export async function preloadSystemConfigEnv(): Promise<number> {
const prisma = new PrismaClient();
try {
const rows = await prisma.systemConfig.findMany();
for (const row of rows) {
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
}
}
return rows.length;
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
) {
console.warn('[config] system_config 表不存在,跳过 DB 预加载');
return 0;
}
throw error;
} finally {
await prisma.$disconnect();
}
}
export function applyEnvOverlay(values: Record<string, string>) {
for (const [key, value] of Object.entries(values)) {
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
process.env[key] = normalizeConfigValue(key, value);
}
}
}
@@ -1,10 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
import { SystemConfigService } from './system-config.service';
@Global()
@Module({
providers: [SystemConfigService, MockSmsCodeService],
exports: [SystemConfigService, MockSmsCodeService],
})
export class SystemConfigModule {}
@@ -1,251 +0,0 @@
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types';
/** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'feature', label: '功能开关' },
{ key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' },
{ key: 'wechat_mini', label: '微信小程序配置' },
{ key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
{ key: 'winery_bank', label: '酒厂银行账户' },
{ key: 'finance', label: '财务结算' },
];
const G = {
feature: 'feature',
sms: 'sms',
wechat: 'wechat',
wechat_mini: 'wechat_mini',
oss: 'oss',
app: 'app',
deploy: 'deploy',
winery_bank: 'winery_bank',
finance: 'finance',
} as const;
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{
key: 'MOCK_SMS',
label: 'Mock 短信',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启后不发真实短信,验证码为随机 6 位数字,并记录在下方列表',
},
{
key: 'MOCK_PAY',
label: 'Mock 支付',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '关闭且已配置微信商户参数时走真实 JSAPI 支付',
},
{
key: 'MOCK_WECHAT',
label: 'Mock 微信授权',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启走 Mock OAuth/登录;关闭且已配置 WX_APP_ID/SECRET 时走真实微信',
},
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false },
{ key: 'AUTO_APPROVE_STORE', label: '门店自动审核通过', group: G.feature, type: 'boolean', requiresRestart: false },
{
key: 'WECOM_AIBOT_ENABLED',
label: '启用企微机器人长连接',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '总开关。开启后连接 HQ「企微机器人」模块中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
},
{
key: 'WECOM_ALERT_ENABLED',
label: '启用企微运营告警 Webhook',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启后向群机器人 Webhook 推送异常告警;Webhook URL 仅在服务器 .env 配置(WECOM_ALERT_WEBHOOK_URL',
},
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM', label: '核销确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_PROXY_ORDER', label: '代下单确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_ACCESS_KEY_ID', label: '短信 AccessKey ID', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'ALIYUN_SMS_ACCESS_KEY_SECRET', label: '短信 AccessKey Secret', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_APP_ID', label: '服务号 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_APP_SECRET', label: '服务号 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MINI_APP_ID', label: '小程序 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MINI_APP_SECRET', label: '小程序 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MCH_ID', label: '微信商户号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_SERIAL_NO', label: '商户证书序列号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_PRIVATE_KEY', label: '商户私钥 PEM', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{ key: 'WX_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{
key: 'WX_MINI_MSG_TOKEN',
label: '小程序消息推送 Token',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description:
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
},
{
key: 'WX_MINI_MSG_AES_KEY',
label: '小程序消息推送 EncodingAESKey',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description: '消息推送安全模式 EncodingAESKey43 位);明文模式可留空',
},
{
key: 'MINI_HOME_BANNERS',
label: '首页轮播图',
group: G.wechat_mini,
type: 'imageList',
requiresRestart: false,
description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张;上传后需点击右上角「保存」',
},
{
key: 'MINI_HOME_FOOTER_URL',
label: '首页底部图',
group: G.wechat_mini,
type: 'image',
requiresRestart: false,
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
},
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
{ key: 'OSS_REGION', label: 'OSS Region', group: G.oss, type: 'string', requiresRestart: true, placeholder: 'oss-cn-hangzhou' },
{ key: 'OSS_ENDPOINT', label: 'OSS Endpoint', group: G.oss, type: 'string', requiresRestart: true, description: '可选,内网 endpoint' },
{ key: 'OSS_AUTHORIZATION_V4', label: 'OSS V4 签名', group: G.oss, type: 'boolean', requiresRestart: true },
{ key: 'OSS_CDN_BASE', label: 'OSS 公网域名', group: G.oss, type: 'string', requiresRestart: false },
{ key: 'OSS_UPLOAD_PREFIX', label: '上传前缀', group: G.oss, type: 'string', requiresRestart: false, placeholder: 'uploads' },
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{
key: 'TENCENT_LBS_KEY',
label: '腾讯位置服务 Key',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description: '须开启 WebServiceAPI;服务端地理编码/地点搜索与前端选点组件共用',
},
{
key: 'TENCENT_LBS_SECRET_KEY',
label: '腾讯位置服务 SecretKeySK',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description:
'控制台开启 WebServiceAPI「签名校验」后生成;仅服务端计算 sig,勿泄露。配置后接口请求自动附带签名',
},
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
{
key: 'SENTRY_DSN',
label: 'Sentry DSN',
group: G.deploy,
type: 'password',
secret: true,
requiresRestart: true,
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
},
{
key: 'WINERY_BANK_ACCOUNT_NAME',
label: '户名',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
description: '酒厂收款账户户名,打款时对照',
},
{
key: 'WINERY_BANK_NAME',
label: '开户银行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'WINERY_BANK_BRANCH',
label: '开户支行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
placeholder: '可选',
},
{
key: 'WINERY_BANK_ACCOUNT_NO',
label: '银行账号',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'STORE_WITHDRAW_DAILY_LIMIT',
label: '门店未出账提现单日上限(元)',
group: G.finance,
type: 'number',
requiresRestart: false,
description: 'FIN-002:单店单日提现上限,默认 5000',
placeholder: '5000',
},
];
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
export const SYSTEM_CONFIG_RETIRED_KEYS = [
'NODE_ENV',
'DATABASE_URL',
'REDIS_URL',
'PORT',
'TRUST_PROXY',
'JWT_SECRET',
'JWT_EXPIRES_IN',
'MOCK_SMS_CODE',
'WX_AUTHORIZE',
'WECHAT_AUTH_ENABLED',
'WECHAT_PAY_ENABLED',
'OSS_ENABLED',
'WECOM_AIBOT_BOT_ID',
'WECOM_AIBOT_SECRET',
'WECOM_AIBOT_WELCOME',
'WECOM_BOT_CS_ENABLED',
'WECOM_BOT_CS_BOT_ID',
'WECOM_BOT_CS_SECRET',
'WECOM_BOT_CS_WELCOME',
'WECOM_BOT_CS_PERMISSIONS',
'WECOM_BOT_TECH_ENABLED',
'WECOM_BOT_TECH_BOT_ID',
'WECOM_BOT_TECH_SECRET',
'WECOM_BOT_TECH_WELCOME',
'WECOM_BOT_TECH_PERMISSIONS',
'WECOM_BOT_TEAM_ENABLED',
'WECOM_BOT_TEAM_BOT_ID',
'WECOM_BOT_TEAM_SECRET',
'WECOM_BOT_TEAM_WELCOME',
'WECOM_BOT_TEAM_PERMISSIONS',
] as const;
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key);
}
@@ -1,263 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { writeFileSync } from 'fs';
import type {
SystemConfigFormResponse,
SystemConfigSyncResult,
SystemConfigUpdateRequest,
} from '@dukang/shared-types';
import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
import {
SYSTEM_CONFIG_FIELDS,
SYSTEM_CONFIG_GROUPS,
SYSTEM_CONFIG_KEY_SET,
SYSTEM_CONFIG_RETIRED_KEYS,
getSystemConfigField,
} from './system-config.registry';
import { applyEnvOverlay, resolveEnvFilePath } from './system-config.env';
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
const SECRET_PLACEHOLDER = '********';
function isMissingSystemConfigTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
);
}
@Injectable()
export class SystemConfigService implements OnModuleInit {
private lastUpdatedAt: Date | null = null;
private tableReady = true;
constructor(
private readonly prisma: PrismaService,
private readonly mockSmsCodes: MockSmsCodeService,
) {}
async onModuleInit() {
try {
await this.purgeRetiredKeys();
await this.seedMissingFromProcessEnv();
const rows = await this.prisma.systemConfig.findMany();
applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value])));
this.lastUpdatedAt = rows.reduce<Date | null>(
(max, r) => (!max || r.updatedAt > max ? r.updatedAt : max),
null,
);
} catch (error) {
if (!isMissingSystemConfigTable(error)) throw error;
this.tableReady = false;
console.warn('[config] system_config 表不存在,请执行 npx prisma db push');
}
}
getMergedEnv(): Record<string, string | undefined> {
return { ...process.env };
}
getAppConfig() {
return loadAppConfig(this.getMergedEnv());
}
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
if (!this.tableReady) {
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
}
const groups =
allowedGroups == null
? SYSTEM_CONFIG_GROUPS
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
const allowedGroupSet = new Set(groups.map((g) => g.key));
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
const rows = await this.prisma.systemConfig.findMany();
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
const values: Record<string, string> = {};
const configuredSecrets: string[] = [];
for (const field of fields) {
const fromDb = dbMap.get(field.key);
const fromEnv = process.env[field.key];
const raw = fromDb ?? fromEnv ?? '';
if (field.secret) {
if (raw) configuredSecrets.push(field.key);
values[field.key] = '';
} else {
values[field.key] = raw;
}
}
return {
groups,
fields,
values,
configuredSecrets,
envFilePath: resolveEnvFilePath(),
updatedAt: this.lastUpdatedAt?.toISOString() ?? null,
mockSmsCodes: await this.mockSmsCodes.listRecent(),
};
}
async update(
dto: SystemConfigUpdateRequest,
allowedGroups?: string[] | null,
): Promise<{
updatedKeys: string[];
requiresRestartKeys: string[];
}> {
const updatedKeys: string[] = [];
const requiresRestartKeys: string[] = [];
const overlay: Record<string, string> = {};
const allowedGroupSet =
allowedGroups == null ? null : new Set(allowedGroups);
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
const meta = getSystemConfigField(key);
if (!meta) continue;
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
let value = String(rawValue ?? '').trim();
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
continue;
}
value = this.normalizeByMeta(meta, value);
await this.prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, value },
update: { value },
});
overlay[key] = value;
updatedKeys.push(key);
if (meta.requiresRestart) requiresRestartKeys.push(key);
}
if (updatedKeys.length) {
applyEnvOverlay(overlay);
const latest = await this.prisma.systemConfig.findFirst({
orderBy: { updatedAt: 'desc' },
select: { updatedAt: true },
});
this.lastUpdatedAt = latest?.updatedAt ?? new Date();
}
return { updatedKeys, requiresRestartKeys: [...new Set(requiresRestartKeys)] };
}
async syncToEnvFile(): Promise<SystemConfigSyncResult> {
const rows = await this.prisma.systemConfig.findMany();
const path = resolveEnvFilePath();
const lines: string[] = [
'# 由 HQ 系统设置同步生成,请勿手工删改键名',
`# synced_at=${new Date().toISOString()}`,
'',
];
let currentGroup = '';
for (const field of SYSTEM_CONFIG_FIELDS) {
if (field.group !== currentGroup) {
currentGroup = field.group;
const groupLabel = SYSTEM_CONFIG_GROUPS.find((g) => g.key === currentGroup)?.label ?? currentGroup;
lines.push(`# --- ${groupLabel} ---`);
}
const row = rows.find((r) => r.configKey === field.key);
const value = row?.value ?? process.env[field.key] ?? '';
lines.push(formatEnvLine(field.key, value));
}
lines.push('');
writeFileSync(path, lines.join('\n'), 'utf8');
const requiresRestartKeys = SYSTEM_CONFIG_FIELDS.filter((f) => f.requiresRestart).map((f) => f.key);
return {
envFilePath: path,
writtenKeys: SYSTEM_CONFIG_FIELDS.length,
requiresRestartKeys,
message: `已写入 ${path},共 ${SYSTEM_CONFIG_FIELDS.length} 项。修改「需重启」类配置后请重启 API 进程。`,
};
}
async importFromProcessEnv(): Promise<{ imported: number }> {
let imported = 0;
const overlay: Record<string, string> = {};
for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue;
const normalized = this.normalizeByMeta(field, envVal);
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing?.value?.trim()) continue;
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value: normalized },
update: { value: normalized },
});
overlay[field.key] = normalized;
imported += 1;
}
if (imported) applyEnvOverlay(overlay);
await this.onModuleInit();
return { imported };
}
private async purgeRetiredKeys() {
await this.prisma.systemConfig.deleteMany({
where: {
configKey: { in: [...SYSTEM_CONFIG_RETIRED_KEYS] },
},
});
}
private async seedMissingFromProcessEnv() {
for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue;
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing?.value?.trim()) continue;
const value = this.normalizeByMeta(field, envVal);
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value },
update: { value },
});
}
}
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
if (meta.type === 'boolean') {
return raw === 'true' || raw === '1' ? 'true' : 'false';
}
if (meta.type === 'imageList') {
return serializeMiniHomeBanners(parseMiniHomeBanners(raw));
}
if (meta.type === 'image') {
return raw.trim();
}
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
let value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).trim();
}
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
}
return raw;
}
}
function formatEnvLine(key: string, value: string): string {
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
if (!needsQuote) return `${key}=${value}`;
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/\r\n/g, '\\n')
.replace(/\n/g, '\\n')
.replace(/"/g, '\\"');
return `${key}="${escaped}"`;
}
@@ -1,85 +0,0 @@
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 };
}
@@ -1,61 +0,0 @@
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';
}
}
@@ -1,7 +0,0 @@
export const COURIER_PROVIDER = 'COURIER_PROVIDER';
export enum CourierProviderCode {
XIAOFEIXIA = 'XIAOFEIXIA',
SF = 'SF',
JD = 'JD',
}
@@ -1,11 +0,0 @@
export class CourierApiError extends Error {
constructor(
message: string,
readonly code: string,
readonly providerCode?: string,
readonly raw?: unknown,
) {
super(message);
this.name = 'CourierApiError';
}
}
@@ -1,34 +0,0 @@
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 {}
@@ -1,59 +0,0 @@
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);
}
}
@@ -1,123 +0,0 @@
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;
}
@@ -1,5 +0,0 @@
export * from './courier.constants';
export * from './courier.types';
export * from './courier.error';
export * from './courier.service';
export * from './courier.module';
@@ -1,192 +0,0 @@
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(
`小飞侠响应非 JSONHTTP ${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;
}
}
@@ -1,12 +0,0 @@
/** 小飞侠接口 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';
@@ -1,191 +0,0 @@
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,
};
}
}
@@ -1,28 +0,0 @@
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();
}
@@ -1,57 +0,0 @@
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;
}
@@ -1,4 +0,0 @@
export interface IDeliveryProvider {
scheduleAutoAdvance(orderId: bigint): Promise<void>;
advanceTo(orderId: bigint, targetStatus: string): Promise<void>;
}
@@ -1,37 +0,0 @@
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,
});
}
}
@@ -1,6 +0,0 @@
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';
@@ -1,70 +0,0 @@
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 {}
@@ -1,68 +0,0 @@
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);
}
@@ -1,83 +0,0 @@
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;
}
}
@@ -1,9 +0,0 @@
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 {}
@@ -1,441 +0,0 @@
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 };
}
}
}
@@ -1,39 +0,0 @@
import { createHash } from 'node:crypto';
const LBS_HOST = 'https://apis.map.qq.com';
/**
* 腾讯位置服务 WebServiceAPIGET)签名 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()}`;
}
@@ -1,47 +0,0 @@
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;
}
}
@@ -1,25 +0,0 @@
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`;
}
@@ -1,112 +0,0 @@
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,
};
}
}
@@ -1,47 +0,0 @@
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 直传 hostmock 时为空 */
host?: string;
/** Base64 policymock 时为空 */
policy?: string;
/** HMAC-SHA1 签名,mock 时为空 */
signature?: string;
/** RAM AccessKeyIdmock 时为空 */
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;
}
@@ -1,20 +0,0 @@
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}`;
}
@@ -1,49 +0,0 @@
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,
};
}
}
@@ -1,22 +0,0 @@
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>;
}
@@ -1,41 +0,0 @@
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 };
}
}
@@ -1,35 +0,0 @@
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);
}
}
@@ -1,111 +0,0 @@
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,
};
}
}
@@ -1,14 +0,0 @@
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');
}
@@ -1,53 +0,0 @@
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);
}
}
@@ -1,141 +0,0 @@
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);
}
}
@@ -1,15 +0,0 @@
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>;
}
@@ -1,44 +0,0 @@
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);
}
}
@@ -1,31 +0,0 @@
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);
}
}
@@ -1,40 +0,0 @@
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;
}
@@ -1,158 +0,0 @@
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,
};
}
@@ -1,310 +0,0 @@
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_state3 确认收货;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;
}
}
@@ -1,74 +0,0 @@
/** 常见快递公司名称 → 微信运力 IDget_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;
}
@@ -1,97 +0,0 @@
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();
}
@@ -1,166 +0,0 @@
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,
},
});
}
}
@@ -1,928 +0,0 @@
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}&timestamp=${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_tokengetPhoneNumber / 发货管理等 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>;
}
}
@@ -1,85 +0,0 @@
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();
}
}
@@ -1,188 +0,0 @@
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[]>;
}
@@ -1,132 +0,0 @@
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: '韵达速递' },
];
}
}
@@ -1,125 +0,0 @@
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();
}
}
@@ -1,337 +0,0 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { WSClient, generateReqId, type WsFrame } from '@wecom/aibot-node-sdk';
import {
WECOM_BOT_ROLES,
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:
'您好,我是杜康好客【客服】助手。发送「帮助」查看:创建售后工单、查用户(需短信验证)、查快递。',
TECH_SUPPORT:
'您好,我是杜康好客【技术支持】助手。发送「帮助」查看:创建技术支持工单、查看开发进度。',
TEAM_ASSISTANT:
'您好,我是杜康好客【团队助手】。发送「帮助」或「手册 关键词」查询系统使用说明。',
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;
};
function isWecomRole(v: string): v is WecomBotRole {
return (WECOM_BOT_ROLES as readonly string[]).includes(v);
}
@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;
aiEnabled: boolean;
llmConfigId: bigint | null;
knowledgeBaseId: bigint | null;
enabled: boolean;
}): WecomBotRuntimeConfig {
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
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),
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();
try {
if (!content || lower === '帮助' || lower === 'help' || content === '?' || content === '') {
await this.replyText(client, frame, this.actions.buildHelp(cfg));
return;
}
if (lower === '状态' || lower === 'status' || lower === 'ping') {
await this.replyText(client, frame, this.formatStatusMarkdown());
return;
}
const useAi = cfg.aiEnabled && !!cfg.llmConfigId;
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
skipNaturalFallback: useAi,
});
if (reply != null) {
await this.replyText(client, frame, reply);
return;
}
if (useAi) {
const aiReply = await this.ai.replyIfConfigured(cfg, content);
await this.replyText(
client,
frame,
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
);
return;
}
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;
}
}

Some files were not shown because too many files have changed in this diff Show More