"use server" import { db } from "@/app/server/db" import { redPackets } from "@/app/server/schema/red-packet" import { redPacketDraws } from "@/app/server/schema/red-packet-draws" import { eq, sql } from "drizzle-orm" import { randomUUID } from "crypto" import { formatAmount } from "@/lib/format-amount" import { formatAmountFromInt, parseAmountToInt, randomBigInt } from "@/lib/utils" type DrawResult = | { ok: true; amount: string } | { ok: false; message: string } export async function drawRedPacket( redPacketId: string, userId: string ): Promise { return await db.transaction(async (tx) => { /* 1️⃣ 锁定红包行 */ const [packet] = await tx .select() .from(redPackets) .where(eq(redPackets.id, redPacketId)) .for("update") if (!packet) { return { ok: false, message: "红包不存在" } } /* 2 已抽数量 & 剩余数量 */ const [{ usedCount }] = await tx .select({ usedCount: sql`COUNT(*)::int`, }) .from(redPacketDraws) .where(eq(redPacketDraws.redPacketId, redPacketId)) const remainingCount = packet.count - usedCount if (remainingCount <= 0) { return { ok: false, message: "红包已被抽完" } } /* 3 用户抽取次数校验 */ const [{ count }] = await tx .select({ count: sql`COUNT(*)::int`, }) .from(redPacketDraws) .where( sql`${redPacketDraws.redPacketId} = ${redPacketId} AND ${redPacketDraws.userId} = ${userId}` ) if (count >= (packet.maxDrawTimes ?? 1)) { return { ok: false, message: "已达到最大抽取次数" } } /* 4️⃣ 计算剩余金额 */ const [{ usedAmount }] = await tx .select({ usedAmount: sql`COALESCE(SUM(amount), 0)`, }) .from(redPacketDraws) .where(eq(redPacketDraws.redPacketId, redPacketId)) const precision = packet.currencyPrecision; const rule = packet.rule let amount: string /* 5️⃣ 金额计算(关键) */ if (rule.type === "fixed") { amount = formatAmount(rule.singleAmount, precision) } else if (rule.type === "random") { const minInt = parseAmountToInt(rule.min, precision) const maxInt = parseAmountToInt(rule.max, precision) if (minInt < 0n || maxInt < 0n) { throw new Error("Invalid min/max: negative amount") } if (minInt > maxInt) { throw new Error("Invalid min/max: min > max") } const amountInt = randomBigInt(minInt, maxInt) amount = formatAmountFromInt(amountInt, precision) } else { // 拼手气 const totalInt = parseAmountToInt(rule.totalAmount, precision) const usedInt = parseAmountToInt(usedAmount, precision) const remainInt = totalInt - usedInt if (remainInt < 0n) { throw new Error("Invalid state: remaining amount < 0") } let amountInt: bigint if (remainingCount === 1) { amountInt = remainInt } else { const count = BigInt(remainingCount) const MIN_UNIT = 1n // 1 分 / 1 sat / 1 最小单位 // max = (remain / remainingCount) * 2 const maxInt = (remainInt / count) * 2n if (maxInt < MIN_UNIT) { // 剩余过小,防御性兜底 amountInt = MIN_UNIT } else { amountInt = randomBigInt(MIN_UNIT, maxInt) } } if (amountInt < 0n) { throw new Error("Invalid amount: negative") } if (amountInt > remainInt) { throw new Error("Invalid amount: exceeds remaining") } amount = formatAmountFromInt(amountInt, precision) } /* 6️⃣ 写入抽取记录 */ await tx.insert(redPacketDraws).values({ id: randomUUID(), redPacketId, userId, amount, }) return { ok: true, amount } }) }