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
@@ -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,