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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 16:01:30 +08:00
parent cb6ecaaba6
commit 3328c52cb4
27 changed files with 1036 additions and 103 deletions
@@ -7,6 +7,7 @@ import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import type {
CreateSupportTicketDto,
RejectSupportTicketDto,
@@ -20,7 +21,10 @@ function generateSupportTicketNo() {
@Injectable()
export class SupportTicketService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async create(
dto: CreateSupportTicketDto,
@@ -38,6 +42,13 @@ export class SupportTicketService {
creatorName: creator.name,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建技术支持工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n标题 ${ticket.title}\n创建人 ${creator.name}`,
dedupeKey: `support_ticket_create|${ticket.ticketNo}`,
});
return serializeBigInt(ticket);
}
@@ -3,6 +3,7 @@ import type { ActorType, TicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import type { TicketListQueryDto } from './dto/common-query.dto';
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
@@ -12,7 +13,10 @@ function generateTicketNo() {
@Injectable()
export class TicketService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
async create(dto: CreateTicketDto) {
const ticket = await this.prisma.commonTicket.create({
@@ -27,6 +31,13 @@ export class TicketService {
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
},
});
this.alert.notify({
level: 'P2',
category: 'ops',
title: '新建售后工单',
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n关联 ${ticket.refType}:${ticket.refId}\n${dto.remark ?? ''}`,
dedupeKey: `ticket_create|${ticket.ticketNo}`,
});
return serializeBigInt(ticket);
}
@@ -1,9 +1,35 @@
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
check() {
return { status: 'ok', service: 'dukang-api', version: 'prev1' };
async check() {
let db: 'ok' | 'error' = 'ok';
let redis: 'ok' | 'error' = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
db = 'error';
}
try {
const pong = await this.redis.client.ping();
if (pong !== 'PONG') redis = 'error';
} catch {
redis = 'error';
}
const status = db === 'ok' && redis === 'ok' ? 'ok' : 'degraded';
return {
status,
service: 'dukang-api',
version: 'prev1',
checks: { db, redis },
};
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
/** Prisma / Redis 已为 GlobalHealth 直接注入探活 */
@Module({ controllers: [HealthController] })
export class HealthModule {}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
import {
SYSTEM_CONFIG_GROUP_PERMISSION,
@@ -18,6 +18,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { SystemConfigService } from '../../common/system-config/system-config.service';
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
import { AlertService } from '../../common/alert/alert.service';
@Controller('admin/system-config')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@@ -26,6 +27,7 @@ export class AdminSystemConfigController {
private readonly systemConfig: SystemConfigService,
private readonly permissions: HqPermissionsResolver,
private readonly wecomAibot: WecomAibotService,
private readonly alert: AlertService,
) {}
@Get()
@@ -77,6 +79,22 @@ export class AdminSystemConfigController {
importEnv() {
return this.systemConfig.importFromProcessEnv();
}
/** 向企微群机器人发送一条测试告警 */
@Post('wecom-alert/test')
@RequireAnySystemSettings()
@HqOperation({
action: HqOperationAction.WECOM_ALERT_TEST,
refType: 'SYSTEM_CONFIG',
batch: true,
})
async testWecomAlert() {
const result = await this.alert.sendTestAlert();
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
}
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
@@ -28,6 +28,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
type TokenPayload = {
userId: string;
@@ -70,6 +71,7 @@ export class RedeemService {
private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService,
private readonly authService: AuthService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
) {}
private maskPhoneForStore(phone: string) {
@@ -414,36 +416,48 @@ export class RedeemService {
}
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
const meta = { storeId, userId: undefined as string | undefined };
try {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
}
meta.userId = session.userId;
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
const normalizedAllocations = session.allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const amount = Number(session.amount);
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - amount) > 0.001) {
throw new BadRequestException('核销分摊数据异常');
}
await this.validateAllocations(normalizedAllocations);
this.payRedeemAnomaly.onRedeemAttempt(amount, {
storeId,
userId: session.userId,
});
const record = await this.executeRedeem(
account,
BigInt(session.userId),
amount,
normalizedAllocations,
{ channel: 'phone', sessionId },
);
await this.redis.del(this.phoneSessionKey(sessionId));
return serializeBigInt(record);
} catch (e) {
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '手机号核销失败', meta);
throw e;
}
await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM);
const normalizedAllocations = session.allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const amount = Number(session.amount);
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - amount) > 0.001) {
throw new BadRequestException('核销分摊数据异常');
}
await this.validateAllocations(normalizedAllocations);
const record = await this.executeRedeem(
account,
BigInt(session.userId),
amount,
normalizedAllocations,
{ channel: 'phone', sessionId },
);
await this.redis.del(this.phoneSessionKey(sessionId));
return serializeBigInt(record);
}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -593,75 +607,87 @@ export class RedeemService {
}
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
const meta = { storeId, userId: undefined as string | undefined };
try {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
const existingResult = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
if (existingResult) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id: BigInt(existingResult.recordId) },
});
if (record) {
return serializeBigInt(record);
const existingResult = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
if (existingResult) {
const record = await this.prisma.redeemRecord.findUnique({
where: { id: BigInt(existingResult.recordId) },
});
if (record) {
return serializeBigInt(record);
}
}
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const allocations =
cached.allocations ??
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
const allocations =
cached.allocations ??
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
const normalizedAllocations = allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const tokenAmount = Number(cached.amount);
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
throw new BadRequestException('核销码数据异常');
}
const normalizedAllocations = allocations.map((item) => ({
couponId: String(item.couponId),
amount: Number(item.amount),
}));
const tokenAmount = Number(cached.amount);
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
throw new BadRequestException('核销码数据异常');
}
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - tokenAmount) > 0.001) {
throw new BadRequestException('核销码数据异常');
}
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - tokenAmount) > 0.001) {
throw new BadRequestException('核销码数据异常');
}
await this.validateAllocations(normalizedAllocations);
await this.validateAllocations(normalizedAllocations);
const record = await this.executeRedeem(
account,
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
meta.userId = cached.userId;
this.payRedeemAnomaly.onRedeemAttempt(tokenAmount, {
storeId,
userId: cached.userId,
amount: tokenAmount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${token}`);
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
});
return serializeBigInt(record);
const record = await this.executeRedeem(
account,
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
userId: cached.userId,
amount: tokenAmount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${token}`);
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
return serializeBigInt(record);
} catch (e) {
this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '核销失败', meta);
throw e;
}
}
private netFailKey(storeAccountId: bigint, token: string) {
@@ -705,6 +731,11 @@ export class RedeemService {
},
});
this.payRedeemAnomaly.onRedeemFail(
body.message?.slice(0, 200) || `弱网核销失败:${body.errorClass}/${body.step}`,
{ storeId },
);
if (thresholdReached && body.errorClass === 'NETWORK') {
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
storeId,
@@ -716,6 +747,11 @@ export class RedeemService {
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
},
});
this.payRedeemAnomaly.notifyRedeemOps(
'弱网核销达阈值',
`门店 ${storeId}\n失败次数 ${failCount}(阈值 ${REDEEM_WEAKNET_FAIL_THRESHOLD}\ntoken …${token.slice(-8)}`,
`redeem_weaknet|${storeId}|${token.slice(-8)}`,
);
}
return {
@@ -822,6 +858,12 @@ export class RedeemService {
},
});
this.payRedeemAnomaly.notifyRedeemOps(
'新建补核销待办',
`待办 ${pending.pendingNo}\n门店 ${account.storeId}\n金额 ${Number(pending.amount)}\n失败次数 ${failCount}`,
`redeem_pending|${pending.pendingNo}`,
);
return serializeBigInt({
pendingId: pending.id,
pendingNo: pending.pendingNo,
@@ -32,6 +32,8 @@ import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/e
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import type { Request } from 'express';
@Injectable()
@@ -51,6 +53,8 @@ export class TradeService {
@Inject(forwardRef(() => FulfillmentService))
private readonly fulfillmentService: FulfillmentService,
private readonly wechatOrderShipping: WechatOrderShippingService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
) {}
async preview(
@@ -265,14 +269,33 @@ export class TradeService {
throw new BadRequestException('订单状态不可支付');
}
const payAmountYuan = Number(order.payAmount);
this.payRedeemAnomaly.onPayAttempt(payAmountYuan, {
orderNo: order.orderNo,
userId,
});
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const openId = user?.wxOpenId ?? undefined;
const appConfig = loadAppConfig();
if (!appConfig.mockPay && !openId) {
this.payRedeemAnomaly.onPayFail(WECHAT_AUTH_REQUIRED, {
orderNo: order.orderNo,
userId,
});
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
}
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
const payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '拉起支付失败', {
orderNo: order.orderNo,
userId,
});
throw e;
}
if (payResult.mode === 'jsapi') {
return {
@@ -330,6 +353,11 @@ export class TradeService {
await this.afterOrderPaid(order.id);
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
orderNo: order.orderNo,
userId,
});
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
@@ -380,8 +408,18 @@ export class TradeService {
return { orderId: order.id.toString(), alreadyPaid: true };
}
const expectedFen = Math.round(Number(order.payAmount) * 100);
const payAmountYuan = Number(order.payAmount);
const expectedFen = Math.round(payAmountYuan * 100);
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
const reason = `支付金额与订单不符 expected=${expectedFen} got=${params.amountFen}`;
this.payRedeemAnomaly.onPayFail(reason, { orderNo: order.orderNo, userId: order.userId });
this.alert.notify({
level: 'P0',
category: 'pay',
title: '支付金额不一致',
detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen}`,
dedupeKey: `pay_amount_mismatch|${order.orderNo}`,
});
throw new BadRequestException('支付金额与订单不符');
}
@@ -449,6 +487,10 @@ export class TradeService {
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
if (refreshed?.payStatus === 'PAID') {
await this.afterOrderPaid(order.id);
this.payRedeemAnomaly.onPaySuccess(payAmountYuan, {
orderNo: order.orderNo,
userId: order.userId,
});
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
@@ -1653,16 +1695,34 @@ export class TradeService {
throw new BadRequestException('订单已超时未支付');
}
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
orderNo: order.orderNo,
userId: order.userId,
});
let openId: string | undefined;
if (payMethod === 'JSAPI') {
openId = primary.wxOpenId ?? undefined;
const appConfig = loadAppConfig();
if (!appConfig.mockPay && !openId) {
this.payRedeemAnomaly.onPayFail('代付未绑定微信', {
orderNo: order.orderNo,
userId: order.userId,
});
throw new BadRequestException('请先在微信内登录并绑定微信后再代付');
}
}
const payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod);
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod);
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '代付拉起失败', {
orderNo: order.orderNo,
userId: order.userId,
});
throw e;
}
if (payResult.mode === 'native') {
return {
@@ -1709,8 +1769,26 @@ export class TradeService {
throw new BadRequestException('订单已超时未支付');
}
const payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE');
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
orderNo: order.orderNo,
userId: order.userId,
});
let payResult;
try {
payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE');
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '总部代付拉起失败', {
orderNo: order.orderNo,
userId: order.userId,
});
throw e;
}
if (payResult.mode !== 'native') {
this.payRedeemAnomaly.onPayFail('无法生成收款码', {
orderNo: order.orderNo,
userId: order.userId,
});
throw new BadRequestException('无法生成收款码');
}
return {