From 2181b3b885c3e9912a46ae3f23d26ec7c68c7d60 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 25 May 2026 14:01:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=AE=97=E5=8A=9B=E7=82=B9=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E6=89=A3=E8=B4=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在管理后台设置页面添加模型算力点配置表格 - 实现后端AI接口调用时按模型扣除用户算力点 - 添加算力点余额检查和不足时的错误处理 - 在credit_logs中记录AI调用消费流水 - 更新前端远程调用后刷新用户信息显示 - 文档中补充模型算力点配置说明 --- docs/backend-database.md | 15 ++++ docs/pending-test.md | 2 + docs/system-settings.md | 12 +++ docs/todo.md | 1 - handler/ai.go | 30 ++++++- model/setting.go | 21 +++-- model/user.go | 1 + repository/user.go | 25 ++++++ service/auth.go | 39 +++++++++ service/settings.go | 23 ++++++ web/src/app/(admin)/admin/settings/page.tsx | 87 +++++++++++++++++++-- web/src/services/api/admin.ts | 6 ++ web/src/services/api/image.ts | 13 ++- web/src/services/api/video.ts | 5 ++ 14 files changed, 262 insertions(+), 18 deletions(-) diff --git a/docs/backend-database.md b/docs/backend-database.md index 569ab66..79f88ba 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -120,6 +120,7 @@ | 字段 | 类型 | 说明 | |-------------------|----------|----------------| | `availableModels` | string[] | 系统可用模型列表 | +| `modelCosts` | object[] | 模型算力点配置 | | `defaultModel` | string | 默认模型 | | `defaultImageModel` | string | 默认图片模型 | | `defaultVideoModel` | string | 默认视频模型 | @@ -127,6 +128,13 @@ | `systemPrompt` | string | 系统提示词 | | `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 | +`modelCosts` 每项字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `model` | string | 模型名称 | +| `credits` | number | 每次后端模型接口调用成功后扣除的算力点,未配置默认不扣除 | + `auth.linuxDo` 当前字段: | 字段 | 类型 | 说明 | @@ -201,6 +209,13 @@ | `extra` | json | 扩展信息 | | `created_at` | string | 创建时间 | +`type` 当前取值: + +| 值 | 说明 | +| --- | --- | +| `admin_adjust` | 后台手动调整 | +| `ai_consume` | 调用后端模型接口消费 | + ### orders 订单表。统一记录充值、订阅购买等支付订单。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 258613e..bf092a0 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,6 +1,7 @@ # 待测试 - 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,后端 `/api/v1` 模型渠道接口需要登录后调用;Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,Linux.do 原始资料写入 `users.extra.linuxDo`;后台 `/admin/users` 可新增、编辑、删除用户并在编辑弹窗内调整算力点,编辑用户不修改头像和算力点,算力点变化会写入 `credit_logs`;后台新增 `/admin/credit-logs` 算力点日志表格和增删改接口,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除、算力点调整、日志增删改查、登录态下生图/对话/视频和算力点流水都正常。 +- 管理后台 `/admin/settings` 公开配置新增模型算力点配置;后端 `/api/v1` 生图、图生图、对话和视频创建接口会在上游调用成功后按模型扣除用户算力点并写入 `credit_logs` 的 `ai_consume` 记录,余额不足会拒绝调用,前端远程调用成功后会刷新当前用户信息;系统可用模型临时删除时会保留 JSON 里的历史模型算力点配置,渠道里临时移除模型时公开配置下拉仍保留已见过的模型候选,需要确认不同模型扣费、余额不足拦截、流水记录、顶部余额更新、算力点输入不回跳、临时删除模型后配置保留和可重新添加正常。 - 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data`、`size`、`seconds`、`vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。 - 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。 - 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。 @@ -11,4 +12,5 @@ - 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。 - 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。 - 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。 +- 管理后台 `/admin/settings` 编辑渠道抽屉里的渠道可用模型选择器改为单行响应式展示,模型过多时用省略标签收起;从当前渠道移除某个模型后,该模型仍会保留在抽屉下拉候选里,方便重新添加,需要确认不会撑高输入框、右侧获取模型列表按钮对齐和移除后可重新选择正常。 - 提示词远程源新增 `davidwuw0811-boop/awesome-gpt-image2-prompts`,同步时会读取仓库 `prompts.json` 的结构化数据,需要确认后台同步后分类、封面图、标签和提示词内容展示正常。 diff --git a/docs/system-settings.md b/docs/system-settings.md index f1db66f..21e7488 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -13,6 +13,10 @@ { "modelChannel": { "availableModels": ["gpt-5.5", "gpt-image-2"], + "modelCosts": [ + { "model": "gpt-5.5", "credits": 1 }, + { "model": "gpt-image-2", "credits": 10 } + ], "defaultModel": "gpt-image-2", "defaultImageModel": "gpt-image-2", "defaultTextModel": "gpt-5.5", @@ -31,12 +35,20 @@ | 字段 | 类型 | 说明 | | --- | --- | --- | | `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 | +| `modelCosts` | object[] | 模型算力点配置,后端模型接口调用成功后按模型扣除;未配置默认不扣除 | | `defaultModel` | string | 默认模型,从 `availableModels` 中选择 | | `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 | | `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 | | `systemPrompt` | string | 系统提示词 | | `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 | +`modelCosts` 每项字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `model` | string | 模型名称 | +| `credits` | number | 每次成功调用扣除的算力点 | + 用户侧请求模式: | 模式 | 说明 | diff --git a/docs/todo.md b/docs/todo.md index e500ef3..ed6a3cf 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,4 +7,3 @@ - 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID 等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。 - 日志管理:新增 `credit_logs` 记录用户算力点变更流水。 -- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。 \ No newline at end of file diff --git a/handler/ai.go b/handler/ai.go index 9e5050f..00dd9b2 100644 --- a/handler/ai.go +++ b/handler/ai.go @@ -54,7 +54,7 @@ func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) { return } request.Header.Set("Authorization", "Bearer "+channel.APIKey) - copyAIResponse(w, request) + copyAIResponse(w, request, nil) } func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) { @@ -64,6 +64,21 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) { Fail(w, "AI 接口请求失败") return } + user, ok := service.UserFromContext(r.Context()) + if !ok { + Fail(w, "未登录或权限不足") + return + } + credits, err := service.ModelCost(modelName) + if err != nil { + log.Printf("AI proxy read model cost failed: model=%s err=%v", modelName, err) + Fail(w, "AI 接口请求失败") + return + } + if err := service.EnsureUserCredits(user.ID, credits); err != nil { + FailError(w, err) + return + } channel, err := service.SelectModelChannel(modelName) if err != nil { log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err) @@ -80,10 +95,12 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) { if contentType != "" { request.Header.Set("Content-Type", contentType) } - copyAIResponse(w, request) + copyAIResponse(w, request, func() error { + return service.ConsumeUserCredits(user.ID, modelName, credits, path) + }) } -func copyAIResponse(w http.ResponseWriter, request *http.Request) { +func copyAIResponse(w http.ResponseWriter, request *http.Request, beforeWrite func() error) { response, err := http.DefaultClient.Do(request) if err != nil { log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err) @@ -98,6 +115,13 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request) { Fail(w, "AI 接口请求失败") return } + if beforeWrite != nil { + if err := beforeWrite(); err != nil { + log.Printf("AI proxy before write failed: url=%s err=%v", request.URL.String(), err) + FailError(w, err) + return + } + } for key, values := range response.Header { if strings.EqualFold(key, "Content-Length") { diff --git a/model/setting.go b/model/setting.go index 76f00ac..60dc301 100644 --- a/model/setting.go +++ b/model/setting.go @@ -21,15 +21,22 @@ type ModelChannel struct { Remark string `json:"remark"` } +// ModelCost 模型算力点配置。 +type ModelCost struct { + Model string `json:"model"` + Credits int `json:"credits"` +} + // PublicModelChannelSetting 公开模型渠道配置。 type PublicModelChannelSetting struct { - AvailableModels []string `json:"availableModels"` - DefaultModel string `json:"defaultModel"` - DefaultImageModel string `json:"defaultImageModel"` - DefaultVideoModel string `json:"defaultVideoModel"` - DefaultTextModel string `json:"defaultTextModel"` - SystemPrompt string `json:"systemPrompt"` - AllowCustomChannel *bool `json:"allowCustomChannel"` + AvailableModels []string `json:"availableModels"` + ModelCosts []ModelCost `json:"modelCosts"` + DefaultModel string `json:"defaultModel"` + DefaultImageModel string `json:"defaultImageModel"` + DefaultVideoModel string `json:"defaultVideoModel"` + DefaultTextModel string `json:"defaultTextModel"` + SystemPrompt string `json:"systemPrompt"` + AllowCustomChannel *bool `json:"allowCustomChannel"` } // PublicSetting 公开配置。 diff --git a/model/user.go b/model/user.go index 91c1872..5c834cf 100644 --- a/model/user.go +++ b/model/user.go @@ -79,6 +79,7 @@ type CreditLogType string const ( CreditLogTypeAdminAdjust CreditLogType = "admin_adjust" + CreditLogTypeAIConsume CreditLogType = "ai_consume" ) // CreditLog 用户算力点变更流水。 diff --git a/repository/user.go b/repository/user.go index fcee7d8..b690527 100644 --- a/repository/user.go +++ b/repository/user.go @@ -79,6 +79,31 @@ func SaveUser(user model.User) (model.User, error) { return user, db.Save(&user).Error } +func HasUserCredits(id string, credits int) (bool, error) { + user, ok, err := GetUserByID(id) + return ok && user.Credits >= credits, err +} + +func ConsumeUserCredits(id string, credits int, now string) (model.User, bool, error) { + db, err := DB() + if err != nil { + return model.User{}, false, err + } + if credits <= 0 { + user, ok, err := GetUserByID(id) + return user, ok, err + } + tx := db.Model(&model.User{}).Where("id = ? AND credits >= ?", id, credits).Updates(map[string]any{ + "credits": gorm.Expr("credits - ?", credits), + "updated_at": now, + }) + if tx.Error != nil { + return model.User{}, false, tx.Error + } + user, ok, err := GetUserByID(id) + return user, ok && tx.RowsAffected > 0, err +} + // SaveCreditLog 保存算力点变更流水。 func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) { db, err := DB() diff --git a/service/auth.go b/service/auth.go index a9b2d38..369520d 100644 --- a/service/auth.go +++ b/service/auth.go @@ -318,6 +318,45 @@ func AdjustUserCredits(id string, credits int) (model.User, error) { return user, err } +func EnsureUserCredits(userID string, credits int) error { + if credits <= 0 { + return nil + } + ok, err := repository.HasUserCredits(userID, credits) + if err != nil { + return err + } + if !ok { + return safeMessageError{message: "算力点不足"} + } + return nil +} + +func ConsumeUserCredits(userID string, modelName string, credits int, path string) error { + if credits <= 0 { + return nil + } + user, ok, err := repository.ConsumeUserCredits(userID, credits, now()) + if err != nil { + return err + } + if !ok { + return safeMessageError{message: "算力点不足"} + } + extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path}) + _, err = repository.SaveCreditLog(model.CreditLog{ + ID: newID("credit"), + UserID: userID, + Type: model.CreditLogTypeAIConsume, + Amount: -credits, + Balance: user.Credits, + Remark: "调用模型 " + modelName, + Extra: string(extra), + CreatedAt: now(), + }) + return err +} + func ListCreditLogs(q model.Query) (model.CreditLogList, error) { logs, total, err := repository.ListCreditLogs(q) if err != nil { diff --git a/service/settings.go b/service/settings.go index 2c18436..0f30d6a 100644 --- a/service/settings.go +++ b/service/settings.go @@ -65,6 +65,15 @@ func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { if setting.ModelChannel.AvailableModels == nil { setting.ModelChannel.AvailableModels = []string{} } + if setting.ModelChannel.ModelCosts == nil { + setting.ModelChannel.ModelCosts = []model.ModelCost{} + } + for i := range setting.ModelChannel.ModelCosts { + setting.ModelChannel.ModelCosts[i].Model = strings.TrimSpace(setting.ModelChannel.ModelCosts[i].Model) + if setting.ModelChannel.ModelCosts[i].Credits < 0 { + setting.ModelChannel.ModelCosts[i].Credits = 0 + } + } if setting.ModelChannel.AllowCustomChannel == nil { enabled := true setting.ModelChannel.AllowCustomChannel = &enabled @@ -72,6 +81,20 @@ func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { return setting } +func ModelCost(modelName string) (int, error) { + settings, err := repository.GetSettings() + if err != nil { + return 0, err + } + modelName = strings.TrimSpace(modelName) + for _, item := range normalizePublicSetting(settings.Public).ModelChannel.ModelCosts { + if item.Model == modelName { + return item.Credits, nil + } + } + return 0, nil +} + func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting { if setting.Channels == nil { setting.Channels = []model.ModelChannel{} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 0fb9004..867d4dc 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -7,7 +7,7 @@ import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; import { EditorView } from "@uiw/react-codemirror"; -import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminSettings } from "@/services/api/admin"; +import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminModelCost, type AdminSettings } from "@/services/api/admin"; import { useUserStore } from "@/stores/use-user-store"; const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false }); @@ -28,6 +28,7 @@ const emptySettings: AdminSettings = { public: { modelChannel: { availableModels: [], + modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", @@ -62,10 +63,12 @@ export default function AdminSettingsPage() { const [testResults, setTestResults] = useState>({}); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [modelCosts, setModelCosts] = useState([]); + const [knownModels, setKnownModels] = useState([]); const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; const channelModels = useMemo(() => collectChannelModels(channels), [channels]); const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); - const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); + const modelOptions = useMemo(() => uniqueModels([...knownModels, ...publicModels, ...channelModels, ...modelCosts.map((item) => item.model)]), [knownModels, publicModels, channelModels, modelCosts]); const activeMode = editorMode[activeTab]; const activeJsonText = jsonText[activeTab]; const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : ""; @@ -77,6 +80,8 @@ export default function AdminSettingsPage() { const data = normalizeSettings(await fetchAdminSettings(token)); form.setFieldsValue(data); setChannels(data.private.channels); + setModelCosts(data.public.modelChannel.modelCosts); + setKnownModels(collectKnownModels(data)); setJsonText({ public: JSON.stringify(data.public, null, 2), private: JSON.stringify(data.private, null, 2), @@ -108,6 +113,8 @@ export default function AdminSettingsPage() { const merged = mergeChannelApiKeys(values.private.channels, saved); form.setFieldsValue(merged); setChannels(merged.private.channels); + setModelCosts(merged.public.modelChannel.modelCosts); + rememberKnownModels(merged); setJsonText({ public: JSON.stringify(merged.public, null, 2), private: JSON.stringify(merged.private, null, 2), @@ -136,6 +143,8 @@ export default function AdminSettingsPage() { } form.setFieldsValue({ [tab]: parsed } as Partial); if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels); + if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts); + rememberKnownModels({ ...normalizeSettings(form.getFieldsValue(true) as AdminSettings), [tab]: parsed }); setEditorMode((current) => ({ ...current, [tab]: nextMode })); }; @@ -145,6 +154,7 @@ export default function AdminSettingsPage() { message.error("JSON 格式不正确"); return; } + if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts); setJsonText((current) => ({ ...current, [tab]: JSON.stringify(parsed, null, 2), @@ -154,7 +164,9 @@ export default function AdminSettingsPage() { const openChannelDrawer = (index: number | null) => { setEditingChannelIndex(index); setIsChannelDrawerOpen(true); - channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index])); + const channel = index === null ? emptyChannel : normalizeChannel(channels[index]); + channelForm.setFieldsValue(channel); + rememberModels(channel.models); }; const closeChannelDrawer = () => { @@ -165,6 +177,7 @@ export default function AdminSettingsPage() { const saveChannel = async () => { const channel = normalizeChannel(await channelForm.validateFields()); + rememberModels(channel.models); const nextChannels = [...channels]; if (editingChannelIndex === null) nextChannels.push(channel); else nextChannels[editingChannelIndex] = channel; @@ -186,12 +199,21 @@ export default function AdminSettingsPage() { try { const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); channelForm.setFieldValue("models", channelModels); + rememberModels(channelModels); message.success(`已获取 ${channelModels.length} 个模型`); } catch (error) { message.error(error instanceof Error ? error.message : "读取模型失败"); } }; + function rememberModels(models: string[]) { + setKnownModels((current) => uniqueModels([...current, ...models])); + } + + function rememberKnownModels(settings: AdminSettings) { + rememberModels(collectKnownModels(settings)); + } + const openTestDialog = (index: number) => { const channel = normalizeChannel(channels[index]); if (!channel.baseUrl || channel.models.length === 0) { @@ -248,6 +270,8 @@ export default function AdminSettingsPage() { const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); const merged = mergeChannelApiKeys(nextChannels, saved); setChannels(merged.private.channels); + setModelCosts(merged.public.modelChannel.modelCosts); + rememberKnownModels(merged); form.setFieldsValue(merged); setJsonText({ public: JSON.stringify(merged.public, null, 2), @@ -313,7 +337,7 @@ export default function AdminSettingsPage() {
- + +