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(
|
function renderField(
|
||||||
field: SystemConfigFieldMeta,
|
field: SystemConfigFieldMeta,
|
||||||
configuredSecrets: string[],
|
configuredSecrets: string[],
|
||||||
@@ -263,6 +291,8 @@ export default function SystemSettingsPage() {
|
|||||||
meta.configuredSecrets,
|
meta.configuredSecrets,
|
||||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||||
|
) : f.key === 'WECOM_ALERT_ENABLED' ? (
|
||||||
|
<WecomAlertTestButton />
|
||||||
) : undefined,
|
) : undefined,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|
||||||
|
# 运营告警:企业微信群机器人 Webhook(单向推送,与智能机器人长连接无关)
|
||||||
|
WECOM_ALERT_ENABLED=false
|
||||||
|
WECOM_ALERT_WEBHOOK_URL=
|
||||||
|
WECOM_ALERT_ENV_LABEL=local
|
||||||
|
|
||||||
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
|
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
|
||||||
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
|
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
|
||||||
# 未开签名校验时可只填 KEY;SK 勿下发前端
|
# 未开签名校验时可只填 KEY;SK 勿下发前端
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
||||||
WECOM_AIBOT_ENABLED=false
|
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_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
OSS_BUCKET=dukang-dev
|
OSS_BUCKET=dukang-dev
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
|
|
||||||
WECOM_AIBOT_ENABLED=false
|
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_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
OSS_BUCKET=dukang-dev
|
OSS_BUCKET=dukang-dev
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
import { PrismaModule } from './common/prisma/prisma.module';
|
import { PrismaModule } from './common/prisma/prisma.module';
|
||||||
import { GeoModule } from './common/geo/geo.module';
|
import { GeoModule } from './common/geo/geo.module';
|
||||||
import { RedisModule } from './common/redis/redis.module';
|
import { RedisModule } from './common/redis/redis.module';
|
||||||
|
import { AlertModule } from './common/alert/alert.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { IamModule } from './modules/iam/iam.module';
|
import { IamModule } from './modules/iam/iam.module';
|
||||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
@@ -35,6 +36,7 @@ import { WecomModule } from './integrations/wecom/wecom.module';
|
|||||||
SystemConfigModule,
|
SystemConfigModule,
|
||||||
GeoModule,
|
GeoModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
AlertModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
IamModule,
|
IamModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Request, Response } from 'express';
|
|||||||
import { TradeService } from '../modules/trade/trade.service';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
|
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
|
||||||
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
|
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 };
|
type RawBodyRequest = Request & { body: Buffer };
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ export class WechatPayCallbackController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tradeService: TradeService,
|
private readonly tradeService: TradeService,
|
||||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
|
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('pay')
|
@Post('pay')
|
||||||
@@ -19,11 +23,13 @@ export class WechatPayCallbackController {
|
|||||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
|
let orderNo: string | undefined;
|
||||||
try {
|
try {
|
||||||
const rawBody =
|
const rawBody =
|
||||||
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
|
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
|
||||||
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
|
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
|
||||||
const notify = await this.wechat.parsePayNotification(headers, rawBody);
|
const notify = await this.wechat.parsePayNotification(headers, rawBody);
|
||||||
|
orderNo = notify.outTradeNo;
|
||||||
await this.tradeService.handlePaySuccess({
|
await this.tradeService.handlePaySuccess({
|
||||||
orderNo: notify.outTradeNo,
|
orderNo: notify.outTradeNo,
|
||||||
transactionId: notify.transactionId,
|
transactionId: notify.transactionId,
|
||||||
@@ -32,6 +38,15 @@ export class WechatPayCallbackController {
|
|||||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '处理失败';
|
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 });
|
return res.status(500).json({ code: 'FAIL', message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { PrismaService } from '../common/prisma/prisma.module';
|
import { PrismaService } from '../common/prisma/prisma.module';
|
||||||
|
import { AlertService } from '../common/alert/alert.service';
|
||||||
|
|
||||||
@Controller('callbacks/wechat')
|
@Controller('callbacks/wechat')
|
||||||
export class WechatRefundCallbackController {
|
export class WechatRefundCallbackController {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('refund')
|
@Post('refund')
|
||||||
async refundNotify(
|
async refundNotify(
|
||||||
@@ -38,6 +42,14 @@ export class WechatRefundCallbackController {
|
|||||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '处理失败';
|
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 });
|
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,
|
ExceptionFilter,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AlertService } from '../alert/alert.service';
|
||||||
|
|
||||||
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
|
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
|
||||||
switch (exception.code) {
|
switch (exception.code) {
|
||||||
@@ -24,10 +26,16 @@ function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
|
@Injectable()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly alert: AlertService) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse();
|
const response = ctx.getResponse();
|
||||||
|
const request = ctx.getRequest<{ method?: string; url?: string }>();
|
||||||
|
const method = request?.method ?? '?';
|
||||||
|
const path = request?.url ?? '?';
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
const status = exception.getStatus();
|
const status = exception.getStatus();
|
||||||
@@ -36,9 +44,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
typeof res === 'string'
|
typeof res === 'string'
|
||||||
? res
|
? res
|
||||||
: (res as { message?: string | string[] }).message || exception.message;
|
: (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({
|
response.status(status).json({
|
||||||
code: status,
|
code: status,
|
||||||
message: Array.isArray(message) ? message.join(', ') : message,
|
message: msgText,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -46,6 +65,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
console.error(exception);
|
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({
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
code: 400,
|
code: 400,
|
||||||
message: prismaErrorMessage(exception),
|
message: prismaErrorMessage(exception),
|
||||||
@@ -64,9 +91,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(exception);
|
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({
|
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: exception instanceof Error ? exception.message : 'Internal server error',
|
message: errMsg,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const HqOperationAction = {
|
|||||||
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
|
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
|
||||||
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
|
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
|
||||||
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
|
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
|
||||||
|
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
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_UPDATE]: '更新系统配置',
|
||||||
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
|
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
|
||||||
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
|
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
|
||||||
|
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
|
||||||
STORE_PAYOUT: '门店打款确认',
|
STORE_PAYOUT: '门店打款确认',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '总开关。开启后连接 HQ「企微机器人」模块中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
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_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
||||||
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
{ key: 'ALIYUN_SMS_TEMPLATE_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 { SettlementModule } from '../modules/settlement/settlement.module';
|
||||||
import { DeliveryProcessor } from './delivery.processor';
|
import { DeliveryProcessor } from './delivery.processor';
|
||||||
import { SettlementScheduler } from './settlement.scheduler';
|
import { SettlementScheduler } from './settlement.scheduler';
|
||||||
|
import { MonitorScheduler } from './monitor.scheduler';
|
||||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -14,6 +15,6 @@ import { DELIVERY_QUEUE } from './jobs.constants';
|
|||||||
TradeModule,
|
TradeModule,
|
||||||
SettlementModule,
|
SettlementModule,
|
||||||
],
|
],
|
||||||
providers: [DeliveryProcessor, SettlementScheduler],
|
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler],
|
||||||
})
|
})
|
||||||
export class JobsModule {}
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { SettlementService } from '../modules/settlement/settlement.service';
|
import { SettlementService } from '../modules/settlement/settlement.service';
|
||||||
|
import { AlertService } from '../common/alert/alert.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 财务对账单定时任务(Asia/Shanghai)
|
* 财务对账单定时任务(Asia/Shanghai)
|
||||||
@@ -11,7 +12,10 @@ import { SettlementService } from '../modules/settlement/settlement.service';
|
|||||||
export class SettlementScheduler {
|
export class SettlementScheduler {
|
||||||
private readonly logger = new Logger(SettlementScheduler.name);
|
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' })
|
@Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' })
|
||||||
async handleDailyBills() {
|
async handleDailyBills() {
|
||||||
@@ -21,12 +25,26 @@ export class SettlementScheduler {
|
|||||||
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
|
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : 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 {
|
try {
|
||||||
const store = await this.settlementService.generateStoreBillsForDay();
|
const store = await this.settlementService.generateStoreBillsForDay();
|
||||||
this.logger.log(`Store bills: ${JSON.stringify(store)}`);
|
this.logger.log(`Store bills: ${JSON.stringify(store)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Store bill job failed', e instanceof Error ? e.stack : 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(
|
this.logger.log(
|
||||||
`Partner bills: total=${result.total} success=${result.success} failed=${result.failed}`,
|
`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) {
|
} catch (e) {
|
||||||
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : 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 {
|
try {
|
||||||
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
|
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Logistics bills: total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
|
`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) {
|
} catch (e) {
|
||||||
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : 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 { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
||||||
|
import { AlertService } from './common/alert/alert.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
||||||
@@ -35,7 +36,7 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const cfg = loadAppConfig();
|
const cfg = loadAppConfig();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AlertService } from '../../common/alert/alert.service';
|
||||||
import type {
|
import type {
|
||||||
CreateSupportTicketDto,
|
CreateSupportTicketDto,
|
||||||
RejectSupportTicketDto,
|
RejectSupportTicketDto,
|
||||||
@@ -20,7 +21,10 @@ function generateSupportTicketNo() {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportTicketService {
|
export class SupportTicketService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
dto: CreateSupportTicketDto,
|
dto: CreateSupportTicketDto,
|
||||||
@@ -38,6 +42,13 @@ export class SupportTicketService {
|
|||||||
creatorName: creator.name,
|
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);
|
return serializeBigInt(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ActorType, TicketType } from '@prisma/client';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
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 { TicketListQueryDto } from './dto/common-query.dto';
|
||||||
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
|
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
|
||||||
|
|
||||||
@@ -12,7 +13,10 @@ function generateTicketNo() {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TicketService {
|
export class TicketService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateTicketDto) {
|
async create(dto: CreateTicketDto) {
|
||||||
const ticket = await this.prisma.commonTicket.create({
|
const ticket = await this.prisma.commonTicket.create({
|
||||||
@@ -27,6 +31,13 @@ export class TicketService {
|
|||||||
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
|
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);
|
return serializeBigInt(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
check() {
|
async check() {
|
||||||
return { status: 'ok', service: 'dukang-api', version: 'prev1' };
|
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 { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
/** Prisma / Redis 已为 Global,Health 直接注入探活 */
|
||||||
@Module({ controllers: [HealthController] })
|
@Module({ controllers: [HealthController] })
|
||||||
export class HealthModule {}
|
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 type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
SYSTEM_CONFIG_GROUP_PERMISSION,
|
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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
|
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
|
||||||
|
import { AlertService } from '../../common/alert/alert.service';
|
||||||
|
|
||||||
@Controller('admin/system-config')
|
@Controller('admin/system-config')
|
||||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
@@ -26,6 +27,7 @@ export class AdminSystemConfigController {
|
|||||||
private readonly systemConfig: SystemConfigService,
|
private readonly systemConfig: SystemConfigService,
|
||||||
private readonly permissions: HqPermissionsResolver,
|
private readonly permissions: HqPermissionsResolver,
|
||||||
private readonly wecomAibot: WecomAibotService,
|
private readonly wecomAibot: WecomAibotService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -77,6 +79,22 @@ export class AdminSystemConfigController {
|
|||||||
importEnv() {
|
importEnv() {
|
||||||
return this.systemConfig.importFromProcessEnv();
|
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 {
|
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
|||||||
import { SettlementService } from '../settlement/settlement.service';
|
import { SettlementService } from '../settlement/settlement.service';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
import { AuthService } from '../iam/auth.service';
|
import { AuthService } from '../iam/auth.service';
|
||||||
|
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
|
||||||
|
|
||||||
type TokenPayload = {
|
type TokenPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -70,6 +71,7 @@ export class RedeemService {
|
|||||||
private readonly benefitService: BenefitService,
|
private readonly benefitService: BenefitService,
|
||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private maskPhoneForStore(phone: string) {
|
private maskPhoneForStore(phone: string) {
|
||||||
@@ -414,11 +416,14 @@ export class RedeemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
|
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
|
||||||
|
const meta = { storeId, userId: undefined as string | undefined };
|
||||||
|
try {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||||
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
|
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
|
||||||
throw new BadRequestException('请先选择核销金额并发送确认验证码');
|
throw new BadRequestException('请先选择核销金额并发送确认验证码');
|
||||||
}
|
}
|
||||||
|
meta.userId = session.userId;
|
||||||
|
|
||||||
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
|
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
|
||||||
|
|
||||||
@@ -433,6 +438,11 @@ export class RedeemService {
|
|||||||
}
|
}
|
||||||
await this.validateAllocations(normalizedAllocations);
|
await this.validateAllocations(normalizedAllocations);
|
||||||
|
|
||||||
|
this.payRedeemAnomaly.onRedeemAttempt(amount, {
|
||||||
|
storeId,
|
||||||
|
userId: session.userId,
|
||||||
|
});
|
||||||
|
|
||||||
const record = await this.executeRedeem(
|
const record = await this.executeRedeem(
|
||||||
account,
|
account,
|
||||||
BigInt(session.userId),
|
BigInt(session.userId),
|
||||||
@@ -444,6 +454,10 @@ export class RedeemService {
|
|||||||
await this.redis.del(this.phoneSessionKey(sessionId));
|
await this.redis.del(this.phoneSessionKey(sessionId));
|
||||||
|
|
||||||
return serializeBigInt(record);
|
return serializeBigInt(record);
|
||||||
|
} catch (e) {
|
||||||
|
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '手机号核销失败', meta);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||||
@@ -593,6 +607,8 @@ export class RedeemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
|
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
|
||||||
|
const meta = { storeId, userId: undefined as string | undefined };
|
||||||
|
try {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const token = body.token?.trim();
|
const token = body.token?.trim();
|
||||||
if (!token) throw new BadRequestException('请提供核销码');
|
if (!token) throw new BadRequestException('请提供核销码');
|
||||||
@@ -637,6 +653,12 @@ export class RedeemService {
|
|||||||
|
|
||||||
await this.validateAllocations(normalizedAllocations);
|
await this.validateAllocations(normalizedAllocations);
|
||||||
|
|
||||||
|
meta.userId = cached.userId;
|
||||||
|
this.payRedeemAnomaly.onRedeemAttempt(tokenAmount, {
|
||||||
|
storeId,
|
||||||
|
userId: cached.userId,
|
||||||
|
});
|
||||||
|
|
||||||
const record = await this.executeRedeem(
|
const record = await this.executeRedeem(
|
||||||
account,
|
account,
|
||||||
BigInt(cached.userId),
|
BigInt(cached.userId),
|
||||||
@@ -662,6 +684,10 @@ export class RedeemService {
|
|||||||
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
|
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
|
||||||
|
|
||||||
return serializeBigInt(record);
|
return serializeBigInt(record);
|
||||||
|
} catch (e) {
|
||||||
|
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '核销失败', meta);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private netFailKey(storeAccountId: bigint, token: string) {
|
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') {
|
if (thresholdReached && body.errorClass === 'NETWORK') {
|
||||||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
storeId,
|
storeId,
|
||||||
@@ -716,6 +747,11 @@ export class RedeemService {
|
|||||||
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
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 {
|
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({
|
return serializeBigInt({
|
||||||
pendingId: pending.id,
|
pendingId: pending.id,
|
||||||
pendingNo: pending.pendingNo,
|
pendingNo: pending.pendingNo,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/e
|
|||||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||||
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.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';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -51,6 +53,8 @@ export class TradeService {
|
|||||||
@Inject(forwardRef(() => FulfillmentService))
|
@Inject(forwardRef(() => FulfillmentService))
|
||||||
private readonly fulfillmentService: FulfillmentService,
|
private readonly fulfillmentService: FulfillmentService,
|
||||||
private readonly wechatOrderShipping: WechatOrderShippingService,
|
private readonly wechatOrderShipping: WechatOrderShippingService,
|
||||||
|
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async preview(
|
async preview(
|
||||||
@@ -265,14 +269,33 @@ export class TradeService {
|
|||||||
throw new BadRequestException('订单状态不可支付');
|
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 user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
const openId = user?.wxOpenId ?? undefined;
|
const openId = user?.wxOpenId ?? undefined;
|
||||||
const appConfig = loadAppConfig();
|
const appConfig = loadAppConfig();
|
||||||
if (!appConfig.mockPay && !openId) {
|
if (!appConfig.mockPay && !openId) {
|
||||||
|
this.payRedeemAnomaly.onPayFail(WECHAT_AUTH_REQUIRED, {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
|
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
|
||||||
}
|
}
|
||||||
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
|
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') {
|
if (payResult.mode === 'jsapi') {
|
||||||
return {
|
return {
|
||||||
@@ -330,6 +353,11 @@ export class TradeService {
|
|||||||
|
|
||||||
await this.afterOrderPaid(order.id);
|
await this.afterOrderPaid(order.id);
|
||||||
|
|
||||||
|
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||||
eventName: 'pay_success',
|
eventName: 'pay_success',
|
||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
@@ -380,8 +408,18 @@ export class TradeService {
|
|||||||
return { orderId: order.id.toString(), alreadyPaid: true };
|
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) {
|
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('支付金额与订单不符');
|
throw new BadRequestException('支付金额与订单不符');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +487,10 @@ export class TradeService {
|
|||||||
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||||
if (refreshed?.payStatus === 'PAID') {
|
if (refreshed?.payStatus === 'PAID') {
|
||||||
await this.afterOrderPaid(order.id);
|
await this.afterOrderPaid(order.id);
|
||||||
|
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId: order.userId,
|
||||||
|
});
|
||||||
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
|
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
|
||||||
eventName: 'pay_success',
|
eventName: 'pay_success',
|
||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
@@ -1653,16 +1695,34 @@ export class TradeService {
|
|||||||
throw new BadRequestException('订单已超时未支付');
|
throw new BadRequestException('订单已超时未支付');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId: order.userId,
|
||||||
|
});
|
||||||
|
|
||||||
let openId: string | undefined;
|
let openId: string | undefined;
|
||||||
if (payMethod === 'JSAPI') {
|
if (payMethod === 'JSAPI') {
|
||||||
openId = primary.wxOpenId ?? undefined;
|
openId = primary.wxOpenId ?? undefined;
|
||||||
const appConfig = loadAppConfig();
|
const appConfig = loadAppConfig();
|
||||||
if (!appConfig.mockPay && !openId) {
|
if (!appConfig.mockPay && !openId) {
|
||||||
|
this.payRedeemAnomaly.onPayFail('代付未绑定微信', {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId: order.userId,
|
||||||
|
});
|
||||||
throw new BadRequestException('请先在微信内登录并绑定微信后再代付');
|
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') {
|
if (payResult.mode === 'native') {
|
||||||
return {
|
return {
|
||||||
@@ -1709,8 +1769,26 @@ export class TradeService {
|
|||||||
throw new BadRequestException('订单已超时未支付');
|
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') {
|
if (payResult.mode !== 'native') {
|
||||||
|
this.payRedeemAnomaly.onPayFail('无法生成收款码', {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
userId: order.userId,
|
||||||
|
});
|
||||||
throw new BadRequestException('无法生成收款码');
|
throw new BadRequestException('无法生成收款码');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
+23
-1
@@ -25,7 +25,7 @@
|
|||||||
14. [配送单](#14-配送单)
|
14. [配送单](#14-配送单)
|
||||||
15. [好客权益](#15-好客权益)
|
15. [好客权益](#15-好客权益)
|
||||||
16. [系统设置](#16-系统设置)
|
16. [系统设置](#16-系统设置)
|
||||||
17. [企业微信对接](#17-企业微信对接)
|
17. [企业微信对接](#17-企业微信对接)(含 [§17.6 运营告警 Webhook](#176-运营告警-webhook群机器人))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -898,6 +898,28 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
|||||||
- Bot Secret 仅 HQ 创建/编辑时写入,列表不回明文;泄露后应在企微后台重置并更新 HQ 配置后重载
|
- Bot Secret 仅 HQ 创建/编辑时写入,列表不回明文;泄露后应在企微后台重置并更新 HQ 配置后重载
|
||||||
- 工作时间话术与 PRD OPT-012 对齐(上线后 3 日内更新)
|
- 工作时间话术与 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 · 端职责矩阵
|
## 附录 A · 端职责矩阵
|
||||||
|
|||||||
Reference in New Issue
Block a user