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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user