添加每月出图限额功能
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2025-12-18 11:08:14 +08:00
parent 53ddb7e585
commit 3f3eed53be
10 changed files with 1021 additions and 13 deletions

View File

@ -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>
{/* 网站设置 */}

View File

@ -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");

View File

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

View File

@ -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();

View 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 }
);
}
}

View File

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

View 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` | 修改(可选) |
---
*文档结束*

View 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表示无限制';

View File

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

View File

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