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 · 端职责矩阵