140 lines
4.4 KiB
TypeScript
140 lines
4.4 KiB
TypeScript
"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<DrawResult> {
|
||
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<number>`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<number>`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<string>`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 }
|
||
})
|
||
} |