init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user