From 3f3eed53be3dc9d0ef88f05d8e043257408acf3c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Dec 2025 11:08:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=AF=8F=E6=9C=88=E5=87=BA?= =?UTF-8?q?=E5=9B=BE=E9=99=90=E9=A2=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/admin-settings-client.tsx | 256 +++++++++ app/(chat)/api/chat/route.ts | 25 + app/api/admin/images/analytics/route.ts | 49 +- app/api/admin/users/route.ts | 5 +- app/api/user/image-quota/route.ts | 31 ++ components/greeting.tsx | 66 ++- docs/每月出图限额功能实施文档.md | 518 ++++++++++++++++++ .../0022_add_monthly_image_limit.sql | 9 + lib/db/queries.ts | 74 ++- lib/db/schema.ts | 1 + 10 files changed, 1021 insertions(+), 13 deletions(-) create mode 100644 app/api/user/image-quota/route.ts create mode 100644 docs/每月出图限额功能实施文档.md create mode 100644 lib/db/migrations/0022_add_monthly_image_limit.sql diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 5a3f86125..88c46e5de 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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() {
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
+
+ 月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"} +
+ {/* 配额设置 */} +
+ { + 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 }); + } + } + }} + /> + 张 +
setEditingUser({ + ...editingUser, + monthlyImageLimit: e.target.value ? parseInt(e.target.value) : null + })} + className="flex-1" + /> + 张/月 +
+

+ 留空或填写 0 表示无限制 +

+ + + + + + + +``` + +### 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 && ( + +
+ 本月出图:{quota.used}/{quota.limit} 张 +
+
+
= 90 ? 'bg-red-500' : + quota.percentage >= 70 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${Math.min(quota.percentage, 100)}%` }} + /> +
+ +)} +``` + +--- + +## 五、实施步骤 + +### 阶段一:数据库迁移(预计 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` | 修改(可选) | + +--- + +*文档结束* + diff --git a/lib/db/migrations/0022_add_monthly_image_limit.sql b/lib/db/migrations/0022_add_monthly_image_limit.sql new file mode 100644 index 000000000..8071e658c --- /dev/null +++ b/lib/db/migrations/0022_add_monthly_image_limit.sql @@ -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表示无限制'; + diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 5a8212554..963696a37 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -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 = { 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 { + try { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const result = await db + .select({ count: sql`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 }; + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9fb733fea..e95a64a4d 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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(), });