diff --git a/apps/admin-web/src/pages/SystemSettingsPage.tsx b/apps/admin-web/src/pages/SystemSettingsPage.tsx
index a4ed925..b5df976 100644
--- a/apps/admin-web/src/pages/SystemSettingsPage.tsx
+++ b/apps/admin-web/src/pages/SystemSettingsPage.tsx
@@ -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 (
+
+
+
+ 需已配置 WECOM_ALERT_WEBHOOK_URL 并开启上方开关
+
+
+ );
+}
+
function renderField(
field: SystemConfigFieldMeta,
configuredSecrets: string[],
@@ -263,6 +291,8 @@ export default function SystemSettingsPage() {
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
+ ) : f.key === 'WECOM_ALERT_ENABLED' ? (
+
) : undefined,
),
)}
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index 99e7da3..fe659b6 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -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 勿下发前端
diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example
index 4771c84..edc99a6 100644
--- a/server/dukang-api/.env.production.example
+++ b/server/dukang-api/.env.production.example
@@ -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
diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example
index 39b3b4e..bc8b5c6 100644
--- a/server/dukang-api/.env.staging.example
+++ b/server/dukang-api/.env.staging.example
@@ -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
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index 0dfa18f..123c3b5 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -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,
diff --git a/server/dukang-api/src/callbacks/wechat-pay.controller.ts b/server/dukang-api/src/callbacks/wechat-pay.controller.ts
index 1b03a0c..2b2d320 100644
--- a/server/dukang-api/src/callbacks/wechat-pay.controller.ts
+++ b/server/dukang-api/src/callbacks/wechat-pay.controller.ts
@@ -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,
@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 });
}
}
diff --git a/server/dukang-api/src/callbacks/wechat-refund.controller.ts b/server/dukang-api/src/callbacks/wechat-refund.controller.ts
index c5f13c4..94b033a 100644
--- a/server/dukang-api/src/callbacks/wechat-refund.controller.ts
+++ b/server/dukang-api/src/callbacks/wechat-refund.controller.ts
@@ -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 });
}
}
diff --git a/server/dukang-api/src/common/alert/alert.constants.ts b/server/dukang-api/src/common/alert/alert.constants.ts
new file mode 100644
index 0000000..2f58877
--- /dev/null
+++ b/server/dukang-api/src/common/alert/alert.constants.ts
@@ -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;
diff --git a/server/dukang-api/src/common/alert/alert.module.ts b/server/dukang-api/src/common/alert/alert.module.ts
new file mode 100644
index 0000000..06725a1
--- /dev/null
+++ b/server/dukang-api/src/common/alert/alert.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/common/alert/alert.service.ts b/server/dukang-api/src/common/alert/alert.service.ts
new file mode 100644
index 0000000..8562286
--- /dev/null
+++ b/server/dukang-api/src/common/alert/alert.service.ts
@@ -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 {
+ 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 {
+ 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)}`,
+ `> 环境:${escapeMd(envLabel)}`,
+ `> 类别:${escapeMd(input.category)}`,
+ `> 时间:${escapeMd(now)}`,
+ '',
+ escapeMd(input.detail).slice(0, 3500),
+ ].join('\n');
+
+ return this.wecomWebhook.sendMarkdown(content);
+ }
+}
+
+/** 轻量转义,保留企微 markdown 的 标签可用 */
+function escapeMd(s: string): string {
+ return s.replace(/([\\`*_[\]])/g, '\\$1');
+}
diff --git a/server/dukang-api/src/common/alert/pay-redeem-anomaly.service.ts b/server/dukang-api/src/common/alert/pay-redeem-anomaly.service.ts
new file mode 100644
index 0000000..ffc8973
--- /dev/null
+++ b/server/dukang-api/src/common/alert/pay-redeem-anomaly.service.ts
@@ -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 {
+ 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');
+}
diff --git a/server/dukang-api/src/common/filters/http-exception.filter.ts b/server/dukang-api/src/common/filters/http-exception.filter.ts
index 56589f8..ae45d01 100644
--- a/server/dukang-api/src/common/filters/http-exception.filter.ts
+++ b/server/dukang-api/src/common/filters/http-exception.filter.ts
@@ -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,
});
}
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index 02bf092..db4cbf9 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -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 = {
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
+ [HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
STORE_PAYOUT: '门店打款确认',
};
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
index 6310e7c..aca17f6 100644
--- a/server/dukang-api/src/common/system-config/system-config.registry.ts
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -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 },
diff --git a/server/dukang-api/src/integrations/wecom/wecom-webhook-alert.service.ts b/server/dukang-api/src/integrations/wecom/wecom-webhook-alert.service.ts
new file mode 100644
index 0000000..60c425b
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-webhook-alert.service.ts
@@ -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 {
+ 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;
+ }
+ }
+}
diff --git a/server/dukang-api/src/jobs/jobs.module.ts b/server/dukang-api/src/jobs/jobs.module.ts
index ebd2aa7..93e36a5 100644
--- a/server/dukang-api/src/jobs/jobs.module.ts
+++ b/server/dukang-api/src/jobs/jobs.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/jobs/monitor.scheduler.ts b/server/dukang-api/src/jobs/monitor.scheduler.ts
new file mode 100644
index 0000000..b3ae945
--- /dev/null
+++ b/server/dukang-api/src/jobs/monitor.scheduler.ts
@@ -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,
+ });
+ }
+ }
+}
diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts
index 0468708..28bc154 100644
--- a/server/dukang-api/src/jobs/settlement.scheduler.ts
+++ b/server/dukang-api/src/jobs/settlement.scheduler.ts
@@ -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)}`,
+ });
}
}
}
diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts
index 551a09d..a48a55d 100644
--- a/server/dukang-api/src/main.ts
+++ b/server/dukang-api/src/main.ts
@@ -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();
diff --git a/server/dukang-api/src/modules/common/support-ticket.service.ts b/server/dukang-api/src/modules/common/support-ticket.service.ts
index 6d369d2..05ad0e9 100644
--- a/server/dukang-api/src/modules/common/support-ticket.service.ts
+++ b/server/dukang-api/src/modules/common/support-ticket.service.ts
@@ -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);
}
diff --git a/server/dukang-api/src/modules/common/ticket.service.ts b/server/dukang-api/src/modules/common/ticket.service.ts
index add97eb..248cd32 100644
--- a/server/dukang-api/src/modules/common/ticket.service.ts
+++ b/server/dukang-api/src/modules/common/ticket.service.ts
@@ -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);
}
diff --git a/server/dukang-api/src/modules/health/health.controller.ts b/server/dukang-api/src/modules/health/health.controller.ts
index d616f4f..9ff190d 100644
--- a/server/dukang-api/src/modules/health/health.controller.ts
+++ b/server/dukang-api/src/modules/health/health.controller.ts
@@ -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 },
+ };
}
}
diff --git a/server/dukang-api/src/modules/health/health.module.ts b/server/dukang-api/src/modules/health/health.module.ts
index fa9d30b..249bb0d 100644
--- a/server/dukang-api/src/modules/health/health.module.ts
+++ b/server/dukang-api/src/modules/health/health.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
+/** Prisma / Redis 已为 Global,Health 直接注入探活 */
@Module({ controllers: [HealthController] })
export class HealthModule {}
diff --git a/server/dukang-api/src/modules/ops/admin-system-config.controller.ts b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
index 4c8bf19..d81e9a7 100644
--- a/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
@@ -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 {
diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts
index 395958a..6944d82 100644
--- a/server/dukang-api/src/modules/redeem/redeem.service.ts
+++ b/server/dukang-api/src/modules/redeem/redeem.service.ts
@@ -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(`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(`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(`redeem:token:${token}`);
- if (!cached) throw new BadRequestException('核销码无效或已过期');
+ const cached = await this.redis.getJson(`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,
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index e79e9a8..2b52c77 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -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 {
diff --git a/杜康好客-知识库.md b/杜康好客-知识库.md
index 7357662..538c0d3 100644
--- a/杜康好客-知识库.md
+++ b/杜康好客-知识库.md
@@ -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 · 端职责矩阵