feat(ops): WeCom webhook alerts for pay/redeem anomalies

Add outbound group-bot alerts, rate/amount rules, stuck-order cron, HQ test button, and health db/redis checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 16:01:30 +08:00
parent cb6ecaaba6
commit 3328c52cb4
27 changed files with 1036 additions and 103 deletions
+5
View File
@@ -63,6 +63,11 @@ WX_MINI_MSG_AES_KEY=
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false
# 运营告警:企业微信群机器人 Webhook(单向推送,与智能机器人长连接无关)
WECOM_ALERT_ENABLED=false
WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=local
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
# 未开签名校验时可只填 KEY;SK 勿下发前端
@@ -51,6 +51,11 @@ WX_MINI_MSG_AES_KEY=
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
WECOM_AIBOT_ENABLED=false
# 运营告警:企业微信群机器人 Webhook
WECOM_ALERT_ENABLED=false
WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=production
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev
+5
View File
@@ -51,6 +51,11 @@ WX_MINI_MSG_AES_KEY=
WECOM_AIBOT_ENABLED=false
# 运营告警:企业微信群机器人 Webhook
WECOM_ALERT_ENABLED=false
WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=staging
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev
+2
View File
@@ -4,6 +4,7 @@ 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';
@@ -35,6 +36,7 @@ import { WecomModule } from './integrations/wecom/wecom.module';
SystemConfigModule,
GeoModule,
RedisModule,
AlertModule,
HealthModule,
IamModule,
CatalogModule,
@@ -3,6 +3,8 @@ 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 };
@@ -11,6 +13,8 @@ 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')
@@ -19,11 +23,13 @@ export class WechatPayCallbackController {
@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,
@@ -32,6 +38,15 @@ export class WechatPayCallbackController {
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,10 +1,14 @@
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { PrismaService } from '../common/prisma/prisma.module';
import { AlertService } from '../common/alert/alert.service';
@Controller('callbacks/wechat')
export class WechatRefundCallbackController {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
@Post('refund')
async refundNotify(
@@ -38,6 +42,14 @@ export class WechatRefundCallbackController {
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: message,
dedupeKey: `refund_callback_fail|${message.slice(0, 40)}`,
dedupeTtlSec: 120,
});
return res.status(500).json({ code: 'FAIL', message });
}
}
@@ -0,0 +1,30 @@
/** 告警级别 */
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;
@@ -0,0 +1,17 @@
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 {}
@@ -0,0 +1,95 @@
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');
}
@@ -0,0 +1,213 @@
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');
}
@@ -4,8 +4,10 @@ import {
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) {
@@ -24,10 +26,16 @@ function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): st
}
@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();
@@ -36,9 +44,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
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: Array.isArray(message) ? message.join(', ') : message,
message: msgText,
data: null,
});
return;
@@ -46,6 +65,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
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),
@@ -64,9 +91,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
}
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: exception instanceof Error ? exception.message : 'Internal server error',
message: errMsg,
data: null,
});
}
@@ -97,6 +97,7 @@ export const HqOperationAction = {
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];
@@ -199,6 +200,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
STORE_PAYOUT: '门店打款确认',
};
@@ -59,6 +59,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
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 },
@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* 企业微信群机器人 Webhook 出站(单向告警,与智能机器人长连接无关)。
* @see https://developer.work.weixin.qq.com/document/path/91770
*/
@Injectable()
export class WecomWebhookAlertService {
private readonly logger = new Logger(WecomWebhookAlertService.name);
isEnabled(): boolean {
return (
process.env.WECOM_ALERT_ENABLED === 'true' &&
!!(process.env.WECOM_ALERT_WEBHOOK_URL || '').trim()
);
}
async sendMarkdown(content: string): Promise<boolean> {
if (!this.isEnabled()) return false;
const url = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { content: content.slice(0, 4000) },
}),
});
const data = (await res.json().catch(() => ({}))) as {
errcode?: number;
errmsg?: string;
};
if (!res.ok || (data.errcode != null && data.errcode !== 0)) {
this.logger.warn(
`wecom webhook failed: HTTP ${res.status} errcode=${data.errcode} ${data.errmsg ?? ''}`,
);
return false;
}
return true;
} catch (e) {
this.logger.warn(
`wecom webhook network error: ${e instanceof Error ? e.message : String(e)}`,
);
return false;
}
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { TradeModule } from '../modules/trade/trade.module';
import { SettlementModule } from '../modules/settlement/settlement.module';
import { DeliveryProcessor } from './delivery.processor';
import { SettlementScheduler } from './settlement.scheduler';
import { MonitorScheduler } from './monitor.scheduler';
import { DELIVERY_QUEUE } from './jobs.constants';
@Module({
@@ -14,6 +15,6 @@ import { DELIVERY_QUEUE } from './jobs.constants';
TradeModule,
SettlementModule,
],
providers: [DeliveryProcessor, SettlementScheduler],
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler],
})
export class JobsModule {}
@@ -0,0 +1,149 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../common/prisma/prisma.module';
import { RedisService } from '../common/redis/redis.service';
import { AlertService } from '../common/alert/alert.service';
import { ALERT_STUCK_ORDER } from '../common/alert/alert.constants';
@Injectable()
export class MonitorScheduler {
private readonly logger = new Logger(MonitorScheduler.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly alert: AlertService,
) {}
/** 每分钟:MySQL / Redis 探活 */
@Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' })
async checkInfra() {
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch (e) {
this.logger.error('MySQL ping failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'infra',
title: 'MySQL 不可用',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: 'infra_mysql_down',
dedupeTtlSec: 120,
});
}
try {
const pong = await this.redis.client.ping();
if (pong !== 'PONG') {
throw new Error(`unexpected ping reply: ${pong}`);
}
} catch (e) {
this.logger.error('Redis ping failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'infra',
title: 'Redis 不可用',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: 'infra_redis_down',
dedupeTtlSec: 120,
});
}
}
/** 每 5 分钟:卡住订单扫描 */
@Cron('*/5 * * * *', { timeZone: 'Asia/Shanghai' })
async scanStuckOrders() {
const now = new Date();
const hourBucket = Math.floor(now.getTime() / 3_600_000);
const sample = ALERT_STUCK_ORDER.sampleLimit;
try {
const expiredUnpaid = await this.prisma.order.findMany({
where: {
status: 'PENDING_PAY',
payExpireAt: { lt: now },
},
select: { orderNo: true },
take: sample,
orderBy: { payExpireAt: 'asc' },
});
const expiredUnpaidTotal = await this.prisma.order.count({
where: { status: 'PENDING_PAY', payExpireAt: { lt: now } },
});
if (expiredUnpaidTotal > 0) {
this.alert.notify({
level: 'P1',
category: 'order',
title: '超时未关待付款订单',
detail: `${expiredUnpaidTotal}\n样例:${expiredUnpaid.map((o) => o.orderNo).join(', ')}`,
dedupeKey: `stuck_pending_pay|${hourBucket}`,
});
}
const shipBefore = new Date(
now.getTime() - ALERT_STUCK_ORDER.pendingShipHours * 3600_000,
);
const stuckShip = await this.prisma.order.findMany({
where: {
status: 'PENDING_SHIP',
paidAt: { lt: shipBefore },
},
select: { orderNo: true },
take: sample,
orderBy: { paidAt: 'asc' },
});
const stuckShipTotal = await this.prisma.order.count({
where: { status: 'PENDING_SHIP', paidAt: { lt: shipBefore } },
});
if (stuckShipTotal > 0) {
this.alert.notify({
level: 'P1',
category: 'order',
title: `待发货超过 ${ALERT_STUCK_ORDER.pendingShipHours}h`,
detail: `${stuckShipTotal}\n样例:${stuckShip.map((o) => o.orderNo).join(', ')}`,
dedupeKey: `stuck_pending_ship|${hourBucket}`,
});
}
const deliveryBefore = new Date(
now.getTime() - ALERT_STUCK_ORDER.inDeliveryHours * 3600_000,
);
const stuckDelivery = await this.prisma.order.findMany({
where: {
status: { in: ['OUT_WAREHOUSE', 'PENDING_RECEIVE'] },
updatedAt: { lt: deliveryBefore },
},
select: { orderNo: true, status: true },
take: sample,
orderBy: { updatedAt: 'asc' },
});
const stuckDeliveryTotal = await this.prisma.order.count({
where: {
status: { in: ['OUT_WAREHOUSE', 'PENDING_RECEIVE'] },
updatedAt: { lt: deliveryBefore },
},
});
if (stuckDeliveryTotal > 0) {
this.alert.notify({
level: 'P1',
category: 'order',
title: `配送中超过 ${ALERT_STUCK_ORDER.inDeliveryHours}h`,
detail: `${stuckDeliveryTotal}\n样例:${stuckDelivery
.map((o) => `${o.orderNo}(${o.status})`)
.join(', ')}`,
dedupeKey: `stuck_in_delivery|${hourBucket}`,
});
}
} catch (e) {
this.logger.error('stuck order scan failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'job',
title: '卡住订单扫描失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: 'stuck_scan_fail',
dedupeTtlSec: 300,
});
}
}
}
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { SettlementService } from '../modules/settlement/settlement.service';
import { AlertService } from '../common/alert/alert.service';
/**
* 财务对账单定时任务(Asia/Shanghai
@@ -11,7 +12,10 @@ import { SettlementService } from '../modules/settlement/settlement.service';
export class SettlementScheduler {
private readonly logger = new Logger(SettlementScheduler.name);
constructor(private readonly settlementService: SettlementService) {}
constructor(
private readonly settlementService: SettlementService,
private readonly alert: AlertService,
) {}
@Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' })
async handleDailyBills() {
@@ -21,12 +25,26 @@ export class SettlementScheduler {
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
} catch (e) {
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'job',
title: '酒厂日账单任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`,
});
}
try {
const store = await this.settlementService.generateStoreBillsForDay();
this.logger.log(`Store bills: ${JSON.stringify(store)}`);
} catch (e) {
this.logger.error('Store bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'job',
title: '门店日账单任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`,
});
}
}
@@ -38,16 +56,48 @@ export class SettlementScheduler {
this.logger.log(
`Partner bills: total=${result.total} success=${result.success} failed=${result.failed}`,
);
if (result.failed > 0) {
this.alert.notify({
level: 'P0',
category: 'job',
title: '合伙人月账单部分失败',
detail: `total=${result.total} success=${result.success} failed=${result.failed}`,
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
});
}
} catch (e) {
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'job',
title: '合伙人月账单任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
});
}
try {
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
this.logger.log(
`Logistics bills: total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
);
if (logistics.failed > 0) {
this.alert.notify({
level: 'P0',
category: 'job',
title: '物流月对账部分失败',
detail: `total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
});
}
} catch (e) {
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P0',
category: 'job',
title: '物流月对账任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_logistics_bill|${new Date().toISOString().slice(0, 7)}`,
});
}
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
import { AlertService } from './common/alert/alert.service';
async function bootstrap() {
const preloaded = await preloadSystemConfigEnv().catch((e) => {
@@ -35,7 +36,7 @@ async function bootstrap() {
}),
);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
app.useGlobalInterceptors(new ResponseInterceptor());
const port = process.env.PORT || 3000;
const cfg = loadAppConfig();
@@ -7,6 +7,7 @@ import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import type {
CreateSupportTicketDto,
RejectSupportTicketDto,
@@ -20,7 +21,10 @@ function generateSupportTicketNo() {
@Injectable()
export class SupportTicketService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async create(
dto: CreateSupportTicketDto,
@@ -38,6 +42,13 @@ export class SupportTicketService {
creatorName: creator.name,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建技术支持工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n标题 ${ticket.title}\n创建人 ${creator.name}`,
dedupeKey: `support_ticket_create|${ticket.ticketNo}`,
});
return serializeBigInt(ticket);
}
@@ -3,6 +3,7 @@ import type { ActorType, TicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import type { TicketListQueryDto } from './dto/common-query.dto';
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
@@ -12,7 +13,10 @@ function generateTicketNo() {
@Injectable()
export class TicketService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async create(dto: CreateTicketDto) {
const ticket = await this.prisma.commonTicket.create({
@@ -27,6 +31,13 @@ export class TicketService {
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建售后工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n关联 ${ticket.refType}:${ticket.refId}\n${dto.remark ?? ''}`,
dedupeKey: `ticket_create|${ticket.ticketNo}`,
});
return serializeBigInt(ticket);
}
@@ -1,9 +1,35 @@
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
check() {
return { status: 'ok', service: 'dukang-api', version: 'prev1' };
async check() {
let db: 'ok' | 'error' = 'ok';
let redis: 'ok' | 'error' = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
db = 'error';
}
try {
const pong = await this.redis.client.ping();
if (pong !== 'PONG') redis = 'error';
} catch {
redis = 'error';
}
const status = db === 'ok' && redis === 'ok' ? 'ok' : 'degraded';
return {
status,
service: 'dukang-api',
version: 'prev1',
checks: { db, redis },
};
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
/** Prisma / Redis 已为 GlobalHealth 直接注入探活 */
@Module({ controllers: [HealthController] })
export class HealthModule {}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
import {
SYSTEM_CONFIG_GROUP_PERMISSION,
@@ -18,6 +18,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { SystemConfigService } from '../../common/system-config/system-config.service';
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
import { AlertService } from '../../common/alert/alert.service';
@Controller('admin/system-config')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@@ -26,6 +27,7 @@ export class AdminSystemConfigController {
private readonly systemConfig: SystemConfigService,
private readonly permissions: HqPermissionsResolver,
private readonly wecomAibot: WecomAibotService,
private readonly alert: AlertService,
) {}
@Get()
@@ -77,6 +79,22 @@ export class AdminSystemConfigController {
importEnv() {
return this.systemConfig.importFromProcessEnv();
}
/** 向企微群机器人发送一条测试告警 */
@Post('wecom-alert/test')
@RequireAnySystemSettings()
@HqOperation({
action: HqOperationAction.WECOM_ALERT_TEST,
refType: 'SYSTEM_CONFIG',
batch: true,
})
async testWecomAlert() {
const result = await this.alert.sendTestAlert();
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
}
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
@@ -28,6 +28,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
type TokenPayload = {
userId: string;
@@ -70,6 +71,7 @@ export class RedeemService {
private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService,
private readonly authService: AuthService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
) {}
private maskPhoneForStore(phone: string) {
@@ -414,36 +416,48 @@ export class RedeemService {
}
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
const meta = { storeId, userId: undefined as string | undefined };
try {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
}
meta.userId = session.userId;
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
const normalizedAllocations = session.allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const amount = Number(session.amount);
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - amount) > 0.001) {
throw new BadRequestException('核销分摊数据异常');
}
await this.validateAllocations(normalizedAllocations);
this.payRedeemAnomaly.onRedeemAttempt(amount, {
storeId,
userId: session.userId,
});
const record = await this.executeRedeem(
account,
BigInt(session.userId),
amount,
normalizedAllocations,
{ channel: 'phone', sessionId },
);
await this.redis.del(this.phoneSessionKey(sessionId));
return serializeBigInt(record);
} catch (e) {
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '手机号核销失败', meta);
throw e;
}
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
const normalizedAllocations = session.allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const amount = Number(session.amount);
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - amount) > 0.001) {
throw new BadRequestException('核销分摊数据异常');
}
await this.validateAllocations(normalizedAllocations);
const record = await this.executeRedeem(
account,
BigInt(session.userId),
amount,
normalizedAllocations,
{ channel: 'phone', sessionId },
);
await this.redis.del(this.phoneSessionKey(sessionId));
return serializeBigInt(record);
}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -593,75 +607,87 @@ export class RedeemService {
}
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
const meta = { storeId, userId: undefined as string | undefined };
try {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
const existingResult = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
if (existingResult) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id: BigInt(existingResult.recordId) },
});
if (record) {
return serializeBigInt(record);
const existingResult = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
if (existingResult) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id: BigInt(existingResult.recordId) },
});
if (record) {
return serializeBigInt(record);
}
}
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const allocations =
cached.allocations ??
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
const allocations =
cached.allocations ??
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
const normalizedAllocations = allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const tokenAmount = Number(cached.amount);
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
throw new BadRequestException('核销码数据异常');
}
const normalizedAllocations = allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const tokenAmount = Number(cached.amount);
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
throw new BadRequestException('核销码数据异常');
}
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - tokenAmount) > 0.001) {
throw new BadRequestException('核销码数据异常');
}
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - tokenAmount) > 0.001) {
throw new BadRequestException('核销码数据异常');
}
await this.validateAllocations(normalizedAllocations);
await this.validateAllocations(normalizedAllocations);
const record = await this.executeRedeem(
account,
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
meta.userId = cached.userId;
this.payRedeemAnomaly.onRedeemAttempt(tokenAmount, {
storeId,
userId: cached.userId,
amount: tokenAmount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${token}`);
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
});
return serializeBigInt(record);
const record = await this.executeRedeem(
account,
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
userId: cached.userId,
amount: tokenAmount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${token}`);
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
return serializeBigInt(record);
} catch (e) {
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '核销失败', meta);
throw e;
}
}
private netFailKey(storeAccountId: bigint, token: string) {
@@ -705,6 +731,11 @@ export class RedeemService {
},
});
this.payRedeemAnomaly.onRedeemFail(
body.message?.slice(0, 200) || `弱网核销失败:${body.errorClass}/${body.step}`,
{ storeId },
);
if (thresholdReached && body.errorClass === 'NETWORK') {
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
storeId,
@@ -716,6 +747,11 @@ export class RedeemService {
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
},
});
this.payRedeemAnomaly.notifyRedeemOps(
'弱网核销达阈值',
`门店 ${storeId}\n失败次数 ${failCount}(阈值 ${REDEEM_WEAKNET_FAIL_THRESHOLD}\ntoken …${token.slice(-8)}`,
`redeem_weaknet|${storeId}|${token.slice(-8)}`,
);
}
return {
@@ -822,6 +858,12 @@ export class RedeemService {
},
});
this.payRedeemAnomaly.notifyRedeemOps(
'新建补核销待办',
`待办 ${pending.pendingNo}\n门店 ${account.storeId}\n金额 ${Number(pending.amount)}\n失败次数 ${failCount}`,
`redeem_pending|${pending.pendingNo}`,
);
return serializeBigInt({
pendingId: pending.id,
pendingNo: pending.pendingNo,
@@ -32,6 +32,8 @@ import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/e
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import type { Request } from 'express';
@Injectable()
@@ -51,6 +53,8 @@ export class TradeService {
@Inject(forwardRef(() => FulfillmentService))
private readonly fulfillmentService: FulfillmentService,
private readonly wechatOrderShipping: WechatOrderShippingService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
) {}
async preview(
@@ -265,14 +269,33 @@ export class TradeService {
throw new BadRequestException('订单状态不可支付');
}
const payAmountYuan = Number(order.payAmount);
this.payRedeemAnomaly.onPayAttempt(payAmountYuan, {
orderNo: order.orderNo,
userId,
});
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const openId = user?.wxOpenId ?? undefined;
const appConfig = loadAppConfig();
if (!appConfig.mockPay && !openId) {
this.payRedeemAnomaly.onPayFail(WECHAT_AUTH_REQUIRED, {
orderNo: order.orderNo,
userId,
});
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
}
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
const payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '拉起支付失败', {
orderNo: order.orderNo,
userId,
});
throw e;
}
if (payResult.mode === 'jsapi') {
return {
@@ -330,6 +353,11 @@ export class TradeService {
await this.afterOrderPaid(order.id);
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
orderNo: order.orderNo,
userId,
});
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
@@ -380,8 +408,18 @@ export class TradeService {
return { orderId: order.id.toString(), alreadyPaid: true };
}
const expectedFen = Math.round(Number(order.payAmount) * 100);
const payAmountYuan = Number(order.payAmount);
const expectedFen = Math.round(payAmountYuan * 100);
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
const reason = `支付金额与订单不符 expected=${expectedFen} got=${params.amountFen}`;
this.payRedeemAnomaly.onPayFail(reason, { orderNo: order.orderNo, userId: order.userId });
this.alert.notify({
level: 'P0',
category: 'pay',
title: '支付金额不一致',
detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen}`,
dedupeKey: `pay_amount_mismatch|${order.orderNo}`,
});
throw new BadRequestException('支付金额与订单不符');
}
@@ -449,6 +487,10 @@ export class TradeService {
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
if (refreshed?.payStatus === 'PAID') {
await this.afterOrderPaid(order.id);
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
orderNo: order.orderNo,
userId: order.userId,
});
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
@@ -1653,16 +1695,34 @@ export class TradeService {
throw new BadRequestException('订单已超时未支付');
}
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
orderNo: order.orderNo,
userId: order.userId,
});
let openId: string | undefined;
if (payMethod === 'JSAPI') {
openId = primary.wxOpenId ?? undefined;
const appConfig = loadAppConfig();
if (!appConfig.mockPay && !openId) {
this.payRedeemAnomaly.onPayFail('代付未绑定微信', {
orderNo: order.orderNo,
userId: order.userId,
});
throw new BadRequestException('请先在微信内登录并绑定微信后再代付');
}
}
const payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod);
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod);
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '代付拉起失败', {
orderNo: order.orderNo,
userId: order.userId,
});
throw e;
}
if (payResult.mode === 'native') {
return {
@@ -1709,8 +1769,26 @@ export class TradeService {
throw new BadRequestException('订单已超时未支付');
}
const payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE');
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
orderNo: order.orderNo,
userId: order.userId,
});
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE');
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '总部代付拉起失败', {
orderNo: order.orderNo,
userId: order.userId,
});
throw e;
}
if (payResult.mode !== 'native') {
this.payRedeemAnomaly.onPayFail('无法生成收款码', {
orderNo: order.orderNo,
userId: order.userId,
});
throw new BadRequestException('无法生成收款码');
}
return {