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:
@@ -61,6 +61,34 @@ function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loadin
|
||||
);
|
||||
}
|
||||
|
||||
function WecomAlertTestButton() {
|
||||
const [testing, setTesting] = useState(false);
|
||||
async function onTest() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await request<{ ok: boolean; message: string }>(
|
||||
'/admin/system-config/wecom-alert/test',
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
message.success(res.message || '已发送测试告警');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
<Button size="small" loading={testing} onClick={() => void onTest()}>
|
||||
发送测试告警
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
需已配置 WECOM_ALERT_WEBHOOK_URL 并开启上方开关
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: SystemConfigFieldMeta,
|
||||
configuredSecrets: string[],
|
||||
@@ -263,6 +291,8 @@ export default function SystemSettingsPage() {
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : f.key === 'WECOM_ALERT_ENABLED' ? (
|
||||
<WecomAlertTestButton />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 已为 Global,Health 直接注入探活 */
|
||||
@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 {
|
||||
|
||||
+23
-1
@@ -25,7 +25,7 @@
|
||||
14. [配送单](#14-配送单)
|
||||
15. [好客权益](#15-好客权益)
|
||||
16. [系统设置](#16-系统设置)
|
||||
17. [企业微信对接](#17-企业微信对接)
|
||||
17. [企业微信对接](#17-企业微信对接)(含 [§17.6 运营告警 Webhook](#176-运营告警-webhook群机器人))
|
||||
|
||||
---
|
||||
|
||||
@@ -898,6 +898,28 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
||||
- Bot Secret 仅 HQ 创建/编辑时写入,列表不回明文;泄露后应在企微后台重置并更新 HQ 配置后重载
|
||||
- 工作时间话术与 PRD OPT-012 对齐(上线后 3 日内更新)
|
||||
|
||||
### 17.6 运营告警 Webhook(群机器人)
|
||||
|
||||
与 §17.2 **智能机器人长连接**(会话内指令)不同:本能力用企业微信 **群机器人 Webhook** 单向推送异常,不依赖 BotID/Secret。
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `WECOM_ALERT_ENABLED` | 总开关(HQ 功能开关可改;亦可写 `.env`) |
|
||||
| `WECOM_ALERT_WEBHOOK_URL` | 群机器人 Webhook 完整 URL,**仅服务器 `.env`**,勿提交仓库 |
|
||||
| `WECOM_ALERT_ENV_LABEL` | 消息前缀环境名(local / staging / production) |
|
||||
|
||||
启用步骤:企微群 → 添加群机器人 → 复制 Webhook → 写入服务器环境变量 → 打开 `WECOM_ALERT_ENABLED`。
|
||||
|
||||
| 类别 | 典型触发 |
|
||||
|------|----------|
|
||||
| API | 未捕获异常 / HTTP ≥500 / Prisma 已知错误 |
|
||||
| 支付 | 金额 `<1` 或 `>5000` 元;1 分钟尝试 `>3`;1 分钟失败 `>5`;回调失败;金额不一致 |
|
||||
| 核销 | 金额 `<1` 或 `>1000` 元;1 分钟尝试 `>3`;1 分钟失败 `>5`;弱网达阈值;新建补核销待办 |
|
||||
| 订单 | 超时未关待付款;待发货 >24h;配送中 >48h(每 5 分钟扫描) |
|
||||
| 运维 | MySQL/Redis 探活失败;结算 Cron 失败;新建售后/技术支持工单 |
|
||||
|
||||
告警经 Redis 去重(同指纹默认 10 分钟内不重复推);**只通知、不拦截**交易与核销。Webhook key 泄露时在企微后台重置机器人并更新 `.env`。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 端职责矩阵
|
||||
|
||||
Reference in New Issue
Block a user