Files
dukang/server/dukang-api/src/modules/redeem/redeem.service.ts
T
jacy 26334ed072
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新2
2026-08-20 20:09:05 +08:00

1342 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import {
calcRedeemSettleAmount,
generateRedeemNo,
generateRedeemPendingNo,
REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate,
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';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type TokenPayload = {
userId: string;
couponId?: string;
amount: number;
storeId?: string | null;
allocations?: Array<{ couponId: string; amount: number }>;
};
/** C 端公示:用户138****5678 / 用户*** */
function maskRedeemUserLabel(phone?: string | null): string {
const digits = String(phone || '').replace(/\D/g, '');
if (digits.length >= 7) {
return `用户${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
return '用户***';
}
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 readonly wecomPush: WecomMessagePushService,
) {}
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 amountNum = Number(amount);
if (!Number.isFinite(amountNum) || amountNum <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
const settlementRate = resolveSettlementRate(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amountNum, settlementRate);
if (!Number.isFinite(settleAmount) || settleAmount < 0) {
throw new BadRequestException('结算金额计算异常');
}
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
this.prisma.store.findUnique({
where: { id: account.storeId },
select: { isTest: true },
}),
]);
const isTest = !!(userRow?.isTest || storeRow?.isTest);
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: amountNum,
settleAmount,
channel: redeemChannel,
isTest,
allocations: {
create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId),
amount: item.amount,
sortOrder: index,
})),
},
},
});
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amountNum,
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: amountNum,
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: amountNum,
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,
});
if (!isTest) {
const channelLabel = redeemChannel === 'PHONE' ? '手机号' : '扫码';
void this.wecomPush.dispatchEvent(
'redeem.success',
{
redeemNo: record.redeemNo,
amount: amountNum.toFixed(2),
storeName: account.store.name || String(account.storeId),
channel: channelLabel,
},
{
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
},
);
}
return {
...record,
amount: amountNum,
settleAmount,
};
}
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 }) {
const amount = Number(body.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('核销金额必须大于 0');
}
body = { ...body, amount };
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,
);
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'benefit_redeem_start',
refType: body.storeId ? 'STORE' : 'BENEFIT_COUPON',
refId: body.storeId ? BigInt(body.storeId) : primaryCouponId,
extraJson: {
amount: body.amount,
storeId: body.storeId ?? null,
couponId: primaryCouponId.toString(),
},
});
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);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('待处理单核销金额无效');
}
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.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
payout: r.payout
? {
...r.payout,
redeemAmount: Number(r.payout.redeemAmount),
payoutAmount: Number(r.payout.payoutAmount),
settlementRate: Number(r.payout.settlementRate),
}
: r.payout,
})),
),
total,
page,
pageSize,
};
}
async getShopRedeemStats(
storeAccountId: bigint,
storeId: bigint,
range: 'today' | '7d' | '30d' = 'today',
) {
await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
});
const start = new Date();
start.setHours(0, 0, 0, 0);
if (range === '7d') start.setDate(start.getDate() - 6);
if (range === '30d') start.setDate(start.getDate() - 29);
const records = await this.prisma.redeemRecord.findMany({
where: { storeId, createdAt: { gte: start } },
select: { channel: true, amount: true, settleAmount: true },
});
const buckets: Record<'SCAN' | 'PHONE', { count: number; amount: number; settleAmount: number }> = {
SCAN: { count: 0, amount: 0, settleAmount: 0 },
PHONE: { count: 0, amount: 0, settleAmount: 0 },
};
for (const r of records) {
const key = r.channel === 'PHONE' ? 'PHONE' : 'SCAN';
buckets[key].count += 1;
buckets[key].amount += Number(r.amount);
buckets[key].settleAmount += Number(r.settleAmount);
}
const byChannel = (['SCAN', 'PHONE'] as const).map((channel) => ({
channel,
count: buckets[channel].count,
amount: Number(buckets[channel].amount.toFixed(2)),
settleAmount: Number(buckets[channel].settleAmount.toFixed(2)),
}));
return {
range,
totalCount: records.length,
totalAmount: Number(byChannel.reduce((s, b) => s + b.amount, 0).toFixed(2)),
totalSettleAmount: Number(byChannel.reduce((s, b) => s + b.settleAmount, 0).toFixed(2)),
byChannel,
};
}
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 todayScanCount = records.filter((r) => r.channel !== 'PHONE').length;
const todayPhoneCount = records.filter((r) => r.channel === 'PHONE').length;
const recent = await this.prisma.redeemRecord.findMany({
where: { storeId },
orderBy: { createdAt: 'desc' },
take: 3,
});
return serializeBigInt({
store: binding.store,
todayCount,
todayAmount,
todayScanCount,
todayPhoneCount,
recentRecords: recent.map((r) => ({
...r,
amount: Number(r.amount),
settleAmount: Number(r.settleAmount),
})),
});
}
async listUserRecords(userId: bigint, page = 1, pageSize = 20) {
const take = Math.min(Math.max(pageSize, 1), 50);
const skip = (Math.max(page, 1) - 1) * take;
const [list, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take,
include: {
store: { select: { id: true, name: true } },
rating: { select: { serviceScore: true, envScore: true } },
},
}),
this.prisma.redeemRecord.count({ where: { userId } }),
]);
return {
list: serializeBigInt(
list.map((r) => ({
id: r.id,
redeemNo: r.redeemNo,
amount: Number(r.amount),
storeId: r.storeId,
storeName: r.store?.name ?? '门店',
createdAt: r.createdAt,
rating: r.rating
? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore }
: null,
})),
),
total,
page: Math.max(page, 1),
pageSize: take,
};
}
/** C 端门店详情走马灯:脱敏用户 + 时间 + 金额 */
async listPublicStoreRecentRedeems(storeId: bigint, limit = 20) {
const take = Math.min(Math.max(limit, 1), 50);
const list = await this.prisma.redeemRecord.findMany({
where: { storeId },
orderBy: { createdAt: 'desc' },
take,
include: { user: { select: { phone: true } } },
});
const fmtAmount = (n: number) => {
if (!Number.isFinite(n)) return '0';
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
};
const fmtTime = (d: Date) => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((p) => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
};
return list.map((r) => {
const userLabel = maskRedeemUserLabel(r.user?.phone);
const amount = Number(r.amount);
const createdAt = fmtTime(r.createdAt);
return {
userLabel,
amount,
createdAt,
/** 前端可直接展示 */
text: `${userLabel} ${createdAt} 核销${fmtAmount(amount)}元`,
};
});
}
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 existing = await this.prisma.storeRating.findUnique({
where: { redeemRecordId: record.id },
});
if (existing) return serializeBigInt(existing);
const rating = await this.prisma.storeRating.create({
data: {
redeemRecordId: record.id,
storeId: record.storeId,
serviceScore: body.serviceScore,
envScore: body.envScore,
},
});
return serializeBigInt(rating);
}
}