import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { calcRedeemSettleAmount, generateRedeemNo, validateRedeemAmount, allocateBenefitCoupons, } from '@dukang/domain'; import { 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 }>; }; @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>(`redeem:token:${token}`); if (!cached) throw new NotFoundException('核销码已过期'); return cached; } 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(`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}`); 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(`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 cityRule = await this.prisma.commonCityCommissionRule.findUnique({ where: { cityId: account.store.cityId }, }); const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6; 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.del(`redeem:token:${body.token}`); this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { eventName: 'benefit_redeem_success', refType: 'STORE', refId: account.storeId, extraJson: { redeemRecordId: record.id.toString(), storeId: account.storeId.toString(), amount, }, }); 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); } }