This commit is contained in:
2026-06-30 10:33:56 +08:00
commit 6e047dc0a5
607 changed files with 65966 additions and 0 deletions
@@ -0,0 +1,251 @@
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 { SettlementService } from '../settlement/settlement.service';
@Injectable()
export class RedeemService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly settlementService: SettlementService,
) {}
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);
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 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);
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.prisma.redeemToken.create({
data: {
token,
userId,
couponId: primaryCouponId,
storeId: body.storeId ? BigInt(body.storeId) : null,
amount: body.amount,
expireAt,
},
});
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 };
}
async getToken(token: string) {
const cached = await this.redis.getJson<Record<string, unknown>>(`redeem:token:${token}`);
if (!cached) throw new NotFoundException('核销码已过期');
return cached;
}
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<{
userId: string;
couponId?: string;
amount: number;
allocations?: Array<{ couponId: string; amount: number }>;
}>(`redeem:token:${body.token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
const allocations =
cached.allocations ??
(cached.couponId
? [{ couponId: cached.couponId, amount: cached.amount }]
: []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0);
if (Math.abs(allocSum - cached.amount) > 0.001) {
throw new BadRequestException('核销码数据异常');
}
for (const alloc of allocations) {
const coupon = await this.prisma.benefitCoupon.findUnique({
where: { id: BigInt(alloc.couponId) },
});
if (!coupon) throw new BadRequestException('券不存在');
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount);
if (!check.ok) throw new BadRequestException(check.message);
}
const amount = Number(cached.amount);
const cityRule = await this.prisma.cityCommissionRule.findFirst({
where: { city: { stores: { some: { id: account.storeId } } } },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const record = await this.prisma.$transaction(async (tx) => {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = alloc.amount;
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
},
});
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.benefitLedger.create({
data: {
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType: 'STORE',
refId: account.storeId,
},
});
}
const redeemRecord = await tx.redeemRecord.create({
data: {
redeemNo: generateRedeemNo(),
userId: BigInt(cached.userId),
couponId: BigInt(allocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
});
await tx.redeemToken.updateMany({
where: { token: body.token },
data: { status: 'USED', usedAt: new Date(), storeId: account.storeId },
});
return redeemRecord;
});
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
await this.redis.del(`redeem:token:${body.token}`);
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,
}),
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);
}
}