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