feat: support admin points deduction with usage ledger
This commit is contained in:
parent
ab779c57ed
commit
38b0a87fe1
@ -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>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 "积分调整";
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user