refactor(ai): 重构AI接口算力点扣费逻辑并添加失败返还机制

- 移除预检查算力点步骤,改为请求前预扣算力点
- 添加请求失败时自动返还算力点的功能
- 修改copyAIResponse函数参数以支持失败回调处理
- 在响应状态异常时也执行算力点返还操作
- 从auth服务中移除EnsureUserCredits方法
- 新增RefundUserCredits方法用于返还用户算力点
- 添加AI_REFUND算力点日志类型常量
- 更新算力点日志类型说明及展示标签
This commit is contained in:
HouYunFei
2026-05-25 15:23:51 +08:00
parent 01f2a4d9d5
commit 2934b1d0bc
10 changed files with 98 additions and 178 deletions
+13 -1
View File
@@ -2,7 +2,19 @@
## Unreleased ## Unreleased
+ [新增] 新增视频生成节点 + [新增] 新增用户账号与算力点体系,支持账号密码注册登录、Linux.do OAuth 登录、用户管理、算力点调整和算力点日志
+ [新增] 后端模型渠道调用支持按模型算力点计费,生图、图生图、文本对话和视频接口会在请求前预扣算力点,上游失败时自动返还。
+ [新增] 管理后台公开配置支持设置模型算力点、默认图片模型、默认视频模型和默认文本模型。
+ [新增] 画布右上角展示当前用户算力点余额,节点发送按钮、右侧助手和生成配置节点会展示本次预计消耗算力点。
+ [新增] 新增视频生成节点,支持本地直连和云端渠道的视频生成、轮询、下载、保存到素材和参考图生成视频。
+ [优化] 画布模型选择器统一为共享下拉组件,云端模式使用后台公开配置的可用模型,并按模型名称展示常见厂商图标。
+ [优化] 画布图片和视频节点会按素材或生成结果的真实宽高比调整尺寸,空节点切换比例时也会同步更新外框。
+ [优化] 画布生图设置弹层改为紧凑面板,模型选择保留在底部工具栏,尺寸、比例、质量和生成数量配置更集中。
+ [优化] 管理后台渠道模型列表获取和模型测试改为走后端接口,不再由前端直接读取或转发 API Key。
+ [优化] 管理后台渠道可用模型选择器支持单行省略展示,临时移除模型后仍保留候选,方便重新添加。
+ [优化] 后台提示词定时同步默认开启并默认每 5 分钟执行一次。
+ [修复] 云端默认模型为空时前端会回落到第一个系统可用模型,避免模型下拉为空或误弹配置弹窗。
+ [修复] 模型算力点配置输入不再因失焦回跳为 0,系统可用模型临时删除时会保留历史算力点配置。
## v0.0.7 - 2026-05-23 ## v0.0.7 - 2026-05-23
+14 -134
View File
@@ -1,6 +1,6 @@
# 后端数据库说明 # 后端数据库说明
本文档记录后端当前已经使用,以及后续规划会用到的主要数据表。 本文档记录后端当前已经使用的主要数据表。
## 数据库 ## 数据库
@@ -20,11 +20,11 @@
- `assets` - `assets`
- `settings` - `settings`
后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表 后续新增表时再同步补充本文档,未实际使用的规划表不提前写入
### users ### users
系统用户表。用户基础信息、角色、算力点余额和邀请关系放在该表中。 系统用户表。用户基础信息、角色、算力点余额和第三方登录标识放在该表中。
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|-----------------|--------|--------------------------| |-----------------|--------|--------------------------|
@@ -39,9 +39,9 @@
| `aff_code` | string | 用户自己的邀请码,唯一索引 | | `aff_code` | string | 用户自己的邀请码,唯一索引 |
| `aff_count` | number | 已邀请用户数量,冗余统计字段 | | `aff_count` | number | 已邀请用户数量,冗余统计字段 |
| `inviter_id` | string | 邀请人用户 ID | | `inviter_id` | string | 邀请人用户 ID |
| `github_id` | string | GitHub 用户 ID,规划字段 | | `github_id` | string | GitHub 用户 ID |
| `linux_do_id` | string | Linux.do 用户 ID | | `linux_do_id` | string | Linux.do 用户 ID |
| `wechat_id` | string | 微信用户 ID,规划字段 | | `wechat_id` | string | 微信用户 ID |
| `status` | string | 用户状态:`active``ban` | | `status` | string | 用户状态:`active``ban` |
| `last_login_at` | string | 最近登录时间 | | `last_login_at` | string | 最近登录时间 |
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` | | `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
@@ -50,7 +50,7 @@
### prompts ### prompts
提示词表。后续公开提示词、内置 GitHub 系统提示词、分类和扩展信息都优先放在该表字段或 JSON 中 提示词表。用于保存公开提示词、内置 GitHub 系统提示词、分类和预览内容
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|--------------|--------|------------------------------| |--------------|--------|------------------------------|
@@ -60,9 +60,7 @@
| `prompt` | string | 提示词内容 | | `prompt` | string | 提示词内容 |
| `tags` | json | 标签列表 | | `tags` | json | 标签列表 |
| `category` | string | 分类标识 | | `category` | string | 分类标识 |
| `visibility` | string | 可见性:公开、私有、系统内置等,规划字段 |
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 | | `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 | | `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 | | `updated_at` | string | 更新时间 |
@@ -70,25 +68,19 @@
### assets ### assets
素材表。当前用于素材库;后续通过 `user_id``visibility``type` 区分系统公开素材和用户私有素材 素材表。当前用于后台素材库。
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------------------|--------|-------------------------------| |------------------|--------|-------------------------------|
| `id` | string | 主键 | | `id` | string | 主键 |
| `user_id` | string | 所属用户,为空或系统用户表示公开素材,规划字段 |
| `title` | string | 标题 | | `title` | string | 标题 |
| `type` | string | 素材类型:`text``image``video` 等 | | `type` | string | 素材类型:`text``image``video` 等 |
| `visibility` | string | 可见性:公开、私有,规划字段 |
| `cover_url` | string | 封面图 | | `cover_url` | string | 封面图 |
| `tags` | json | 标签列表 | | `tags` | json | 标签列表 |
| `category` | string | 分类标识 | | `category` | string | 分类标识 |
| `description` | string | 描述 | | `description` | string | 描述 |
| `content` | text | 文本或 Markdown 内容 | | `content` | text | 文本或 Markdown 内容 |
| `url` | string | 图片、视频等媒体地址 | | `url` | string | 图片、视频等媒体地址 |
| `like_count` | number | 点赞量,规划字段 |
| `favorite_count` | number | 收藏量,规划字段 |
| `view_count` | number | 查看量,规划字段 |
| `extra` | json | 扩展信息,规划字段 |
| `created_at` | string | 创建时间 | | `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 | | `updated_at` | string | 更新时间 |
@@ -103,8 +95,8 @@
| `created_at` | string | 创建时间 | | `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 | | `updated_at` | string | 更新时间 |
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。 `public.value` 常放前端展示和可公开读取的配置,例如模型列表、登录开关等。
`private.value` 常放渠道密钥、支付配置、奖励规则、后台内部开关等。 `private.value` 常放渠道密钥、登录密钥、后台内部开关等。
当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。 当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。
@@ -133,7 +125,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `model` | string | 模型名称 | | `model` | string | 模型名称 |
| `credits` | number | 每次后端模型接口调用成功后扣除的算力点,未配置默认不扣除 | | `credits` | number | 每次后端模型接口调用前预扣的算力点,未配置默认不扣除 |
`auth.linuxDo` 当前字段: `auth.linuxDo` 当前字段:
@@ -178,33 +170,18 @@
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
### dicts
字典表。一个字典一行,具体字典项数据放在 `items`
| 字段 | 类型 | 说明 |
|--------------|--------|-------|
| `code` | string | 字典编码 |
| `name` | string | 字典名称 |
| `remark` | string | 备注 |
| `items` | text | 字典值数据 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
可维护分类、标签、业务枚举、模型分类、日志类型等。
### credit_logs ### credit_logs
用户算力点变更流水表。充值、消费、订阅扣减、邀请奖励、后台调整等余额变化都写入该表 用户算力点变更流水表。当前记录后台手动调整、模型调用预扣和模型调用失败返还
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|--------------|--------|--------------------------| |--------------|--------|--------------------------|
| `id` | string | 主键 | | `id` | string | 主键 |
| `user_id` | string | 关联用户 ID | | `user_id` | string | 关联用户 ID |
| `type` | string | 类型:充值、消费、订阅扣减、邀请奖励、后台调整等 | | `type` | string | 类型:`admin_adjust``ai_consume``ai_refund` |
| `amount` | number | 本次变动数量,增加为正,扣减为负 | | `amount` | number | 本次变动数量,增加为正,扣减为负 |
| `balance` | number | 变动后的用户算力点余额 | | `balance` | number | 变动后的用户算力点余额 |
| `related_id` | string | 关联订单、任务或日志 ID,可为空 | | `related_id` | string | 关联业务 ID,可为空 |
| `remark` | string | 备注 | | `remark` | string | 备注 |
| `extra` | json | 扩展信息 | | `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 | | `created_at` | string | 创建时间 |
@@ -215,101 +192,4 @@
| --- | --- | | --- | --- |
| `admin_adjust` | 后台手动调整 | | `admin_adjust` | 后台手动调整 |
| `ai_consume` | 调用后端模型接口消费 | | `ai_consume` | 调用后端模型接口消费 |
| `ai_refund` | 后端模型接口调用失败返还 |
### orders
订单表。统一记录充值、订阅购买等支付订单。
| 字段 | 类型 | 说明 |
|---------------------|--------|----------------------|
| `id` | string | 主键 |
| `user_id` | string | 关联用户 ID |
| `type` | string | 订单类型:充值、订阅等 |
| `provider` | string | 支付渠道:Linux LDC、聚合支付等 |
| `amount` | number | 支付金额 |
| `credits` | number | 到账算力点 |
| `status` | string | 订单状态:待支付、已支付、失败、关闭等 |
| `provider_order_id` | string | 第三方订单号 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `paid_at` | string | 支付时间 |
| `updated_at` | string | 更新时间 |
### subscriptions
用户订阅表。一个用户可以有多个订阅记录,套餐配置放在 `settings.public.value` 中。
| 字段 | 类型 | 说明 |
|-----------------|--------|------------------------------------------|
| `id` | string | 主键 |
| `user_id` | string | 关联用户 ID |
| `plan_key` | string | 套餐标识,对应 `settings.public.value` 中的订阅套餐配置 |
| `order_id` | string | 关联订单 ID,可为空 |
| `status` | string | 状态:生效中、已过期、已取消等 |
| `total_credits` | number | 订阅总额度 |
| `used_credits` | number | 已使用额度 |
| `started_at` | string | 开始时间 |
| `expired_at` | string | 过期时间 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
### files
文件表。用于统一管理上传图片、视频等文件,保存最终可访问地址。缩略图和视频封面优先按 URL 命名规则推导,特殊情况放在
`extra.coverUrl`
| 字段 | 类型 | 说明 |
|--------------|--------|-------------|
| `id` | string | 主键 |
| `user_id` | string | 上传用户 ID,可为空 |
| `name` | string | 原始文件名 |
| `url` | string | 完整可访问地址 |
| `mime_type` | string | MIME 类型 |
| `size` | number | 文件大小 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
### canvases
画布表。保存用户私有画布、公开画布和模板,分享、协作、审核等低频配置放在 `extra`
| 字段 | 类型 | 说明 |
|------------------|--------|---------------------------------------------|
| `id` | string | 主键 |
| `user_id` | string | 所属用户 ID |
| `title` | string | 画布标题 |
| `description` | string | 描述 |
| `cover_url` | string | 封面图 |
| `data` | json | 画布节点、边、视图等数据 |
| `visibility` | string | 可见性:`private``public` |
| `status` | string | 状态:`draft``pending``published``rejected` |
| `is_template` | bool | 是否模板 |
| `view_count` | number | 查看量 |
| `like_count` | number | 点赞量 |
| `favorite_count` | number | 收藏量 |
| `copy_count` | number | 复制量 |
| `extra` | json | 扩展信息,如分享、协作、审核备注等 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
### generation_tasks
接口调用队列表。用于图片、文本、图生图等后端模型调用的排队、状态和结果记录。
| 字段 | 类型 | 说明 |
|---------------|--------|----------------------|
| `id` | string | 主键 |
| `user_id` | string | 发起用户 ID |
| `type` | string | 任务类型:文本生成、文生图、图生图等 |
| `model` | string | 使用模型 |
| `channel` | string | 使用渠道 |
| `status` | string | 状态:排队中、执行中、成功、失败、取消等 |
| `credits` | number | 扣除算力点 |
| `input` | json | 请求参数 |
| `output` | json | 生成结果 |
| `error` | string | 错误信息 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `started_at` | string | 开始时间 |
| `finished_at` | string | 完成时间 |
+1 -1
View File
@@ -1,7 +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 配置保存和回调、后台筛选分页、用户保存删除、算力点调整、日志增删改查、登录态下生图/对话/视频和算力点流水都正常。 - 用户模块新增 `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 里的历史模型算力点配置,渠道里临时移除模型时公开配置下拉仍保留已见过的模型候选,云端默认模型为空时前端会回落到第一个系统可用模型,云端模型下拉为空时不会弹出配置弹窗,需要确认不同模型扣费、余额不足拦截、流水记录、顶部余额更新、算力点输入不回跳、临时删除模型后配置保留和可重新添加正常。 - 管理后台 `/admin/settings` 公开配置新增模型算力点配置;后端 `/api/v1` 生图、图生图、对话和视频创建接口会在请求上游前按模型扣用户算力点并写入 `credit_logs``ai_consume` 记录,上游失败时写入 `ai_refund` 返还记录,余额不足会拒绝调用,前端远程调用成功后会刷新当前用户信息;系统可用模型临时删除时会保留 JSON 里的历史模型算力点配置,渠道里临时移除模型时公开配置下拉仍保留已见过的模型候选,云端默认模型为空时前端会回落到第一个系统可用模型,云端模型下拉为空时不会弹出配置弹窗,需要确认不同模型扣费、余额不足拦截、失败返还、流水记录、顶部余额更新、算力点输入不回跳、临时删除模型后配置保留和可重新添加正常。
- 画布详情页右上角用户头像左侧新增当前算力点余额展示,未登录或无用户信息时显示 0,需要确认登录、未登录和调用后刷新余额显示正常。 - 画布详情页右上角用户头像左侧新增当前算力点余额展示,未登录或无用户信息时显示 0,需要确认登录、未登录和调用后刷新余额显示正常。
- 画布节点下方对话框、右侧助手发送按钮和生成配置节点开始生成按钮新增本次预计消耗算力点展示,费用来自公开配置的模型算力点,未配置显示 0,生图按生成张数倍乘;画布里的模型下拉在云端模式下会使用后台公开配置返回的系统可用模型,需要确认文本、生图、图生图、生成配置节点和本地直连模式显示正常。 - 画布节点下方对话框、右侧助手发送按钮和生成配置节点开始生成按钮新增本次预计消耗算力点展示,费用来自公开配置的模型算力点,未配置显示 0,生图按生成张数倍乘;画布里的模型下拉在云端模式下会使用后台公开配置返回的系统可用模型,需要确认文本、生图、图生图、生成配置节点和本地直连模式显示正常。
- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data``size``seconds``vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。 - 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data``size``seconds``vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。
+2 -2
View File
@@ -35,7 +35,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 | | `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 |
| `modelCosts` | object[] | 模型算力点配置,后端模型接口调用成功后按模型扣除;未配置默认不扣除 | | `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 |
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 | | `defaultModel` | string | 默认模型,从 `availableModels` 中选择 |
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 | | `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 | | `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
@@ -47,7 +47,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `model` | string | 模型名称 | | `model` | string | 模型名称 |
| `credits` | number | 每次成功调用扣除的算力点 | | `credits` | number | 每次后端模型接口调用前预扣的算力点 |
用户侧请求模式: 用户侧请求模式:
-6
View File
@@ -1,9 +1,3 @@
# TODO # TODO
本文档用来记录当前项目后续比较值得处理的事项。 本文档用来记录当前项目后续比较值得处理的事项。
## P0 近期优先
- 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID
等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。
- 日志管理:新增 `credit_logs` 记录用户算力点变更流水。
+15 -14
View File
@@ -77,10 +77,6 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
return return
} }
credits *= readAIRequestCount(body, contentType) credits *= readAIRequestCount(body, contentType)
if err := service.EnsureUserCredits(user.ID, credits); err != nil {
FailError(w, err)
return
}
channel, err := service.SelectModelChannel(modelName) channel, err := service.SelectModelChannel(modelName)
if err != nil { if err != nil {
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err) log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
@@ -97,15 +93,24 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
if contentType != "" { if contentType != "" {
request.Header.Set("Content-Type", contentType) request.Header.Set("Content-Type", contentType)
} }
copyAIResponse(w, request, func() error { if err := service.ConsumeUserCredits(user.ID, modelName, credits, path); err != nil {
return service.ConsumeUserCredits(user.ID, modelName, credits, path) FailError(w, err)
return
}
copyAIResponse(w, request, func() {
if err := service.RefundUserCredits(user.ID, modelName, credits, path); err != nil {
log.Printf("AI proxy refund credits failed: user=%s model=%s credits=%d err=%v", user.ID, modelName, credits, err)
}
}) })
} }
func copyAIResponse(w http.ResponseWriter, request *http.Request, beforeWrite func() error) { func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func()) {
response, err := http.DefaultClient.Do(request) response, err := http.DefaultClient.Do(request)
if err != nil { if err != nil {
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err) log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
if onFailure != nil {
onFailure()
}
Fail(w, "AI 接口请求失败") Fail(w, "AI 接口请求失败")
return return
} }
@@ -114,16 +119,12 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, beforeWrite fu
if response.StatusCode >= http.StatusBadRequest { if response.StatusCode >= http.StatusBadRequest {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload))) log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload)))
if onFailure != nil {
onFailure()
}
Fail(w, "AI 接口请求失败") Fail(w, "AI 接口请求失败")
return 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 { for key, values := range response.Header {
if strings.EqualFold(key, "Content-Length") { if strings.EqualFold(key, "Content-Length") {
+1
View File
@@ -80,6 +80,7 @@ type CreditLogType string
const ( const (
CreditLogTypeAdminAdjust CreditLogType = "admin_adjust" CreditLogTypeAdminAdjust CreditLogType = "admin_adjust"
CreditLogTypeAIConsume CreditLogType = "ai_consume" CreditLogTypeAIConsume CreditLogType = "ai_consume"
CreditLogTypeAIRefund CreditLogType = "ai_refund"
) )
// CreditLog 用户算力点变更流水。 // CreditLog 用户算力点变更流水。
+20 -5
View File
@@ -79,11 +79,6 @@ func SaveUser(user model.User) (model.User, error) {
return user, db.Save(&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) { func ConsumeUserCredits(id string, credits int, now string) (model.User, bool, error) {
db, err := DB() db, err := DB()
if err != nil { if err != nil {
@@ -104,6 +99,26 @@ func ConsumeUserCredits(id string, credits int, now string) (model.User, bool, e
return user, ok && tx.RowsAffected > 0, err return user, ok && tx.RowsAffected > 0, err
} }
func RefundUserCredits(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 = ?", id).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 保存算力点变更流水。 // SaveCreditLog 保存算力点变更流水。
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) { func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
db, err := DB() db, err := DB()
+25 -14
View File
@@ -318,20 +318,6 @@ func AdjustUserCredits(id string, credits int) (model.User, error) {
return user, err 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 { func ConsumeUserCredits(userID string, modelName string, credits int, path string) error {
if credits <= 0 { if credits <= 0 {
return nil return nil
@@ -357,6 +343,31 @@ func ConsumeUserCredits(userID string, modelName string, credits int, path strin
return err return err
} }
func RefundUserCredits(userID string, modelName string, credits int, path string) error {
if credits <= 0 {
return nil
}
user, ok, err := repository.RefundUserCredits(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.CreditLogTypeAIRefund,
Amount: credits,
Balance: user.Credits,
Remark: "模型调用失败返还 " + modelName,
Extra: string(extra),
CreatedAt: now(),
})
return err
}
func ListCreditLogs(q model.Query) (model.CreditLogList, error) { func ListCreditLogs(q model.Query) (model.CreditLogList, error) {
logs, total, err := repository.ListCreditLogs(q) logs, total, err := repository.ListCreditLogs(q)
if err != nil { if err != nil {
@@ -11,6 +11,12 @@ import { useAdminCreditLogs } from "./use-admin-credit-logs";
type CreditLogFormValues = Partial<AdminCreditLog>; type CreditLogFormValues = Partial<AdminCreditLog>;
const creditLogTypeLabels: Record<string, string> = {
admin_adjust: "后台调整",
ai_consume: "模型消费",
ai_refund: "失败返还",
};
export default function AdminCreditLogsPage() { export default function AdminCreditLogsPage() {
const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs(); const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs();
const [form] = Form.useForm<CreditLogFormValues>(); const [form] = Form.useForm<CreditLogFormValues>();
@@ -41,7 +47,7 @@ export default function AdminCreditLogsPage() {
title: "类型", title: "类型",
dataIndex: "type", dataIndex: "type",
width: 140, width: 140,
render: (_, item) => <Tag>{item.type || "-"}</Tag>, render: (_, item) => <Tag>{creditLogTypeLabels[item.type] || item.type || "-"}</Tag>,
}, },
{ {
title: "变动", title: "变动",