Files
dukang/server/dukang-api/src/modules/redeem/redeem.service.ts
T

384 lines
13 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import {
calcRedeemSettleAmount,
generateRedeemNo,
validateRedeemAmount,
allocateBenefitCoupons,
} from '@dukang/domain';
import { REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS } 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';
type TokenPayload = {
userId: string;
couponId?: string;
amount: number;
storeId?: string | null;
allocations?: Array<{ couponId: string; amount: number }>;
};
type RedeemResultPayload = {
recordId: string;
redeemNo: string;
userId: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
@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,
) {}
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, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
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}`);
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: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
},
});
return serializeBigInt({
token,
amount: cached.amount,
user,
boundStoreId: cached.storeId,
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
expireInSeconds: ttl > 0 ? ttl : 0,
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
});
}
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 cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.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('核销码数据异常');
}
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);
}
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;
}
await this.redis.setJson(
`redeem:result:${body.token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
userId: cached.userId,
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:${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);
}
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
});
const [list, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where: { storeId: account.storeId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { payout: true },
}),
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
]);
return { list: serializeBigInt(list), total, page, pageSize };
}
async getShopDashboard(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
const start = new Date();
start.setHours(0, 0, 0, 0);
const records = await this.prisma.redeemRecord.findMany({
where: { storeId: account.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: account.storeId },
orderBy: { createdAt: 'desc' },
take: 3,
});
return serializeBigInt({
store: account.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);
}
}