Compare commits

...

4 Commits

Author SHA1 Message Date
4b29089ec2 feat: 永远显示单用户最大支持抽取次数 2026-01-09 10:24:33 +08:00
d4ca396ab2 feat: 支持新的金额表示格式 2026-01-09 10:23:09 +08:00
9a48cc1dee chore: 快照忘了.. 2026-01-09 10:02:55 +08:00
8bb7544b3e chore: 调整金额表示格式 2026-01-09 10:02:38 +08:00
15 changed files with 869 additions and 444 deletions

View File

@@ -3,8 +3,6 @@ import { notFound } from "next/navigation"
import { PublicIdSection } from "./public-id-section";
import { DrawList } from "./draw-list.client";
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws";
import { getDecimalPlacesFromStep } from "@/lib/currency";
import { formatAmount } from "@/lib/format-amount";
export const metadata = {
title: "红包管理 - tonesc 红包",
@@ -37,7 +35,7 @@ export default async function AdminRedPacketPage({ params }: Props) {
const initialDraws = await getRedPacketDrawsByRedPacketId(redPacketId);
const precision = getDecimalPlacesFromStep(redPacket.currencyPrecision);
const precision = redPacket.currencyPrecision;
return (
<main className="mx-auto max-w-md p-4 space-y-4">
@@ -57,16 +55,14 @@ export default async function AdminRedPacketPage({ params }: Props) {
<div>
<span className="text-gray-500"></span>
<span>
{redPacket.currencyName} {formatAmount(redPacket.currencyPrecision, precision)}
{redPacket.currencyName} {precision}
</span>
</div>
{redPacket.maxDrawTimes !== null && (
<div>
<span className="text-gray-500"></span>
<span>{redPacket.maxDrawTimes}</span>
</div>
)}
<div>
<span className="text-gray-500"></span>
<span>{redPacket.maxDrawTimes ?? 1}</span>
</div>
</section>
<section className="rounded-lg border p-3">

View File

@@ -0,0 +1,393 @@
"use client"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
import { createRedPacket } from "../server/actions/create-red-packet"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
type RuleType = "fixed" | "random" | "luck"
type CurrencyUnit = {
name: string
precision: number
}
type CurrencyUnitPreset = {
key: string
name: string
precision: number
custom?: boolean
}
const CURRENCY_PRESETS: CurrencyUnitPreset[] = [
{ key: "rmb", name: "人民币", precision: 2 },
{ key: "custom", name: "自定义", precision: 0, custom: true },
]
/* ---------- utils ---------- */
function formatByPrecision(value: string, precision: number) {
if (!value) return ""
const num = Number(value)
if (Number.isNaN(num)) return ""
return num.toFixed(precision)
}
function isValidPrecision(p: string) {
const n = Number(p)
if (isNaN(n)) return false
if (n < 0 || n > 8) return false
return true
}
function buildCreatePayload(params: {
count: number
currency: { name: string; precision: number }
ruleType: RuleType
singleAmount: string
minAmount: string
maxAmount: string
totalAmount: string
maxDrawTimes?: string
}): CreateRedPacketPayload {
const {
count,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
} = params
const rule =
ruleType === "fixed"
? {
type: "fixed",
singleAmount,
} as const
: ruleType === "random"
? {
type: "random",
min: minAmount,
max: maxAmount,
} as const
: {
type: "luck",
totalAmount,
} as const;
return {
count,
currency,
rule,
maxDrawTimes: maxDrawTimes
? Number(maxDrawTimes)
: null,
}
}
/* ---------- page ---------- */
export function CreateRedPacketPage() {
const router = useRouter();
/* 红包数量(允许为空) */
const [count, setCount] = useState("1")
const parsedCount = Math.max(1, Number(count) || 1)
/* 金额单位 */
const [unitKey, setUnitKey] = useState("rmb")
const preset = CURRENCY_PRESETS.find((u) => u.key === unitKey)!
const [customUnitName, setCustomUnitName] = useState("")
const [customPrecision, setCustomPrecision] = useState("0")
const currency: CurrencyUnit = preset.custom
? {
name: customUnitName,
precision: Number(customPrecision) || preset.precision,
}
: {
name: preset.name,
precision: preset.precision,
}
const precision = currency.precision
/* 规则 */
const [ruleType, setRuleType] = useState<RuleType>("fixed")
/* 金额 */
const [singleAmount, setSingleAmount] = useState("")
const [minAmount, setMinAmount] = useState("")
const [maxAmount, setMaxAmount] = useState("")
const [totalAmount, setTotalAmount] = useState("")
/* 抽取次数 */
const [maxDrawTimes, setMaxDrawTimes] = useState("")
/* ---------- total estimate ---------- */
function renderTotalEstimate() {
if (ruleType === "fixed" && singleAmount) {
return `总金额:${formatByPrecision(
(Number(singleAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
if (ruleType === "luck" && totalAmount) {
return `总金额:${formatByPrecision(
totalAmount,
precision
)} ${currency.name}`
}
if (ruleType === "random" && minAmount && maxAmount) {
return `总金额范围:${formatByPrecision(
(Number(minAmount) * parsedCount).toString(),
precision
)} ${formatByPrecision(
(Number(maxAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
return null
}
return (
<div className="min-h-screen bg-gray-100 px-4 py-6">
<div className="mx-auto max-w-md space-y-5">
<h1 className="text-center text-2xl font-semibold"></h1>
<Card>
<Field label="红包数量">
<Input
type="number"
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={() => {
if (!count || Number(count) < 1) setCount("1")
}}
/>
</Field>
<Field label="金额单位">
<Select value={unitKey} onValueChange={setUnitKey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCY_PRESETS.map((u) => (
<SelectItem key={u.key} value={u.key}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{preset.custom && (
<>
<Field label="单位名称">
<Input
value={customUnitName}
onChange={(e) => setCustomUnitName(e.target.value)}
/>
</Field>
<Field label="金额精度支持08">
<Input
type="number"
value={customPrecision}
onChange={(e) => {
let n = Number(e.target.value);
if (isNaN(n)) n = 0;
setCustomPrecision(`${n}`)
}}
/>
{!isValidPrecision(customPrecision) && (
<p className="text-xs text-red-500"></p>
)}
</Field>
</>
)}
</Card>
<Card>
<RuleSelector value={ruleType} onChange={setRuleType} />
{ruleType === "fixed" && (
<MoneyInput
label="单个红包金额"
value={singleAmount}
precision={precision}
onChange={setSingleAmount}
/>
)}
{ruleType === "random" && (
<>
<MoneyInput
label="最小金额"
value={minAmount}
precision={precision}
onChange={setMinAmount}
/>
<MoneyInput
label="最大金额"
value={maxAmount}
precision={precision}
onChange={setMaxAmount}
/>
</>
)}
{ruleType === "luck" && (
<MoneyInput
label="总金额"
value={totalAmount}
precision={precision}
onChange={setTotalAmount}
/>
)}
</Card>
{parsedCount >= 2 && (
<Card>
<Field label="单个用户最大抽取次数(可选)">
<Input
inputMode="numeric"
placeholder="不填默认为1"
value={maxDrawTimes}
onChange={(e) => setMaxDrawTimes(e.target.value)}
/>
</Field>
</Card>
)}
{renderTotalEstimate() && (
<div className="rounded-xl bg-blue-50 px-4 py-3 text-sm text-blue-700">
{renderTotalEstimate()}
</div>
)}
<Button
className="h-12 w-full text-base"
onClick={async () => {
const payload = buildCreatePayload({
count: parsedCount,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
})
const result = await createRedPacket(payload).catch((e) => {
toast.error(`${e.message || '请求失败'}`)
return null;
});
if (result && result.adminId) {
router.push(`/admin/${result.adminId}`)
}
}}
>
</Button>
</div>
</div>
)
}
/* ---------- UI helpers ---------- */
function Card({ children }: { children: React.ReactNode }) {
return <div className="space-y-4 rounded-2xl bg-white p-4">{children}</div>
}
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="space-y-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
</div>
)
}
function MoneyInput({
label,
value,
precision,
onChange,
}: {
label: string
value: string
precision: number
onChange: (v: string) => void
}) {
return (
<Field label={label}>
<Input
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onChange(formatByPrecision(value, precision))}
placeholder={`精度 ${precision}`}
/>
</Field>
)
}
function RuleSelector({
value,
onChange,
}: {
value: RuleType
onChange: (v: RuleType) => void
}) {
const rules = [
{ type: "fixed", label: "固定" },
{ type: "random", label: "随机" },
{ type: "luck", label: "拼手气" },
] as const
return (
<div className="flex rounded-xl bg-muted p-1">
{rules.map((r) => (
<button
key={r.type}
onClick={() => onChange(r.type)}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition
${value === r.type
? "bg-background shadow"
: "text-muted-foreground"
}`}
>
{r.label}
</button>
))}
</div>
)
}

View File

@@ -1,404 +1,19 @@
"use client"
import { CreateRedPacketPage } from "./create-page";
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
import { createRedPacket } from "../server/actions/create-red-packet"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
// export const metadata = {
// title: "创建红包 - tonesc 红包",
// description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
// keywords: "创建红包, 私有红包, 随机红包, 固定红包, 拼手气红包, tonesc",
// robots: "noindex, nofollow",
// openGraph: {
// title: "创建红包 - Tonesc 红包",
// description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
// url: "https://redpacket.lab.tonesc.com/create",
// siteName: "Tonesc 红包",
// type: "website"
// }
// }
type RuleType = "fixed" | "random" | "luck"
type CurrencyUnit = {
name: string
precision: number
}
type CurrencyUnitPreset = {
key: string
name: string
precision: number
custom?: boolean
}
const CURRENCY_PRESETS: CurrencyUnitPreset[] = [
{ key: "rmb", name: "人民币", precision: 0.01 },
{ key: "custom", name: "自定义", precision: 0.01, custom: true },
]
/* ---------- utils ---------- */
function decimalsFromPrecision(p: number) {
return p.toString().split(".")[1]?.length ?? 0
}
function formatByPrecision(value: string, precision: number) {
if (!value) return ""
const num = Number(value)
if (Number.isNaN(num)) return ""
return num.toFixed(decimalsFromPrecision(precision))
}
function isValidPrecision(p: string) {
return /^1$|^0\.0*1$/.test(p)
}
function buildCreatePayload(params: {
count: number
currency: { name: string; precision: number }
ruleType: RuleType
singleAmount: string
minAmount: string
maxAmount: string
totalAmount: string
maxDrawTimes?: string
}): CreateRedPacketPayload {
const {
count,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
} = params
const rule =
ruleType === "fixed"
? {
type: "fixed",
singleAmount,
} as const
: ruleType === "random"
? {
type: "random",
min: minAmount,
max: maxAmount,
} as const
: {
type: "luck",
totalAmount,
} as const;
return {
count,
currency,
rule,
maxDrawTimes: maxDrawTimes
? Number(maxDrawTimes)
: null,
export const metadata = {
title: "创建红包 - tonesc 红包",
description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
keywords: "创建红包, 私有红包, 随机红包, 固定红包, 拼手气红包, tonesc",
robots: "noindex, nofollow",
openGraph: {
title: "创建红包 - Tonesc 红包",
description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
url: "https://redpacket.lab.tonesc.com/create",
siteName: "Tonesc 红包",
type: "website"
}
}
/* ---------- page ---------- */
export default function CreateRedPacketPage() {
const router = useRouter();
/* 红包数量(允许为空) */
const [count, setCount] = useState("1")
const parsedCount = Math.max(1, Number(count) || 1)
/* 金额单位 */
const [unitKey, setUnitKey] = useState("rmb")
const preset = CURRENCY_PRESETS.find((u) => u.key === unitKey)!
const [customUnitName, setCustomUnitName] = useState("")
const [customPrecision, setCustomPrecision] = useState("0.01")
const currency: CurrencyUnit = preset.custom
? {
name: customUnitName,
precision: Number(customPrecision) || preset.precision,
}
: {
name: preset.name,
precision: preset.precision,
}
const precision = currency.precision
/* 规则 */
const [ruleType, setRuleType] = useState<RuleType>("fixed")
/* 金额 */
const [singleAmount, setSingleAmount] = useState("")
const [minAmount, setMinAmount] = useState("")
const [maxAmount, setMaxAmount] = useState("")
const [totalAmount, setTotalAmount] = useState("")
/* 抽取次数 */
const [maxDrawTimes, setMaxDrawTimes] = useState("")
/* ---------- total estimate ---------- */
function renderTotalEstimate() {
if (ruleType === "fixed" && singleAmount) {
return `总金额:${formatByPrecision(
(Number(singleAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
if (ruleType === "luck" && totalAmount) {
return `总金额:${formatByPrecision(
totalAmount,
precision
)} ${currency.name}`
}
if (ruleType === "random" && minAmount && maxAmount) {
return `总金额范围:${formatByPrecision(
(Number(minAmount) * parsedCount).toString(),
precision
)} ${formatByPrecision(
(Number(maxAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
return null
}
return (
<div className="min-h-screen bg-gray-100 px-4 py-6">
<div className="mx-auto max-w-md space-y-5">
<h1 className="text-center text-2xl font-semibold"></h1>
<Card>
<Field label="红包数量">
<Input
inputMode="numeric"
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={() => {
if (!count || Number(count) < 1) setCount("1")
}}
/>
</Field>
<Field label="金额单位">
<Select value={unitKey} onValueChange={setUnitKey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCY_PRESETS.map((u) => (
<SelectItem key={u.key} value={u.key}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{preset.custom && (
<>
<Field label="单位名称">
<Input
value={customUnitName}
onChange={(e) => setCustomUnitName(e.target.value)}
/>
</Field>
<Field label="金额精度1 / 0.1 / 0.01...">
<Input
value={customPrecision}
onChange={(e) => setCustomPrecision(e.target.value)}
/>
{!isValidPrecision(customPrecision) && (
<p className="text-xs text-red-500"></p>
)}
</Field>
</>
)}
</Card>
<Card>
<RuleSelector value={ruleType} onChange={setRuleType} />
{ruleType === "fixed" && (
<MoneyInput
label="单个红包金额"
value={singleAmount}
precision={precision}
onChange={setSingleAmount}
/>
)}
{ruleType === "random" && (
<>
<MoneyInput
label="最小金额"
value={minAmount}
precision={precision}
onChange={setMinAmount}
/>
<MoneyInput
label="最大金额"
value={maxAmount}
precision={precision}
onChange={setMaxAmount}
/>
</>
)}
{ruleType === "luck" && (
<MoneyInput
label="总金额"
value={totalAmount}
precision={precision}
onChange={setTotalAmount}
/>
)}
</Card>
{parsedCount >= 2 && (
<Card>
<Field label="单个用户最大抽取次数(可选)">
<Input
inputMode="numeric"
placeholder="不填默认为1"
value={maxDrawTimes}
onChange={(e) => setMaxDrawTimes(e.target.value)}
/>
</Field>
</Card>
)}
{renderTotalEstimate() && (
<div className="rounded-xl bg-blue-50 px-4 py-3 text-sm text-blue-700">
{renderTotalEstimate()}
</div>
)}
<Button
className="h-12 w-full text-base"
onClick={async () => {
const payload = buildCreatePayload({
count: parsedCount,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
})
const result = await createRedPacket(payload).catch((e) => {
toast.error(`${e.message || '请求失败'}`)
return null;
});
if (result && result.adminId) {
router.push(`/admin/${result.adminId}`)
}
}}
>
</Button>
</div>
</div>
)
}
/* ---------- UI helpers ---------- */
function Card({ children }: { children: React.ReactNode }) {
return <div className="space-y-4 rounded-2xl bg-white p-4">{children}</div>
}
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="space-y-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
</div>
)
}
function MoneyInput({
label,
value,
precision,
onChange,
}: {
label: string
value: string
precision: number
onChange: (v: string) => void
}) {
return (
<Field label={label}>
<Input
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onChange(formatByPrecision(value, precision))}
placeholder={`精度 ${precision}`}
/>
</Field>
)
}
function RuleSelector({
value,
onChange,
}: {
value: RuleType
onChange: (v: RuleType) => void
}) {
const rules = [
{ type: "fixed", label: "固定" },
{ type: "random", label: "随机" },
{ type: "luck", label: "拼手气" },
] as const
return (
<div className="flex rounded-xl bg-muted p-1">
{rules.map((r) => (
<button
key={r.type}
onClick={() => onChange(r.type)}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition
${value === r.type
? "bg-background shadow"
: "text-muted-foreground"
}`}
>
{r.label}
</button>
))}
</div>
)
}
export default function CreatePage() {
return <CreateRedPacketPage />
}

View File

@@ -1,7 +1,6 @@
import { notFound } from "next/navigation"
import RedPacketClient from "./red-packet-client"
import { getRedPacketByPublicId } from "@/app/server/actions/get-red-packet";
import { getDecimalPlacesFromStep } from "@/lib/currency";
export const metadata = {
title: "抽红包 - tonesc 红包",
@@ -29,7 +28,7 @@ export default async function RedPacketPage({ params }: Props) {
if (!packet) notFound()
const { id, currencyName, currencyPrecision, maxDrawTimes } = packet;
const precision = getDecimalPlacesFromStep(currencyPrecision);
const precision = currencyPrecision;
return <RedPacketClient packet={{
id, currencyName, precision, maxDrawTimes

View File

@@ -30,7 +30,7 @@ export async function createRedPacket(
count: payload.count,
currencyName: payload.currency.name,
currencyPrecision: payload.currency.precision.toString(),
currencyPrecision: payload.currency.precision,
rule: payload.rule,

View File

@@ -5,7 +5,6 @@ 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 { getDecimalPlacesFromStep } from "@/lib/currency"
import { formatAmount } from "@/lib/format-amount"
type DrawResult =
@@ -64,7 +63,7 @@ export async function drawRedPacket(
.from(redPacketDraws)
.where(eq(redPacketDraws.redPacketId, redPacketId))
const precision = getDecimalPlacesFromStep(packet.currencyPrecision);
const precision = packet.currencyPrecision;
const rule = packet.rule
let amount: string

View File

@@ -15,8 +15,8 @@ export const redPacketDraws = pgTable("red_packet_draws", {
userId: text("user_id").notNull(),
amount: numeric("amount", {
precision: 20,
scale: 10,
precision: 38,
scale: 8,
}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })

View File

@@ -21,10 +21,7 @@ export const redPackets = pgTable("red_packets", {
count: integer("count").notNull(),
currencyName: text("currency_name").notNull(),
currencyPrecision: numeric("currency_precision", {
precision: 20,
scale: 10,
}).notNull(),
currencyPrecision: integer("currency_precision").notNull(),
rule: jsonb("rule")
.$type<CreateRedPacketPayload["rule"]>()

View File

@@ -0,0 +1 @@
ALTER TABLE "red_packet_draws" ALTER COLUMN "amount" SET DATA TYPE numeric(38, 8);

View File

@@ -0,0 +1 @@
ALTER TABLE "red_packets" ALTER COLUMN "currency_precision" SET DATA TYPE integer;

View File

@@ -0,0 +1,215 @@
{
"id": "d833ecb6-de56-4777-8907-a8d8d7737a24",
"prevId": "11a8b31d-ae2f-40d8-81fc-8a6b3bdcdd48",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(38, 8)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_red_packet_draws_packet_id_created_at": {
"name": "idx_red_packet_draws_packet_id_created_at",
"columns": [
{
"expression": "red_packet_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "\"created_at\" desc",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_red_packets_public_id": {
"name": "uniq_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"uniq_red_packets_admin_id": {
"name": "uniq_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_public_id": {
"name": "idx_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_admin_id": {
"name": "idx_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,215 @@
{
"id": "c91ad204-8ce6-4029-9490-763ed4dcc177",
"prevId": "d833ecb6-de56-4777-8907-a8d8d7737a24",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(38, 8)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_red_packet_draws_packet_id_created_at": {
"name": "idx_red_packet_draws_packet_id_created_at",
"columns": [
{
"expression": "red_packet_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "\"created_at\" desc",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_red_packets_public_id": {
"name": "uniq_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"uniq_red_packets_admin_id": {
"name": "uniq_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_public_id": {
"name": "idx_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_admin_id": {
"name": "idx_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -36,6 +36,20 @@
"when": 1767792389199,
"tag": "0004_overconfident_nova",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767924016559,
"tag": "0005_quiet_leech",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767924109495,
"tag": "0006_rare_maginty",
"breakpoints": true
}
]
}

View File

@@ -1,15 +0,0 @@
/**
* 从最小单位step中推导小数位数
*
* "1.00000000" -> 0
* "0.01000000" -> 2
* "0.00010000" -> 4
*/
export function getDecimalPlacesFromStep(step: string): number {
const [, decimal = ""] = step.split(".")
if (!decimal) return 0
const index = decimal.search(/[1-9]/)
return index === -1 ? 0 : index + 1
}

View File

@@ -6,10 +6,6 @@ function isPositiveInteger(n: number) {
return Number.isInteger(n) && n > 0
}
function decimalsFromPrecision(p: number) {
return p.toString().split(".")[1]?.length ?? 0
}
function isValidAmountByPrecision(
value: string,
precision: number
@@ -18,9 +14,8 @@ function isValidAmountByPrecision(
const num = Number(value)
if (Number.isNaN(num) || num < 0) return false
const decimals = decimalsFromPrecision(precision)
const [, frac = ""] = value.split(".")
return frac.length <= decimals
return frac.length <= precision
}
/* ---------- result type ---------- */
@@ -46,8 +41,8 @@ export function validateCreateRedPacketPayload(
return { ok: false, message: "金额单位名称不能为空" }
}
if (currency.precision <= 0) {
return { ok: false, message: "金额精度必须大于 0" }
if (currency.precision < 0 || currency.precision > 8) {
return { ok: false, message: "金额精度必须大于 0 且小于 8" }
}
/* 抽取次数 */