3328c52cb4
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>
1114 lines
37 KiB
TypeScript
1114 lines
37 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { randomBytes } from 'crypto';
|
||
import {
|
||
calcRedeemSettleAmount,
|
||
generateRedeemNo,
|
||
generateRedeemPendingNo,
|
||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||
validateRedeemAmount,
|
||
allocateBenefitCoupons,
|
||
} from '@dukang/domain';
|
||
import {
|
||
ClientApp,
|
||
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
REDEEM_RESULT_TTL_SECONDS,
|
||
REDEEM_TOKEN_TTL_SECONDS,
|
||
SmsScene,
|
||
} from '@dukang/shared-types';
|
||
import { Prisma } from '@prisma/client';
|
||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||
import { RedisService } from '../../common/redis/redis.service';
|
||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||
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;
|
||
couponId?: string;
|
||
amount: number;
|
||
storeId?: string | null;
|
||
allocations?: Array<{ couponId: string; amount: number }>;
|
||
};
|
||
|
||
type PendingSnapshot = TokenPayload & {
|
||
redeemType: 'DIRECT' | 'COUPON';
|
||
};
|
||
|
||
type RedeemResultPayload = {
|
||
recordId: string;
|
||
redeemNo: string;
|
||
userId: string;
|
||
amount: number;
|
||
storeId: string;
|
||
storeName: string;
|
||
createdAt: string;
|
||
};
|
||
|
||
type PhoneRedeemSession = {
|
||
userId: string;
|
||
phone: string;
|
||
storeAccountId: string;
|
||
storeId: string;
|
||
amount?: number;
|
||
allocations?: Array<{ couponId: string; amount: number }>;
|
||
confirmPrepared?: boolean;
|
||
};
|
||
|
||
@Injectable()
|
||
export class RedeemService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly redis: RedisService,
|
||
private readonly settlementService: SettlementService,
|
||
private readonly benefitService: BenefitService,
|
||
private readonly analyticsService: AnalyticsService,
|
||
private readonly authService: AuthService,
|
||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||
) {}
|
||
|
||
private maskPhoneForStore(phone: string) {
|
||
if (phone.length < 7) return phone;
|
||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||
}
|
||
|
||
private normalizeMobilePhone(phone: string) {
|
||
const normalized = String(phone ?? '').trim();
|
||
if (!/^1\d{10}$/.test(normalized)) {
|
||
throw new BadRequestException('手机号格式无效');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
private phoneSessionKey(sessionId: string) {
|
||
return `redeem:phone-session:${sessionId}`;
|
||
}
|
||
|
||
private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) {
|
||
const binding = await this.prisma.storeAccountStore.findUnique({
|
||
where: {
|
||
storeAccountId_storeId: { storeAccountId, storeId },
|
||
},
|
||
include: {
|
||
storeAccount: true,
|
||
store: true,
|
||
},
|
||
});
|
||
if (!binding) throw new BadRequestException('无权访问该门店');
|
||
if (binding.storeAccount.status !== 'ACTIVE') {
|
||
throw new BadRequestException('门店账号已停用');
|
||
}
|
||
if (binding.store.status !== 'OPEN') {
|
||
throw new BadRequestException('门店未营业');
|
||
}
|
||
return {
|
||
...binding.storeAccount,
|
||
storeId: binding.store.id,
|
||
store: binding.store,
|
||
};
|
||
}
|
||
|
||
private async resolveUserByPhone(phone: string) {
|
||
const user = await this.prisma.user.findFirst({
|
||
where: { phone, mergedIntoUserId: null, status: 1 },
|
||
select: { id: true, userNo: true, phone: true, nickname: true, phoneVerifiedAt: true },
|
||
});
|
||
if (!user) throw new NotFoundException('该手机号未注册好客用户');
|
||
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
|
||
return user;
|
||
}
|
||
|
||
private async computeDirectAllocations(userId: bigint, amount: number) {
|
||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||
where: { userId, status: 'ACTIVE' },
|
||
orderBy: { createdAt: 'asc' },
|
||
});
|
||
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
|
||
const result = allocateBenefitCoupons(
|
||
coupons.map((c) => ({
|
||
id: c.id.toString(),
|
||
balance: Number(c.balance),
|
||
createdAt: c.createdAt.getTime(),
|
||
})),
|
||
amount,
|
||
);
|
||
if (!result.ok) throw new BadRequestException(result.message);
|
||
const check = validateRedeemAmount(totalBalance, amount);
|
||
if (!check.ok) throw new BadRequestException(check.message);
|
||
return { allocations: result.allocations, totalBalance };
|
||
}
|
||
|
||
private async validateAllocations(allocations: Array<{ couponId: string; amount: number }>) {
|
||
for (const alloc of allocations) {
|
||
let couponId: bigint;
|
||
try {
|
||
couponId = BigInt(alloc.couponId);
|
||
} catch {
|
||
throw new BadRequestException('核销分摊数据异常');
|
||
}
|
||
const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id: couponId } });
|
||
if (!coupon) throw new BadRequestException('券不存在');
|
||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
||
if (!check.ok) throw new BadRequestException(check.message);
|
||
}
|
||
}
|
||
|
||
private async executeRedeem(
|
||
account: Awaited<ReturnType<RedeemService['loadOpenStoreAccount']>>,
|
||
userId: bigint,
|
||
amount: number,
|
||
normalizedAllocations: Array<{ couponId: string; amount: number }>,
|
||
analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string },
|
||
) {
|
||
const settlementRate = Number(account.store.settlementRate);
|
||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||
|
||
let record;
|
||
try {
|
||
record = await this.prisma.$transaction(async (tx) => {
|
||
await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId);
|
||
|
||
const redeemRecord = await tx.redeemRecord.create({
|
||
data: {
|
||
redeemNo: generateRedeemNo(),
|
||
userId,
|
||
couponId: BigInt(normalizedAllocations[0].couponId),
|
||
storeId: account.storeId,
|
||
amount,
|
||
settleAmount,
|
||
allocations: {
|
||
create: normalizedAllocations.map((item, index) => ({
|
||
couponId: BigInt(item.couponId),
|
||
amount: item.amount,
|
||
sortOrder: index,
|
||
})),
|
||
},
|
||
},
|
||
});
|
||
|
||
await this.settlementService.createStorePayout(
|
||
redeemRecord.id,
|
||
account.storeId,
|
||
amount,
|
||
settleAmount,
|
||
settlementRate,
|
||
tx,
|
||
);
|
||
|
||
return redeemRecord;
|
||
});
|
||
} catch (e) {
|
||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||
throw new BadRequestException('核销失败,请重试');
|
||
}
|
||
if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') {
|
||
throw new BadRequestException('核销分摊数据异常');
|
||
}
|
||
throw e;
|
||
}
|
||
|
||
const redeemExtra = {
|
||
redeemRecordId: record.id.toString(),
|
||
storeId: account.storeId.toString(),
|
||
amount,
|
||
channel: analyticsExtra?.channel ?? 'token',
|
||
...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}),
|
||
...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}),
|
||
};
|
||
this.analyticsService.trackStoreOneSafe(account.id, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_confirm',
|
||
refType: 'REDEEM_RECORD',
|
||
refId: record.id,
|
||
extraJson: {
|
||
redeemNo: record.redeemNo,
|
||
amount,
|
||
userId: userId.toString(),
|
||
channel: analyticsExtra?.channel ?? 'token',
|
||
},
|
||
});
|
||
this.analyticsService.trackOneSafe(userId, ClientApp.SHOP_H5, {
|
||
eventName: 'benefit_redeem_success',
|
||
refType: 'STORE',
|
||
refId: account.storeId,
|
||
extraJson: redeemExtra,
|
||
});
|
||
this.analyticsService.trackOneSafe(userId, ClientApp.USER_H5, {
|
||
eventName: 'benefit_redeem_success',
|
||
refType: 'STORE',
|
||
refId: account.storeId,
|
||
extraJson: redeemExtra,
|
||
});
|
||
|
||
return record;
|
||
}
|
||
|
||
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||
await this.resolveUserByPhone(normalizedPhone);
|
||
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, {
|
||
clientApp: ClientApp.SHOP_H5,
|
||
});
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_phone_lookup_sms',
|
||
extraJson: { phone: this.maskPhoneForStore(normalizedPhone) },
|
||
});
|
||
return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) };
|
||
}
|
||
|
||
async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||
const user = await this.resolveUserByPhone(normalizedPhone);
|
||
await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP);
|
||
|
||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||
where: { userId: user.id, status: 'ACTIVE' },
|
||
});
|
||
const balance = coupons.reduce((sum, c) => sum + Number(c.balance), 0);
|
||
|
||
const sessionId = randomBytes(16).toString('hex');
|
||
await this.redis.setJson(
|
||
this.phoneSessionKey(sessionId),
|
||
{
|
||
userId: user.id.toString(),
|
||
phone: normalizedPhone,
|
||
storeAccountId: storeAccountId.toString(),
|
||
storeId: account.storeId.toString(),
|
||
} satisfies PhoneRedeemSession,
|
||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
);
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_phone_balance',
|
||
extraJson: {
|
||
phone: this.maskPhoneForStore(normalizedPhone),
|
||
totalBalance: balance,
|
||
sessionId,
|
||
},
|
||
});
|
||
|
||
return serializeBigInt({
|
||
sessionId,
|
||
totalBalance: balance,
|
||
maskedPhone: this.maskPhoneForStore(normalizedPhone),
|
||
user: {
|
||
id: user.id,
|
||
userNo: user.userNo,
|
||
nickname: user.nickname,
|
||
phone: this.maskPhoneForStore(normalizedPhone),
|
||
},
|
||
});
|
||
}
|
||
|
||
private async loadPhoneSession(sessionId: string, storeAccountId: bigint): Promise<PhoneRedeemSession> {
|
||
const session = await this.redis.getJson<PhoneRedeemSession>(this.phoneSessionKey(sessionId));
|
||
if (!session) throw new BadRequestException('核销会话已过期,请重新验证手机号');
|
||
if (session.storeAccountId !== storeAccountId.toString()) {
|
||
throw new BadRequestException('核销会话无效');
|
||
}
|
||
return session;
|
||
}
|
||
|
||
async preparePhoneRedeemDirect(
|
||
storeAccountId: bigint,
|
||
storeId: bigint,
|
||
phone: string,
|
||
amount: number,
|
||
) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||
const user = await this.resolveUserByPhone(normalizedPhone);
|
||
const { allocations, totalBalance } = await this.computeDirectAllocations(user.id, amount);
|
||
const sessionId = randomBytes(16).toString('hex');
|
||
|
||
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_CONFIRM, {
|
||
clientApp: ClientApp.SHOP_H5,
|
||
});
|
||
|
||
await this.redis.setJson(
|
||
this.phoneSessionKey(sessionId),
|
||
{
|
||
userId: user.id.toString(),
|
||
phone: normalizedPhone,
|
||
storeAccountId: storeAccountId.toString(),
|
||
storeId: account.storeId.toString(),
|
||
amount,
|
||
allocations,
|
||
confirmPrepared: true,
|
||
} satisfies PhoneRedeemSession,
|
||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
);
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_phone_prepare',
|
||
extraJson: {
|
||
sessionId,
|
||
amount,
|
||
phone: this.maskPhoneForStore(normalizedPhone),
|
||
flow: 'direct',
|
||
},
|
||
});
|
||
|
||
return serializeBigInt({
|
||
sessionId,
|
||
amount,
|
||
totalBalance,
|
||
maskedPhone: this.maskPhoneForStore(normalizedPhone),
|
||
expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
user: {
|
||
id: user.id,
|
||
userNo: user.userNo,
|
||
nickname: user.nickname,
|
||
phone: this.maskPhoneForStore(normalizedPhone),
|
||
},
|
||
});
|
||
}
|
||
|
||
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||
const userId = BigInt(session.userId);
|
||
const { allocations } = await this.computeDirectAllocations(userId, amount);
|
||
|
||
await this.authService.sendSms(session.phone, SmsScene.REDEEM_PHONE_CONFIRM, {
|
||
clientApp: ClientApp.SHOP_H5,
|
||
});
|
||
|
||
const nextSession: PhoneRedeemSession = {
|
||
...session,
|
||
amount,
|
||
allocations,
|
||
confirmPrepared: true,
|
||
};
|
||
await this.redis.setJson(
|
||
this.phoneSessionKey(sessionId),
|
||
nextSession,
|
||
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
);
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_phone_prepare',
|
||
extraJson: {
|
||
sessionId,
|
||
amount,
|
||
phone: this.maskPhoneForStore(session.phone),
|
||
},
|
||
});
|
||
|
||
return {
|
||
sessionId,
|
||
amount,
|
||
expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||
};
|
||
}
|
||
|
||
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||
let allocations: Array<{ couponId: string; amount: number }>;
|
||
|
||
if (body.couponId) {
|
||
const coupon = await this.prisma.benefitCoupon.findFirst({
|
||
where: { id: BigInt(body.couponId), userId, status: 'ACTIVE' },
|
||
});
|
||
if (!coupon) throw new NotFoundException('券不存在');
|
||
const balance = Number(coupon.balance);
|
||
const check = validateRedeemAmount(balance, body.amount, balance);
|
||
if (!check.ok) throw new BadRequestException(check.message);
|
||
allocations = [{ couponId: coupon.id.toString(), amount: body.amount }];
|
||
} else {
|
||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||
where: { userId, status: 'ACTIVE' },
|
||
orderBy: { createdAt: 'asc' },
|
||
});
|
||
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
|
||
const result = allocateBenefitCoupons(
|
||
coupons.map((c) => ({
|
||
id: c.id.toString(),
|
||
balance: Number(c.balance),
|
||
createdAt: c.createdAt.getTime(),
|
||
})),
|
||
body.amount,
|
||
);
|
||
if (!result.ok) throw new BadRequestException(result.message);
|
||
const check = validateRedeemAmount(totalBalance, body.amount);
|
||
if (!check.ok) throw new BadRequestException(check.message);
|
||
allocations = result.allocations;
|
||
}
|
||
|
||
const primaryCouponId = BigInt(allocations[0].couponId);
|
||
const token = randomBytes(16).toString('hex');
|
||
const expireAt = new Date(Date.now() + REDEEM_TOKEN_TTL_SECONDS * 1000);
|
||
|
||
await this.redis.setJson(
|
||
`redeem:token:${token}`,
|
||
{
|
||
userId: userId.toString(),
|
||
couponId: primaryCouponId.toString(),
|
||
amount: body.amount,
|
||
storeId: body.storeId ?? null,
|
||
allocations,
|
||
},
|
||
REDEEM_TOKEN_TTL_SECONDS,
|
||
);
|
||
|
||
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||
}
|
||
|
||
async getToken(token: string) {
|
||
const cached = await this.redis.getJson<Record<string, unknown>>(`redeem:token:${token}`);
|
||
if (!cached) throw new NotFoundException('核销码已过期');
|
||
return cached;
|
||
}
|
||
|
||
async getTokenStatus(token: string, userId: bigint) {
|
||
const result = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
|
||
if (result) {
|
||
if (result.userId !== userId.toString()) {
|
||
throw new NotFoundException('核销码不存在');
|
||
}
|
||
return {
|
||
status: 'CONSUMED' as const,
|
||
record: {
|
||
id: result.recordId,
|
||
redeemNo: result.redeemNo,
|
||
amount: result.amount,
|
||
storeId: result.storeId,
|
||
storeName: result.storeName,
|
||
createdAt: result.createdAt,
|
||
},
|
||
};
|
||
}
|
||
|
||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||
if (cached) {
|
||
if (cached.userId !== userId.toString()) {
|
||
throw new NotFoundException('核销码不存在');
|
||
}
|
||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||
return {
|
||
status: 'PENDING' as const,
|
||
expireInSeconds: ttl > 0 ? ttl : 0,
|
||
amount: cached.amount,
|
||
};
|
||
}
|
||
|
||
return { status: 'EXPIRED' as const };
|
||
}
|
||
|
||
async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
|
||
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('该核销码仅限指定门店使用');
|
||
}
|
||
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: BigInt(cached.userId) },
|
||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||
});
|
||
|
||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||
const redeemType: 'DIRECT' | 'COUPON' =
|
||
cached.allocations && cached.allocations.length > 1
|
||
? 'DIRECT'
|
||
: cached.couponId
|
||
? 'COUPON'
|
||
: 'DIRECT';
|
||
|
||
await this.redis.setJson(
|
||
`redeem:pending-snapshot:${token}`,
|
||
{
|
||
...cached,
|
||
redeemType,
|
||
} satisfies PendingSnapshot,
|
||
REDEEM_PENDING_SNAPSHOT_TTL_SECONDS,
|
||
);
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_preview',
|
||
extraJson: {
|
||
tokenSuffix: token.slice(-8),
|
||
amount: cached.amount,
|
||
userId: cached.userId,
|
||
redeemType,
|
||
},
|
||
});
|
||
|
||
return serializeBigInt({
|
||
token,
|
||
amount: cached.amount,
|
||
user,
|
||
boundStoreId: cached.storeId,
|
||
redeemType,
|
||
expireInSeconds: ttl > 0 ? ttl : 0,
|
||
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
||
});
|
||
}
|
||
|
||
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
|
||
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 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('该核销码仅限指定门店使用');
|
||
}
|
||
|
||
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 allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
|
||
if (Math.abs(allocSum - tokenAmount) > 0.001) {
|
||
throw new BadRequestException('核销码数据异常');
|
||
}
|
||
|
||
await this.validateAllocations(normalizedAllocations);
|
||
|
||
meta.userId = cached.userId;
|
||
this.payRedeemAnomaly.onRedeemAttempt(tokenAmount, {
|
||
storeId,
|
||
userId: cached.userId,
|
||
});
|
||
|
||
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) {
|
||
return `redeem:netfail:${storeAccountId}:${token}`;
|
||
}
|
||
|
||
async reportNetworkFailure(
|
||
storeAccountId: bigint,
|
||
storeId: bigint,
|
||
body: {
|
||
token: string;
|
||
errorClass: 'NETWORK' | 'BUSINESS';
|
||
message?: string;
|
||
step: 'preview' | 'confirm';
|
||
},
|
||
) {
|
||
await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const token = body.token.trim();
|
||
if (!token) throw new BadRequestException('请提供核销码');
|
||
|
||
let failCount = 0;
|
||
if (body.errorClass === 'NETWORK') {
|
||
failCount = await this.redis.incr(this.netFailKey(storeAccountId, token), REDEEM_TOKEN_TTL_SECONDS);
|
||
} else {
|
||
const raw = await this.redis.get(this.netFailKey(storeAccountId, token));
|
||
failCount = raw ? Number(raw) || 0 : 0;
|
||
}
|
||
|
||
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId,
|
||
eventName: 'store_redeem_confirm_fail',
|
||
extraJson: {
|
||
token,
|
||
tokenSuffix: token.slice(-8),
|
||
errorClass: body.errorClass,
|
||
failCount,
|
||
step: body.step,
|
||
message: body.message?.slice(0, 200) ?? null,
|
||
},
|
||
});
|
||
|
||
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,
|
||
eventName: 'store_redeem_weaknet_threshold',
|
||
extraJson: {
|
||
token,
|
||
tokenSuffix: token.slice(-8),
|
||
failCount,
|
||
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 {
|
||
failCount,
|
||
thresholdReached,
|
||
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||
};
|
||
}
|
||
|
||
private async resolvePendingSnapshot(token: string): Promise<PendingSnapshot> {
|
||
const live = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||
if (live) {
|
||
return {
|
||
...live,
|
||
redeemType:
|
||
live.allocations && live.allocations.length > 1
|
||
? 'DIRECT'
|
||
: live.couponId
|
||
? 'COUPON'
|
||
: 'DIRECT',
|
||
};
|
||
}
|
||
const snap = await this.redis.getJson<PendingSnapshot>(`redeem:pending-snapshot:${token}`);
|
||
if (!snap) throw new BadRequestException('核销码已失效且无可用快照,请用户重新出码或改用手机号核销');
|
||
return snap;
|
||
}
|
||
|
||
async submitPendingRedeem(
|
||
storeAccountId: bigint,
|
||
storeId: bigint,
|
||
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
|
||
) {
|
||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||
const token = body.token.trim();
|
||
if (!token) throw new BadRequestException('请提供核销码');
|
||
|
||
const existingPending = await this.prisma.redeemPendingRecord.findFirst({
|
||
where: { redeemToken: token, storeId: account.storeId, status: 'PENDING' },
|
||
});
|
||
if (existingPending) {
|
||
return serializeBigInt({
|
||
pendingId: existingPending.id,
|
||
pendingNo: existingPending.pendingNo,
|
||
redeemToken: existingPending.redeemToken,
|
||
failCount: existingPending.failCount,
|
||
});
|
||
}
|
||
|
||
const alreadyDone = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
|
||
if (alreadyDone) {
|
||
throw new BadRequestException('该核销码已核销成功,无需提交待处理单');
|
||
}
|
||
|
||
const snapshot = await this.resolvePendingSnapshot(token);
|
||
if (snapshot.storeId && snapshot.storeId !== account.storeId.toString()) {
|
||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||
}
|
||
|
||
const photoId = BigInt(body.photoResourceId);
|
||
const photo = await this.prisma.commonResource.findFirst({
|
||
where: { id: photoId, status: 'ACTIVE' },
|
||
});
|
||
if (!photo) throw new BadRequestException('核销码照片不存在');
|
||
|
||
const allocations =
|
||
snapshot.allocations ??
|
||
(snapshot.couponId ? [{ couponId: snapshot.couponId, amount: snapshot.amount }] : []);
|
||
if (!allocations.length) throw new BadRequestException('核销码数据异常');
|
||
|
||
const rawFail = await this.redis.get(this.netFailKey(storeAccountId, token));
|
||
const failCount = Math.max(
|
||
Number(body.failCount) || 0,
|
||
rawFail ? Number(rawFail) || 0 : 0,
|
||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||
);
|
||
|
||
const pending = await this.prisma.redeemPendingRecord.create({
|
||
data: {
|
||
pendingNo: generateRedeemPendingNo(),
|
||
redeemToken: token,
|
||
storeId: account.storeId,
|
||
storeAccountId: account.id,
|
||
userId: BigInt(snapshot.userId),
|
||
amount: snapshot.amount,
|
||
redeemType: snapshot.redeemType,
|
||
allocationsJson: allocations as Prisma.InputJsonValue,
|
||
photoResourceId: photoId,
|
||
failCount,
|
||
remark: body.remark?.trim() || null,
|
||
},
|
||
});
|
||
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: account.storeId,
|
||
eventName: 'store_redeem_pending_submit',
|
||
refType: 'REDEEM_PENDING',
|
||
refId: pending.id,
|
||
extraJson: {
|
||
pendingNo: pending.pendingNo,
|
||
redeemToken: token,
|
||
photoResourceId: photoId.toString(),
|
||
failCount,
|
||
amount: Number(pending.amount),
|
||
},
|
||
});
|
||
|
||
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,
|
||
redeemToken: pending.redeemToken,
|
||
failCount: pending.failCount,
|
||
});
|
||
}
|
||
|
||
async listPendingRedeems(query: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
status?: 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||
storeId?: string;
|
||
pendingNo?: string;
|
||
redeemToken?: string;
|
||
}) {
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
const where: Prisma.RedeemPendingRecordWhereInput = {};
|
||
if (query.status) where.status = query.status;
|
||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||
if (query.pendingNo) where.pendingNo = { contains: query.pendingNo };
|
||
if (query.redeemToken) where.redeemToken = { contains: query.redeemToken };
|
||
|
||
const [items, total] = await Promise.all([
|
||
this.prisma.redeemPendingRecord.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
include: {
|
||
store: { select: { id: true, name: true, cityName: true } },
|
||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||
photoResource: { select: { id: true, url: true } },
|
||
redeemRecord: { select: { id: true, redeemNo: true } },
|
||
},
|
||
}),
|
||
this.prisma.redeemPendingRecord.count({ where }),
|
||
]);
|
||
|
||
return serializeBigInt({
|
||
items: items.map((row) => ({
|
||
...row,
|
||
photoUrl: row.photoResource?.url ?? null,
|
||
photoResource: undefined,
|
||
})),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
});
|
||
}
|
||
|
||
async getPendingRedeem(id: bigint) {
|
||
const row = await this.prisma.redeemPendingRecord.findUnique({
|
||
where: { id },
|
||
include: {
|
||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||
photoResource: { select: { id: true, url: true } },
|
||
redeemRecord: { select: { id: true, redeemNo: true, amount: true, settleAmount: true } },
|
||
storeAccount: { select: { id: true, name: true, phone: true } },
|
||
},
|
||
});
|
||
if (!row) throw new NotFoundException('待处理核销单不存在');
|
||
return serializeBigInt({
|
||
...row,
|
||
photoUrl: row.photoResource?.url ?? null,
|
||
});
|
||
}
|
||
|
||
async completePendingRedeem(pendingId: bigint, hqAccountId: bigint) {
|
||
const pending = await this.prisma.redeemPendingRecord.findUnique({
|
||
where: { id: pendingId },
|
||
});
|
||
if (!pending) throw new NotFoundException('待处理核销单不存在');
|
||
if (pending.status === 'COMPLETED' && pending.redeemRecordId) {
|
||
const record = await this.prisma.redeemRecord.findUnique({
|
||
where: { id: pending.redeemRecordId },
|
||
});
|
||
return serializeBigInt({ pending, record });
|
||
}
|
||
if (pending.status !== 'PENDING') {
|
||
throw new BadRequestException('待处理单状态不可补核销');
|
||
}
|
||
|
||
const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId);
|
||
|
||
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
|
||
const normalizedAllocations = allocationsRaw.map((item) => ({
|
||
couponId: String(item.couponId),
|
||
amount: Number(item.amount),
|
||
}));
|
||
await this.validateAllocations(normalizedAllocations);
|
||
|
||
const amount = Number(pending.amount);
|
||
const record = await this.executeRedeem(
|
||
account,
|
||
pending.userId,
|
||
amount,
|
||
normalizedAllocations,
|
||
{ channel: 'token', tokenSuffix: pending.redeemToken.slice(-8) },
|
||
);
|
||
|
||
const now = new Date();
|
||
const updated = await this.prisma.redeemPendingRecord.update({
|
||
where: { id: pending.id },
|
||
data: {
|
||
status: 'COMPLETED',
|
||
redeemRecordId: record.id,
|
||
processedAt: now,
|
||
processedByHqId: hqAccountId,
|
||
},
|
||
include: {
|
||
store: { select: { id: true, name: true } },
|
||
user: { select: { id: true, userNo: true, phone: true } },
|
||
redeemRecord: true,
|
||
},
|
||
});
|
||
|
||
await this.redis.setJson(
|
||
`redeem:result:${pending.redeemToken}`,
|
||
{
|
||
recordId: record.id.toString(),
|
||
redeemNo: record.redeemNo,
|
||
userId: pending.userId.toString(),
|
||
amount,
|
||
storeId: account.storeId.toString(),
|
||
storeName: account.store.name,
|
||
createdAt: record.createdAt.toISOString(),
|
||
} satisfies RedeemResultPayload,
|
||
REDEEM_RESULT_TTL_SECONDS,
|
||
);
|
||
await this.redis.del(`redeem:token:${pending.redeemToken}`);
|
||
await this.redis.del(`redeem:pending-snapshot:${pending.redeemToken}`);
|
||
await this.redis.del(this.netFailKey(pending.storeAccountId, pending.redeemToken));
|
||
|
||
this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: pending.storeId,
|
||
eventName: 'store_redeem_pending_complete',
|
||
refType: 'REDEEM_PENDING',
|
||
refId: pending.id,
|
||
extraJson: {
|
||
pendingNo: pending.pendingNo,
|
||
redeemToken: pending.redeemToken,
|
||
redeemNo: record.redeemNo,
|
||
hqAccountId: hqAccountId.toString(),
|
||
},
|
||
});
|
||
|
||
return serializeBigInt({ pending: updated, record });
|
||
}
|
||
|
||
async rejectPendingRedeem(pendingId: bigint, hqAccountId: bigint, reason: string) {
|
||
const pending = await this.prisma.redeemPendingRecord.findUnique({
|
||
where: { id: pendingId },
|
||
});
|
||
if (!pending) throw new NotFoundException('待处理核销单不存在');
|
||
if (pending.status !== 'PENDING') {
|
||
throw new BadRequestException('仅待处理状态可驳回');
|
||
}
|
||
const rejectReason = reason?.trim();
|
||
if (!rejectReason) throw new BadRequestException('请填写驳回原因');
|
||
|
||
const updated = await this.prisma.redeemPendingRecord.update({
|
||
where: { id: pendingId },
|
||
data: {
|
||
status: 'REJECTED',
|
||
rejectReason,
|
||
processedAt: new Date(),
|
||
processedByHqId: hqAccountId,
|
||
},
|
||
});
|
||
|
||
this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, {
|
||
storeId: pending.storeId,
|
||
eventName: 'store_redeem_pending_reject',
|
||
refType: 'REDEEM_PENDING',
|
||
refId: pending.id,
|
||
extraJson: {
|
||
pendingNo: pending.pendingNo,
|
||
redeemToken: pending.redeemToken,
|
||
reason: rejectReason,
|
||
hqAccountId: hqAccountId.toString(),
|
||
},
|
||
});
|
||
|
||
return serializeBigInt(updated);
|
||
}
|
||
|
||
async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||
});
|
||
const [list, total] = await Promise.all([
|
||
this.prisma.redeemRecord.findMany({
|
||
where: { storeId },
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
include: { payout: true },
|
||
}),
|
||
this.prisma.redeemRecord.count({ where: { storeId } }),
|
||
]);
|
||
return { list: serializeBigInt(list), total, page, pageSize };
|
||
}
|
||
|
||
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||
include: { store: true },
|
||
});
|
||
const start = new Date();
|
||
start.setHours(0, 0, 0, 0);
|
||
const records = await this.prisma.redeemRecord.findMany({
|
||
where: { storeId, createdAt: { gte: start } },
|
||
});
|
||
const todayCount = records.length;
|
||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||
const recent = await this.prisma.redeemRecord.findMany({
|
||
where: { storeId },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 3,
|
||
});
|
||
return serializeBigInt({
|
||
store: binding.store,
|
||
todayCount,
|
||
todayAmount,
|
||
recentRecords: recent,
|
||
});
|
||
}
|
||
|
||
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||
const record = await this.prisma.redeemRecord.findFirst({
|
||
where: { id: BigInt(body.redeemRecordId), userId },
|
||
});
|
||
if (!record) throw new NotFoundException('核销记录不存在');
|
||
const rating = await this.prisma.storeRating.create({
|
||
data: {
|
||
redeemRecordId: record.id,
|
||
storeId: record.storeId,
|
||
serviceScore: body.serviceScore,
|
||
envScore: body.envScore,
|
||
},
|
||
});
|
||
return serializeBigInt(rating);
|
||
}
|
||
}
|