Compare commits
5 Commits
4b29089ec2
...
ea63c83cbe
| Author | SHA1 | Date | |
|---|---|---|---|
| ea63c83cbe | |||
| 0c6e642a33 | |||
| 25d3865a6c | |||
| b505c48a0f | |||
| 07e4df306e |
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws"
|
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws"
|
||||||
import { formatAmount } from "@/lib/format-amount"
|
import { formatAmount } from "@/lib/format-amount"
|
||||||
|
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
|
||||||
|
import { formatAmountFromInt, parseAmountToInt } from "@/lib/utils"
|
||||||
|
|
||||||
type Draw = {
|
type Draw = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,10 +14,14 @@ type Draw = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DrawList({
|
export function DrawList({
|
||||||
|
count,
|
||||||
|
rule,
|
||||||
precision,
|
precision,
|
||||||
redPacketId,
|
redPacketId,
|
||||||
initialDraws,
|
initialDraws,
|
||||||
}: {
|
}: {
|
||||||
|
count: number
|
||||||
|
rule: CreateRedPacketPayload['rule']
|
||||||
precision: number
|
precision: number
|
||||||
redPacketId: string
|
redPacketId: string
|
||||||
initialDraws: Draw[]
|
initialDraws: Draw[]
|
||||||
@@ -37,6 +43,39 @@ export function DrawList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getBalance = useCallback(() => {
|
||||||
|
const used = draws.reduce((p, draw) => {
|
||||||
|
const pInt = parseAmountToInt(p, precision)
|
||||||
|
const dInt = parseAmountToInt(draw.amount, precision)
|
||||||
|
return formatAmountFromInt(dInt + pInt, precision)
|
||||||
|
}, '0.0');
|
||||||
|
|
||||||
|
if (rule.type === 'fixed') {
|
||||||
|
const amount = formatAmountFromInt(
|
||||||
|
parseAmountToInt(rule.singleAmount, precision) * BigInt(count),
|
||||||
|
precision
|
||||||
|
)
|
||||||
|
|
||||||
|
return formatAmountFromInt(
|
||||||
|
parseAmountToInt(amount, precision) - parseAmountToInt(used, precision),
|
||||||
|
precision,
|
||||||
|
)
|
||||||
|
} else if (rule.type === 'luck') {
|
||||||
|
const amount = formatAmountFromInt(
|
||||||
|
parseAmountToInt(rule.totalAmount, precision),
|
||||||
|
precision
|
||||||
|
)
|
||||||
|
|
||||||
|
return formatAmountFromInt(
|
||||||
|
parseAmountToInt(amount, precision) - parseAmountToInt(used, precision),
|
||||||
|
precision,
|
||||||
|
)
|
||||||
|
} else if (rule.type === 'random') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [rule, draws])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border p-3 space-y-2">
|
<section className="rounded-lg border p-3 space-y-2">
|
||||||
{/* 标题 + 刷新按钮 */}
|
{/* 标题 + 刷新按钮 */}
|
||||||
@@ -54,31 +93,39 @@ export function DrawList({
|
|||||||
{draws.length === 0 ? (
|
{draws.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400">暂无领取记录</div>
|
<div className="text-sm text-gray-400">暂无领取记录</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<>
|
||||||
{draws.map((draw) => (
|
<ul className="space-y-1">
|
||||||
<li
|
{draws.map((draw) => (
|
||||||
key={draw.id}
|
<li
|
||||||
className="flex justify-between items-center text-sm font-mono"
|
key={draw.id}
|
||||||
>
|
className="flex justify-between items-center text-sm font-mono"
|
||||||
{/* 用户 */}
|
>
|
||||||
<span>
|
{/* 用户 */}
|
||||||
{draw.userId.slice(0, 6)}…
|
<span>
|
||||||
</span>
|
{draw.userId.slice(0, 6)}…
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
{/* 金额 */}
|
|
||||||
<span className="text-right">
|
|
||||||
{formatAmount(draw.amount, precision)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 时间 */}
|
<div className="flex flex-1 flex-col">
|
||||||
<span className="text-right text-gray-400">
|
{/* 金额 */}
|
||||||
{draw.createdAt.toLocaleString()}
|
<span className="text-right">
|
||||||
</span>
|
{formatAmount(draw.amount, precision)}
|
||||||
</div>
|
</span>
|
||||||
</li>
|
|
||||||
))}
|
{/* 时间 */}
|
||||||
</ul>
|
<span className="text-right text-gray-400">
|
||||||
|
{draw.createdAt.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{getBalance() && <div className="border-t py-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span>剩余:</span>
|
||||||
|
<span>{getBalance()}</span>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ export default async function AdminRedPacketPage({ params }: Props) {
|
|||||||
|
|
||||||
<section className="rounded-lg border p-3">
|
<section className="rounded-lg border p-3">
|
||||||
<div className="text-gray-500 mb-1">红包规则</div>
|
<div className="text-gray-500 mb-1">红包规则</div>
|
||||||
<pre className="text-sm bg-gray-50 rounded p-2 overflow-x-auto">
|
<pre className="text-sm bg-gray-50 dark:bg-gray-900 rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(redPacket.rule, null, 2)}
|
{JSON.stringify(redPacket.rule, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DrawList
|
<DrawList
|
||||||
|
count={redPacket.count}
|
||||||
|
rule={redPacket.rule}
|
||||||
precision={precision}
|
precision={precision}
|
||||||
redPacketId={redPacketId}
|
redPacketId={redPacketId}
|
||||||
initialDraws={initialDraws}
|
initialDraws={initialDraws}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function CreateRedPacketPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 px-4 py-6">
|
<div className="min-h-screen bg-gray-100 dark:bg-zinc-900 px-4 py-6">
|
||||||
<div className="mx-auto max-w-md space-y-5">
|
<div className="mx-auto max-w-md space-y-5">
|
||||||
<h1 className="text-center text-2xl font-semibold">创建红包</h1>
|
<h1 className="text-center text-2xl font-semibold">创建红包</h1>
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ export function CreateRedPacketPage() {
|
|||||||
/* ---------- UI helpers ---------- */
|
/* ---------- UI helpers ---------- */
|
||||||
|
|
||||||
function Card({ children }: { children: React.ReactNode }) {
|
function Card({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="space-y-4 rounded-2xl bg-white p-4">{children}</div>
|
return <div className="space-y-4 rounded-2xl bg-white dark:bg-black p-4">{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function createRedPacket(
|
|||||||
|
|
||||||
count: payload.count,
|
count: payload.count,
|
||||||
|
|
||||||
currencyName: payload.currency.name,
|
currencyName: payload.currency.name.trim(),
|
||||||
currencyPrecision: payload.currency.precision,
|
currencyPrecision: payload.currency.precision,
|
||||||
|
|
||||||
rule: payload.rule,
|
rule: payload.rule,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { redPacketDraws } from "@/app/server/schema/red-packet-draws"
|
|||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
import { formatAmount } from "@/lib/format-amount"
|
import { formatAmount } from "@/lib/format-amount"
|
||||||
|
import { formatAmountFromInt, parseAmountToInt, randomBigInt } from "@/lib/utils"
|
||||||
|
|
||||||
type DrawResult =
|
type DrawResult =
|
||||||
| { ok: true; amount: string }
|
| { ok: true; amount: string }
|
||||||
@@ -65,26 +66,65 @@ export async function drawRedPacket(
|
|||||||
|
|
||||||
const precision = packet.currencyPrecision;
|
const precision = packet.currencyPrecision;
|
||||||
const rule = packet.rule
|
const rule = packet.rule
|
||||||
|
|
||||||
let amount: string
|
let amount: string
|
||||||
|
|
||||||
/* 5️⃣ 金额计算(关键) */
|
/* 5️⃣ 金额计算(关键) */
|
||||||
if (rule.type === "fixed") {
|
if (rule.type === "fixed") {
|
||||||
amount = formatAmount(rule.singleAmount, precision)
|
amount = formatAmount(rule.singleAmount, precision)
|
||||||
} else if (rule.type === "random") {
|
} else if (rule.type === "random") {
|
||||||
const min = Number(rule.min)
|
const minInt = parseAmountToInt(rule.min, precision)
|
||||||
const max = Number(rule.max)
|
const maxInt = parseAmountToInt(rule.max, precision)
|
||||||
amount = randomByPrecision(min, 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 {
|
} else {
|
||||||
// 拼手气
|
// 拼手气
|
||||||
const total = Number(rule.totalAmount)
|
const totalInt = parseAmountToInt(rule.totalAmount, precision)
|
||||||
const remain = total - Number(usedAmount)
|
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) {
|
if (remainingCount === 1) {
|
||||||
amount = remain.toFixed(precision)
|
amountInt = remainInt
|
||||||
} else {
|
} else {
|
||||||
const max = remain / remainingCount * 2
|
const count = BigInt(remainingCount)
|
||||||
amount = randomByPrecision(0.01, max, precision)
|
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️⃣ 写入抽取记录 */
|
/* 6️⃣ 写入抽取记录 */
|
||||||
@@ -97,18 +137,4 @@ export async function drawRedPacket(
|
|||||||
|
|
||||||
return { ok: true, amount }
|
return { ok: true, amount }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomByPrecision(
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
precision: number
|
|
||||||
) {
|
|
||||||
const factor = Math.pow(10, precision)
|
|
||||||
const value =
|
|
||||||
Math.floor(
|
|
||||||
(Math.random() * (max - min) + min) * factor
|
|
||||||
) / factor
|
|
||||||
|
|
||||||
return value.toFixed(precision)
|
|
||||||
}
|
|
||||||
@@ -4,3 +4,48 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAmountToInt(amountStr: string, precision: number): bigint {
|
||||||
|
// 例:"123.45", precision=2 → 12345n
|
||||||
|
// 例:"0.00001234", precision=8 → 1234n
|
||||||
|
|
||||||
|
// 去掉正负号处理(如不需要可删)
|
||||||
|
const negative = amountStr.startsWith("-")
|
||||||
|
const normalizedStr = negative ? amountStr.slice(1) : amountStr
|
||||||
|
|
||||||
|
const [intPart, fracRaw = ""] = normalizedStr.split(".")
|
||||||
|
|
||||||
|
// 关键修复点:允许尾随 0
|
||||||
|
const fracTrimmed = fracRaw.replace(/0+$/, "")
|
||||||
|
|
||||||
|
if (fracTrimmed.length > precision) {
|
||||||
|
throw new Error(
|
||||||
|
`Amount ${amountStr} exceeds allowed precision ${precision}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedFrac = fracTrimmed.padEnd(precision, "0")
|
||||||
|
const combined = intPart + paddedFrac
|
||||||
|
|
||||||
|
const value = BigInt(combined)
|
||||||
|
|
||||||
|
return negative ? -value : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmountFromInt(value: bigint, precision: number): string {
|
||||||
|
const s = value.toString().padStart(precision + 1, "0")
|
||||||
|
|
||||||
|
if (precision === 0) return s
|
||||||
|
|
||||||
|
const i = s.length - precision
|
||||||
|
return `${s.slice(0, i)}.${s.slice(i)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomBigInt(min: bigint, max: bigint): bigint {
|
||||||
|
if (max < min) throw new Error("max < min")
|
||||||
|
|
||||||
|
const range = max - min + 1n
|
||||||
|
const rand = BigInt(Math.floor(Math.random() * Number(range)))
|
||||||
|
|
||||||
|
return min + rand
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export function validateCreateRedPacketPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 金额单位 */
|
/* 金额单位 */
|
||||||
if (!currency.name) {
|
if (!currency.name.trim()) {
|
||||||
return { ok: false, message: "金额单位名称不能为空" }
|
return { ok: false, message: "金额单位名称不能为空" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user