手机号核销确认
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user