添加每月出图限额功能
This commit is contained in:
parent
53ddb7e585
commit
3f3eed53be
@ -67,6 +67,7 @@ type User = {
|
||||
name: string | null;
|
||||
role: "guest" | "regular" | "admin";
|
||||
isActive: boolean;
|
||||
monthlyImageLimit: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@ -181,6 +182,24 @@ type ImageAnalytics = {
|
||||
dailyStats: Array<{ date: string; count: number }>;
|
||||
sizeStats: Array<{ imageSize: string | null; count: number }>;
|
||||
modelStats: Array<{ modelId: string | null; count: number }>;
|
||||
modelDetailStats: Array<{
|
||||
modelId: string | null;
|
||||
count: number;
|
||||
userCount: number;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
}>;
|
||||
providerTypeStats: Array<{
|
||||
providerType: string | null;
|
||||
providerName: string | null;
|
||||
count: number;
|
||||
}>;
|
||||
modelTrendStats: Array<{
|
||||
date: string;
|
||||
modelId: string | null;
|
||||
count: number;
|
||||
}>;
|
||||
monthlyStats: Array<{ month: string; count: number }>;
|
||||
hourlyStats: Array<{ hour: number; count: number }>;
|
||||
};
|
||||
@ -898,8 +917,38 @@ export function AdminSettingsClient() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 配额设置 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="无限制"
|
||||
className="w-20 h-8 text-xs"
|
||||
defaultValue={user.monthlyImageLimit ?? ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value ? parseInt(e.target.value, 10) : null;
|
||||
if (value !== user.monthlyImageLimit) {
|
||||
handleUpdateUser(user.id, { monthlyImageLimit: value });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
? parseInt((e.target as HTMLInputElement).value, 10)
|
||||
: null;
|
||||
if (value !== user.monthlyImageLimit) {
|
||||
handleUpdateUser(user.id, { monthlyImageLimit: value });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">张</span>
|
||||
</div>
|
||||
<Select
|
||||
value={user.role}
|
||||
onValueChange={(value) => handleUpdateUser(user.id, { role: value as any })}
|
||||
@ -1434,6 +1483,213 @@ export function AdminSettingsClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 🆕 模型使用分析 */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
模型使用分析
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 模型使用排行 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模型使用排行</CardTitle>
|
||||
<CardDescription className="text-xs">按生图数量排序</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{analyticsData?.modelDetailStats?.slice(0, 8).map((stat, index) => {
|
||||
const maxCount = analyticsData?.modelDetailStats?.[0]?.count || 1;
|
||||
const percentage = (stat.count / maxCount) * 100;
|
||||
return (
|
||||
<div key={stat.modelId || index} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
index === 0 ? "bg-yellow-500 text-white" :
|
||||
index === 1 ? "bg-gray-400 text-white" :
|
||||
index === 2 ? "bg-amber-600 text-white" :
|
||||
"bg-gray-200 text-gray-600"
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-medium truncate max-w-[150px]" title={stat.modelName || stat.modelId || "未知模型"}>
|
||||
{stat.modelName || stat.modelId || "未知模型"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold">{stat.count}</span>
|
||||
<span className="text-muted-foreground text-xs ml-1">张</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
index === 0 ? "bg-yellow-500" :
|
||||
index === 1 ? "bg-gray-400" :
|
||||
index === 2 ? "bg-amber-600" :
|
||||
"bg-blue-400"
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{stat.providerName || "未知提供商"}</span>
|
||||
<span>{stat.userCount} 用户</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!analyticsData?.modelDetailStats || analyticsData.modelDetailStats.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 提供商类型分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">提供商分布</CardTitle>
|
||||
<CardDescription className="text-xs">按 AI 提供商统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const providerColors: Record<string, string> = {
|
||||
"gemini": "bg-purple-500",
|
||||
"volcano-vision": "bg-orange-500",
|
||||
"volcano-video": "bg-red-500",
|
||||
"alibaba": "bg-blue-500",
|
||||
"openai": "bg-green-500",
|
||||
"qianwen-video": "bg-cyan-500",
|
||||
"custom": "bg-gray-500",
|
||||
"local": "bg-yellow-500",
|
||||
};
|
||||
const total = analyticsData?.providerTypeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
|
||||
|
||||
return analyticsData?.providerTypeStats?.map((stat, index) => {
|
||||
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
|
||||
const color = providerColors[stat.providerType || ""] || "bg-gray-400";
|
||||
return (
|
||||
<div key={stat.providerType || index} className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${color}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{stat.providerName || stat.providerType || "未知"}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<span className="font-semibold">{stat.count}</span>
|
||||
<span className="text-muted-foreground ml-1">({percentage}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mt-1">
|
||||
<div
|
||||
className={`h-full rounded-full ${color}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(!analyticsData?.providerTypeStats || analyticsData.providerTypeStats.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 图片尺寸分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">图片尺寸分布</CardTitle>
|
||||
<CardDescription className="text-xs">按生成图片尺寸统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{analyticsData?.sizeStats?.map((stat) => {
|
||||
const total = analyticsData?.sizeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
|
||||
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div
|
||||
key={stat.imageSize || "unknown"}
|
||||
className="flex-1 min-w-[100px] p-3 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg border border-indigo-100"
|
||||
>
|
||||
<p className="text-xl font-bold text-indigo-600">{stat.imageSize || "未知"}</p>
|
||||
<p className="text-sm text-muted-foreground">{stat.count} 张</p>
|
||||
<p className="text-xs text-indigo-400">{percentage}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 模型使用趋势(最近7天) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模型使用趋势</CardTitle>
|
||||
<CardDescription className="text-xs">最近7天各模型使用量</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
// 按日期分组
|
||||
const groupedByDate = analyticsData?.modelTrendStats?.reduce((acc, stat) => {
|
||||
if (!acc[stat.date]) acc[stat.date] = [];
|
||||
acc[stat.date].push(stat);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof analyticsData.modelTrendStats>) || {};
|
||||
|
||||
const dates = Object.keys(groupedByDate).sort().slice(-7);
|
||||
const maxDailyTotal = Math.max(
|
||||
...dates.map(date =>
|
||||
groupedByDate[date]?.reduce((sum, s) => sum + s.count, 0) || 0
|
||||
),
|
||||
1
|
||||
);
|
||||
|
||||
return dates.map(date => {
|
||||
const dayStats = groupedByDate[date] || [];
|
||||
const dayTotal = dayStats.reduce((sum, s) => sum + s.count, 0);
|
||||
const heightPercent = (dayTotal / maxDailyTotal) * 100;
|
||||
|
||||
return (
|
||||
<div key={date} className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-20">
|
||||
{date.slice(5)}
|
||||
</span>
|
||||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
||||
{dayStats.map((stat, idx) => {
|
||||
const statPercent = dayTotal > 0 ? (stat.count / dayTotal) * 100 : 0;
|
||||
const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500"];
|
||||
return (
|
||||
<div
|
||||
key={stat.modelId || idx}
|
||||
className={`h-full ${colors[idx % colors.length]}`}
|
||||
style={{ width: `${statPercent * (heightPercent / 100)}%` }}
|
||||
title={`${stat.modelId || "未知"}: ${stat.count}张`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs font-medium w-10 text-right">{dayTotal}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(!analyticsData?.modelTrendStats || analyticsData.modelTrendStats.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 网站设置 */}
|
||||
|
||||
@ -43,6 +43,7 @@ import {
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSubModelsByParentModelId,
|
||||
getAiProviderById,
|
||||
checkUserImageQuota,
|
||||
} from "@/lib/db/queries";
|
||||
import { db } from "@/lib/db";
|
||||
import type { DBMessage } from "@/lib/db/schema";
|
||||
@ -557,6 +558,30 @@ export async function POST(request: Request) {
|
||||
actualModelId = languageModel.modelId;
|
||||
}
|
||||
|
||||
// ========== 图片生成配额检查 ==========
|
||||
const isImageGenerationModel =
|
||||
(languageModel as any).provider === "gemini-image" ||
|
||||
(languageModel as any).provider === "volcano-vision";
|
||||
|
||||
if (isImageGenerationModel) {
|
||||
const quota = await checkUserImageQuota(session.user.id);
|
||||
|
||||
if (!quota.allowed) {
|
||||
console.log(`[Chat API] ⛔ 图片配额已用完: ${quota.used}/${quota.limit}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "quota_exceeded",
|
||||
message: `本月图片生成配额已用完(已使用 ${quota.used}/${quota.limit} 张),配额将于下月1日重置,如需更多配额请联系管理员。`,
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Chat API] ✅ 图片配额检查通过: ${quota.used}/${quota.limit ?? '无限制'}`);
|
||||
}
|
||||
|
||||
// ========== Gemini 图片生成独立处理 ==========
|
||||
if ((languageModel as any).provider === "gemini-image" && typeof (languageModel as any).doGenerate === "function") {
|
||||
console.log("[Chat API] Using Gemini image generation");
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user } from "@/lib/db/schema";
|
||||
import { generatedImage, user, aiModel, aiProvider } from "@/lib/db/schema";
|
||||
import { eq, sql, desc, count, and, gte, lt } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
@ -69,7 +69,7 @@ export async function GET(request: NextRequest) {
|
||||
.groupBy(generatedImage.imageSize)
|
||||
.orderBy(desc(sql`count(*)`));
|
||||
|
||||
// 5. 按模型统计
|
||||
// 5. 按模型统计(基础)
|
||||
const modelStats = await db
|
||||
.select({
|
||||
modelId: generatedImage.modelId,
|
||||
@ -80,6 +80,48 @@ export async function GET(request: NextRequest) {
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(10);
|
||||
|
||||
// 5.1 按模型详细统计(包含模型名称和提供商信息)
|
||||
const modelDetailStats = await db
|
||||
.select({
|
||||
modelId: generatedImage.modelId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
userCount: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
||||
modelName: aiModel.displayName,
|
||||
providerName: aiProvider.name,
|
||||
providerType: aiProvider.type,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||
.leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||||
.groupBy(generatedImage.modelId, aiModel.displayName, aiProvider.name, aiProvider.type)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(20);
|
||||
|
||||
// 5.2 按提供商类型统计
|
||||
const providerTypeStats = await db
|
||||
.select({
|
||||
providerType: aiProvider.type,
|
||||
providerName: aiProvider.name,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||
.leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||||
.groupBy(aiProvider.type, aiProvider.name)
|
||||
.orderBy(desc(sql`count(*)`));
|
||||
|
||||
// 5.3 模型使用趋势(最近7天,按模型分组)
|
||||
const modelTrendStats = await db
|
||||
.select({
|
||||
date: sql<string>`date(${generatedImage.createdAt})::text`,
|
||||
modelId: generatedImage.modelId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`)
|
||||
.groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId)
|
||||
.orderBy(sql`date(${generatedImage.createdAt})`);
|
||||
|
||||
// 6. 按月份统计
|
||||
const monthlyStats = await db
|
||||
.select({
|
||||
@ -174,6 +216,9 @@ export async function GET(request: NextRequest) {
|
||||
dailyStats,
|
||||
sizeStats,
|
||||
modelStats,
|
||||
modelDetailStats,
|
||||
providerTypeStats,
|
||||
modelTrendStats,
|
||||
monthlyStats,
|
||||
hourlyStats,
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ const updateUserSchema = z.object({
|
||||
role: z.enum(["guest", "regular", "admin"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
email: z.string().email().optional(),
|
||||
monthlyImageLimit: z.number().int().min(0).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@ -48,9 +49,9 @@ export async function PATCH(request: Request) {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
const { id, role, isActive, email } = updateUserSchema.parse(body);
|
||||
const { id, role, isActive, email, monthlyImageLimit } = updateUserSchema.parse(body);
|
||||
|
||||
const result = await updateUser({ id, role, isActive, email });
|
||||
const result = await updateUser({ id, role, isActive, email, monthlyImageLimit });
|
||||
|
||||
if (result.length === 0) {
|
||||
return new ChatSDKError("bad_request:api", "User not found").toResponse();
|
||||
|
||||
31
app/api/user/image-quota/route.ts
Normal file
31
app/api/user/image-quota/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { checkUserImageQuota } from "@/lib/db/queries";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quota = await checkUserImageQuota(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
remaining: quota.remaining,
|
||||
unlimited: quota.limit === null,
|
||||
// 计算百分比(用于进度条显示)
|
||||
percentage: quota.limit ? Math.round((quota.used / quota.limit) * 100) : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[User Image Quota API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch quota" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,18 @@ import { motion } from "framer-motion";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type QuotaInfo = {
|
||||
used: number;
|
||||
limit: number | null;
|
||||
remaining: number | null;
|
||||
unlimited: boolean;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
export const Greeting = () => {
|
||||
const { data: session } = useSession();
|
||||
const [imageCount, setImageCount] = useState<number | null>(null);
|
||||
const [quota, setQuota] = useState<QuotaInfo | null>(null);
|
||||
|
||||
// 获取用户名或邮箱前缀
|
||||
const getUserDisplayName = () => {
|
||||
@ -22,25 +31,40 @@ export const Greeting = () => {
|
||||
|
||||
const displayName = getUserDisplayName();
|
||||
|
||||
// 获取用户生图统计数据
|
||||
// 获取用户生图统计数据和配额信息
|
||||
useEffect(() => {
|
||||
const fetchImageStats = async () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user/image-stats");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setImageCount(data.totalImages);
|
||||
// 获取生图统计
|
||||
const statsResponse = await fetch("/api/user/image-stats");
|
||||
if (statsResponse.ok) {
|
||||
const statsData = await statsResponse.json();
|
||||
setImageCount(statsData.totalImages);
|
||||
}
|
||||
|
||||
// 获取配额信息
|
||||
const quotaResponse = await fetch("/api/user/image-quota");
|
||||
if (quotaResponse.ok) {
|
||||
const quotaData = await quotaResponse.json();
|
||||
setQuota(quotaData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch image stats:", error);
|
||||
console.error("Failed to fetch data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (session?.user?.id) {
|
||||
fetchImageStats();
|
||||
fetchData();
|
||||
}
|
||||
}, [session?.user?.id]);
|
||||
|
||||
// 获取进度条颜色
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-yellow-500";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
|
||||
@ -69,6 +93,32 @@ export const Greeting = () => {
|
||||
>
|
||||
今天我能为你做些什么?
|
||||
</motion.div>
|
||||
|
||||
{/* 配额进度条 - 仅当有限额时显示 */}
|
||||
{quota && !quota.unlimited && quota.limit && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="mt-4"
|
||||
>
|
||||
<div className="text-sm text-zinc-500 mb-1">
|
||||
本月出图:{quota.used}/{quota.limit} 张
|
||||
{quota.remaining !== null && quota.remaining <= 10 && quota.remaining > 0 && (
|
||||
<span className="ml-2 text-yellow-600">(剩余 {quota.remaining} 张)</span>
|
||||
)}
|
||||
{quota.remaining === 0 && (
|
||||
<span className="ml-2 text-red-600">(配额已用完)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-64 h-2 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${getProgressColor(quota.percentage)}`}
|
||||
style={{ width: `${Math.min(quota.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
518
docs/每月出图限额功能实施文档.md
Normal file
518
docs/每月出图限额功能实施文档.md
Normal file
@ -0,0 +1,518 @@
|
||||
# 每月出图限额功能实施文档
|
||||
|
||||
> 文档版本:v1.0
|
||||
> 创建日期:2024-12-18
|
||||
> 状态:待实施
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
### 1.1 需求背景
|
||||
|
||||
为了更好地管理系统资源和控制成本,需要在系统设置的"用户管理"中增加限制每个用户每月出图数量的功能。
|
||||
|
||||
### 1.2 功能目标
|
||||
|
||||
- 管理员可为每个用户设置每月图片生成限额
|
||||
- 用户达到限额后无法继续生成图片,给予友好提示
|
||||
- 用户可查看自己的配额使用情况
|
||||
- 每月自动重置配额(按自然月统计)
|
||||
|
||||
### 1.3 核心规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 默认值 | `null` 或 `0` 表示无限制 |
|
||||
| 统计周期 | 自然月(每月1日0点自动重置) |
|
||||
| 管理员 | 可设置为无限制 |
|
||||
| 计数对象 | `GeneratedImage` 表中的记录 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库设计
|
||||
|
||||
### 2.1 表结构变更
|
||||
|
||||
**修改 `User` 表,添加新字段:**
|
||||
|
||||
```sql
|
||||
-- 迁移文件:0022_add_monthly_image_limit.sql
|
||||
|
||||
-- 添加每月出图限额字段
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "monthlyImageLimit" integer DEFAULT NULL;
|
||||
|
||||
-- 添加字段注释
|
||||
COMMENT ON COLUMN "User"."monthlyImageLimit" IS '每月图片生成限额,NULL或0表示无限制';
|
||||
```
|
||||
|
||||
### 2.2 Schema 定义更新
|
||||
|
||||
**文件:`lib/db/schema.ts`**
|
||||
|
||||
```typescript
|
||||
export const user = pgTable("User", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
email: varchar("email", { length: 64 }).notNull(),
|
||||
password: varchar("password", { length: 64 }),
|
||||
name: varchar("name", { length: 100 }),
|
||||
role: varchar("role", { enum: ["guest", "regular", "admin"] })
|
||||
.notNull()
|
||||
.default("regular"),
|
||||
isActive: boolean("isActive").notNull().default(true),
|
||||
monthlyImageLimit: integer("monthlyImageLimit"), // 🆕 每月出图限额
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、后端实现
|
||||
|
||||
### 3.1 配额查询函数
|
||||
|
||||
**文件:`lib/db/queries.ts`**
|
||||
|
||||
```typescript
|
||||
import { generatedImage } from "./schema";
|
||||
|
||||
/**
|
||||
* 获取用户当月已生成图片数量
|
||||
*/
|
||||
export async function getUserMonthlyImageCount(userId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(and(
|
||||
eq(generatedImage.userId, userId),
|
||||
gte(generatedImage.createdAt, startOfMonth)
|
||||
));
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户图片生成配额
|
||||
* @returns 配额状态对象
|
||||
*/
|
||||
export async function checkUserImageQuota(userId: string): Promise<{
|
||||
allowed: boolean; // 是否允许继续生成
|
||||
used: number; // 本月已使用数量
|
||||
limit: number | null; // 限额(null表示无限制)
|
||||
remaining: number | null; // 剩余数量
|
||||
}> {
|
||||
// 获取用户信息
|
||||
const [userData] = await db
|
||||
.select({ monthlyImageLimit: user.monthlyImageLimit })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
const limit = userData?.monthlyImageLimit;
|
||||
const used = await getUserMonthlyImageCount(userId);
|
||||
|
||||
// null 或 0 表示无限制
|
||||
if (!limit || limit === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
used,
|
||||
limit: null,
|
||||
remaining: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: used < limit,
|
||||
used,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - used),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 更新用户函数
|
||||
|
||||
**文件:`lib/db/queries.ts`** - 修改 `updateUser` 函数
|
||||
|
||||
```typescript
|
||||
export async function updateUser({
|
||||
id,
|
||||
email,
|
||||
role,
|
||||
isActive,
|
||||
monthlyImageLimit, // 🆕 新增参数
|
||||
}: {
|
||||
id: string;
|
||||
email?: string;
|
||||
role?: "guest" | "regular" | "admin";
|
||||
isActive?: boolean;
|
||||
monthlyImageLimit?: number | null; // 🆕 null 表示无限制
|
||||
}) {
|
||||
try {
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (email !== undefined) updateData.email = email;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
if (monthlyImageLimit !== undefined) {
|
||||
// 0 或空字符串转为 null
|
||||
updateData.monthlyImageLimit = monthlyImageLimit || null;
|
||||
}
|
||||
|
||||
return await db
|
||||
.update(user)
|
||||
.set(updateData)
|
||||
.where(eq(user.id, id))
|
||||
.returning();
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError("bad_request:database", "Failed to update user");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 用户管理 API 更新
|
||||
|
||||
**文件:`app/api/admin/users/route.ts`**
|
||||
|
||||
```typescript
|
||||
// 更新 Schema 验证
|
||||
const updateUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
role: z.enum(["guest", "regular", "admin"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
email: z.string().email().optional(),
|
||||
monthlyImageLimit: z.number().int().min(0).nullable().optional(), // 🆕
|
||||
});
|
||||
|
||||
// GET 请求 - 返回用户列表时包含配额信息
|
||||
export async function GET(request: Request) {
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 返回时包含 monthlyImageLimit 字段
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
monthlyImageLimit: user.monthlyImageLimit, // 🆕
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
.from(user)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 用户配额查询 API
|
||||
|
||||
**新建文件:`app/api/user/image-quota/route.ts`**
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { checkUserImageQuota } from "@/lib/db/queries";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quota = await checkUserImageQuota(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
remaining: quota.remaining,
|
||||
unlimited: quota.limit === null,
|
||||
// 计算百分比(用于进度条显示)
|
||||
percentage: quota.limit ? Math.round((quota.used / quota.limit) * 100) : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[User Image Quota API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch quota" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 图片生成前配额检查
|
||||
|
||||
**文件:`app/(chat)/api/chat/route.ts`**
|
||||
|
||||
在调用生图模型之前添加配额检查:
|
||||
|
||||
```typescript
|
||||
import { checkUserImageQuota } from "@/lib/db/queries";
|
||||
|
||||
// 在处理图片生成请求时
|
||||
if (isImageModel || model.supportsImageGen) {
|
||||
// 检查用户配额
|
||||
const quota = await checkUserImageQuota(userId);
|
||||
|
||||
if (!quota.allowed) {
|
||||
// 返回友好的错误提示
|
||||
const errorMessage = `⚠️ 本月图片生成配额已用完\n\n` +
|
||||
`已使用:${quota.used} 张\n` +
|
||||
`月度限额:${quota.limit} 张\n\n` +
|
||||
`配额将于下月1日自动重置,如需更多配额请联系管理员。`;
|
||||
|
||||
return new Response(errorMessage, {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、前端实现
|
||||
|
||||
### 4.1 类型定义更新
|
||||
|
||||
**文件:`app/(admin)/settings/admin-settings-client.tsx`**
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: "guest" | "regular" | "admin";
|
||||
isActive: boolean;
|
||||
monthlyImageLimit: number | null; // 🆕
|
||||
createdAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 用户列表显示配额
|
||||
|
||||
```tsx
|
||||
{/* 用户列表 */}
|
||||
{usersData?.users?.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{user.name || user.email}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
邮箱: {user.email}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
角色: {ROLE_LABELS[user.role]} | 状态: {user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
{/* 🆕 显示配额信息 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
|
||||
</div>
|
||||
</div>
|
||||
{/* 操作按钮 */}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
### 4.3 编辑用户对话框
|
||||
|
||||
```tsx
|
||||
{/* 编辑用户对话框 */}
|
||||
<Dialog open={isEditUserOpen} onOpenChange={setIsEditUserOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 现有字段... */}
|
||||
|
||||
{/* 🆕 每月出图限额 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthlyImageLimit">每月出图限额</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="monthlyImageLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空或填0表示无限制"
|
||||
value={editingUser?.monthlyImageLimit || ""}
|
||||
onChange={(e) => setEditingUser({
|
||||
...editingUser,
|
||||
monthlyImageLimit: e.target.value ? parseInt(e.target.value) : null
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">张/月</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
留空或填写 0 表示无限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleUpdateUser}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### 4.4 用户首页配额显示(可选)
|
||||
|
||||
**文件:`components/greeting.tsx`**
|
||||
|
||||
```tsx
|
||||
// 获取配额信息
|
||||
const [quota, setQuota] = useState<{
|
||||
used: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuota = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user/image-quota");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQuota(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch quota:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (session?.user?.id) {
|
||||
fetchQuota();
|
||||
}
|
||||
}, [session?.user?.id]);
|
||||
|
||||
// 显示配额进度条
|
||||
{quota && quota.limit && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="mt-4"
|
||||
>
|
||||
<div className="text-sm text-zinc-500 mb-1">
|
||||
本月出图:{quota.used}/{quota.limit} 张
|
||||
</div>
|
||||
<div className="w-64 h-2 bg-zinc-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
quota.percentage >= 90 ? 'bg-red-500' :
|
||||
quota.percentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(quota.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、实施步骤
|
||||
|
||||
### 阶段一:数据库迁移(预计 10 分钟)
|
||||
|
||||
1. [ ] 创建迁移文件 `0022_add_monthly_image_limit.sql`
|
||||
2. [ ] 更新 `lib/db/schema.ts`
|
||||
3. [ ] 在服务器执行迁移
|
||||
|
||||
### 阶段二:后端开发(预计 30 分钟)
|
||||
|
||||
1. [ ] 添加 `getUserMonthlyImageCount` 函数
|
||||
2. [ ] 添加 `checkUserImageQuota` 函数
|
||||
3. [ ] 修改 `updateUser` 函数
|
||||
4. [ ] 更新 `/api/admin/users` 路由
|
||||
5. [ ] 创建 `/api/user/image-quota` 接口
|
||||
6. [ ] 在图片生成流程中添加配额检查
|
||||
|
||||
### 阶段三:前端开发(预计 45 分钟)
|
||||
|
||||
1. [ ] 更新 User 类型定义
|
||||
2. [ ] 用户列表显示配额信息
|
||||
3. [ ] 编辑用户对话框添加限额设置
|
||||
4. [ ] (可选)首页显示配额进度
|
||||
|
||||
### 阶段四:测试与部署(预计 30 分钟)
|
||||
|
||||
1. [ ] 本地功能测试
|
||||
2. [ ] 部署到服务器
|
||||
3. [ ] 生产环境验证
|
||||
|
||||
---
|
||||
|
||||
## 六、测试用例
|
||||
|
||||
### 6.1 功能测试
|
||||
|
||||
| 测试场景 | 预期结果 |
|
||||
|---------|---------|
|
||||
| 设置限额为 10,生成第 11 张图 | 显示配额已用完提示 |
|
||||
| 设置限额为 0 | 无限制,可无限生成 |
|
||||
| 留空限额 | 无限制,可无限生成 |
|
||||
| 月初查看配额 | 已使用数量重置为 0 |
|
||||
| 管理员查看用户列表 | 显示每个用户的限额设置 |
|
||||
|
||||
### 6.2 边界测试
|
||||
|
||||
| 测试场景 | 预期结果 |
|
||||
|---------|---------|
|
||||
| 负数限额 | 表单验证拒绝 |
|
||||
| 超大数字限额 | 正常保存 |
|
||||
| 并发生成(接近限额时) | 正确计数,不会超额 |
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与注意事项
|
||||
|
||||
### 7.1 兼容性
|
||||
|
||||
- ✅ 现有用户默认无限制(`null`)
|
||||
- ✅ 不影响现有功能
|
||||
- ✅ 无需停机迁移
|
||||
|
||||
### 7.2 性能考虑
|
||||
|
||||
- 配额检查会增加一次数据库查询
|
||||
- 可考虑使用 Redis 缓存配额信息(后续优化)
|
||||
|
||||
### 7.3 安全性
|
||||
|
||||
- 配额修改仅管理员可操作
|
||||
- 配额检查在服务端执行,不可绕过
|
||||
|
||||
---
|
||||
|
||||
## 八、后续优化建议
|
||||
|
||||
1. **批量设置**:支持批量为多个用户设置相同限额
|
||||
2. **配额预警**:用户达到 80% 限额时发送提醒
|
||||
3. **历史统计**:记录每月配额使用历史
|
||||
4. **角色默认值**:为不同角色设置默认限额模板
|
||||
5. **临时额度**:支持临时增加额度功能
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文件清单
|
||||
|
||||
| 文件路径 | 操作 |
|
||||
|---------|------|
|
||||
| `lib/db/schema.ts` | 修改 |
|
||||
| `lib/db/queries.ts` | 修改 |
|
||||
| `lib/db/migrations/0022_add_monthly_image_limit.sql` | 新建 |
|
||||
| `app/api/admin/users/route.ts` | 修改 |
|
||||
| `app/api/user/image-quota/route.ts` | 新建 |
|
||||
| `app/(chat)/api/chat/route.ts` | 修改 |
|
||||
| `app/(admin)/settings/admin-settings-client.tsx` | 修改 |
|
||||
| `components/greeting.tsx` | 修改(可选) |
|
||||
|
||||
---
|
||||
|
||||
*文档结束*
|
||||
|
||||
9
lib/db/migrations/0022_add_monthly_image_limit.sql
Normal file
9
lib/db/migrations/0022_add_monthly_image_limit.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- 迁移文件:0022_add_monthly_image_limit.sql
|
||||
-- 功能:添加每月出图限额字段
|
||||
|
||||
-- 添加每月出图限额字段到 User 表
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "monthlyImageLimit" integer DEFAULT NULL;
|
||||
|
||||
-- 添加字段注释
|
||||
COMMENT ON COLUMN "User"."monthlyImageLimit" IS '每月图片生成限额,NULL或0表示无限制';
|
||||
|
||||
@ -48,6 +48,7 @@ import {
|
||||
navLinks,
|
||||
authSettings,
|
||||
loginToken,
|
||||
generatedImage,
|
||||
} from "./schema";
|
||||
import { generateHashedPassword } from "./utils";
|
||||
|
||||
@ -664,6 +665,7 @@ export async function getAllUsers({
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
monthlyImageLimit: user.monthlyImageLimit,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
@ -702,17 +704,23 @@ export async function updateUser({
|
||||
email,
|
||||
role,
|
||||
isActive,
|
||||
monthlyImageLimit,
|
||||
}: {
|
||||
id: string;
|
||||
email?: string;
|
||||
role?: "guest" | "regular" | "admin";
|
||||
isActive?: boolean;
|
||||
monthlyImageLimit?: number | null;
|
||||
}) {
|
||||
try {
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (email !== undefined) updateData.email = email;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
if (monthlyImageLimit !== undefined) {
|
||||
// 0 或空转为 null(表示无限制)
|
||||
updateData.monthlyImageLimit = monthlyImageLimit || null;
|
||||
}
|
||||
|
||||
return await db
|
||||
.update(user)
|
||||
@ -1494,3 +1502,67 @@ export async function getAuthSettings() {
|
||||
return { dingTalkAppKey: "", dingTalkAppSecret: "", isDingTalkEnabled: false, isRegistrationEnabled: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当月已生成图片数量
|
||||
*/
|
||||
export async function getUserMonthlyImageCount(userId: string): Promise<number> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(and(
|
||||
eq(generatedImage.userId, userId),
|
||||
gte(generatedImage.createdAt, startOfMonth)
|
||||
));
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
} catch (_error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户图片生成配额
|
||||
* @returns 配额状态对象
|
||||
*/
|
||||
export async function checkUserImageQuota(userId: string): Promise<{
|
||||
allowed: boolean;
|
||||
used: number;
|
||||
limit: number | null;
|
||||
remaining: number | null;
|
||||
}> {
|
||||
try {
|
||||
// 获取用户信息
|
||||
const [userData] = await db
|
||||
.select({ monthlyImageLimit: user.monthlyImageLimit })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
const limit = userData?.monthlyImageLimit;
|
||||
const used = await getUserMonthlyImageCount(userId);
|
||||
|
||||
// null 或 0 表示无限制
|
||||
if (!limit || limit === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
used,
|
||||
limit: null,
|
||||
remaining: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: used < limit,
|
||||
used,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - used),
|
||||
};
|
||||
} catch (_error) {
|
||||
// 出错时默认允许
|
||||
return { allowed: true, used: 0, limit: null, remaining: null };
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ export const user = pgTable("User", {
|
||||
.notNull()
|
||||
.default("regular"),
|
||||
isActive: boolean("isActive").notNull().default(true),
|
||||
monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user