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