From 38b0a87fe16e538ed5f6c7ad11fd14ece056f85a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Feb 2026 02:05:14 +0800 Subject: [PATCH] feat: support admin points deduction with usage ledger --- .../settings/admin-settings-client.tsx | 54 +++++++++++++++++++ app/api/admin/users/route.ts | 21 +++++++- app/api/user/account-center/route.ts | 1 + lib/db/queries.ts | 41 ++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 107afe156..3c8892b0b 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -418,6 +418,7 @@ export function AdminSettingsClient() { const [userPage, setUserPage] = useState(1); const [userLimit] = useState(10); const [giftPointsDraftByUser, setGiftPointsDraftByUser] = useState>({}); + const [deductPointsDraftByUser, setDeductPointsDraftByUser] = useState>({}); // 批量设置出图限额状态 const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false); @@ -1142,6 +1143,32 @@ export function AdminSettingsClient() { } }; + const handleDeductPoints = async (userId: string) => { + const rawValue = deductPointsDraftByUser[userId] || ""; + const points = Number.parseInt(rawValue, 10); + if (!Number.isFinite(points) || points <= 0) { + toast.error("请输入有效的扣减积分(大于 0)"); + return; + } + + try { + const response = await fetch("/api/admin/users", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: userId, deductPoints: points }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.message || data?.error || "扣减失败"); + } + toast.success(`已扣减 ${points} 积分`); + setDeductPointsDraftByUser((prev) => ({ ...prev, [userId]: "" })); + mutateUsers(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "扣减积分失败"); + } + }; + const handleDeleteUser = async (userId: string) => { if (!confirm("确定要删除此用户吗?")) return; try { @@ -2339,6 +2366,33 @@ export function AdminSettingsClient() { 赠送 +
+ + setDeductPointsDraftByUser((prev) => ({ + ...prev, + [user.id]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleDeductPoints(user.id); + } + }} + /> + +
diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 5fd78eae7..8278430ac 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -16,6 +16,7 @@ const updateUserSchema = z.object({ monthlyImageLimit: z.number().int().min(0).nullable().optional(), pointsBalance: z.number().int().min(0).nullable().optional(), giftPoints: z.number().int().min(1).max(100000).optional(), + deductPoints: z.number().int().min(1).max(100000).optional(), pointsNote: z.string().max(200).optional(), }); @@ -52,9 +53,26 @@ export async function PATCH(request: Request) { await requireAdmin(); const body = await request.json(); - const { id, role, isActive, email, monthlyImageLimit, pointsBalance, giftPoints, pointsNote } = + const { id, role, isActive, email, monthlyImageLimit, pointsBalance, giftPoints, deductPoints, pointsNote } = updateUserSchema.parse(body); + if (giftPoints !== undefined && deductPoints !== undefined) { + return new ChatSDKError("bad_request:api", "giftPoints and deductPoints cannot be used together").toResponse(); + } + if (deductPoints !== undefined) { + const existingUser = await getUserById({ id }); + const currentBalance = Math.max(0, Number(existingUser?.pointsBalance || 0)); + if (!existingUser) { + return new ChatSDKError("bad_request:api", "User not found").toResponse(); + } + if (currentBalance < deductPoints) { + return new ChatSDKError( + "bad_request:api", + `积分不足,当前余额 ${currentBalance},无法扣减 ${deductPoints}` + ).toResponse(); + } + } + const session = await requireAdmin(); const result = await updateUser({ id, @@ -64,6 +82,7 @@ export async function PATCH(request: Request) { monthlyImageLimit, pointsBalance, giftPoints, + deductPoints, pointsNote, operatorId: session.user.id, }); diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index e13672adf..72140bdbb 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -23,6 +23,7 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides"; function mapLedgerReasonLabel(reason: string) { if (reason === "image_generation") return "图片生成"; if (reason === "video_generation") return "视频生成"; + if (reason === "deduct") return "管理员扣减"; if (reason === "gift") return "管理员赠送"; if (reason === "recharge") return "积分充值"; if (reason === "adjust") return "积分调整"; diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 40459ba8f..554727ea8 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -809,6 +809,7 @@ export async function updateUser({ monthlyImageLimit, pointsBalance, giftPoints, + deductPoints, pointsNote, operatorId, }: { @@ -819,11 +820,19 @@ export async function updateUser({ monthlyImageLimit?: number | null; pointsBalance?: number | null; giftPoints?: number; + deductPoints?: number; pointsNote?: string; operatorId?: string; }) { try { return await db.transaction(async (tx) => { + if (giftPoints !== undefined && deductPoints !== undefined) { + throw new ChatSDKError( + "bad_request:database", + "giftPoints and deductPoints cannot be used together" + ); + } + const updateData: Record = { updatedAt: new Date() }; if (email !== undefined) updateData.email = email; if (role !== undefined) updateData.role = role; @@ -842,6 +851,29 @@ export async function updateUser({ giftPoints )})`; } + if (deductPoints !== undefined && deductPoints !== 0) { + const [currentUser] = await tx + .select({ + pointsBalance: user.pointsBalance, + }) + .from(user) + .where(eq(user.id, id)) + .limit(1); + + if (!currentUser) { + return []; + } + + const currentBalance = Math.max(0, Number(currentUser.pointsBalance || 0)); + if (currentBalance < deductPoints) { + throw new ChatSDKError( + "bad_request:database", + `Insufficient balance for deduction: current ${currentBalance}, need ${deductPoints}` + ); + } + + updateData.pointsBalance = sql`GREATEST(0, COALESCE(${user.pointsBalance}, 0) - ${deductPoints})`; + } const updatedUsers = await tx .update(user) @@ -858,6 +890,15 @@ export async function updateUser({ createdBy: operatorId, }); } + if (updatedUsers.length > 0 && deductPoints !== undefined && deductPoints !== 0) { + await tx.insert(pointsLedger).values({ + userId: id, + amount: -Math.abs(deductPoints), + reason: "deduct", + note: pointsNote || "管理员扣减积分", + createdBy: operatorId, + }); + } return updatedUsers; });