feat: support admin points deduction with usage ledger
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 02:05:14 +08:00
parent ab779c57ed
commit 38b0a87fe1
4 changed files with 116 additions and 1 deletions

View File

@ -418,6 +418,7 @@ export function AdminSettingsClient() {
const [userPage, setUserPage] = useState(1);
const [userLimit] = useState(10);
const [giftPointsDraftByUser, setGiftPointsDraftByUser] = useState<Record<string, string>>({});
const [deductPointsDraftByUser, setDeductPointsDraftByUser] = useState<Record<string, string>>({});
// 批量设置出图限额状态
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() {
赠送
</Button>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="1"
placeholder="扣减积分"
className="h-8 w-24 text-xs"
value={deductPointsDraftByUser[user.id] ?? ""}
onChange={(e) =>
setDeductPointsDraftByUser((prev) => ({
...prev,
[user.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleDeductPoints(user.id);
}
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleDeductPoints(user.id)}
>
扣减
</Button>
</div>
<Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)}>
<Trash2 className="h-4 w-4" />
</Button>

View File

@ -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,
});

View File

@ -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 "积分调整";

View File

@ -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<string, unknown> = { 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;
});