384 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|