手机号核销确认

This commit is contained in:
2026-07-12 10:34:58 +08:00
parent 7f031cc4c2
commit d949301bc1
17 changed files with 1285 additions and 215 deletions
+2
View File
@@ -14,6 +14,8 @@ MOCK_SMS_CODE=123456
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
@@ -58,13 +58,24 @@ export class SmsAliyunProvider implements ISmsProvider {
return this.client;
}
private getTemplateCode(scene: string): string {
if (
scene === 'REDEEM_PHONE_CONFIRM' &&
this.config.aliyunSmsRedeemConfirmTemplateCode
) {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
return this.config.aliyunSmsTemplateCode;
}
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
const code = await this.smsCodeStore.generateAndStore(phone, scene);
const masked = maskPhone(phone);
const templateCode = this.getTemplateCode(scene);
const request = new SendSmsRequest({
phoneNumbers: phone,
signName: this.config.aliyunSmsSignName,
templateCode: this.config.aliyunSmsTemplateCode,
templateCode,
templateParam: JSON.stringify({ code }),
});
@@ -75,7 +86,7 @@ export class SmsAliyunProvider implements ISmsProvider {
refId: actorRef?.refId,
requestBody: {
phone: masked,
templateCode: this.config.aliyunSmsTemplateCode,
templateCode,
signName: this.config.aliyunSmsSignName,
},
};
@@ -87,6 +87,9 @@ export class AuthService {
return ClientApp.PARTNER_H5;
case SmsScene.HQ_LOGIN:
return ClientApp.HQ_WEB;
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
default:
return ClientApp.USER_H5;
}
@@ -129,6 +132,14 @@ export class AuthService {
});
return account ? { refType: 'HQ', refId: account.id } : undefined;
}
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
default:
return undefined;
}
@@ -259,6 +270,15 @@ export class AuthService {
if (existing) throw new BadRequestException('该手机号已被使用');
return;
}
if (scene === SmsScene.REDEEM_PHONE_LOOKUP || scene === SmsScene.REDEEM_PHONE_CONFIRM) {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true, phoneVerifiedAt: true },
});
if (!user) throw new BadRequestException('该手机号未注册好客用户');
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
}
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
@@ -5,6 +5,10 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@@ -42,4 +46,30 @@ export class AdminRedeemDebugController {
confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
return this.service.confirm(dto);
}
@Post('phone/send-lookup-sms')
sendPhoneLookupSms(@Body() dto: AdminRedeemDebugPhoneStoreDto) {
return this.service.sendPhoneLookupSms(dto);
}
@Post('phone/balance')
phoneBalance(@Body() dto: AdminRedeemDebugPhoneBalanceDto) {
return this.service.phoneBalance(dto);
}
@Post('phone/prepare')
phonePrepare(@Body() dto: AdminRedeemDebugPhonePrepareDto) {
return this.service.phonePrepare(dto);
}
@Post('phone/confirm')
@HqOperation({
action: HqOperationAction.REDEEM_DEBUG_CONFIRM,
refType: 'REDEEM_DEBUG',
batch: true,
includeBody: true,
})
phoneConfirm(@Body() dto: AdminRedeemDebugPhoneConfirmDto) {
return this.service.phoneConfirm(dto);
}
}
@@ -3,6 +3,10 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { RedeemService } from '../redeem/redeem.service';
import type {
AdminRedeemDebugCreateTokenDto,
AdminRedeemDebugPhoneBalanceDto,
AdminRedeemDebugPhoneConfirmDto,
AdminRedeemDebugPhonePrepareDto,
AdminRedeemDebugPhoneStoreDto,
AdminRedeemDebugStoreTokenDto,
} from './dto/admin-mutate.dto';
@@ -80,4 +84,24 @@ export class AdminRedeemDebugService {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
}
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone);
}
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code);
}
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount);
}
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code);
}
}
@@ -643,6 +643,51 @@ export class AdminRedeemDebugStoreTokenDto {
token: string;
}
export class AdminRedeemDebugPhoneStoreDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
phone: string;
}
export class AdminRedeemDebugPhoneBalanceDto extends AdminRedeemDebugPhoneStoreDto {
@IsString()
@IsNotEmpty()
code: string;
}
export class AdminRedeemDebugPhonePrepareDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
sessionId: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
}
export class AdminRedeemDebugPhoneConfirmDto {
@IsString()
@IsNotEmpty()
storeId: string;
@IsString()
@IsNotEmpty()
sessionId: string;
@IsString()
@IsNotEmpty()
code: string;
}
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
@@ -0,0 +1,39 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
export class RedeemPhoneSendLookupSmsDto {
@IsString()
@IsNotEmpty()
phone: string;
}
export class RedeemPhoneBalanceDto {
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
code: string;
}
export class RedeemPhonePrepareDto {
@IsString()
@IsNotEmpty()
sessionId: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
}
export class RedeemPhoneConfirmDto {
@IsString()
@IsNotEmpty()
sessionId: string;
@IsString()
@IsNotEmpty()
code: string;
}
@@ -2,6 +2,12 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { RedeemService } from './redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
RedeemPhoneBalanceDto,
RedeemPhoneConfirmDto,
RedeemPhonePrepareDto,
RedeemPhoneSendLookupSmsDto,
} from './dto/phone-redeem.dto';
@Controller('redeem')
@UseGuards(JwtAuthGuard)
@@ -52,4 +58,24 @@ export class ShopRedeemController {
) {
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize));
}
@Post('phone/send-lookup-sms')
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
return this.redeemService.sendPhoneLookupSms(user.actorId, body.phone);
}
@Post('phone/balance')
phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) {
return this.redeemService.verifyPhoneAndGetBalance(user.actorId, body.phone, body.code);
}
@Post('phone/prepare')
phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) {
return this.redeemService.preparePhoneRedeem(user.actorId, body.sessionId, body.amount);
}
@Post('phone/confirm')
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
return this.redeemService.confirmPhoneRedeem(user.actorId, body.sessionId, body.code);
}
}
@@ -10,13 +10,20 @@ import {
validateRedeemAmount,
allocateBenefitCoupons,
} from '@dukang/domain';
import { REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import {
ClientApp,
REDEEM_PHONE_SESSION_TTL_SECONDS,
REDEEM_RESULT_TTL_SECONDS,
REDEEM_TOKEN_TTL_SECONDS,
SmsScene,
} from '@dukang/shared-types';
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';
type TokenPayload = {
userId: string;
@@ -36,6 +43,16 @@ type RedeemResultPayload = {
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(
@@ -44,8 +61,307 @@ export class RedeemService {
private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService,
private readonly authService: AuthService,
) {}
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) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
return account;
}
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,
},
});
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, phone: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
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, phone: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
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 preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) {
const account = await this.loadOpenStoreAccount(storeAccountId);
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, sessionId: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
}
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 }) {
let allocations: Array<{ couponId: string; amount: number }>;
@@ -139,13 +455,7 @@ export class RedeemService {
}
async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const account = await this.loadOpenStoreAccount(storeAccountId);
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
@@ -184,13 +494,7 @@ export class RedeemService {
}
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const account = await this.loadOpenStoreAccount(storeAccountId);
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
@@ -220,61 +524,15 @@ export class RedeemService {
throw new BadRequestException('核销码数据异常');
}
for (const alloc of normalizedAllocations) {
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);
}
await this.validateAllocations(normalizedAllocations);
const amount = tokenAmount;
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: BigInt(cached.userId),
couponId: BigInt(normalizedAllocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
});
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 record = await this.executeRedeem(
account,
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: body.token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${body.token}`,
@@ -282,7 +540,7 @@ export class RedeemService {
recordId: record.id.toString(),
redeemNo: record.redeemNo,
userId: cached.userId,
amount,
amount: tokenAmount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
@@ -291,35 +549,6 @@ export class RedeemService {
);
await this.redis.del(`redeem:token:${body.token}`);
const redeemExtra = {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
};
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
storeId: account.storeId,
eventName: 'store_redeem_confirm',
refType: 'REDEEM_RECORD',
refId: record.id,
extraJson: {
redeemNo: record.redeemNo,
amount,
userId: cached.userId,
},
});
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
eventName: 'benefit_redeem_success',
refType: 'STORE',
refId: account.storeId,
extraJson: redeemExtra,
});
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'USER_H5', {
eventName: 'benefit_redeem_success',
refType: 'STORE',
refId: account.storeId,
extraJson: redeemExtra,
});
return serializeBigInt(record);
}