From b2cae2471d42b97b3047ce9f7cdadedb82a858fa Mon Sep 17 00:00:00 2001
From: HouYunFei <1844025705@qq.com>
Date: Mon, 25 May 2026 11:21:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=92=8CLinux.do=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增AdminUser类型定义及用户管理API接口
- 实现用户列表查询、保存和删除功能
- 添加Linux.do第三方登录支持
- 集成Linux.do OAuth认证流程
- 在系统设置中添加Linux.do登录配置选项
- 实现算力点余额调整记录功能
- 优化搜索组件关键词状态管理
- 更新数据库迁移添加credit_logs表
- 完善用户状态和信用等级验证逻辑
---
config/config.go | 17 +-
docs/backend-database.md | 30 +-
docs/pending-test.md | 1 +
docs/todo.md | 38 +--
handler/auth.go | 60 +++-
model/setting.go | 25 +-
model/user.go | 77 ++++--
repository/db.go | 1 +
repository/user.go | 23 ++
router/router.go | 2 +
service/auth.go | 257 +++++++++++++++++-
service/settings.go | 11 +
web/public/icons/linuxdo.svg | 9 +
web/src/app/(admin)/admin/assets/page.tsx | 16 +-
web/src/app/(admin)/admin/layout.tsx | 15 +-
web/src/app/(admin)/admin/page.tsx | 2 +-
web/src/app/(admin)/admin/prompts/page.tsx | 16 +-
web/src/app/(admin)/admin/settings/page.tsx | 68 ++++-
web/src/app/(admin)/admin/users/page.tsx | 244 +++++++++++++++++
.../(admin)/admin/users/use-admin-users.ts | 79 ++++++
web/src/app/(user)/login/page.tsx | 65 ++++-
web/src/app/api/[...path]/route.ts | 1 +
.../components/layout/user-status-actions.tsx | 24 +-
web/src/services/api/admin.ts | 54 ++++
web/src/services/api/auth.ts | 5 +-
25 files changed, 1023 insertions(+), 117 deletions(-)
create mode 100644 web/public/icons/linuxdo.svg
create mode 100644 web/src/app/(admin)/admin/users/page.tsx
create mode 100644 web/src/app/(admin)/admin/users/use-admin-users.ts
diff --git a/config/config.go b/config/config.go
index bcee3f0..f91f51f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -10,13 +10,16 @@ import (
)
type Config struct {
- Port string `env:"PORT" envDefault:"8080"`
- AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
- AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
- JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
- JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
- StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
- DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
+ Port string `env:"PORT" envDefault:"8080"`
+ AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
+ AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
+ JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
+ JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
+ StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
+ DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
+ LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"`
+ LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"`
+ LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"`
}
var Cfg Config
diff --git a/docs/backend-database.md b/docs/backend-database.md
index f91a3eb..016dc32 100644
--- a/docs/backend-database.md
+++ b/docs/backend-database.md
@@ -15,6 +15,7 @@
当前启动时执行 `AutoMigrate`,自动维护以下表:
- `users`
+- `credit_logs`
- `prompts`
- `assets`
- `settings`
@@ -34,14 +35,14 @@
| `display_name` | string | 昵称 |
| `avatar_url` | string | 头像地址 |
| `role` | string | 角色:`user`、`admin` |
-| `credits` | number | 算力点余额,规划字段 |
-| `aff_code` | string | 用户自己的邀请码,唯一索引,规划字段 |
-| `aff_count` | number | 已邀请用户数量,冗余统计字段,规划字段 |
-| `inviter_id` | string | 邀请人用户 ID,规划字段 |
+| `credits` | number | 算力点余额 |
+| `aff_code` | string | 用户自己的邀请码,唯一索引 |
+| `aff_count` | number | 已邀请用户数量,冗余统计字段 |
+| `inviter_id` | string | 邀请人用户 ID |
| `github_id` | string | GitHub 用户 ID,规划字段 |
-| `linux_do_id` | string | Linux.do 用户 ID,规划字段 |
+| `linux_do_id` | string | Linux.do 用户 ID |
| `wechat_id` | string | 微信用户 ID,规划字段 |
-| `status` | string | 用户状态:`active`、`ban`,规划字段 |
+| `status` | string | 用户状态:`active`、`ban` |
| `last_login_at` | string | 最近登录时间 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
@@ -112,6 +113,7 @@
| 字段 | 类型 | 说明 |
|-------------------|----------|----------------|
| `modelChannel` | object | 模型渠道公开配置组 |
+| `auth` | object | 公开登录配置 |
`modelChannel` 当前字段:
@@ -125,12 +127,19 @@
| `systemPrompt` | string | 系统提示词 |
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 |
+`auth.linuxDo` 当前字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `enabled` | bool | 是否开启 Linux.do 登录 |
+
`private.value` 当前字段:
| 字段 | 类型 | 说明 |
|------------|----------|----------|
| `channels` | object[] | 模型渠道配置列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
+| `auth` | object | 私有登录配置 |
`channels` 每项字段:
@@ -152,6 +161,15 @@
| `enabled` | bool | 是否开启定时同步,默认开启 |
| `cron` | string | Cron 表达式,默认每 5 分钟 |
+`auth.linuxDo` 当前字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `clientId` | string | Linux.do OAuth App Client ID |
+| `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 |
+| `redirectUri` | string | Linux.do OAuth 回调 URL |
+| `minimumTrustLevel` | number | 允许登录的最低信任等级 |
+
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
### dicts
diff --git a/docs/pending-test.md b/docs/pending-test.md
index 3f742ba..1a976d5 100644
--- a/docs/pending-test.md
+++ b/docs/pending-test.md
@@ -1,5 +1,6 @@
# 待测试
+- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,后台 `/admin/users` 可新增、编辑、删除用户并手动调整算力点,算力点变化会写入 `credit_logs`,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除和算力点流水都正常。
- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `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 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
diff --git a/docs/todo.md b/docs/todo.md
index 6a1ee7b..e500ef3 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -4,41 +4,7 @@
## P0 近期优先
-暂无。
-
-## P1 核心账号和后台
-
- 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID
等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。
-- 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。
-- 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。
-- 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。
-- 画布助手对话框优化:把当前对话面板调整得更简洁,减少干扰,突出输入、引用和结果。
-
-## P1 算力点和商业化
-
-- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。
-- 算力点订阅模块:订阅套餐配置先放 `settings.public.value`,用户订阅记录新增 `subscriptions`
- 表;用户购买订阅后,模型调用优先从可用订阅额度扣除,不足时再扣用户余额。
-- 支付模块:新增 `orders` 表,统一记录充值、订阅购买等订单,预留 Linux LDC 支付和第三方聚合支付;该模块优先级较低,先只设计结构,暂不重点实现。
-- 邀请奖励模块:邀请码、邀请人和邀请人数放 `users`,注册奖励、充值奖励配置放 `settings.private.value`,奖励发放记录写入
- `credit_logs`。
-
-## P1 内容、素材和画布
-
-- 文件存储管理:新增 `files` 表和存储接口,文件表只记录最终可访问 URL 等基础信息;后续再考虑图片缩略图、文件清理等能力,后台可查询所有图片和文件。
-- 提示词管理:新增 `prompts` 表,公开提示词、内置 GitHub 系统提示词、分类和扩展信息等尽量放字段或 JSON,支持后台维护和随时爬取更新。
-- 提示词分类:已移除 `prompt_groups` 表;分类数据很少,并且必须和抓取代码一一对应,直接在代码内存里写死维护。
-- 画布管理:新增 `canvases` 表,后台管理员可以查看每个人的画布数据,普通用户可以查看自己的画布列表。
-- 公开画布:在 `canvases` 表增加公开状态、审核状态、公开信息等字段;管理员可将自己的画布设为公开,用户也可以申请公开并由管理员审核。
-- 画布分享与模板:在云端画布基础上支持只读分享、复制为模板、团队内共享,分享配置和模板标记优先放在 `canvases` 字段或 JSON 中。
-- 项目库:基于公开画布增加系统公开项目库,用于筛选、打开、复制公开画布项目。
-- 画布协作能力:后续如果支持多人协作,协作成员、节点操作事件、锁定/光标、冲突处理和增量同步优先放在 `canvases` 的协作 JSON
- 中,确实不够用时再拆表。
-- 素材管理和我的素材:新增 `assets` 表,用 `user_id`、`visibility`、`type` 区分系统公开素材和用户私有素材;管理员可管理公开素材,也可以查看每个用户自己的素材。
-- 素材互动:公开素材增加点赞量、收藏量、查看量等统计字段;用户侧我的收藏、我的点赞先放在 `assets` 或用户偏好 JSON
- 中,后续数据量大了再拆互动表。
-
-## P2 体验和工程
-
-- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。
+- 日志管理:新增 `credit_logs` 记录用户算力点变更流水。
+- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。
\ No newline at end of file
diff --git a/handler/auth.go b/handler/auth.go
index 209f715..50e7171 100644
--- a/handler/auth.go
+++ b/handler/auth.go
@@ -3,6 +3,8 @@ package handler
import (
"encoding/json"
"net/http"
+ "net/url"
+ "strings"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/service"
@@ -19,10 +21,15 @@ type registerRequest struct {
}
type saveUserRequest struct {
- ID string `json:"id"`
- Username string `json:"username"`
- Password string `json:"password"`
- Role model.UserRole `json:"role"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ AvatarURL string `json:"avatarUrl"`
+ Role model.UserRole `json:"role"`
+ Credits int `json:"credits"`
+ Status model.UserStatus `json:"status"`
}
func Register(w http.ResponseWriter, r *http.Request) {
@@ -44,11 +51,25 @@ func Login(w http.ResponseWriter, r *http.Request) {
FailError(w, err)
return
}
- if session.User.Role != model.UserRoleAdmin {
- Fail(w, "需要管理员权限")
+ OK(w, session)
+}
+
+func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) {
+ authURL, err := service.LinuxDoAuthorizeURL(r.URL.Query().Get("redirect"))
+ if err != nil {
+ FailError(w, err)
return
}
- OK(w, session)
+ http.Redirect(w, r, authURL, http.StatusFound)
+}
+
+func LinuxDoCallback(w http.ResponseWriter, r *http.Request) {
+ session, redirect, err := service.LoginWithLinuxDo(r.URL.Query().Get("code"), r.URL.Query().Get("state"))
+ if err != nil {
+ http.Redirect(w, r, loginRedirect(redirect, "", err.Error()), http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, loginRedirect(redirect, session.Token, ""), http.StatusFound)
}
func AdminLogin(w http.ResponseWriter, r *http.Request) {
@@ -87,9 +108,14 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
var request saveUserRequest
_ = json.NewDecoder(r.Body).Decode(&request)
user, err := service.SaveUser(model.User{
- ID: request.ID,
- Username: request.Username,
- Role: request.Role,
+ ID: request.ID,
+ Username: request.Username,
+ Email: request.Email,
+ DisplayName: request.DisplayName,
+ AvatarURL: request.AvatarURL,
+ Role: request.Role,
+ Credits: request.Credits,
+ Status: request.Status,
}, request.Password)
if err != nil {
FailError(w, err)
@@ -98,6 +124,20 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
OK(w, user)
}
+func loginRedirect(redirect string, token string, message string) string {
+ values := url.Values{}
+ if strings.TrimSpace(token) != "" {
+ values.Set("token", token)
+ }
+ if strings.TrimSpace(message) != "" {
+ values.Set("error", message)
+ }
+ if strings.TrimSpace(redirect) != "" {
+ values.Set("redirect", redirect)
+ }
+ return "/login?" + values.Encode()
+}
+
func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) {
if err := service.DeleteUser(id); err != nil {
FailError(w, err)
diff --git a/model/setting.go b/model/setting.go
index 701d4a1..3b3a3b3 100644
--- a/model/setting.go
+++ b/model/setting.go
@@ -35,12 +35,22 @@ type PublicModelChannelSetting struct {
// PublicSetting 公开配置。
type PublicSetting struct {
ModelChannel PublicModelChannelSetting `json:"modelChannel"`
+ Auth PublicAuthSetting `json:"auth"`
+}
+
+type PublicAuthSetting struct {
+ LinuxDo PublicLinuxDoAuthSetting `json:"linuxDo"`
+}
+
+type PublicLinuxDoAuthSetting struct {
+ Enabled bool `json:"enabled"`
}
// PrivateSetting 私有配置。
type PrivateSetting struct {
- Channels []ModelChannel `json:"channels"`
- PromptSync PromptSyncSetting `json:"promptSync"`
+ Channels []ModelChannel `json:"channels"`
+ PromptSync PromptSyncSetting `json:"promptSync"`
+ Auth PrivateAuthSetting `json:"auth"`
}
// PromptSyncSetting 提示词定时同步配置。
@@ -49,6 +59,17 @@ type PromptSyncSetting struct {
Cron string `json:"cron"`
}
+type PrivateAuthSetting struct {
+ LinuxDo PrivateLinuxDoAuthSetting `json:"linuxDo"`
+}
+
+type PrivateLinuxDoAuthSetting struct {
+ ClientID string `json:"clientId"`
+ ClientSecret string `json:"clientSecret"`
+ RedirectURI string `json:"redirectUri"`
+ MinimumTrustLevel int `json:"minimumTrustLevel"`
+}
+
// Setting 系统配置。
type Setting struct {
Key SettingKey `json:"key" gorm:"primaryKey"`
diff --git a/model/user.go b/model/user.go
index 770643c..17fa781 100644
--- a/model/user.go
+++ b/model/user.go
@@ -8,14 +8,34 @@ const (
UserRoleAdmin UserRole = "admin"
)
+type UserStatus string
+
+const (
+ UserStatusActive UserStatus = "active"
+ UserStatusBan UserStatus = "ban"
+)
+
// User 系统用户。
type User struct {
- ID string `json:"id" gorm:"primaryKey"`
- Username string `json:"username" gorm:"uniqueIndex"`
- Password string `json:"password,omitempty"`
- Role UserRole `json:"role"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id" gorm:"primaryKey"`
+ Username string `json:"username" gorm:"uniqueIndex"`
+ Password string `json:"password,omitempty"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ AvatarURL string `json:"avatarUrl"`
+ Role UserRole `json:"role"`
+ Credits int `json:"credits"`
+ AffCode string `json:"affCode" gorm:"uniqueIndex"`
+ AffCount int `json:"affCount"`
+ InviterID string `json:"inviterId"`
+ GithubID string `json:"githubId"`
+ LinuxDoID string `json:"linuxDoId" gorm:"index"`
+ WechatID string `json:"wechatId"`
+ Status UserStatus `json:"status"`
+ LastLoginAt string `json:"lastLoginAt"`
+ Extra string `json:"extra" gorm:"type:text"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
}
// UserList 用户分页结果。
@@ -26,11 +46,14 @@ type UserList struct {
// AuthUser 用户公开信息。
type AuthUser struct {
- ID string `json:"id"`
- Username string `json:"username"`
- Role UserRole `json:"role"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"displayName"`
+ AvatarURL string `json:"avatarUrl"`
+ Role UserRole `json:"role"`
+ Credits int `json:"credits"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
}
// AuthSession 登录会话信息。
@@ -41,10 +64,32 @@ type AuthSession struct {
func PublicUser(user User) AuthUser {
return AuthUser{
- ID: user.ID,
- Username: user.Username,
- Role: user.Role,
- CreatedAt: user.CreatedAt,
- UpdatedAt: user.UpdatedAt,
+ ID: user.ID,
+ Username: user.Username,
+ DisplayName: user.DisplayName,
+ AvatarURL: user.AvatarURL,
+ Role: user.Role,
+ Credits: user.Credits,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
}
}
+
+type CreditLogType string
+
+const (
+ CreditLogTypeAdminAdjust CreditLogType = "admin_adjust"
+)
+
+// CreditLog 用户算力点变更流水。
+type CreditLog struct {
+ ID string `json:"id" gorm:"primaryKey"`
+ UserID string `json:"userId" gorm:"index"`
+ Type CreditLogType `json:"type"`
+ Amount int `json:"amount"`
+ Balance int `json:"balance"`
+ RelatedID string `json:"relatedId"`
+ Remark string `json:"remark"`
+ Extra string `json:"extra" gorm:"type:text"`
+ CreatedAt string `json:"createdAt"`
+}
diff --git a/repository/db.go b/repository/db.go
index c7f4a08..aed850a 100644
--- a/repository/db.go
+++ b/repository/db.go
@@ -47,6 +47,7 @@ func DB() (*gorm.DB, error) {
}
dbErr = db.AutoMigrate(
&model.User{},
+ &model.CreditLog{},
&model.Prompt{},
&model.Asset{},
&model.Setting{},
diff --git a/repository/user.go b/repository/user.go
index 1ef2615..cf77894 100644
--- a/repository/user.go
+++ b/repository/user.go
@@ -2,6 +2,7 @@ package repository
import (
"errors"
+ "strings"
"github.com/basketikun/infinite-canvas/model"
"gorm.io/gorm"
@@ -15,6 +16,10 @@ func ListUsers(q model.Query) ([]model.User, int64, error) {
}
q.Normalize()
tx := db.Model(&model.User{})
+ if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
+ like := "%" + keyword + "%"
+ tx = tx.Where("username LIKE ? OR display_name LIKE ? OR email LIKE ? OR linux_do_id LIKE ?", like, like, like, like)
+ }
var total int64
if err := tx.Count(&total).Error; err != nil {
@@ -74,6 +79,15 @@ func SaveUser(user model.User) (model.User, error) {
return user, db.Save(&user).Error
}
+// SaveCreditLog 保存算力点变更流水。
+func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
+ db, err := DB()
+ if err != nil {
+ return log, err
+ }
+ return log, db.Save(&log).Error
+}
+
// DeleteUser 删除指定用户。
func DeleteUser(id string) error {
db, err := DB()
@@ -83,6 +97,15 @@ func DeleteUser(id string) error {
return db.Delete(&model.User{}, "id = ?", id).Error
}
+// GetUserByLinuxDoID 根据 Linux.do ID 查询用户。
+func GetUserByLinuxDoID(id string) (model.User, bool, error) {
+ db, err := DB()
+ if err != nil {
+ return model.User{}, false, err
+ }
+ return findUser(db, "linux_do_id = ?", id)
+}
+
// findUser 查询单个用户,并将未命中转换为 ok=false。
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
user := model.User{}
diff --git a/router/router.go b/router/router.go
index b4f6c6a..f80350f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -18,6 +18,8 @@ func New() *gin.Engine {
})
api.POST("/auth/register", gin.WrapF(handler.Register))
api.POST("/auth/login", gin.WrapF(handler.Login))
+ api.GET("/auth/linux-do/authorize", gin.WrapF(handler.LinuxDoAuthorize))
+ api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback))
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
api.GET("/settings", gin.WrapF(handler.Settings))
api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations))
diff --git a/service/auth.go b/service/auth.go
index 0d059d1..d789de7 100644
--- a/service/auth.go
+++ b/service/auth.go
@@ -1,8 +1,15 @@
package service
import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
"errors"
+ "fmt"
+ "io"
"log"
+ "net/http"
+ "net/url"
"strings"
"time"
@@ -39,6 +46,8 @@ func EnsureDefaultAdmin() error {
Username: strings.TrimSpace(config.Cfg.AdminUsername),
Password: hash,
Role: model.UserRoleAdmin,
+ AffCode: newAffCode(),
+ Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
@@ -46,19 +55,18 @@ func EnsureDefaultAdmin() error {
}
func Register(username string, password string) (model.AuthSession, error) {
- return model.AuthSession{}, errors.New("注册功能暂时关闭")
username = strings.TrimSpace(username)
if strings.ContainsAny(username, " \t\r\n") {
- return model.AuthSession{}, errors.New("用户名不能包含空格")
+ return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
}
if username == "" || password == "" {
- return model.AuthSession{}, errors.New("用户名和密码不能为空")
+ return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
}
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
if err != nil {
return model.AuthSession{}, err
}
- return model.AuthSession{}, errors.New("用户名已存在")
+ return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
}
hash, err := hashPassword(password)
if err != nil {
@@ -69,6 +77,8 @@ func Register(username string, password string) (model.AuthSession, error) {
Username: username,
Password: hash,
Role: model.UserRoleUser,
+ AffCode: newAffCode(),
+ Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
@@ -84,11 +94,102 @@ func Login(username string, password string) (model.AuthSession, error) {
return model.AuthSession{}, err
}
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
- return model.AuthSession{}, errors.New("用户名或密码错误")
+ return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"}
+ }
+ if user.Status == model.UserStatusBan {
+ return model.AuthSession{}, safeMessageError{message: "账号已被禁用"}
+ }
+ normalizeUserDefaults(&user)
+ user.LastLoginAt = now()
+ user.UpdatedAt = now()
+ user, err = repository.SaveUser(user)
+ if err != nil {
+ return model.AuthSession{}, err
}
return newSession(user)
}
+func LinuxDoAuthorizeURL(redirect string) (string, error) {
+ settings, err := repository.GetSettings()
+ if err != nil {
+ return "", err
+ }
+ settings = normalizeSettings(settings)
+ linuxDo := settings.Private.Auth.LinuxDo
+ if !settings.Public.Auth.LinuxDo.Enabled {
+ return "", safeMessageError{message: "Linux.do 登录未开启"}
+ }
+ if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" || strings.TrimSpace(linuxDo.RedirectURI) == "" {
+ return "", safeMessageError{message: "Linux.do 登录未配置"}
+ }
+ values := url.Values{}
+ values.Set("client_id", linuxDo.ClientID)
+ values.Set("redirect_uri", linuxDo.RedirectURI)
+ values.Set("response_type", "code")
+ values.Set("scope", "read")
+ values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect)))
+ return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil
+}
+
+func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, error) {
+ redirect := decodeState(state)
+ settings, err := repository.GetSettings()
+ if err != nil {
+ return model.AuthSession{}, redirect, err
+ }
+ settings = normalizeSettings(settings)
+ linuxDo := settings.Private.Auth.LinuxDo
+ if !settings.Public.Auth.LinuxDo.Enabled {
+ return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"}
+ }
+ token, err := linuxDoAccessToken(code, linuxDo)
+ if err != nil {
+ return model.AuthSession{}, redirect, err
+ }
+ profile, err := linuxDoProfile(token)
+ if err != nil {
+ return model.AuthSession{}, redirect, err
+ }
+ linuxDoID := fmt.Sprint(profile.ID)
+ if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" {
+ return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"}
+ }
+ if profile.TrustLevel < linuxDo.MinimumTrustLevel {
+ return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 账号信任等级不足"}
+ }
+ user, ok, err := repository.GetUserByLinuxDoID(linuxDoID)
+ if err != nil {
+ return model.AuthSession{}, redirect, err
+ }
+ if !ok {
+ user = model.User{
+ ID: newID("user"),
+ Username: linuxDoUsername(profile.Username, linuxDoID),
+ DisplayName: strings.TrimSpace(profile.Name),
+ AvatarURL: linuxDoAvatar(profile.AvatarTemplate),
+ Role: model.UserRoleUser,
+ AffCode: newAffCode(),
+ LinuxDoID: linuxDoID,
+ Status: model.UserStatusActive,
+ CreatedAt: now(),
+ }
+ } else if user.Status == model.UserStatusBan {
+ return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"}
+ }
+ user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName)
+ user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
+ user.LastLoginAt = now()
+ user.UpdatedAt = now()
+ extra, _ := json.Marshal(profile)
+ user.Extra = string(extra)
+ user, err = repository.SaveUser(user)
+ if err != nil {
+ return model.AuthSession{}, redirect, err
+ }
+ session, err := newSession(user)
+ return session, redirect, err
+}
+
func ParseToken(tokenText string) (TokenClaims, error) {
claims := TokenClaims{}
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
@@ -122,6 +223,7 @@ func ListUsers(q model.Query) (model.UserList, error) {
}
for i := range users {
users[i].Password = ""
+ normalizeUserDefaults(&users[i])
}
return model.UserList{Items: users, Total: int(total)}, nil
}
@@ -129,27 +231,43 @@ func ListUsers(q model.Query) (model.UserList, error) {
func SaveUser(user model.User, password string) (model.User, error) {
user.Username = strings.TrimSpace(user.Username)
if strings.ContainsAny(user.Username, " \t\r\n") {
- return user, errors.New("用户名不能包含空格")
+ return user, safeMessageError{message: "用户名不能包含空格"}
}
if user.Username == "" {
- return user, errors.New("用户名不能为空")
+ return user, safeMessageError{message: "用户名不能为空"}
}
if user.Role == "" || user.Role == model.UserRoleGuest {
user.Role = model.UserRoleUser
}
+ if user.Status == "" {
+ user.Status = model.UserStatusActive
+ }
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
return user, err
} else if ok && saved.ID != user.ID {
- return user, errors.New("用户名已存在")
+ return user, safeMessageError{message: "用户名已存在"}
}
+ oldCredits := 0
if user.ID == "" {
user.ID = newID("user")
+ user.AffCode = newAffCode()
user.CreatedAt = now()
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
return user, err
} else if ok {
+ oldCredits = saved.Credits
user.CreatedAt = saved.CreatedAt
user.Password = saved.Password
+ if user.AffCode == "" {
+ user.AffCode = saved.AffCode
+ }
+ if user.AffCode == "" {
+ user.AffCode = newAffCode()
+ }
+ if user.LinuxDoID == "" {
+ user.LinuxDoID = saved.LinuxDoID
+ }
+ user.LastLoginAt = saved.LastLoginAt
}
if password != "" {
hash, err := hashPassword(password)
@@ -159,10 +277,21 @@ func SaveUser(user model.User, password string) (model.User, error) {
user.Password = hash
}
if user.Password == "" {
- return user, errors.New("密码不能为空")
+ return user, safeMessageError{message: "密码不能为空"}
}
user.UpdatedAt = now()
user, err := repository.SaveUser(user)
+ if err == nil && user.Credits != oldCredits {
+ _, err = repository.SaveCreditLog(model.CreditLog{
+ ID: newID("credit"),
+ UserID: user.ID,
+ Type: model.CreditLogTypeAdminAdjust,
+ Amount: user.Credits - oldCredits,
+ Balance: user.Credits,
+ Remark: "后台手动调整",
+ CreatedAt: now(),
+ })
+ }
user.Password = ""
return user, err
}
@@ -214,6 +343,116 @@ func newID(prefix string) string {
return prefix + "-" + uuid.NewString()
}
+func newAffCode() string {
+ return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", ""))
+}
+
+func normalizeUserDefaults(user *model.User) {
+ if user.Status == "" {
+ user.Status = model.UserStatusActive
+ }
+ if user.AffCode == "" {
+ user.AffCode = newAffCode()
+ }
+}
+
+type linuxDoTokenResponse struct {
+ AccessToken string `json:"access_token"`
+}
+
+type linuxDoUserResponse struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+ AvatarTemplate string `json:"avatar_template"`
+ TrustLevel int `json:"trust_level"`
+}
+
+func linuxDoAccessToken(code string, setting model.PrivateLinuxDoAuthSetting) (string, error) {
+ values := url.Values{}
+ values.Set("client_id", setting.ClientID)
+ values.Set("client_secret", setting.ClientSecret)
+ values.Set("grant_type", "authorization_code")
+ values.Set("code", code)
+ values.Set("redirect_uri", setting.RedirectURI)
+ req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ var payload linuxDoTokenResponse
+ if err := doLinuxDoJSON(req, &payload); err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(payload.AccessToken) == "" {
+ return "", safeMessageError{message: "Linux.do 登录失败"}
+ }
+ return payload.AccessToken, nil
+}
+
+func linuxDoProfile(token string) (linuxDoUserResponse, error) {
+ req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ var payload linuxDoUserResponse
+ err := doLinuxDoJSON(req, &payload)
+ return payload, err
+}
+
+func doLinuxDoJSON(req *http.Request, payload any) error {
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return safeMessageError{message: "Linux.do 登录失败"}
+ }
+ return json.NewDecoder(bytes.NewReader(body)).Decode(payload)
+}
+
+func linuxDoUsername(username string, id string) string {
+ base := strings.TrimSpace(username)
+ if base == "" {
+ base = "linuxdo-" + id
+ }
+ if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok {
+ return base
+ }
+ return base + "-" + id
+}
+
+func linuxDoAvatar(template string) string {
+ if strings.TrimSpace(template) == "" {
+ return ""
+ }
+ if strings.HasPrefix(template, "//") {
+ template = "https:" + template
+ }
+ if strings.HasPrefix(template, "/") {
+ template = "https://linux.do" + template
+ }
+ return strings.ReplaceAll(template, "{size}", "120")
+}
+
+func decodeState(state string) string {
+ data, err := base64.RawURLEncoding.DecodeString(state)
+ if err != nil {
+ return "/"
+ }
+ redirect := string(data)
+ if !strings.HasPrefix(redirect, "/") {
+ return "/"
+ }
+ return redirect
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func WarnDefaultSecurityConfig() {
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
diff --git a/service/settings.go b/service/settings.go
index 1b9a3f2..7267b14 100644
--- a/service/settings.go
+++ b/service/settings.go
@@ -31,6 +31,7 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
}
settings = normalizeSettings(settings)
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
+ keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
result, err := repository.SaveSettings(settings, now())
if err == nil {
RefreshPromptSyncScheduler()
@@ -76,6 +77,9 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
setting.Channels = []model.ModelChannel{}
}
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
+ if setting.Auth.LinuxDo.MinimumTrustLevel < 0 {
+ setting.Auth.LinuxDo.MinimumTrustLevel = 0
+ }
for i := range setting.Channels {
if setting.Channels[i].Protocol == "" {
setting.Channels[i].Protocol = "openai"
@@ -94,6 +98,7 @@ func hidePrivateAPIKeys(settings model.Settings) model.Settings {
for i := range settings.Private.Channels {
settings.Private.Channels[i].APIKey = ""
}
+ settings.Private.Auth.LinuxDo.ClientSecret = ""
return settings
}
@@ -108,6 +113,12 @@ func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) {
}
}
+func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) {
+ if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" {
+ settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret
+ }
+}
+
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
for _, item := range saved {
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
diff --git a/web/public/icons/linuxdo.svg b/web/public/icons/linuxdo.svg
new file mode 100644
index 0000000..e79667b
--- /dev/null
+++ b/web/public/icons/linuxdo.svg
@@ -0,0 +1,9 @@
+
diff --git a/web/src/app/(admin)/admin/assets/page.tsx b/web/src/app/(admin)/admin/assets/page.tsx
index 5fdfefa..ae9a7ba 100644
--- a/web/src/app/(admin)/admin/assets/page.tsx
+++ b/web/src/app/(admin)/admin/assets/page.tsx
@@ -23,6 +23,7 @@ export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const copyText = useCopyText();
const [form] = Form.useForm();
+ const [keywordText, setKeywordText] = useState(keyword);
const [editingAsset, setEditingAsset] = useState | null>(null);
const [detailAsset, setDetailAsset] = useState(null);
const [deletingAsset, setDeletingAsset] = useState(null);
@@ -33,6 +34,8 @@ export default function AdminAssetsPage() {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
+ useEffect(() => setKeywordText(keyword), [keyword]);
+
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
@@ -119,7 +122,7 @@ export default function AdminAssetsPage() {
- } onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} />
+ } onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
@@ -135,8 +138,15 @@ export default function AdminAssetsPage() {
-
- } onClick={refreshAssets}>
+
+ } onClick={() => searchAssets(keywordText)}>
查询
diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx
index 8ebbbff..3882625 100644
--- a/web/src/app/(admin)/admin/layout.tsx
+++ b/web/src/app/(admin)/admin/layout.tsx
@@ -1,6 +1,6 @@
"use client";
-import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons";
+import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography, theme } from "antd";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -12,6 +12,7 @@ import { adminLayoutStyle } from "@/lib/app-theme";
import { useUserStore } from "@/stores/use-user-store";
const adminMenus = [
+ { key: "/admin/users", icon: , label: "用户管理" },
{ key: "/admin/prompts", icon: , label: "提示词管理" },
{ key: "/admin/assets", icon: , label: "素材库" },
{ key: "/admin/settings", icon: , label: "系统设置" },
@@ -25,8 +26,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
- const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
- const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
+ const activeKey = pathname.startsWith("/admin/settings")
+ ? "/admin/settings"
+ : pathname.startsWith("/admin/assets")
+ ? "/admin/assets"
+ : pathname.startsWith("/admin/prompts")
+ ? "/admin/prompts"
+ : pathname.startsWith("/admin/users")
+ ? "/admin/users"
+ : "";
+ const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : "用户管理";
useEffect(() => {
if (!isReady) return;
diff --git a/web/src/app/(admin)/admin/page.tsx b/web/src/app/(admin)/admin/page.tsx
index ace3dbf..5593850 100644
--- a/web/src/app/(admin)/admin/page.tsx
+++ b/web/src/app/(admin)/admin/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
- redirect("/admin/assets");
+ redirect("/admin/users");
}
diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx
index d604376..6d9baa7 100644
--- a/web/src/app/(admin)/admin/prompts/page.tsx
+++ b/web/src/app/(admin)/admin/prompts/page.tsx
@@ -36,6 +36,7 @@ export default function AdminPromptsPage() {
} = useAdminPrompts();
const copyText = useCopyText();
const [form] = Form.useForm & { tagText?: string }>();
+ const [keywordText, setKeywordText] = useState(keyword);
const [editingPrompt, setEditingPrompt] = useState | null>(null);
const [detailPrompt, setDetailPrompt] = useState(null);
const [deletingPrompt, setDeletingPrompt] = useState(null);
@@ -51,6 +52,8 @@ export default function AdminPromptsPage() {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
+ useEffect(() => setKeywordText(keyword), [keyword]);
+
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({
@@ -135,7 +138,7 @@ export default function AdminPromptsPage() {
- } onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
+ } onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
@@ -151,8 +154,15 @@ export default function AdminPromptsPage() {
-
- } onClick={refreshPrompts}>
+
+ } onClick={() => searchPrompts(keywordText)}>
查询
diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx
index 456763a..51502eb 100644
--- a/web/src/app/(admin)/admin/settings/page.tsx
+++ b/web/src/app/(admin)/admin/settings/page.tsx
@@ -35,8 +35,9 @@ const emptySettings: AdminSettings = {
systemPrompt: "",
allowCustomChannel: true,
},
+ auth: { linuxDo: { enabled: false } },
},
- private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } },
+ private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "", redirectUri: "", minimumTrustLevel: 0 } } },
};
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
@@ -61,7 +62,9 @@ export default function AdminSettingsPage() {
const [testResults, setTestResults] = useState>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
+ const [linuxDoCallbackUrl, setLinuxDoCallbackUrl] = useState("");
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
+ const linuxDoRedirectUri = Form.useWatch(["private", "auth", "linuxDo", "redirectUri"], 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]);
@@ -91,6 +94,16 @@ export default function AdminSettingsPage() {
void loadSettings();
}, [token]);
+ useEffect(() => {
+ setLinuxDoCallbackUrl(`${window.location.origin}/api/auth/linux-do/callback`);
+ }, []);
+
+ useEffect(() => {
+ if (!linuxDoCallbackUrl) return;
+ if (linuxDoRedirectUri) return;
+ form.setFieldValue(["private", "auth", "linuxDo", "redirectUri"], linuxDoCallbackUrl);
+ }, [form, linuxDoCallbackUrl, linuxDoRedirectUri]);
+
const changeTab = (nextTab: SettingsTabKey) => {
setActiveTab(nextTab);
};
@@ -365,6 +378,46 @@ export default function AdminSettingsPage() {
diff --git a/web/src/app/api/[...path]/route.ts b/web/src/app/api/[...path]/route.ts
index 398313d..f932727 100644
--- a/web/src/app/api/[...path]/route.ts
+++ b/web/src/app/api/[...path]/route.ts
@@ -35,6 +35,7 @@ async function proxy(request: NextRequest, context: RouteContext) {
headers: proxyHeaders(request),
body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined,
+ redirect: "manual",
} as RequestInit & { duplex?: "half" });
return new Response(response.body, {
diff --git a/web/src/components/layout/user-status-actions.tsx b/web/src/components/layout/user-status-actions.tsx
index e7a7d0f..d48b867 100644
--- a/web/src/components/layout/user-status-actions.tsx
+++ b/web/src/components/layout/user-status-actions.tsx
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, RefObject } from "react";
-import { Dropdown } from "antd";
+import { Avatar, Dropdown } from "antd";
import { Keyboard, LogOut, Settings2, Shield } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import Link from "next/link";
@@ -32,14 +32,15 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const logout = useUserStore((state) => state.clearSession);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
- const userName = user?.username || "用户";
+ const userName = user?.displayName || user?.username || "用户";
+ const avatarUrl = user?.avatarUrl?.trim();
const avatarText = (userName.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const versionStyle = iconStyle;
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
const gitHubStyle = iconStyle;
- const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
+ const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: {userName} },
...(user?.role === "admin" ? [{ key: "admin", icon: , label: 管理后台 }] : []),
@@ -60,13 +61,16 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
-
diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts
index 40b9974..1d5f5ed 100644
--- a/web/src/services/api/admin.ts
+++ b/web/src/services/api/admin.ts
@@ -10,6 +10,47 @@ export type AdminPromptCategory = {
remote: boolean;
};
+export type AdminUser = {
+ id: string;
+ username: string;
+ email: string;
+ displayName: string;
+ avatarUrl: string;
+ role: "user" | "admin";
+ credits: number;
+ affCode: string;
+ affCount: number;
+ inviterId: string;
+ linuxDoId: string;
+ status: "active" | "ban";
+ lastLoginAt: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type AdminUserListResponse = {
+ items: AdminUser[];
+ total: number;
+};
+
+export type AdminUserQuery = {
+ keyword?: string;
+ page?: number;
+ pageSize?: number;
+};
+
+export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
+ return apiGet("/api/admin/users", compactApiParams(query), token);
+}
+
+export async function saveAdminUser(token: string, user: Partial & { password?: string }) {
+ return apiPost("/api/admin/users", user, token);
+}
+
+export async function deleteAdminUser(token: string, id: string) {
+ return apiDelete(`/api/admin/users/${encodeURIComponent(id)}`, token);
+}
+
export async function fetchAdminPromptCategories(token: string) {
return apiGet("/api/admin/prompt-categories", undefined, token);
}
@@ -105,6 +146,11 @@ export type AdminPublicModelChannelSettings = {
export type AdminPublicSettings = {
modelChannel: AdminPublicModelChannelSettings;
+ auth: {
+ linuxDo: {
+ enabled: boolean;
+ };
+ };
};
export type AdminPrivateSettings = {
@@ -113,6 +159,14 @@ export type AdminPrivateSettings = {
enabled: boolean;
cron: string;
};
+ auth: {
+ linuxDo: {
+ clientId: string;
+ clientSecret: string;
+ redirectUri: string;
+ minimumTrustLevel: number;
+ };
+ };
};
export type AdminSettings = {
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index afe1dee..35b5685 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -7,7 +7,10 @@ export type UserRole = "guest" | "user" | "admin";
export type AuthUser = {
id: string;
username: string;
+ displayName: string;
+ avatarUrl: string;
role: UserRole;
+ credits: number;
createdAt: string;
updatedAt: string;
};
@@ -23,7 +26,7 @@ export type AuthPayload = {
};
export async function login(payload: AuthPayload) {
- return apiPost("/api/admin/login", payload);
+ return apiPost("/api/auth/login", payload);
}
export async function register(payload: AuthPayload) {