This commit is contained in:
ryan
2026-06-07 19:47:24 +08:00
parent 48d414e197
commit 360a26f109
60 changed files with 10063 additions and 637 deletions
+364
View File
@@ -0,0 +1,364 @@
# 项目开发规范
> 本文档面向 AI 代理(Agent)和开发者,描述项目的目录结构、模块职责与开发规范。
---
## 一、技术栈
### 后端
| 技术 | 用途 |
|------|------|
| Go (1.25+) | 主语言 |
| Gin | HTTP 框架 |
| GORM | ORM,主库 PostgreSQL,可选 ClickHouse |
| Redis | 缓存 / Session / 队列 |
| Asynq | 异步任务队列(基于 Redis) |
| Cobra + Viper | CLI 入口 + 配置加载 |
| Swaggo | Swagger 文档生成 |
| OpenTelemetry | 链路追踪 |
| Zap | 结构化日志 |
| AWS SDK v2 | S3 兼容文件存储 |
| Snowflake | 分布式 ID 生成 |
### 前端
| 技术 | 用途 |
|------|------|
| Next.js (App Router) | 前端框架 |
| TypeScript | 主语言 |
| Tailwind CSS | 样式 |
| pnpm | 包管理 |
| shadcn/ui | 组件库 |
---
## 二、顶层目录结构
```
Refreshing/ # 项目根目录(模块名: github.com/linux-do/credit)
├── main.go # 程序入口,调用 internal/cmd
├── go.mod / go.sum # Go 模块依赖
├── config.yaml # 运行时配置(不提交到 Git)
├── config.example.yaml # 配置模板(需提交)
├── Makefile # 常用命令(swagger/tidy/license)
├── Dockerfile # 后端容器镜像构建
├── .editorconfig # 编辑器格式规范
├── .gitignore
├── docs/ # Swagger 自动生成文档(不要手动编辑)
├── frontend/ # Next.js 前端项目
├── internal/ # 后端核心代码(Go private,不对外暴露)
├── scripts/ # CI/本地工具脚本
└── support-files/ # 辅助文件(如 nginx 配置等)
```
---
## 三、后端 `internal/` 目录结构
```
internal/
├── cmd/ # CLI 命令入口(Cobra)
│ ├── root.go # 根命令,加载配置、初始化依赖
│ ├── api.go # 启动 HTTP API 服务器子命令
│ ├── scheduler.go # 启动定时任务调度器子命令
│ └── worker.go # 启动 Asynq Worker 子命令
│
├── config/ # 配置加载与结构定义
│ ├── model.go # 所有配置结构体(AppConfig / DB / Redis 等)
│ └── config.go # Viper 加载逻辑,暴露全局 config.Config
│
├── router/ # HTTP 路由注册(唯一路由注册点)
│ ├── router.go # 路由总入口,注册所有分组路由、中间件、启动 HTTP Server
│ └── middlewares.go # 全局中间件(如请求日志)
│
├── apps/ # 业务功能模块(按功能域划分)
│ ├── oauth/ # OAuth / OIDC 登录、会话、用户信息
│ ├── user/ # 用户密码登录、注册、登出
│ ├── upload/ # 文件上传、文件服务、清理任务
│ ├── health/ # 健康检查端点
│ ├── config/ # 公开配置接口(前端读取)
│ └── admin/ # 管理后台功能(需 Admin 权限)
│ ├── middlewares.go # Admin 鉴权中间件
│ ├── errs.go # Admin 错误常量
│ ├── auth_source/ # 认证源管理(CRUD)
│ ├── system_config/ # 系统配置管理(CRUD)
│ ├── task/ # 任务手动调度接口
│ └── user/ # 用户管理(列表、状态)
│
├── model/ # 数据模型(GORM 实体 + 业务方法)
│ ├── users.go # User 实体、OAuthUserInfo、查询/更新方法
│ ├── auth_source.go # AuthSource 实体(OAuth 接入源)
│ ├── system_configs.go # SystemConfig 实体(KV 系统配置)
│ └── uploads.go # Upload 实体(上传文件记录)
│
├── db/ # 数据库连接与基础设施
│ ├── postgres.go # PostgreSQL 初始化、读写分离、GORM 配置
│ ├── redis.go # Redis 初始化(单机/哨兵/集群)
│ ├── clickhouse.go # ClickHouse 初始化(可选)
│ ├── postgres_logger.go # 自定义 GORM 日志(对接 Zap)
│ ├── idgen/ # Snowflake 分布式 ID 生成器
│ └── migrator/ # 数据库迁移(AutoMigrate)
│
├── storage/ # 文件存储抽象层
│ ├── s3.go # S3 兼容存储(上传/下载/URL 生成)
│ ├── cache.go # 本地磁盘缓存(S3 内容缓存)
│ └── errs.go # 存储层错误常量
│
├── task/ # 异步任务定义与调度
│ ├── constants.go # 任务类型名称常量(TaskType)、队列名
│ ├── utils.go # 任务工具函数(RedisOpt 等)
│ ├── scheduler/ # Asynq 定时任务调度器(Cron 注册)
│ └── worker/ # Asynq Worker 服务端(任务处理器注册)
│ ├── worker.go # StartWorker 入口,注册 Handler
│ └── middlewares.go # Worker 中间件(日志等)
│
├── service/ # 复杂业务逻辑服务层(当前占位,待填充)
│
├── common/ # 跨模块共享代码
│ ├── constants.go # 全局常量(错误消息字符串等)
│ ├── errs.go # 通用错误定义
│ ├── bind/ # 请求参数绑定封装(统一处理错误响应)
│ └── response/ # 统一 HTTP 响应格式封装
│
├── util/ # 无业务依赖的纯工具函数
│ ├── crypto.go # 加密/签名工具
│ ├── password.go # 密码 Hash(bcrypt)
│ ├── http_clients.go # HTTP 客户端封装
│ ├── context.go # Context 存取工具
│ ├── response.go # ResponseAny 等响应结构体
│ ├── session.go # Session 选项构建
│ ├── uuid.go # UUID / 唯一 ID 生成
│ ├── strings.go # 字符串工具
│ ├── validate.go # 参数校验工具
│ └── custom_types.go # 自定义类型
│
├── logger/ # 日志封装(基于 Zap + OTel)
│ ├── logger.go # 全局 Logger 初始化
│ └── utils.go # InfoF / WarnF / ErrorF 快捷函数
│
├── listener/ # 事件监听器(Webhook / 消息消费)
│
└── otel_trace/ # OpenTelemetry 链路追踪封装
└── ... # Span 创建、Exporter 配置
```
---
## 四、`apps/` 模块内部文件规范
每个业务模块(`apps/<module>/`)内部按照以下约定组织文件:
| 文件名 | 职责 |
|--------|------|
| `routers.go` | **HTTP Handler 函数**(业务逻辑入口,对应 Controller 层)|
| `controllers.go` | 可选,当 Handler 较多时拆分(同 `routers.go` 职责)|
| `middlewares.go` | 本模块专属中间件(如 `LoginRequired`、`LoginAdminRequired`)|
| `errs.go` | 本模块专属错误消息字符串常量(`const`)|
| `constants.go` | 本模块专属业务常量(非错误)|
> **规则**:
> - 路由 **不在** 模块内部注册,统一在 `internal/router/router.go` 中注册。
> - `errs.go` 只定义字符串常量,不定义 `error` 类型值,错误通过 `response.RespondFailure(c, errMsg)` 输出。
### `admin/` 子模块结构示例
```
apps/admin/
├── middlewares.go # LoginAdminRequired 中间件
├── errs.go # admin 级别错误常量
├── auth_source/ # 认证源 CRUD
│ └── routers.go
├── system_config/ # 系统 KV 配置 CRUD
│ └── routers.go
├── task/ # 任务调度接口
│ └── routers.go
├── user/ # 用户管理
│ ├── routers.go
│ └── errs.go
└── user_pay_config/ # 用户支付配置
└── routers.go
```
---
## 五、前端 `frontend/` 目录结构
```
frontend/
├── app/ # Next.js App Router 页面目录
│ ├── layout.tsx # 根布局(全局 Provider、字体、meta)
│ ├── globals.css # 全局样式
│ ├── page.tsx # 首页重定向
│ ├── (auth)/ # 认证相关页面组(登录/注册/OAuth 回调)
│ ├── (main)/ # 主应用页面组(用户界面)
│ └── (docs)/ # 文档类页面组
│
├── components/ # 可复用 React 组件
│ ├── ui/ # shadcn/ui 基础组件(Button/Input/Dialog 等)
│ ├── common/ # 通用业务组件(跨页面复用)
│ ├── layout/ # 布局组件(Header / Sidebar / Footer)
│ ├── auth/ # 认证相关组件
│ ├── home/ # 首页专属组件
│ ├── animate-ui/ # 动画 UI 组件
│ └── providers/ # Context Provider 组件
│
├── contexts/ # React Context(全局状态)
├── hooks/ # 自定义 React Hooks
├── lib/ # 前端工具函数、API 客户端封装
├── types/ # TypeScript 类型定义
├── public/ # 静态资源
├── proxy.ts # 开发环境代理配置
├── next.config.ts # Next.js 配置
├── package.json
├── tsconfig.json
├── .env # 环境变量(不提交)
└── .env.example # 环境变量模板(需提交)
```
---
## 六、开发规范
### 6.1 命名规范
| 对象 | 规范 | 示例 |
|------|------|------|
| Go 包名 | 小写,下划线分词(单词) | `auth_source`、`system_config` |
| Go 文件名 | 小写,下划线分词 | `routers.go`、`postgres_logger.go` |
| Go 导出函数 | PascalCase | `ListUsers`、`StartWorker` |
| Go 未导出函数 | camelCase | `buildQueuesFromConfig` |
| Go 结构体请求/响应 | camelCase + 后缀 | `listUsersRequest`、`listUsersResponse` |
| 错误常量 | camelCase 字符串 `const` | `const userNotFound = "用户不存在"` |
| 任务类型常量 | 全大写蛇形 | `CleanupUnusedUploadsTask` |
| 配置 Key | 全小写蛇形(YAML) | `session_cookie_name`、`max_idle_conn` |
### 6.2 HTTP Handler 规范
```go
// Handler 函数命名:动词 + 名词(PascalCase)
func ListUsers(c *gin.Context) {
// 1. 参数绑定(使用 ShouldBindQuery / ShouldBindJSON)
var req listUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 2. 业务逻辑
// 3. 统一响应
c.JSON(http.StatusOK, util.OK(data))
}
```
**响应格式约定**:
- 成功:`util.OK(data)` 或 `util.OKNil()`
- 失败:`util.Err(msg)` + 对应 HTTP 状态码
- 通过 `response.RespondSuccess / RespondFailure` 也可(两套工具共存)
### 6.3 Swagger 注释规范
所有对外 Handler 必须添加 Swaggo 注释:
```go
// ListUsers 获取用户列表
// @Summary 获取用户列表
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
// @Tags admin
// @Produce json
// @Param request query listUsersRequest true "查询参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/users [get]
func ListUsers(c *gin.Context) { ... }
```
生成文档:`make swagger`(执行 `scripts/swagger.sh`)
### 6.4 错误处理规范
- **模块内错误消息**:定义在本模块 `errs.go` 中,使用 `const` 字符串。
- **跨模块错误消息**:定义在 `internal/common/errs.go` 或 `common/constants.go`。
- **数据库错误**:直接 `err.Error()` 返回给响应(开发阶段),生产环境应屏蔽详情。
- **gorm.ErrRecordNotFound**:显式判断,返回 404。
### 6.5 中间件使用规范
| 中间件 | 位置 | 作用 |
|--------|------|------|
| `gin.Recovery()` | 全局 | Panic 恢复 |
| `otelgin.Middleware()` | 全局 | OTel 链路追踪 |
| `loggerMiddleware()` | 全局 | 请求日志 |
| `sessions.Sessions()` | 全局 | Session 注入 |
| `oauth.LoginRequired()` | 路由组 | 登录校验 |
| `admin.LoginAdminRequired()` | Admin 路由组 | 管理员校验 |
### 6.6 配置访问规范
- 所有配置通过 `config.Config.<Section>.<Field>` 访问(全局单例)。
- 不允许在业务代码中使用 `os.Getenv()` 读取配置,统一通过 Viper 加载。
- 新增配置项:先在 `config.example.yaml` 添加注释模板,再在 `internal/config/model.go` 添加结构体字段。
### 6.7 数据库访问规范
- 直接使用 GORM:`model.DB.Where(...).Find(&result)`(适合简单查询)。
- 通过 `db.DB(ctx)` 获取带链路追踪的 DB 实例(Admin 模块推荐)。
- 禁止在 Handler 层直接写复杂 SQL,应封装到 `model/` 层方法或 `service/` 层。
- 数据库迁移使用 `db/migrator/` 中的 AutoMigrate,不允许手动执行 DDL。
### 6.8 异步任务规范
**定义任务**:
1. 在 `internal/task/constants.go` 中定义任务类型常量。
2. 实现 Handler 函数(放在对应 `apps/` 模块的 `tasks.go` 文件中)。
3. 在 `internal/task/worker/worker.go` 中注册 Handler:`mux.HandleFunc(task.XxxTask, handler)`。
4. 调度:在 `internal/task/scheduler/` 中按 Cron 表达式调度,或通过 Admin API 手动触发。
**队列优先级**(从高到低):`webhook` > `whitelist_only` > `default`
---
## 七、新增功能开发流程
以新增 **管理员功能模块** 为例:
```
1. 在 internal/model/ 中定义/扩展数据模型
2. 在 db/migrator/ 中注册 AutoMigrate
3. 在 internal/apps/admin/<module>/ 中创建:
- routers.go (Handler 实现 + Swagger 注释)
- errs.go (错误常量,按需)
4. 在 internal/router/router.go 中注册路由
5. 执行 make swagger 更新文档
```
以新增 **异步任务** 为例:
```
1. 在 internal/task/constants.go 定义任务类型常量
2. 在对应 apps/<module>/tasks.go 实现 Handle 函数
3. 在 internal/task/worker/worker.go 注册 Handler
4. 在 internal/task/scheduler/ 添加 Cron 调度(或 Admin API 手动触发)
5. 在 config.example.yaml 的 scheduler 段添加 Cron 配置项
6. 在 internal/config/model.go 添加配置字段
```
---
## 八、关键依赖版本
| 依赖 | 版本 |
|------|------|
| Go | 1.25+ |
| Gin | v1.11.0 |
| GORM | v1.31.1 |
| go-redis | v9.16.0 |
| Asynq | v0.25.1 |
| Cobra | v1.10.1 |
| Viper | v1.21.0 |
| Zap | v1.27.0 |
| Snowflake | v0.3.0 |
| OpenTelemetry | v1.36.0 |
| Next.js | (见 frontend/package.json) |
| pnpm | (见 frontend/pnpm-workspace.yaml) |
-7
View File
@@ -1,10 +1,3 @@
swagger:
scripts/swagger.sh
tidy:
scripts/tidy.sh
check_license:
scripts/license.sh
pre_commit: tidy swagger check_license
+1642 -29
View File
File diff suppressed because it is too large Load Diff
+1642 -27
View File
File diff suppressed because it is too large Load Diff
+996 -27
View File
File diff suppressed because it is too large Load Diff
+55 -39
View File
@@ -1,13 +1,13 @@
"use client"
import { motion } from "motion/react"
import { usePathname } from "next/navigation"
import { useState } from "react"
import { AppSidebar } from "@/components/layout/sidebar"
import { SiteHeader } from "@/components/layout/header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { UserProvider } from "@/contexts/user-context"
import {motion} from "motion/react"
import {usePathname, useRouter} from "next/navigation"
import {useEffect, useState} from "react"
import {AppSidebar} from "@/components/layout/sidebar"
import {SiteHeader} from "@/components/layout/header"
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"
import {LoadingPage} from "@/components/layout/loading"
import {useUser} from "@/contexts/user-context"
export default function MainLayout({
@@ -15,40 +15,56 @@ export default function MainLayout({
}: {
children: React.ReactNode
}) {
const router = useRouter()
const pathname = usePathname()
const {user, loading} = useUser()
const [isFullWidth, setIsFullWidth] = useState(false)
useEffect(() => {
if (loading || user) return
const queryString = window.location.search
const callbackUrl = queryString ? `${pathname}${queryString}` : pathname
const loginUrl = new URL("/login", window.location.origin)
loginUrl.searchParams.set("callbackUrl", callbackUrl)
sessionStorage.setItem("redirect_after_login", callbackUrl)
router.replace(loginUrl.toString())
}, [loading, pathname, router, user])
if (loading || !user) {
return <LoadingPage text="登录状态" badgeText="Auth" />
}
return (
<UserProvider>
<SidebarProvider
className="h-screen"
style={
{
"--header-height": "60px",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex flex-col min-w-0 h-screen">
<SiteHeader isFullWidth={isFullWidth} onToggleFullWidth={setIsFullWidth} />
<div className="flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar">
<div className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"}`}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
ease: "easeOut",
}}
className="w-full"
>
{children}
</motion.div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</UserProvider>
<SidebarProvider
className="h-screen"
style={
{
"--header-height": "60px",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex flex-col min-w-0 h-screen">
<SiteHeader isFullWidth={isFullWidth} onToggleFullWidth={setIsFullWidth} />
<div className="flex flex-1 flex-col bg-background overflow-y-auto overflow-x-hidden min-w-0 hide-scrollbar">
<div className={`w-full mx-auto px-4 sm:px-6 md:px-8 lg:px-12 min-w-0 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"}`}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
ease: "easeOut",
}}
className="w-full"
>
{children}
</motion.div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}
+26 -6
View File
@@ -1,7 +1,9 @@
import Link from "next/link"
import { Card, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
import { UserRound, Bell, Shield, Palette } from "lucide-react"
"use client"
import Link from "next/link"
import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card"
import {Bell, Loader2, Palette, Shield, UserRound} from "lucide-react"
import {useAuth} from "@/components/providers/auth-provider"
/* 设置项 */
const settingsItems = [
@@ -28,7 +30,7 @@ const settingsItems = [
},
{
title: "外观设置",
description: "自定义界面主题和显示",
description: "自定义界面主题 and 显示",
icon: Palette,
href: "/settings/appearance",
category: "个人设置",
@@ -36,7 +38,25 @@ const settingsItems = [
]
export default function SettingsPage() {
const groupedSettings = settingsItems.reduce((acc, item) => {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loader2 className="size-6 animate-spin text-indigo-500" />
</div>
)
}
// 非管理员过滤掉安全设置
const filteredItems = settingsItems.filter((item) => {
if (item.href === "/settings/security") {
return !!user?.is_admin
}
return true
})
const groupedSettings = filteredItems.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
@@ -46,7 +66,7 @@ export default function SettingsPage() {
return (
<div className="space-y-6 py-6">
<div className="font-semibold">设置</div>
<div className="font-semibold text-lg">设置</div>
{Object.entries(groupedSettings).map(([category, items]) => (
<div key={category} className="space-y-4">
+18 -12
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { CustomThemeProvider } from "@/lib/theme";
import { BellRingProvider } from "@/contexts/bell-ring-context";
import { NotificationSettingsProvider } from "@/contexts/notification-settings-context";
import type {Metadata} from "next";
import {Toaster} from "@/components/ui/sonner";
import {ThemeProvider} from "@/components/layout/theme-provider";
import {CustomThemeProvider} from "@/lib/theme";
import {BellRingProvider} from "@/contexts/bell-ring-context";
import {NotificationSettingsProvider} from "@/contexts/notification-settings-context";
import {UserProvider} from "@/contexts/user-context";
import {AppQueryProvider} from "@/components/providers/query-provider";
import "./globals.css";
export const metadata: Metadata = {
@@ -32,12 +34,16 @@ export default function RootLayout({
disableTransitionOnChange
>
<CustomThemeProvider>
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
<AppQueryProvider>
<UserProvider>
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
</UserProvider>
</AppQueryProvider>
</CustomThemeProvider>
</ThemeProvider>
</body>
+246 -160
View File
@@ -1,182 +1,268 @@
"use client"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { motion, useAnimation } from "motion/react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { SquareArrowUpRight } from 'lucide-react';
import {useMemo, useState} from "react"
import {useMutation, useQuery} from "@tanstack/react-query"
import {useRouter, useSearchParams} from "next/navigation"
import {KeyRound, ShieldCheck, UserPlus} from "lucide-react"
import {toast} from "sonner"
import { cn } from "@/lib/utils"
import {useAuth} from "@/components/providers/auth-provider"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {Spinner} from "@/components/ui/spinner"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {Card, CardContent} from "@/components/ui/card"
import services from "@/lib/services"
import { termsSections } from "@/components/common/docs/terms"
import { privacySections } from "@/components/common/docs/privacy"
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
const callbackUrl = searchParams.get("callbackUrl")
const storedRedirect = sessionStorage.getItem("redirect_after_login")
const target = callbackUrl || storedRedirect || "/home"
/**
* 登录表单组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginForm />
* ```
* @param {React.ComponentProps<"div">} props - 组件属性
* @param {string} className - 组件类名
* @returns {React.ReactNode} 登录表单组件
*/
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const [isLoading, setIsLoading] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const controls = useAnimation()
useEffect(() => {
const agreed = localStorage.getItem("loginPromptAgreed") === "true"
if (agreed) {
setHasAgreed(true)
}
}, [])
const handleAgreementChange = (checked: boolean | string) => {
const isChecked = checked === true
setHasAgreed(isChecked)
if (isChecked) {
localStorage.setItem("loginPromptAgreed", "true")
} else {
localStorage.removeItem("loginPromptAgreed")
}
if (storedRedirect) {
sessionStorage.removeItem("redirect_after_login")
}
/* 处理登录 */
const handleLogin = async () => {
if (!hasAgreed) {
toast.error("请先阅读并勾选服务条款和隐私政策")
controls.start({
x: [0, -4, 4, -4, 4, 0],
color: ["#ef4444", "inherit"],
transition: { duration: 0.5 }
})
return
}
return target
}
setIsLoading(true)
function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
const callbackUrl = searchParams.get("callbackUrl")
if (callbackUrl) {
sessionStorage.setItem("redirect_after_login", callbackUrl)
}
}
export function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const { setUser } = useAuth()
const [mode, setMode] = useState<"login" | "register">("login")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [nickname, setNickname] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryFn: services.config.getPublicConfig,
})
const authSourcesQuery = useQuery({
queryKey: ["auth-sources"],
queryFn: services.auth.getAuthSources,
enabled: publicConfigQuery.data?.oidc_login_enabled ?? true,
})
const redirectTarget = useMemo(
() => getRedirectTarget(searchParams),
[searchParams],
)
const loginMutation = useMutation({
mutationFn: services.auth.login,
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
},
onError: (error: Error) => {
setErrorMessage(error.message || "登录失败,请重试")
},
})
const registerMutation = useMutation({
mutationFn: services.auth.register,
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
},
onError: (error: Error) => {
setErrorMessage(error.message || "注册失败,请重试")
},
})
const handlePasswordLogin = () => {
setErrorMessage("")
loginMutation.mutate({
username: username.trim(),
password,
})
}
const handleRegister = () => {
setErrorMessage("")
registerMutation.mutate({
username: username.trim(),
password,
nickname: nickname.trim() || undefined,
})
}
const handleOAuthLogin = async (sourceName: string) => {
try {
await services.auth.initiateLogin()
setErrorMessage("")
persistRedirectTarget(searchParams)
const { authorize_url } = await services.auth.getAuthorizeUrl(sourceName)
window.location.href = authorize_url
} catch (error) {
setIsLoading(false)
console.error('Login error:', error)
const message = error instanceof Error ? error.message : "登录失败,请重试"
toast.error(message, {
duration: 5000,
description: error instanceof Error && error.name === 'NetworkError'
? '请确认后端服务已启动'
: undefined
})
toast.error(error instanceof Error ? error.message : "第三方登录失败")
}
}
const registrationEnabled =
(publicConfigQuery.data?.registration_enabled ?? true) &&
(publicConfigQuery.data?.password_register_enabled ?? true)
const passwordLoginEnabled = publicConfigQuery.data?.password_login_enabled ?? true
const authSources = authSourcesQuery.data ?? []
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<div className="grid gap-4 mx-4">
<Button
variant="default"
type="button"
className="w-full h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? <Spinner className="mr-2" /> : <SquareArrowUpRight className="mr-2 h-4 w-4" />}
{isLoading ? "正在跳转..." : "使用 LINUX DO 登录"}
</Button>
</div>
<Card className="w-full border-border/60 bg-background/80 shadow-2xl backdrop-blur">
<CardContent className="space-y-5 p-5 sm:p-6">
<div className="space-y-2 text-center">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
账号登录
</h2>
<p className="text-sm text-muted-foreground">
使用账号密码或第三方 OIDC 认证源登录
</p>
</div>
<motion.div
animate={controls}
className="flex items-center justify-center space-x-2 px-4"
>
<Checkbox
id="terms"
checked={hasAgreed}
onCheckedChange={handleAgreementChange}
/>
<label
htmlFor="terms"
className="text-muted-foreground text-xs text-balance opacity-75 hover:opacity-100 transition-opacity cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
我已阅读并同意
{" "}
<Dialog>
<DialogTrigger asChild>
<button
<Tabs value={mode} onValueChange={(value) => setMode(value as "login" | "register")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">登录</TabsTrigger>
<TabsTrigger value="register" disabled={!registrationEnabled}>
注册
</TabsTrigger>
</TabsList>
<TabsContent value="login" className="space-y-4 pt-4">
<div className="space-y-3">
<div className="space-y-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
autoComplete="username"
/>
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="密码"
autoComplete="current-password"
/>
</div>
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
<Button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
className="w-full"
onClick={handlePasswordLogin}
disabled={!passwordLoginEnabled || loginMutation.isPending}
>
服务条款
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>服务条款</DialogTitle>
<DialogDescription>请仔细阅读以下条款,使用本服务即表示您接受。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{termsSections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
{" "}及{" "}
<Dialog>
<DialogTrigger asChild>
<button
{loginMutation.isPending ? (
<>
<Spinner className="mr-2" />
登录中...
</>
) : (
<>
<KeyRound className="mr-2 size-4" />
账号密码登录
</>
)}
</Button>
</div>
</TabsContent>
<TabsContent value="register" className="space-y-4 pt-4">
<div className="space-y-3">
<div className="space-y-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
autoComplete="username"
/>
<Input
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="昵称(可选)"
autoComplete="nickname"
/>
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="密码(至少 8 位)"
autoComplete="new-password"
/>
</div>
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
<Button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
className="w-full"
onClick={handleRegister}
disabled={!registrationEnabled || registerMutation.isPending}
>
隐私政策
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>隐私政策</DialogTitle>
<DialogDescription>我们重视您的隐私,以下说明信息如何收集与使用。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{privacySections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
</label>
</motion.div>
</div>
{registerMutation.isPending ? (
<>
<Spinner className="mr-2" />
注册中...
</>
) : (
<>
<UserPlus className="mr-2 size-4" />
创建账号
</>
)}
</Button>
</div>
</TabsContent>
</Tabs>
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldCheck className="size-4" />
第三方认证源
</div>
<div className="grid gap-2">
{authSources.length > 0 ? (
authSources.map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="justify-start"
onClick={() => void handleOAuthLogin(source.name)}
>
{source.display_name || source.name} 登录
</Button>
))
) : (
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-sm text-muted-foreground">
暂无可用认证源
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}
+22 -20
View File
@@ -1,23 +1,22 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { LoginForm } from "@/components/auth/login-form"
import { Check } from "lucide-react"
import {useCallback, useEffect, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {useRouter, useSearchParams} from "next/navigation"
import {toast} from "sonner"
import {Spinner} from "@/components/ui/spinner"
import {LoginForm} from "@/components/auth/login-form"
import {Check} from "lucide-react"
import { AuroraBackground } from "@/components/ui/aurora-background"
import {AuroraBackground} from "@/components/ui/aurora-background"
import services from "@/lib/services"
import type { ApiResponse } from "@/lib/services/core/types"
import {useAuth} from "@/components/providers/auth-provider"
/**
* 登录页面组件
* 显示登录表单和登录按钮
*
*
* @example
* ```tsx
* <LoginPage />
@@ -27,6 +26,7 @@ import type { ApiResponse } from "@/lib/services/core/types"
export function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { setUser } = useAuth()
/* 处理OAuth回调 */
const [isProcessingCallback, setIsProcessingCallback] = useState(() => {
@@ -74,14 +74,13 @@ export function LoginPage() {
if (cancelled) return
if (response.ok) {
await response.json() as ApiResponse
const payload = await response.json()
if (payload?.data) {
setUser(payload.data)
}
router.replace(resolveRedirectTarget())
return
}
if (response.status !== 401) {
console.error('Session probe failed:', response.status)
}
} catch (error) {
if (!cancelled) {
console.error('Session probe error:', error)
@@ -98,7 +97,7 @@ export function LoginPage() {
return () => {
cancelled = true
}
}, [router, searchParams, resolveRedirectTarget])
}, [router, searchParams, resolveRedirectTarget, setUser])
/* 回调逻辑 */
useEffect(() => {
@@ -109,9 +108,12 @@ export function LoginPage() {
if (state && code) {
setIsProcessingCallback(true)
try {
await services.auth.handleCallback({ state, code })
const result = await services.auth.handleCallback({ state, code })
if (result.user) {
setUser(result.user)
}
setLoginSuccess(true)
toast.success("登录成功")
toast.success(result.status === "bound" ? "绑定成功" : "登录成功")
setTimeout(() => {
router.replace(resolveRedirectTarget())
@@ -125,7 +127,7 @@ export function LoginPage() {
}
}
handleOAuthCallback()
}, [searchParams, router, resolveRedirectTarget])
}, [searchParams, router, resolveRedirectTarget, setUser])
return (
<AuroraBackground>
@@ -0,0 +1,166 @@
"use client"
import {useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {AdminService} from "@/lib/services"
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
const emptyForm: AuthSourceRequest = {
name: "",
type: "oidc",
display_name: "",
is_active: false,
client_id: "",
client_secret: "",
openid_discovery_url: "",
scopes: "openid profile email",
icon_url: "",
}
export function AuthSourceModal({
isOpen,
source,
onClose,
onChanged,
}: {
isOpen: boolean
source: AuthSource | null
onClose: () => void
onChanged: () => Promise<void>
}) {
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (isOpen) {
if (source) {
setForm({
name: source.name,
type: source.type,
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: "",
openid_discovery_url: source.openid_discovery_url,
scopes: source.scopes || "openid profile email",
icon_url: source.icon_url,
})
} else {
setForm(emptyForm)
}
} else {
setForm(emptyForm)
setSaving(false)
}
}, [isOpen, source])
const saveSource = async () => {
setSaving(true)
try {
if (source) {
await AdminService.updateAuthSource(source.id, form)
} else {
await AdminService.createAuthSource(form)
}
await onChanged()
toast.success("认证源已保存")
onClose()
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存认证源失败")
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
<DialogDescription>
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<Label>标识符 (英文名称)</Label>
<Input
value={form.name}
disabled={!!source}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如: github"
/>
</div>
<div className="space-y-2">
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
/>
</div>
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>图标 URL (可选)</Label>
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
placeholder="https://... 或留空"
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10">
<div>
<div className="font-medium text-sm">启用认证源</div>
<div className="text-xs text-muted-foreground">启用后会立即显示在登录页和账号绑定中。</div>
</div>
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
</div>
<div className="md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={saveSource} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10">
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+135 -7
View File
@@ -2,26 +2,60 @@
import * as React from "react"
import Link from "next/link"
import { motion, useAnimation } from "motion/react"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { useUser } from "@/contexts/user-context"
import { Shield } from "lucide-react"
import {motion, useAnimation} from "motion/react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useUser} from "@/contexts/user-context"
import {ArrowRight, Link2, Loader2, Shield, Unlink} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Separator} from "@/components/ui/separator"
import {AuthService} from "@/lib/services"
import {toast} from "sonner"
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const queryClient = useQueryClient()
const externalAccountBindingsQuery = useQuery({
queryKey: ["auth", "external-accounts"],
queryFn: () => AuthService.getExternalAccountBindings(),
})
const publicAuthSourcesQuery = useQuery({
queryKey: ["auth", "public-sources"],
queryFn: () => AuthService.getAuthSources(),
})
const bindSourceMutation = useMutation({
mutationFn: async (sourceName: string) => {
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName, "bind")
sessionStorage.setItem("redirect_after_login", `${window.location.pathname}${window.location.search}`)
window.location.href = authorize_url
},
onError: (error: Error) => {
toast.error(error.message || "绑定认证源失败")
},
})
const handleAvatarClick = () => {
if (isAnimatingRef.current) return
isAnimatingRef.current = true
controls.start({
rotate: [0, -20, 20, -20, 20, 0],
transition: { duration: 0.5, ease: "easeInOut" }
})
setTimeout(() => {
isAnimatingRef.current = false
}, 650)
@@ -123,6 +157,100 @@ export function ProfileMain() {
</div>
</div>
</div>
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Link2 className="size-4" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">第三方账号绑定</h2>
<p className="text-xs text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
</div>
</div>
{/* 已绑定账号列表 */}
<div className="space-y-3 pt-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
{externalAccountBindingsQuery.isPending ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-indigo-500" />
</div>
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300"
>
<div className="space-y-1">
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
<span className="text-xs text-muted-foreground font-mono block truncate max-w-[180px]">
{binding.external_username || binding.email || "未提供账号标识"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-8 px-2.5 transition-colors"
onClick={async () => {
await AuthService.deleteExternalAccountBinding(binding.id)
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
toast.success("绑定已移除")
}}
>
<Unlink className="size-3.5 mr-1" />
解除绑定
</Button>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
<Link2 className="size-6 text-muted-foreground/30 mb-2" />
暂无绑定的第三方账号
</div>
)}
</div>
<Separator className="border-dashed" />
{/* 绑定新账号列表 */}
<div className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
{publicAuthSourcesQuery.isPending ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-indigo-500" />
</div>
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(publicAuthSourcesQuery.data ?? []).map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="flex items-center justify-between w-full border border-dashed rounded-xl px-4 py-3.5 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-auto"
onClick={() => {
void bindSourceMutation.mutateAsync(source.name)
}}
>
<div className="flex items-center gap-2">
<Link2 className="size-3.5 text-muted-foreground group-hover:text-indigo-500" />
<span>绑定 {source.display_name || source.name}</span>
</div>
<ArrowRight className="size-3.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
</Button>
))}
</div>
) : (
<div className="text-xs text-muted-foreground text-center py-4">
暂无可用第三方认证源
</div>
)}
</div>
</div>
</div>
)
}
+343 -13
View File
@@ -1,14 +1,168 @@
"use client"
import * as React from "react"
import {useEffect, useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Fingerprint, Globe, Loader2, Lock, Pencil, Plus, Settings, ShieldCheck, Trash2, UserPlus} from "lucide-react"
import Link from "next/link"
import { ShieldCheck } from "lucide-react"
import {useRouter} from "next/navigation"
import {motion} from "motion/react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Switch} from "@/components/ui/switch"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useAuth} from "@/components/providers/auth-provider"
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
import {AdminService} from "@/lib/services"
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
import {toast} from "sonner"
const SECURITY_KEYS = [
{
key: "password_login_enabled",
title: "允许密码登录",
description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。",
icon: Lock,
},
{
key: "registration_enabled",
title: "允许注册",
description: "关闭后系统将禁止新用户进行自主账号注册。",
icon: UserPlus,
},
{
key: "password_register_enabled",
title: "允许密码注册",
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
icon: Fingerprint,
},
{
key: "oidc_login_enabled",
title: "允许 OIDC 登录",
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
icon: Globe,
},
] as const
type SecurityKey = (typeof SECURITY_KEYS)[number]["key"]
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config
return accumulator
}, {})
}
export function SecurityMain() {
const queryClient = useQueryClient()
const { user, loading } = useAuth()
const router = useRouter()
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
const systemConfigsQuery = useQuery({
queryKey: ["admin", "system-configs"],
queryFn: () => AdminService.listSystemConfigs("system"),
enabled: !!user?.is_admin,
})
const authSourcesQuery = useQuery({
queryKey: ["auth", "sources"],
queryFn: () => AdminService.listAuthSources(),
enabled: !!user?.is_admin,
})
const configs = useMemo(
() => systemConfigMap(systemConfigsQuery.data ?? []),
[systemConfigsQuery.data],
)
useEffect(() => {
if (!loading && (!user || !user.is_admin)) {
router.replace("/settings/profile")
}
}, [user, loading, router])
useEffect(() => {
if (user?.is_admin) {
void systemConfigsQuery.refetch()
}
}, [systemConfigsQuery, user])
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => {
const config = configs[key]
if (!config) {
throw new Error(`缺少配置项: ${key}`)
}
await AdminService.updateSystemConfig(key, {
value: value ? "true" : "false",
description: config.description,
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("系统安全配置已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const toggleSourceMutation = useMutation({
mutationFn: async (source: AuthSource) => {
await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active })
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源状态已更新")
},
onError: (error: Error) => {
toast.error(error.message || "切换状态失败")
},
})
const deleteSourceMutation = useMutation({
mutationFn: async (sourceId: string) => {
await AdminService.deleteAuthSource(sourceId)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源已删除")
},
onError: (error: Error) => {
toast.error(error.message || "删除认证源失败")
},
})
const handleToggle = (key: SecurityKey, checked: boolean) => {
updateConfigMutation.mutate({ key, value: checked })
}
if (loading || !user || !user.is_admin) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="size-6 animate-spin text-indigo-500" />
</div>
)
}
return (
<div className="py-6 space-y-6">
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="py-6 space-y-6 max-w-4xl mx-auto px-4"
>
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
@@ -25,16 +179,192 @@ export function SecurityMain() {
</Breadcrumb>
</div>
<div className="space-y-6">
<div className="font-medium text-sm text-muted-foreground flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
安全设置
</div>
<div className="text-sm text-muted-foreground">
当前暂无安全配置选项
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-lg shadow-indigo-500/20">
<ShieldCheck className="size-6 animate-pulse" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">系统安全设置</h1>
<p className="text-sm text-muted-foreground">管理系统安全控制及身份验证源</p>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* 系统登录与注册控制 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Settings className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">系统安全与注册控制</CardTitle>
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SECURITY_KEYS.map((item) => {
const config = configs[item.key]
const checked = config ? config.value === "true" : false
const Icon = item.icon
return (
<div
key={item.key}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-indigo-500" />}
<span className="font-medium text-sm text-foreground">{item.title}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
</div>
<Switch
checked={checked}
disabled={updateConfigMutation.isPending}
onCheckedChange={(value) => handleToggle(item.key, value)}
/>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* 认证源配置管理 */}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Globe className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors"
>
<Plus className="mr-1.5 size-3.5" />
新增认证源
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-3">
{authSourcesQuery.isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
</div>
) : (authSourcesQuery.data ?? []).length > 0 ? (
(authSourcesQuery.data ?? []).map((source) => (
<div
key={source.id}
className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">{source.display_name || source.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
source.is_active
? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
}`}>
{source.is_active ? "已启用" : "已禁用"}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
标识符: {source.name} · 类型: {source.type.toUpperCase()}
</div>
</div>
<div className="flex items-center gap-4">
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
source.client_secret_configured
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
}`}>
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
</span>
<div className="flex items-center gap-2">
<Switch
checked={source.is_active}
disabled={toggleSourceMutation.isPending}
className="scale-90 mr-2"
onCheckedChange={() => toggleSourceMutation.mutate(source)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
onClick={() => {
setSelectedSource(source)
setAuthSourceModalOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
disabled={deleteSourceMutation.isPending}
onClick={() => {
if (window.confirm(`确定删除认证源「${source.display_name || source.name}」吗?`)) {
deleteSourceMutation.mutate(source.id)
}
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
<span>暂无配置的认证源,点击上方按钮新增</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
}}
className="border-dashed"
>
<Plus className="mr-1.5 size-3.5" />
新增认证源
</Button>
</div>
)}
</CardContent>
</Card>
</div>
<AuthSourceModal
isOpen={authSourceModalOpen}
source={selectedSource}
onClose={() => setAuthSourceModalOpen(false)}
onChanged={async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
await authSourcesQuery.refetch()
}}
/>
</motion.div>
)
}
+51 -23
View File
@@ -2,24 +2,24 @@
import * as React from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import {usePathname, useRouter} from "next/navigation"
import packageJson from "../../package.json"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { AnimateIcon } from "@/components/animate-ui/icons/icon"
import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left"
import { ChevronRight } from "@/components/animate-ui/icons/chevron-right"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Spinner} from "@/components/ui/spinner"
import {AnimateIcon} from "@/components/animate-ui/icons/icon"
import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left"
import {ChevronRight} from "@/components/animate-ui/icons/chevron-right"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { getCurrentTheme } from "@/components/layout/avater-style/registry"
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
import {
Sidebar,
SidebarContent,
@@ -44,28 +44,30 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Home,
CreditCard,
Settings,
FileText,
LogOut,
ChevronDown,
UserRound,
FileQuestionMark,
ShieldCheck,
Layers,
Key,
Palette,
ArrowUpRight,
ChevronDown,
CreditCard,
FileQuestionMark,
FileText,
Home,
Layers,
LogOut,
Palette,
Settings,
ShieldCheck,
UserRound,
} from "lucide-react"
import { useUser } from "@/contexts/user-context"
import {useUser} from "@/contexts/user-context"
/* 导航数据 */
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
],
systemSettings: [
{ title: "系统设置", url: "/settings/security", icon: Settings },
],
admin: [
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
@@ -279,6 +281,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroupContent>
</SidebarGroup>
{user?.is_admin && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
设置
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.systemSettings.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname.startsWith(item.url)}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{user?.is_admin && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
@@ -0,0 +1,13 @@
"use client"
import { useUser } from "@/contexts/user-context"
/**
* Auth provider bridge hook
*
* Provides a stable interface for components that use the useAuth() pattern.
* Delegates to the canonical UserContext under the hood.
*/
export function useAuth() {
return useUser()
}
@@ -0,0 +1,23 @@
"use client"
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import {useState} from "react"
export function AppQueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
+11 -4
View File
@@ -1,9 +1,9 @@
"use client"
import { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react'
import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'
import services from '@/lib/services'
import { User, TrustLevel, PayLevel } from '@/lib/services/auth/types'
import {PayLevel, TrustLevel, User} from '@/lib/services/auth/types'
/** 用户状态接口 */
@@ -15,6 +15,7 @@ interface UserState {
/** 用户上下文接口 */
interface UserContextValue extends UserState {
setUser: (user: User) => void
refetch: () => Promise<void>
getTrustLevelLabel: (trustLevel: TrustLevel) => string
getPayLevelLabel: (payLevel: PayLevel) => string
@@ -43,7 +44,7 @@ const UserContext = createContext<UserContextValue | undefined>(undefined)
/**
* 用户Provider组件
*
*
* @param {React.ReactNode} children - 用户 Provider 的子元素
* @returns {React.ReactNode} 用户 Provider 组件
* @example
@@ -99,6 +100,11 @@ export function UserProvider({ children }: { children: ReactNode }) {
await fetchUser()
}, [fetchUser])
/** 直接设置用户信息(登录/注册后免二次请求) */
const setUser = useCallback((user: User) => {
setState({ user, loading: false, error: null })
}, [])
/** 用户登出 */
const logout = useCallback(async () => {
try {
@@ -136,6 +142,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
<UserContext.Provider
value={{
...state,
setUser,
refetch,
getTrustLevelLabel,
getPayLevelLabel,
@@ -149,7 +156,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
/**
* 使用用户上下文的Hook
*
*
* @returns {UserContextValue} 用户上下文值
* @example
* ```tsx
+46 -21
View File
@@ -1,13 +1,16 @@
import { BaseService } from '../core/base.service';
import {BaseService} from '../core/base.service';
import type {
SystemConfig,
AuthSource,
AuthSourceRequest,
CreateSystemConfigRequest,
UpdateSystemConfigRequest,
TaskMeta,
TaskTypeResponse,
DispatchTaskRequest,
ListUsersRequest,
ListUsersResponse,
SystemConfig,
TaskMeta,
TaskTypeResponse,
ToggleAuthSourceRequest,
UpdateSystemConfigRequest,
UpdateUserStatusRequest,
} from './types';
@@ -16,7 +19,7 @@ export type { AdminUser } from './types';
/**
* 管理员服务
* 处理系统配置和用户积分配置管理相关的 API 请求
*
*
* @remarks
* 所有接口都需要管理员权限
*/
@@ -32,7 +35,7 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {ValidationError} 当参数验证失败或配置键已存在时
*
*
* @example
* ```typescript
* await AdminService.createSystemConfig({
@@ -53,7 +56,7 @@ export class AdminService extends BaseService {
* @returns 系统配置列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
*
*
* @example
* ```typescript
* const configs = await AdminService.listSystemConfigs();
@@ -72,7 +75,7 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
*
*
* @example
* ```typescript
* const config = await AdminService.getSystemConfig('app.version');
@@ -92,7 +95,7 @@ export class AdminService extends BaseService {
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
* @throws {ValidationError} 当参数验证失败时
*
*
* @example
* ```typescript
* await AdminService.updateSystemConfig('app.version', {
@@ -115,7 +118,7 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
*
*
* @example
* ```typescript
* await AdminService.deleteSystemConfig('app.version');
@@ -125,6 +128,28 @@ export class AdminService extends BaseService {
return this.delete<void>(`/system-configs/${ key }`);
}
// ==================== 认证源管理 ====================
static async listAuthSources(): Promise<AuthSource[]> {
return this.get<AuthSource[]>('/auth-sources');
}
static async createAuthSource(request: AuthSourceRequest): Promise<AuthSource> {
return this.post<AuthSource>('/auth-sources', request);
}
static async updateAuthSource(id: string, request: AuthSourceRequest): Promise<AuthSource> {
return this.put<AuthSource>(`/auth-sources/${ id }`, request);
}
static async toggleAuthSource(id: string, request: ToggleAuthSourceRequest): Promise<void> {
return this.put<void>(`/auth-sources/${ id }/toggle`, request);
}
static async deleteAuthSource(id: string): Promise<void> {
return this.delete<void>(`/auth-sources/${ id }`);
}
// ==================== 任务管理 ====================
@@ -134,7 +159,7 @@ export class AdminService extends BaseService {
* @returns 任务类型列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
*
*
* @example
* ```typescript
* const taskTypes = await AdminService.getTaskTypes();
@@ -162,7 +187,7 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {ValidationError} 当参数验证失败时
*
*
* @example
* ```typescript
* // 下发订单同步任务(带时间范围)
@@ -171,19 +196,19 @@ export class AdminService extends BaseService {
* start_time: '2025-12-01T00:00:00Z',
* end_time: '2025-12-27T23:59:59Z'
* });
*
*
* // 下发用户积分更新任务
* await AdminService.dispatchTask({
* task_type: 'user_gamification',
* user_id: 123
* });
*
*
* // 下发争议自动退款任务
* await AdminService.dispatchTask({
* task_type: 'dispute_auto_refund'
* });
* ```
*
*
* @remarks
* - 不同任务类型需要不同的参数
* - order_sync 支持 start_time 和 end_time 参数
@@ -203,7 +228,7 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {ValidationError} 当参数验证失败时
*
*
* @example
* ```typescript
* const result = await AdminService.listUsers({
@@ -215,7 +240,7 @@ export class AdminService extends BaseService {
* console.log('用户总数:', result.total);
* console.log('用户列表:', result.users);
* ```
*
*
* @remarks
* - page 从 1 开始
* - page_size 范围 1-100
@@ -234,16 +259,16 @@ export class AdminService extends BaseService {
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限或禁用管理员用户时
* @throws {NotFoundError} 当用户不存在时
*
*
* @example
* ```typescript
* // 禁用用户
* await AdminService.updateUserStatus(123, { is_active: false });
*
*
* // 启用用户
* await AdminService.updateUserStatus(123, { is_active: true });
* ```
*
*
* @remarks
* - 不能禁用管理员用户
*/
+3
View File
@@ -24,6 +24,9 @@ export type {
SystemConfig,
CreateSystemConfigRequest,
UpdateSystemConfigRequest,
AuthSource,
AuthSourceRequest,
ToggleAuthSourceRequest,
TaskMeta,
DispatchTaskRequest,
AdminUser,
+35
View File
@@ -171,3 +171,38 @@ export interface UpdateUserStatusRequest {
/** 是否激活 */
is_active: boolean;
}
/**
* 认证源信息
*/
export interface AuthSource {
id: string;
name: string;
type: 'oidc';
display_name: string;
is_active: boolean;
client_id: string;
client_secret?: string;
client_secret_configured?: boolean;
openid_discovery_url: string;
scopes: string;
icon_url: string;
created_at: string;
updated_at: string;
}
export interface AuthSourceRequest {
name: string;
type: 'oidc';
display_name: string;
is_active: boolean;
client_id: string;
client_secret: string;
openid_discovery_url: string;
scopes: string;
icon_url: string;
}
export interface ToggleAuthSourceRequest {
is_active: boolean;
}
+38 -35
View File
@@ -1,7 +1,13 @@
import { BaseService } from '../core/base.service';
import {BaseService} from '../core/base.service';
import type {
OAuthLoginUrlResponse,
AuthSource,
ExternalAccountBinding,
LoginRequest,
OAuthAuthorizeResponse,
OAuthCallbackRequest,
OAuthCallbackResult,
OAuthLoginUrlResponse,
RegisterRequest,
User,
} from './types';
@@ -10,13 +16,13 @@ import type {
* 处理 OAuth 认证、用户信息获取、登出等
*/
export class AuthService extends BaseService {
protected static readonly basePath = '/api/v1/oauth';
protected static readonly basePath = '/api/v1';
/**
* 获取 OAuth 登录 URL
* @returns OAuth 授权 URL
* @throws {ApiErrorBase} 当获取失败时
*
*
* @example
* ```typescript
* const url = await AuthService.getLoginUrl();
@@ -24,7 +30,15 @@ export class AuthService extends BaseService {
* ```
*/
static async getLoginUrl(): Promise<OAuthLoginUrlResponse> {
return this.get<OAuthLoginUrlResponse>('/login');
return this.get<OAuthLoginUrlResponse>('/oauth/login');
}
static async getAuthSources(): Promise<AuthSource[]> {
return this.get<AuthSource[]>('/oauth/sources');
}
static async getAuthorizeUrl(source: string, purpose: 'login' | 'bind' = 'login'): Promise<OAuthAuthorizeResponse> {
return this.get<OAuthAuthorizeResponse>(`/oauth/${encodeURIComponent(source)}/authorize?purpose=${purpose}`);
}
/**
@@ -32,14 +46,14 @@ export class AuthService extends BaseService {
* @param request - OAuth 回调参数(state 和 code)
* @throws {ApiErrorBase} 当回调处理失败时
* @throws {ValidationError} 当 state 无效时
*
*
* @example
* ```typescript
* // 在回调页面获取 URL 参数
* const params = new URLSearchParams(window.location.search);
* const state = params.get('state');
* const code = params.get('code');
*
*
* if (state && code) {
* await AuthService.handleCallback({ state, code });
* // 登录成功,跳转到首页
@@ -47,15 +61,15 @@ export class AuthService extends BaseService {
* }
* ```
*/
static async handleCallback(request: OAuthCallbackRequest): Promise<void> {
return this.post<void>('/callback', request);
static async handleCallback(request: OAuthCallbackRequest): Promise<OAuthCallbackResult> {
return this.post<OAuthCallbackResult>('/oauth/callback', request);
}
/**
* 获取当前登录用户信息
* @returns 用户信息
* @throws {UnauthorizedError} 当未登录时
*
*
* @example
* ```typescript
* try {
@@ -76,7 +90,7 @@ export class AuthService extends BaseService {
/**
* 用户登出
* @throws {ApiErrorBase} 当登出失败时
*
*
* @example
* ```typescript
* await AuthService.logout();
@@ -85,33 +99,22 @@ export class AuthService extends BaseService {
* ```
*/
static async logout(): Promise<void> {
await this.get<void>('/logout');
await this.get<void>('/oauth/logout');
}
/**
* 发起登录流程
* 直接获取登录 URL 并重定向
*
* @example
* ```typescript
* // 在登录按钮点击时调用
* await AuthService.initiateLogin();
* ```
*/
static async initiateLogin(): Promise<void> {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const callbackUrl = params.get('callbackUrl');
static async login(request: LoginRequest): Promise<User> {
return this.post<User>('/user/login', request);
}
if (callbackUrl) {
sessionStorage.setItem('redirect_after_login', callbackUrl);
}
}
static async register(request: RegisterRequest): Promise<User> {
return this.post<User>('/user/register', request);
}
const url = await this.getLoginUrl();
if (typeof window !== 'undefined' && url) {
window.location.href = url;
}
static async getExternalAccountBindings(): Promise<ExternalAccountBinding[]> {
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
}
static async deleteExternalAccountBinding(id: string): Promise<void> {
return this.delete<void>(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`);
}
}
+6 -1
View File
@@ -31,5 +31,10 @@ export type {
User,
OAuthLoginUrlResponse,
OAuthCallbackRequest,
LoginRequest,
RegisterRequest,
OAuthAuthorizeResponse,
OAuthCallbackResult,
AuthSource,
ExternalAccountBinding,
} from './types';
+41
View File
@@ -81,3 +81,44 @@ export interface OAuthCallbackRequest {
/** 授权码 */
code: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
nickname?: string;
}
export interface OAuthAuthorizeResponse {
authorize_url: string;
}
export interface OAuthCallbackResult {
status: 'logged_in' | 'bound';
user?: User;
}
export interface AuthSource {
id: string;
name: string;
type: 'oidc';
display_name: string;
is_active: boolean;
icon_url: string;
client_secret_configured: boolean;
}
export interface ExternalAccountBinding {
id: string;
auth_source_id: string;
auth_source_name: string;
auth_source_type: string;
auth_source_label: string;
external_username: string;
email: string;
created_at: string;
}
+6
View File
@@ -6,8 +6,14 @@ export interface PublicConfigResponse {
upload_allowed_extensions: string;
/** 站点名称 */
site_name: string;
/** 是否允许密码登录 */
password_login_enabled: boolean;
/** 是否允许注册 */
registration_enabled: boolean;
/** 是否允许密码注册 */
password_register_enabled: boolean;
/** 是否允许 OIDC 登录 */
oidc_login_enabled: boolean;
/** 每个用户最大 API Key 数量 */
max_api_keys_per_user: number;
}
+6 -6
View File
@@ -20,11 +20,11 @@
* ```
*/
import { AuthService } from './auth';
import { AdminService } from './admin';
import { UserService } from './user';
import { ConfigService } from './config';
import { UploadService } from './upload';
import {AuthService} from './auth';
import {AdminService} from './admin';
import {UserService} from './user';
import {ConfigService} from './config';
import {UploadService} from './upload';
/**
* 服务对象
@@ -82,7 +82,7 @@ export type {
// 认证服务
export { AuthService, TrustLevel } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding } from './auth';
// 配置服务
export { ConfigService } from './config';
+2 -1
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.16",
"axios": "^1.15.0",
@@ -81,6 +82,6 @@
"eslint-config-next": "15.5.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5.9.3"
}
}
+55 -1
View File
@@ -89,6 +89,9 @@ importers:
'@tabler/icons-react':
specifier: ^3.35.0
version: 3.35.0(react@19.2.3)
'@tanstack/react-query':
specifier: ^5.101.0
version: 5.101.0(react@19.2.3)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -217,7 +220,7 @@ importers:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5
specifier: ^5.9.3
version: 5.9.3
packages:
@@ -389,78 +392,92 @@ packages:
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
@@ -527,24 +544,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
@@ -1418,24 +1439,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
@@ -1473,6 +1498,14 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/query-core@5.101.0':
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
'@tanstack/react-query@5.101.0':
resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
@@ -1637,6 +1670,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -1677,41 +1711,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2725,24 +2767,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -3255,6 +3301,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -4871,6 +4918,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.16
'@tanstack/query-core@5.101.0': {}
'@tanstack/react-query@5.101.0(react@19.2.3)':
dependencies:
'@tanstack/query-core': 5.101.0
react: 19.2.3
'@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/table-core': 8.21.3
+12
View File
@@ -43,6 +43,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ClickHouse/ch-go v0.66.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
@@ -67,10 +68,13 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -111,6 +115,7 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/paulmach/orb v0.12.0 // indirect
@@ -119,6 +124,7 @@ require (
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
@@ -129,6 +135,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
@@ -152,4 +159,9 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+21
View File
@@ -6,6 +6,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9s
github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
@@ -74,6 +76,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -90,6 +94,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -230,6 +238,9 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 h1:+a9h9qxFXdf3gX0FXnDcz7X4
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0/go.mod h1:EtTTC7vnKWgznfG6kBgl9ySLqd7NckRCFUBzVXdeHeI=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -291,6 +302,8 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
@@ -429,3 +442,11 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY=
gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+215
View File
@@ -0,0 +1,215 @@
package auth_source
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
type AuthSourceRequest struct {
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
}
type ToggleAuthSourceRequest struct {
IsActive bool `json:"is_active"`
}
// ListAuthSources 获取认证源列表
// @Summary 获取认证源列表
// @Description 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]model.AuthSource} "认证源列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/auth-sources [get]
func ListAuthSources(c *gin.Context) {
sources, err := model.GetAuthSources()
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(sources))
}
// CreateAuthSource 创建认证源
// @Summary 创建认证源
// @Description 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body auth_source.AuthSourceRequest true "创建认证源参数"
// @Success 200 {object} util.ResponseAny{data=model.AuthSource} "创建成功,返回认证源信息"
// @Failure 400 {object} util.ResponseAny "参数错误或验证失败"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/auth-sources [post]
func CreateAuthSource(c *gin.Context) {
var req AuthSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
source := model.AuthSource{
Name: req.Name,
Type: req.Type,
DisplayName: req.DisplayName,
IsActive: req.IsActive,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OpenIDDiscoveryURL: req.OpenIDDiscoveryURL,
Scopes: req.Scopes,
IconURL: req.IconURL,
}
if err := model.CreateAuthSource(&source); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
source.Sanitize()
c.JSON(http.StatusOK, util.OK(source))
}
// UpdateAuthSource 更新认证源
// @Summary 更新认证源
// @Description 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "认证源 ID 或名称"
// @Param request body auth_source.AuthSourceRequest true "更新认证源参数"
// @Success 200 {object} util.ResponseAny{data=model.AuthSource} "更新成功,返回更新后的认证源信息"
// @Failure 400 {object} util.ResponseAny "参数错误或验证失败"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/auth-sources/{id} [put]
func UpdateAuthSource(c *gin.Context) {
id, err := parseSourceID(c)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var req AuthSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
source := model.AuthSource{
ID: id,
Name: req.Name,
Type: req.Type,
DisplayName: req.DisplayName,
IsActive: req.IsActive,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OpenIDDiscoveryURL: req.OpenIDDiscoveryURL,
Scopes: req.Scopes,
IconURL: req.IconURL,
}
keepSecret := source.ClientSecret == ""
if err := model.UpdateAuthSource(&source, keepSecret); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
updated, err := model.GetAuthSourceByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
updated.Sanitize()
c.JSON(http.StatusOK, util.OK(updated))
}
// ToggleAuthSource 切换认证源启用状态
// @Summary 切换认证源启用状态
// @Description 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "认证源 ID 或名称"
// @Param request body auth_source.ToggleAuthSourceRequest true "启用状态"
// @Success 200 {object} util.ResponseAny{data=string} "切换成功"
// @Failure 400 {object} util.ResponseAny "验证失败或认证源不存在"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/auth-sources/{id}/toggle [put]
func ToggleAuthSource(c *gin.Context) {
id, err := parseSourceID(c)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var req ToggleAuthSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := model.ToggleAuthSource(id, req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteAuthSource 删除认证源
// @Summary 删除认证源
// @Description 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "认证源 ID 或名称"
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
// @Failure 400 {object} util.ResponseAny "ID 无效或删除失败"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/auth-sources/{id} [delete]
func DeleteAuthSource(c *gin.Context) {
id, err := parseSourceID(c)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := model.DeleteAuthSource(id); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
func parseSourceID(c *gin.Context) (uint64, error) {
raw := c.Param("id")
if raw == "" {
return 0, errors.New("认证源 ID 无效")
}
source, err := model.GetAuthSourceByName(raw)
if err == nil {
return source.ID, nil
}
var id uint64
if _, scanErr := fmt.Sscanf(raw, "%d", &id); scanErr != nil || id == 0 {
return 0, errors.New("认证源 ID 无效")
}
return id, nil
}
@@ -0,0 +1,345 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_source
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
adminGroup := r.Group("/api/v1/admin")
// Mock authentication middleware
adminGroup.Use(func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, oauth.UserObjKey, authUser)
}
c.Next()
})
adminGroup.GET("/auth-sources", ListAuthSources)
adminGroup.POST("/auth-sources", CreateAuthSource)
adminGroup.PUT("/auth-sources/:id", UpdateAuthSource)
adminGroup.PUT("/auth-sources/:id/toggle", ToggleAuthSource)
adminGroup.DELETE("/auth-sources/:id", DeleteAuthSource)
return r
}
func TestListAuthSources(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Seed source
source := model.AuthSource{
ID: 1,
Name: "google",
Type: "oidc",
DisplayName: "Google Auth",
IsActive: true,
ClientID: "client_id_123",
ClientSecret: "client_secret_456",
OpenIDDiscoveryURL: "https://accounts.google.com",
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d", w.Code)
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var sources []model.AuthSource
json.Unmarshal(dataBytes, &sources)
if len(sources) != 1 {
t.Errorf("expected 1 auth source, got %d", len(sources))
}
if sources[0].Name != "google" {
t.Errorf("expected name 'google', got '%s'", sources[0].Name)
}
// Verify sanitize removed the secret
if sources[0].ClientSecret != "" {
t.Error("client secret should be sanitized")
}
if !sources[0].ClientSecretConfigured {
t.Error("client secret configured flag should be true")
}
}
func TestCreateAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
reqPayload := AuthSourceRequest{
Name: "github",
Type: "oidc",
DisplayName: "GitHub OIDC",
IsActive: true,
ClientID: "client_id_gh",
ClientSecret: "client_secret_gh",
OpenIDDiscoveryURL: "https://github.com",
}
body, _ := json.Marshal(reqPayload)
req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify database
var src model.AuthSource
dbConn.Where("name = ?", "github").First(&src)
if src.ClientID != "client_id_gh" {
t.Errorf("expected client_id_gh, got '%s'", src.ClientID)
}
})
t.Run("create invalid validation failure", func(t *testing.T) {
reqPayload := AuthSourceRequest{
Name: "invalid name!",
Type: "oidc",
DisplayName: "Invalid",
IsActive: true,
ClientID: "client_id_val",
ClientSecret: "client_secret_val",
OpenIDDiscoveryURL: "https://discovery.url",
}
body, _ := json.Marshal(reqPayload)
req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d", w.Code)
}
})
}
func TestUpdateAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Seed source
source := model.AuthSource{
ID: 1,
Name: "microsoft",
Type: "oidc",
DisplayName: "Microsoft",
IsActive: true,
ClientID: "old_client_id",
ClientSecret: "old_secret",
OpenIDDiscoveryURL: "https://login.microsoftonline.com",
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("update keep client secret", func(t *testing.T) {
reqPayload := AuthSourceRequest{
Name: "microsoft",
Type: "oidc",
DisplayName: "Microsoft Updated",
IsActive: true,
ClientID: "new_client_id",
ClientSecret: "", // empty implies keeping existing secret
OpenIDDiscoveryURL: "https://login.microsoftonline.com",
}
body, _ := json.Marshal(reqPayload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var src model.AuthSource
dbConn.First(&src, 1)
if src.DisplayName != "Microsoft Updated" {
t.Errorf("expected display name update, got '%s'", src.DisplayName)
}
if src.ClientSecret != "old_secret" {
t.Errorf("expected old secret to be preserved, got '%s'", src.ClientSecret)
}
})
t.Run("update new client secret", func(t *testing.T) {
reqPayload := AuthSourceRequest{
Name: "microsoft",
Type: "oidc",
DisplayName: "Microsoft Updated Again",
IsActive: true,
ClientID: "new_client_id",
ClientSecret: "brand_new_secret",
OpenIDDiscoveryURL: "https://login.microsoftonline.com",
}
body, _ := json.Marshal(reqPayload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/microsoft", bytes.NewBuffer(body)) // Using Name instead of ID
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var src model.AuthSource
dbConn.First(&src, 1)
if src.ClientSecret != "brand_new_secret" {
t.Errorf("expected secret update, got '%s'", src.ClientSecret)
}
})
}
func TestToggleAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
source := model.AuthSource{
ID: 1,
Name: "test_source",
Type: "oidc",
DisplayName: "Test Source",
IsActive: false,
ClientID: "",
ClientSecret: "",
OpenIDDiscoveryURL: "https://test.discovery.url",
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("cannot activate without credentials", func(t *testing.T) {
payload := ToggleAuthSourceRequest{IsActive: true}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request when activating without client_id/secret, got %d", w.Code)
}
})
t.Run("toggle success after setting credentials", func(t *testing.T) {
// Set credentials first
dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Updates(map[string]interface{}{
"client_id": "id",
"client_secret": "secret",
})
payload := ToggleAuthSourceRequest{IsActive: true}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var src model.AuthSource
dbConn.First(&src, 1)
if !src.IsActive {
t.Error("auth source should be activated")
}
})
}
func TestDeleteAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
source := model.AuthSource{
ID: 1,
Name: "delete_me",
Type: "oidc",
DisplayName: "Delete Me",
IsActive: true,
ClientID: "id",
ClientSecret: "secret",
OpenIDDiscoveryURL: "https://delete.me",
}
dbConn.Create(&source)
externalAccount := model.ExternalAccount{
ID: 10,
AuthSourceID: 1,
UserID: 50,
ExternalID: "ext_50",
}
dbConn.Create(&externalAccount)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d", w.Code)
}
// Verify AuthSource is deleted
var srcCount int64
dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Count(&srcCount)
if srcCount != 0 {
t.Error("AuthSource should be deleted from the database")
}
// Verify ExternalAccount bindings are also deleted
var extCount int64
dbConn.Model(&model.ExternalAccount{}).Where("auth_source_id = ?", 1).Count(&extCount)
if extCount != 0 {
t.Error("related ExternalAccount bindings should be deleted")
}
}
+32 -7
View File
@@ -47,8 +47,13 @@ type UpdateSystemConfigRequest struct {
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateSystemConfigRequest true "创建请求参数"
// @Success 200 {object} util.ResponseAny
// @Security SessionCookie
// @Param request body system_config.CreateSystemConfigRequest true "创建请求参数"
// @Success 200 {object} util.ResponseAny{data=string} "创建成功"
// @Failure 400 {object} util.ResponseAny "参数错误或配置键已存在"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/system-configs [post]
func CreateSystemConfig(c *gin.Context) {
var req CreateSystemConfigRequest
@@ -98,8 +103,12 @@ func CreateSystemConfig(c *gin.Context) {
// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param type query string false "配置类型(system/business)"
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=[]model.SystemConfig} "系统配置列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/system-configs [get]
func ListSystemConfigs(c *gin.Context) {
configType := c.Query("type")
@@ -122,8 +131,13 @@ func ListSystemConfigs(c *gin.Context) {
// @Description 根据配置键获取对应的系统配置详情,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param key path string true "配置键"
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=model.SystemConfig} "系统配置详情"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 404 {object} util.ResponseAny "配置不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/system-configs/{key} [get]
func GetSystemConfig(c *gin.Context) {
var config model.SystemConfig
@@ -145,9 +159,15 @@ func GetSystemConfig(c *gin.Context) {
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param key path string true "配置键"
// @Param request body UpdateSystemConfigRequest true "更新请求参数"
// @Success 200 {object} util.ResponseAny
// @Param request body system_config.UpdateSystemConfigRequest true "更新请求参数"
// @Success 200 {object} util.ResponseAny{data=string} "更新成功"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 404 {object} util.ResponseAny "配置不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/system-configs/{key} [put]
func UpdateSystemConfig(c *gin.Context) {
var req UpdateSystemConfigRequest
@@ -197,8 +217,13 @@ func UpdateSystemConfig(c *gin.Context) {
// @Description 根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param key path string true "配置键"
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 404 {object} util.ResponseAny "配置不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/system-configs/{key} [delete]
func DeleteSystemConfig(c *gin.Context) {
key := c.Param("key")
@@ -0,0 +1,302 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package system_config
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
adminGroup := r.Group("/api/v1/admin")
// Mock authentication middleware
adminGroup.Use(func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, oauth.UserObjKey, authUser)
}
c.Next()
})
adminGroup.POST("/system-configs", CreateSystemConfig)
adminGroup.GET("/system-configs", ListSystemConfigs)
systemConfigRouter := adminGroup.Group("/system-configs/:key")
{
systemConfigRouter.GET("", GetSystemConfig)
systemConfigRouter.PUT("", UpdateSystemConfig)
systemConfigRouter.DELETE("", DeleteSystemConfig)
}
return r
}
func TestCreateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
payload := CreateSystemConfigRequest{
Key: "custom_key",
Value: "custom_value",
Type: "system",
Description: "desc",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify database
var cfg model.SystemConfig
err := dbConn.Where("key = ?", "custom_key").First(&cfg).Error
if err != nil {
t.Fatalf("failed to find system config in DB: %v", err)
}
// Verify Redis Cache
var redisConfig model.SystemConfig
err = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, "custom_key", &redisConfig)
if err != nil {
t.Fatalf("failed to find system config in Redis: %v", err)
}
if redisConfig.Value != "custom_value" {
t.Errorf("expected value 'custom_value', got '%s'", redisConfig.Value)
}
})
t.Run("create duplicate key error", func(t *testing.T) {
// Key "custom_key" already exists from previous test
payload := CreateSystemConfigRequest{
Key: "custom_key",
Value: "another_value",
Type: "system",
Description: "desc",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request on duplicate key, got %d", w.Code)
}
})
}
func TestListSystemConfigs(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("list all seeded configurations", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", w.Code)
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var configs []model.SystemConfig
json.Unmarshal(dataBytes, &configs)
// Defaults seed 7 configurations
if len(configs) != 7 {
t.Errorf("expected 7 default configs, got %d", len(configs))
}
})
t.Run("filter by type business", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs?type=business", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var configs []model.SystemConfig
json.Unmarshal(dataBytes, &configs)
if len(configs) != 1 || configs[0].Key != model.ConfigKeyMaxAPIKeysPerUser {
t.Errorf("expected 1 business config (max_api_keys_per_user), got %d: %v", len(configs), configs)
}
})
}
func TestGetSystemConfig(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("get existing configuration", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d", w.Code)
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var cfg model.SystemConfig
json.Unmarshal(dataBytes, &cfg)
if cfg.Value != "Antigravity Project" {
t.Errorf("expected 'Antigravity Project', got '%s'", cfg.Value)
}
})
t.Run("get non-existent config", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/non_existent_key", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d", w.Code)
}
})
}
func TestUpdateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("update successfully", func(t *testing.T) {
payload := UpdateSystemConfigRequest{
Value: "Super Site Name",
Description: "Updated Description",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify database
var cfg model.SystemConfig
dbConn.Where("key = ?", model.ConfigKeySiteName).First(&cfg)
if cfg.Value != "Super Site Name" || cfg.Description != "Updated Description" {
t.Errorf("database values not updated: %+v", cfg)
}
// Verify Redis
var redisConfig model.SystemConfig
db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig)
if redisConfig.Value != "Super Site Name" {
t.Errorf("redis cache value not updated, got '%s'", redisConfig.Value)
}
})
t.Run("update non-existent config", func(t *testing.T) {
payload := UpdateSystemConfigRequest{
Value: "New Value",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/invalid_key", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d", w.Code)
}
})
}
func TestDeleteSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("delete successfully", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify database
var count int64
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySiteName).Count(&count)
if count != 0 {
t.Error("config still exists in DB")
}
// Verify Redis Cache removal
var redisConfig model.SystemConfig
err := db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig)
if err == nil {
t.Error("config should have been deleted from Redis cache")
}
})
t.Run("delete non-existent config", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/system-configs/invalid_key", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d", w.Code)
}
})
}
+12 -4
View File
@@ -30,10 +30,13 @@ import (
// ListTaskTypes 获取支持的任务类型列表
// @Summary 获取支持的任务类型
// @Description 返回系统支持的所有可调度任务类型列表,需要管理员权限
// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]task.TaskMeta} "任务类型列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/tasks/types [get]
func ListTaskTypes(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(task.DispatchableTasks))
@@ -53,8 +56,13 @@ type DispatchTaskRequest struct {
// @Tags admin
// @Accept json
// @Produce json
// @Param request body DispatchTaskRequest true "任务请求参数"
// @Success 200 {object} util.ResponseAny
// @Security SessionCookie
// @Param request body task.DispatchTaskRequest true "任务请求参数"
// @Success 200 {object} util.ResponseAny{data=string} "任务已入队"
// @Failure 400 {object} util.ResponseAny "任务类型不存在或参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "任务入队失败"
// @Router /api/v1/admin/tasks/dispatch [post]
func DispatchTask(c *gin.Context) {
var req DispatchTaskRequest
+132
View File
@@ -0,0 +1,132 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package task
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
adminGroup := r.Group("/api/v1/admin")
// Mock authentication middleware
adminGroup.Use(func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, oauth.UserObjKey, authUser)
}
c.Next()
})
adminGroup.GET("/tasks/types", ListTaskTypes)
adminGroup.POST("/tasks/dispatch", DispatchTask)
return r
}
func TestListTaskTypes(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d", w.Code)
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var taskMetas []task.TaskMeta
json.Unmarshal(dataBytes, &taskMetas)
if len(taskMetas) == 0 {
t.Error("expected at least one dispatchable task type")
}
foundCleanup := false
for _, m := range taskMetas {
if m.Type == task.TaskTypeCleanupUploads {
foundCleanup = true
break
}
}
if !foundCleanup {
t.Errorf("expected task type %s to be listed", task.TaskTypeCleanupUploads)
}
}
func TestDispatchTask(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
router := setupTestRouter(adminUser)
t.Run("dispatch valid task successfully", func(t *testing.T) {
payload := DispatchTaskRequest{
TaskType: task.TaskTypeCleanupUploads,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
})
t.Run("dispatch invalid task type failure", func(t *testing.T) {
payload := DispatchTaskRequest{
TaskType: "invalid_task_type",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d", w.Code)
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != InvalidTaskType {
t.Errorf("expected error message '%s', got '%s'", InvalidTaskType, resp.ErrorMsg)
}
})
}
+14 -3
View File
@@ -68,8 +68,13 @@ type listUsersResponse struct {
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param request query listUsersRequest true "查询参数"
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=user.listUsersResponse} "用户列表"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users [get]
func ListUsers(c *gin.Context) {
var req listUsersRequest
@@ -129,9 +134,15 @@ type updateUserStatusRequest struct {
// @Tags admin
// @Accept json
// @Produce json
// @Param id path int true "用户ID"
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Param request body updateUserStatusRequest true "状态参数"
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=string} "更新成功"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限或尝试禁用管理员"
// @Failure 404 {object} util.ResponseAny "用户不存在"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users/{id}/status [put]
func UpdateUserStatus(c *gin.Context) {
var req updateUserStatusRequest
+254
View File
@@ -0,0 +1,254 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package user
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
adminGroup := r.Group("/api/v1/admin")
// Mock authentication middleware
adminGroup.Use(func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, oauth.UserObjKey, authUser)
}
c.Next()
})
adminGroup.GET("/users", ListUsers)
adminGroup.PUT("/users/:id/status", UpdateUserStatus)
return r
}
func TestListUsers(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Seed users
users := []model.User{
{
ID: 1001,
Username: "alice",
Nickname: "Alice Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(100.0),
LastLoginAt: time.Now(),
SignKey: "alice_sign_key",
},
{
ID: 1002,
Username: "bob",
Nickname: "Bob Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(50.0),
LastLoginAt: time.Now(),
SignKey: "bob_sign_key",
},
{
ID: 1003,
Username: "charlie",
Nickname: "Charlie Nickname",
IsActive: false,
IsAdmin: true,
AvailableBalance: decimal.NewFromFloat(9999.0),
LastLoginAt: time.Now(),
SignKey: "charlie_sign_key",
},
}
for _, u := range users {
if err := dbConn.Create(&u).Error; err != nil {
t.Fatalf("failed to seed user: %v", err)
}
}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true, SignKey: "charlie_sign_key"}
router := setupTestRouter(adminUser)
t.Run("basic pagination list", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=2", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Parse data map to our structure
dataBytes, _ := json.Marshal(resp.Data)
var listResp listUsersResponse
if err := json.Unmarshal(dataBytes, &listResp); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if len(listResp.Users) != 2 {
t.Errorf("expected 2 users, got %d", len(listResp.Users))
}
if listResp.Total != 3 {
t.Errorf("expected total 3, got %d", listResp.Total)
}
// Ordered by ID DESC
if listResp.Users[0].ID != 1003 || listResp.Users[1].ID != 1002 {
t.Errorf("expected ordered DESC, got first ID %d, second ID %d", listResp.Users[0].ID, listResp.Users[1].ID)
}
})
t.Run("filter by user_id", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&user_id=1001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var listResp listUsersResponse
json.Unmarshal(dataBytes, &listResp)
if len(listResp.Users) != 1 || listResp.Users[0].ID != 1001 {
t.Errorf("expected 1 user with ID 1001, got total %d", len(listResp.Users))
}
})
t.Run("filter by username prefix", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&username=bo", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var listResp listUsersResponse
json.Unmarshal(dataBytes, &listResp)
if len(listResp.Users) != 1 || listResp.Users[0].Username != "bob" {
t.Errorf("expected bob, got %v", listResp.Users)
}
})
t.Run("invalid pagination parameter", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=0&page_size=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d", w.Code)
}
})
}
func TestUpdateUserStatus(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Seed users
regularUser := model.User{
ID: 1001,
Username: "alice",
IsActive: true,
IsAdmin: false,
SignKey: "alice_sign_key",
}
adminUser := model.User{
ID: 1002,
Username: "bob",
IsActive: true,
IsAdmin: true,
SignKey: "bob_sign_key",
}
dbConn.Create(&regularUser)
dbConn.Create(&adminUser)
router := setupTestRouter(&adminUser)
t.Run("disable regular user successfully", func(t *testing.T) {
payload := updateUserStatusRequest{IsActive: false}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1001/status", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify DB status
var u model.User
dbConn.First(&u, 1001)
if u.IsActive {
t.Error("user should be deactivated in the database")
}
})
t.Run("cannot disable admin user", func(t *testing.T) {
payload := updateUserStatusRequest{IsActive: false}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1002/status", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != cannotDisable {
t.Errorf("expected error message '%s', got '%s'", cannotDisable, resp.ErrorMsg)
}
})
t.Run("cannot enable/disable non-existent user", func(t *testing.T) {
payload := updateUserStatusRequest{IsActive: false}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/admin/users/9999/status", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
+24
View File
@@ -28,7 +28,10 @@ import (
type PublicConfigResponse struct {
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
SiteName string `json:"site_name"` // 站点名称
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
}
@@ -62,6 +65,24 @@ func GetPublicConfig(c *gin.Context) {
registrationEnabled = val
}
// 3.1 password_login_enabled
var passwordLoginEnabled bool
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled); err == nil {
passwordLoginEnabled = val
}
// 3.2 password_register_enabled
var passwordRegisterEnabled bool
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled); err == nil {
passwordRegisterEnabled = val
}
// 3.3 oidc_login_enabled
var oidcLoginEnabled bool
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled); err == nil {
oidcLoginEnabled = val
}
// 4. max_api_keys_per_user
var maxAPIKeys int
if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil {
@@ -71,7 +92,10 @@ func GetPublicConfig(c *gin.Context) {
response := PublicConfigResponse{
UploadAllowedExtensions: uploadExtensions,
SiteName: siteName,
PasswordLoginEnabled: passwordLoginEnabled,
RegistrationEnabled: registrationEnabled,
PasswordRegisterEnabled: passwordRegisterEnabled,
OIDCLoginEnabled: oidcLoginEnabled,
MaxAPIKeysPerUser: maxAPIKeys,
}
+3 -3
View File
@@ -23,12 +23,12 @@ import (
"github.com/linux-do/credit/internal/util"
)
// Health godoc
// Health 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Description 检查服务是否正常运行,可用于负载均衡存活探测
// @Tags health
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=string} "服务正常"
// @Router /api/v1/health [get]
func Health(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
+8 -2
View File
@@ -19,6 +19,7 @@ package oauth
import (
"context"
"log"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/linux-do/credit/internal/config"
@@ -35,14 +36,19 @@ func init() {
if cfg.Issuer != "" {
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
// Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake)
issuer := strings.TrimSuffix(strings.TrimSpace(cfg.Issuer), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Printf("[OAuth] 初始化 OIDC Provider 失败: %v,将仅使用 OAuth2", err)
} else {
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
log.Printf("[OAuth] OIDC Provider 初始化成功: %s", cfg.Issuer)
log.Printf("[OAuth] OIDC Provider 初始化成功: %s", issuer)
}
}
+27
View File
@@ -17,6 +17,7 @@ limitations under the License.
package oauth
import (
"encoding/json"
"time"
)
@@ -30,3 +31,29 @@ const (
OAuthStateCacheKeyFormat = "oauth:state:%s"
OAuthStateCacheKeyExpiration = 10 * time.Minute
)
const (
OAuthPurposeLogin = "login"
OAuthPurposeBind = "bind"
)
type oauthStatePayload struct {
SourceName string `json:"source_name"`
Purpose string `json:"purpose"`
}
func encodeOAuthStatePayload(payload oauthStatePayload) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeOAuthStatePayload(value string) (oauthStatePayload, error) {
var payload oauthStatePayload
if err := json.Unmarshal([]byte(value), &payload); err != nil {
return oauthStatePayload{}, err
}
return payload, nil
}
+39 -50
View File
@@ -55,60 +55,49 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error
var userInfo model.OAuthUserInfo
if config.Config.App.Env == "development" && code == "dev_mock_code" {
userInfo = model.OAuthUserInfo{
Id: 999999,
Username: "dev_user",
Name: "Developer User",
Active: true,
AvatarUrl: "https://linux.do/user_avatar/linux.do/system/45/1_2.png",
TrustLevel: 3,
}
} else {
// 使用授权码换取 Token
token, err := oauthConf.Exchange(ctx, code)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// 使用授权码换取 Token
token, err := oauthConf.Exchange(ctx, code)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if oidcVerifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if nonce != "" && idToken.Nonce != nonce {
span.SetStatus(codes.Error, NonceMismatch)
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
span.SetStatus(codes.Error, claimsErr.Error())
return nil, claimsErr
}
if oidcVerifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if nonce != "" && idToken.Nonce != nonce {
span.SetStatus(codes.Error, NonceMismatch)
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
span.SetStatus(codes.Error, claimsErr.Error())
return nil, claimsErr
}
}
}
if userInfo.GetID() == 0 {
client := oauthConf.Client(ctx, token)
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
if httpErr != nil {
span.SetStatus(codes.Error, httpErr.Error())
return nil, httpErr
}
defer resp.Body.Close()
if userInfo.GetID() == 0 {
client := oauthConf.Client(ctx, token)
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
if httpErr != nil {
span.SetStatus(codes.Error, httpErr.Error())
return nil, httpErr
}
defer resp.Body.Close()
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
span.SetStatus(codes.Error, readErr.Error())
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
span.SetStatus(codes.Error, unmarshalErr.Error())
return nil, unmarshalErr
}
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
span.SetStatus(codes.Error, readErr.Error())
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
span.SetStatus(codes.Error, unmarshalErr.Error())
return nil, unmarshalErr
}
}
@@ -119,7 +108,7 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error
}
var user model.User
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var holder model.User
if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil {
// 存在冲突 -> 将占用者改名并注销
File diff suppressed because it is too large Load Diff
+43 -115
View File
@@ -17,104 +17,15 @@ limitations under the License.
package oauth
import (
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// GetLoginURL godoc
// @Summary 获取登录地址
// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/login [get]
func GetLoginURL(c *gin.Context) {
ctx := c.Request.Context()
// 生成 state
state := uuid.NewString()
cmd := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), state, OAuthStateCacheKeyExpiration)
if cmd.Err() != nil {
c.JSON(http.StatusInternalServerError, util.Err(cmd.Err().Error()))
return
}
// 构造登录 URL
var authURL string
if config.Config.App.Env == "development" {
authURL = fmt.Sprintf("%s/login?code=dev_mock_code&state=%s", config.Config.App.FrontendURL, state)
} else if oidcVerifier != nil {
// OIDC 模式:state 同时用作 nonce
authURL = oauthConf.AuthCodeURL(state, oidc.Nonce(state))
} else {
// 纯 OAuth2 模式
authURL = oauthConf.AuthCodeURL(state)
}
c.JSON(http.StatusOK, util.OK(authURL))
}
type CallbackRequest struct {
State string `json:"state"`
Code string `json:"code"`
}
// Callback godoc
// @Summary OAuth 回调
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话
// @Tags oauth
// @Accept json
// @Param request body CallbackRequest true "回调请求参数"
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/callback [post]
func Callback(c *gin.Context) {
// 解析请求
var req CallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
// 验证 state
cmd := db.Redis.Get(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)))
if cmd.Val() != req.State {
c.JSON(http.StatusBadRequest, util.Err(InvalidState))
return
}
db.Redis.Del(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)))
// 执行 OAuth/OIDC 认证
user, err := doOAuth(ctx, req.Code, req.State)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
session := sessions.Default(c)
session.Set(UserIDKey, user.ID)
session.Set(UserNameKey, user.Username)
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
LogForAudit(ctx, user, c)
c.JSON(http.StatusOK, util.OKNil())
}
type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
@@ -135,46 +46,63 @@ type BasicUserInfo struct {
DailyLimit *int64 `json:"daily_limit"`
}
// UserInfo godoc
func BuildBasicUserInfo(user *model.User) BasicUserInfo {
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
TrustLevel: user.TrustLevel,
AvatarUrl: user.AvatarUrl,
TotalReceive: user.TotalReceive,
TotalPayment: user.TotalPayment,
TotalTransfer: user.TotalTransfer,
TotalCommunity: user.TotalCommunity,
CommunityBalance: user.CommunityBalance,
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsAdmin: user.IsAdmin,
RemainQuota: decimal.NewFromInt(-1),
PayLevel: "Free",
DailyLimit: nil,
}
}
// UserInfo 获取当前登录用户信息
// @Summary 获取当前登录用户信息
// @Description 返回当前登录用户的基本信息及余额数据,需要登录
// @Description 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "用户信息"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/oauth/user-info [get]
func UserInfo(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, UserObjKey)
c.JSON(
http.StatusOK,
util.OK(BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
TrustLevel: user.TrustLevel,
AvatarUrl: user.AvatarUrl,
TotalReceive: user.TotalReceive,
TotalPayment: user.TotalPayment,
TotalTransfer: user.TotalTransfer,
TotalCommunity: user.TotalCommunity,
CommunityBalance: user.CommunityBalance,
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsAdmin: user.IsAdmin,
RemainQuota: decimal.NewFromInt(-1),
PayLevel: "Free",
DailyLimit: nil,
}),
util.OK(BuildBasicUserInfo(user)),
)
}
// Logout godoc
// @Summary 退出登录
// @Description 清除当前用户的登录会话,完成退出
// GetLoginURL 获取登录地址
// @Summary 获取登录地址
// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权。返回的 URL 中包含 state 参数用于 CSRF 防护。
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Success 200 {object} util.ResponseAny{data=string} "OAuth 登录 URL"
// @Failure 500 {object} util.ResponseAny "Redis 异常或内部错误"
// @Router /api/v1/oauth/login [get]
// Logout 退出登录
// @Summary 退出登录
// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
// @Failure 500 {object} util.ResponseAny "Session 清除失败"
// @Router /api/v1/oauth/logout [get]
func Logout(c *gin.Context) {
session := sessions.Default(c)
+611
View File
@@ -0,0 +1,611 @@
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"strconv"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
// AuthSourceView 登录源展示信息
type AuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthAuthorizeResponse 授权 URL 响应
type OAuthAuthorizeResponse struct {
AuthorizeURL string `json:"authorize_url"`
}
// OAuthCallbackResult 回调处理结果
type OAuthCallbackResult struct {
Status string `json:"status"`
User *BasicUserInfo `json:"user,omitempty"`
}
// CallbackRequest OAuth 回调请求参数
type CallbackRequest struct {
State string `json:"state" binding:"required"`
Code string `json:"code" binding:"required"`
}
func defaultAuthSource() *model.AuthSource {
if config.Config.OAuth2.ClientID == "" || config.Config.OAuth2.RedirectURI == "" {
return nil
}
source := &model.AuthSource{
Name: "default",
Type: model.AuthSourceTypeOIDC,
DisplayName: "默认认证源",
IsActive: true,
ClientID: config.Config.OAuth2.ClientID,
ClientSecret: config.Config.OAuth2.ClientSecret,
OpenIDDiscoveryURL: config.Config.OAuth2.Issuer,
}
if source.DisplayName == "" {
source.DisplayName = "默认认证源"
}
return source
}
func resolveAuthSource(sourceName string) (*model.AuthSource, error) {
name := strings.TrimSpace(strings.ToLower(sourceName))
if name == "" || name == "default" {
source := defaultAuthSource()
if source == nil {
return nil, errors.New("默认认证源未配置")
}
return source, nil
}
return model.GetAuthSourceByName(name)
}
func activeLoginSources() []AuthSourceView {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyOIDCLoginEnabled)
if err == nil && !enabled {
return nil
}
sources := make([]AuthSourceView, 0, 4)
if source := defaultAuthSource(); source != nil {
source.Sanitize()
sources = append(sources, AuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
IsActive: source.IsActive,
IconURL: source.IconURL,
ClientSecretConfigured: source.ClientSecretConfigured,
})
}
dbSources, err := model.GetActiveAuthSources()
if err != nil {
return sources
}
for _, source := range dbSources {
sources = append(sources, AuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
IsActive: source.IsActive,
IconURL: source.IconURL,
ClientSecretConfigured: source.ClientSecretConfigured,
})
}
return sources
}
func frontendLoginRedirectURL() string {
if config.Config.App.FrontendURL != "" {
return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login"
}
if config.Config.OAuth2.RedirectURI != "" {
return config.Config.OAuth2.RedirectURI
}
return "/login"
}
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if source == nil {
return nil, nil, errors.New("认证源不能为空")
}
if source.Name == "default" {
scopes := []string{"profile", "email"}
if oidcVerifier != nil {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: config.Config.OAuth2.ClientID,
ClientSecret: config.Config.OAuth2.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: oauth2.Endpoint{
AuthURL: config.Config.OAuth2.AuthorizationEndpoint,
TokenURL: config.Config.OAuth2.TokenEndpoint,
AuthStyle: oauth2.AuthStyleAutoDetect,
},
}, oidcVerifier, nil
}
if source.OpenIDDiscoveryURL == "" {
return nil, nil, errors.New("OIDC 认证源必须配置 Discovery URL")
}
// Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake)
issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID})
scopes := strings.Fields(source.Scopes)
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
if !containsScope(scopes, oidc.ScopeOpenID) {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: provider.Endpoint(),
}, verifier, nil
}
func containsScope(scopes []string, scope string) bool {
for _, item := range scopes {
if item == scope {
return true
}
}
return false
}
func setLoginSession(c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set(UserIDKey, user.ID)
session.Set(UserNameKey, user.Username)
return session.Save()
}
func uniqueUsername(ctx context.Context, base string) (string, error) {
candidate := strings.TrimSpace(base)
if candidate == "" {
candidate = "user"
}
for i := 0; i < 1000; i++ {
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", candidate).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return candidate, nil
}
candidate = fmt.Sprintf("%s-%d", base, i+1)
}
return "", errors.New("无法生成可用用户名")
}
func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return nil, err
}
token, err := authConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
userInfo := &model.OAuthUserInfo{Active: true}
if verifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
return nil, fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
}
if nonce != "" && idToken.Nonce != nonce {
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
return nil, claimsErr
}
}
}
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if userInfo.Username == "" {
client := authConfig.Client(ctx, token)
userEndpoint := config.Config.OAuth2.UserEndpoint
if source.Name != "default" {
userEndpoint = ""
}
if userEndpoint != "" {
resp, httpErr := client.Get(userEndpoint)
if httpErr != nil {
return nil, httpErr
}
defer resp.Body.Close()
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, userInfo); unmarshalErr != nil {
return nil, unmarshalErr
}
}
}
return userInfo, nil
}
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
userInfo.AvatarUrl = strings.TrimSpace(userInfo.AvatarUrl)
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Username == "" {
return errors.New("无法从认证源获取用户名")
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if !userInfo.Active {
userInfo.Active = true
}
return nil
}
func buildCallbackResult(user *model.User, status string) OAuthCallbackResult {
result := OAuthCallbackResult{Status: status}
if user != nil {
info := BuildBasicUserInfo(user)
result.User = &info
}
return result
}
// GetLoginSources 获取可用登录源列表
// @Summary 获取可用登录源
// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny{data=[]oauth.AuthSourceView} "登录源列表"
// @Router /api/v1/oauth/sources [get]
func GetLoginSources(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(activeLoginSources()))
}
// GetLoginURL 获取登录授权地址
// @Summary 获取登录授权地址
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。
// @Tags oauth
// @Produce json
// @Param source query string false "认证源名称,为空使用默认源"
// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} util.ResponseAny "认证源不存在或未配置"
// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败"
// @Router /api/v1/oauth/login [get]
func GetLoginURL(c *gin.Context) {
source, err := resolveAuthSource(c.Query("source"))
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
SourceName: source.Name,
Purpose: OAuthPurposeLogin,
})
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, frontendLoginRedirectURL())
if err != nil {
return "", err
}
if verifier != nil {
return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil
}
return authConfig.AuthCodeURL(state), nil
}
// Authorize 发起指定认证源授权
// @Summary 发起指定认证源授权
// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
// @Tags oauth
// @Produce json
// @Param source path string true "认证源名称"
// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login"
// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} util.ResponseAny "认证源不存在或未启用"
// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败"
// @Router /api/v1/oauth/{source}/authorize [get]
func Authorize(c *gin.Context) {
source, err := resolveAuthSource(c.Param("source"))
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if !source.IsActive {
c.JSON(http.StatusBadRequest, util.Err("认证源未启用"))
return
}
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
if purpose != OAuthPurposeBind {
purpose = OAuthPurposeLogin
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
SourceName: source.Name,
Purpose: purpose,
})
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
// Callback OAuth 回调处理
// @Summary OAuth 回调处理
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。
// @Tags oauth
// @Accept json
// @Produce json
// @Param request body oauth.CallbackRequest true "回调请求参数"
// @Success 200 {object} util.ResponseAny{data=oauth.OAuthCallbackResult} "登录或绑定成功"
// @Failure 400 {object} util.ResponseAny "state 无效、参数错误或认证源错误"
// @Failure 401 {object} util.ResponseAny "绑定场景未登录"
// @Failure 500 {object} util.ResponseAny "OAuth 认证失败或内部错误"
// @Router /api/v1/oauth/callback [post]
func Callback(c *gin.Context) {
var req CallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
stateKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State))
payloadRaw, err := db.Redis.Get(ctx, stateKey).Result()
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidState))
return
}
_ = db.Redis.Del(ctx, stateKey)
payload, err := decodeOAuthStatePayload(payloadRaw)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
source, err := resolveAuthSource(payload.SourceName)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, frontendLoginRedirectURL())
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := normalizeOAuthUserInfo(userInfo); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if userInfo.Sub == "" {
userInfo.Sub = userInfo.Username
}
var user model.User
if payload.Purpose == OAuthPurposeBind {
userID := GetUserIDFromContext(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
return
}
if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := model.BindExternalAccount(&model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user.LastLoginAt = time.Now()
_ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound")))
return
}
account, err := model.FindExternalAccount(source.ID, userInfo.Sub)
switch {
case err == nil:
if err := db.DB(ctx).First(&user, "id = ?", account.UserID).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
case errors.Is(err, gorm.ErrRecordNotFound):
username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
if uniqueErr != nil {
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
return
}
user = model.User{
Username: username,
Nickname: userInfo.Name,
AvatarUrl: userInfo.AvatarUrl,
TrustLevel: userInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
IsActive: true,
LastLoginAt: time.Now(),
}
if err := db.DB(ctx).Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := model.BindExternalAccount(&model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
default:
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
user.LastLoginAt = time.Now()
_ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
if err := setLoginSession(c, &user); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in")))
}
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
// @Summary 获取外部帐号列表
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]model.ExternalAccountView} "外部帐号列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/oauth/external-accounts [get]
func ListExternalAccounts(c *gin.Context) {
userID := GetUserIDFromContext(c)
accounts, err := model.ListExternalAccountsByUserID(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(accounts))
}
// DeleteExternalAccount 解除外部帐号绑定
// @Summary 解除外部帐号绑定
// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "外部帐号绑定记录 ID"
// @Success 200 {object} util.ResponseAny{data=string} "解除绑定成功"
// @Failure 400 {object} util.ResponseAny "ID 无效或解除失败"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/oauth/external-accounts/{id}/delete [post]
func DeleteExternalAccount(c *gin.Context) {
userID := GetUserIDFromContext(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
return
}
rawID := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, util.Err("绑定记录 ID 无效"))
return
}
if err := model.DeleteExternalAccountForUser(id, userID); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+8 -3
View File
@@ -28,11 +28,16 @@ import (
"gorm.io/gorm"
)
// ServeFileByID serves an uploaded file by its ID
// ServeFileByID 根据 ID 获取并提供已上传的文件
// @Summary 获取已上传文件
// @Description 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回
// @Tags upload
// @Produce octet-stream
// @Param id path string true "Upload ID"
// @Success 200
// @Param id path string true "文件 ID"
// @Success 200 {file} file "成功获取文件内容"
// @Failure 400 {object} util.ResponseAny "文件 ID 格式错误"
// @Failure 404 {object} util.ResponseAny "文件未找到"
// @Failure 500 {object} util.ResponseAny "服务内部错误"
// @Router /f/{id} [get]
func ServeFileByID(c *gin.Context) {
idStr := c.Param("id")
+219
View File
@@ -0,0 +1,219 @@
package user
import (
"context"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/common/bind"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
DisplayName string `json:"display_name"`
}
func isPasswordLoginEnabled() bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
if err != nil {
return true
}
return enabled
}
func isPasswordRegisterEnabled() bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled)
if err != nil {
return true
}
return enabled
}
func isRegistrationEnabled() bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled)
if err != nil {
return true
}
return enabled
}
func setLoginSession(c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set(oauth.UserIDKey, user.ID)
session.Set(oauth.UserNameKey, user.Username)
if err := session.Save(); err != nil {
return err
}
return nil
}
// Login 用户密码登录
// @Summary 用户密码登录
// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
// @Tags user
// @Accept json
// @Produce json
// @Param request body user.loginRequest true "登录请求参数"
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等"
// @Failure 500 {object} util.ResponseAny "服务内部错误"
// @Router /api/v1/user/login [post]
func Login(c *gin.Context) {
if !isPasswordLoginEnabled() {
response.RespondFailure(c, "管理员关闭了密码登录")
return
}
var req loginRequest
if !bind.JSON(c, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
return
}
var user model.User
ctx := c.Request.Context()
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
response.RespondFailure(c, "用户名或密码错误")
return
}
if !user.IsActive {
response.RespondFailure(c, common.BannedAccount)
return
}
if !user.CheckPassword(req.Password) {
response.RespondFailure(c, "用户名或密码错误")
return
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
response.RespondFailure(c, err.Error())
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
}
// Register 用户注册
// @Summary 用户注册
// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。
// @Tags user
// @Accept json
// @Produce json
// @Param request body user.registerRequest true "注册请求参数"
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭"
// @Failure 500 {object} util.ResponseAny "服务内部错误"
// @Router /api/v1/user/register [post]
func Register(c *gin.Context) {
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
response.RespondFailure(c, "管理员关闭了注册")
return
}
var req registerRequest
if !bind.JSON(c, &req) {
return
}
req.Username = strings.TrimSpace(req.Username)
req.Password = strings.TrimSpace(req.Password)
req.Nickname = strings.TrimSpace(req.Nickname)
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
return
}
if len(req.Password) < 8 {
response.RespondFailure(c, "密码长度不能少于 8 位")
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
response.RespondFailure(c, err.Error())
return
}
if count > 0 {
response.RespondFailure(c, "用户名已存在")
return
}
user := model.User{
Username: req.Username,
Nickname: req.Nickname,
AvatarUrl: "",
TrustLevel: model.TrustLevelNewUser,
PayScore: 0,
SignKey: util.GenerateUniqueIDSimple(),
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
}
if user.Nickname == "" {
user.Nickname = req.DisplayName
}
if user.Nickname == "" {
user.Nickname = req.Username
}
if err := user.SetPassword(req.Password); err != nil {
response.RespondFailure(c, err.Error())
return
}
if err := db.DB(ctx).Create(&user).Error; err != nil {
response.RespondFailure(c, err.Error())
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
}
// Logout 用户退出登录
// @Summary 用户退出登录
// @Description 清除用户登录 Session,完成退出
// @Tags user
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
// @Failure 500 {object} util.ResponseAny "Session 清除失败"
// @Router /api/v1/user/logout [get]
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Options(util.GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
+48
View File
@@ -0,0 +1,48 @@
package bind
import (
"encoding/json"
"errors"
"io"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common/response"
)
// DecodeJSONBody decodes JSON reader to target
func DecodeJSONBody(body io.Reader, target any) error {
return json.NewDecoder(body).Decode(target)
}
// OptionalJSON decodes optional JSON body of reader to target, allowing EOF
func OptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
// IDParam parses "id" parameter from context path
func IDParam(c *gin.Context) (uint, bool) {
return IDParamByName(c, "id")
}
// IDParamByName parses target parameter from context path
func IDParamByName(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.RespondBadRequest(c, "")
return 0, false
}
return uint(id), true
}
// JSON binds JSON body of context request to target
func JSON(c *gin.Context, target any) bool {
if err := DecodeJSONBody(c.Request.Body, target); err != nil {
response.RespondBadRequest(c, "")
return false
}
return true
}
+82
View File
@@ -0,0 +1,82 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
const invalidParamsMessage = "参数错误"
// RespondSuccess sends a successful response with data
func RespondSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
// RespondSuccessWithExtras sends a successful response with data and extra fields
func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) {
payload := gin.H{
"success": true,
"message": "",
"data": data,
}
for key, value := range extras {
payload[key] = value
}
c.JSON(http.StatusOK, payload)
}
// RespondSuccessMessage sends a successful response with a custom message
func RespondSuccessMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
// RespondFailure sends a failed response with http.StatusOK and a failure message
func RespondFailure(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
})
}
// RespondBadRequest sends a bad request response (400)
func RespondBadRequest(c *gin.Context, message string) {
if message == "" {
message = invalidParamsMessage
}
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": message,
})
}
// RespondUnauthorized sends an unauthorized response (401)
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": message,
})
}
// RespondForbidden sends a forbidden response (403)
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": message,
})
}
// RespondErrorWithStatus sends a response with target HTTP status code and a message
func RespondErrorWithStatus(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{
"success": false,
"message": message,
})
}
+39 -1
View File
@@ -18,19 +18,50 @@ package config
import (
"encoding/json"
"flag"
"log"
"os"
"strings"
"github.com/spf13/viper"
)
var Config *configModel
// findConfigPath searches upward for the config file to handle tests running in subdirectories.
func findConfigPath(configPath string) string {
if _, err := os.Stat(configPath); err == nil {
return configPath
}
dir := "."
for i := 0; i < 5; i++ {
dir = dir + "/.."
path := dir + "/" + configPath
if _, err := os.Stat(path); err == nil {
return path
}
}
return configPath
}
// isTest checks if the current execution context is within 'go test'.
func isTest() bool {
if flag.Lookup("test.v") != nil {
return true
}
for _, arg := range os.Args {
if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") {
return true
}
}
return false
}
func init() {
// 加载配置文件路径
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "config.yaml"
configPath = findConfigPath("config.yaml")
}
// 设置配置文件
@@ -48,6 +79,13 @@ func init() {
log.Fatalf("[Config] parse config failed: %v\n", err)
}
// Disable standard DB/Redis initializations during tests to prevent connection attempts.
if isTest() {
c.Database.Enabled = false
c.Redis.Enabled = false
c.ClickHouse.Enabled = false
}
// 设置全局配置
Config = &c
+20
View File
@@ -33,6 +33,8 @@ func Migrate() {
if err := db.DB(context.Background()).AutoMigrate(
&model.User{},
&model.AuthSource{},
&model.ExternalAccount{},
&model.SystemConfig{},
&model.Upload{},
); err != nil {
@@ -71,12 +73,30 @@ func initSystemConfigs() {
Type: "system",
Description: "系统平台的展示名称",
},
{
Key: model.ConfigKeyPasswordLoginEnabled,
Value: "true",
Type: "system",
Description: "是否允许使用账号密码登录",
},
{
Key: model.ConfigKeyRegistrationEnabled,
Value: "true",
Type: "system",
Description: "控制普通用户是否可以自主注册(true/false)",
},
{
Key: model.ConfigKeyPasswordRegisterEnabled,
Value: "true",
Type: "system",
Description: "是否允许通过密码创建本地账号",
},
{
Key: model.ConfigKeyOIDCLoginEnabled,
Value: "true",
Type: "system",
Description: "是否允许使用第三方 OIDC 认证源登录",
},
{
Key: model.ConfigKeyMaxAPIKeysPerUser,
Value: "5",
+5
View File
@@ -175,3 +175,8 @@ func buildDSN(host string, port int, username, password string) string {
func DB(ctx context.Context) *gorm.DB {
return db.WithContext(ctx)
}
// SetDB sets the package-level database instance for testing.
func SetDB(d *gorm.DB) {
db = d
}
+286
View File
@@ -0,0 +1,286 @@
package model
import (
"context"
"errors"
"regexp"
"strings"
"time"
"github.com/linux-do/credit/internal/db"
"gorm.io/gorm"
)
const (
AuthSourceTypeOIDC = "oidc"
)
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
type AuthSource struct {
ID uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
Type string `json:"type" gorm:"size:20;not null"`
DisplayName string `json:"display_name" gorm:"size:100"`
IsActive bool `json:"is_active" gorm:"index;not null;default:false"`
ClientID string `json:"client_id" gorm:"size:255"`
ClientSecret string `json:"-" gorm:"size:1024"`
OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"`
Scopes string `json:"scopes" gorm:"size:255"`
IconURL string `json:"icon_url" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"`
}
type ExternalAccount struct {
ID uint64 `json:"id" gorm:"primaryKey"`
AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"`
ExternalUsername string `json:"external_username" gorm:"size:255"`
Email string `json:"email" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExternalAccountView struct {
ID uint64 `json:"id"`
AuthSourceID uint64 `json:"auth_source_id"`
AuthSourceName string `json:"auth_source_name"`
AuthSourceType string `json:"auth_source_type"`
AuthSourceLabel string `json:"auth_source_label"`
ExternalUsername string `json:"external_username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
func (source *AuthSource) Normalize() {
source.Type = strings.ToLower(strings.TrimSpace(source.Type))
source.Name = strings.TrimSpace(source.Name)
source.DisplayName = strings.TrimSpace(source.DisplayName)
source.ClientID = strings.TrimSpace(source.ClientID)
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
source.Scopes = strings.TrimSpace(source.Scopes)
source.IconURL = strings.TrimSpace(source.IconURL)
if source.DisplayName == "" {
source.DisplayName = source.Name
}
if source.Type == AuthSourceTypeOIDC && source.Scopes == "" {
source.Scopes = "openid profile email"
}
}
func (source *AuthSource) Validate() error {
source.Normalize()
if source.Name == "" {
return errors.New("认证源名称不能为空")
}
if !authSourceNamePattern.MatchString(source.Name) {
return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头")
}
if source.Type != AuthSourceTypeOIDC {
return errors.New("认证源类型仅支持 oidc")
}
if source.OpenIDDiscoveryURL == "" {
return errors.New("OIDC 认证源必须配置 Discovery URL")
}
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
return errors.New("启用认证源前必须配置 Client ID 和 Client Secret")
}
return nil
}
func (source *AuthSource) Sanitize() {
source.ClientSecretConfigured = source.ClientSecret != ""
source.ClientSecret = ""
}
func GetAuthSources() ([]AuthSource, error) {
var sources []AuthSource
if err := db.DB(context.Background()).Order("id asc").Find(&sources).Error; err != nil {
return nil, err
}
for i := range sources {
sources[i].Sanitize()
}
return sources, nil
}
func GetActiveAuthSources() ([]AuthSource, error) {
var sources []AuthSource
if err := db.DB(context.Background()).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil {
return nil, err
}
for i := range sources {
sources[i].Sanitize()
}
return sources, nil
}
func GetAuthSourceByID(id uint64) (*AuthSource, error) {
if id == 0 {
return nil, errors.New("认证源 ID 不能为空")
}
var source AuthSource
if err := db.DB(context.Background()).First(&source, "id = ?", id).Error; err != nil {
return nil, err
}
source.ClientSecretConfigured = source.ClientSecret != ""
return &source, nil
}
func GetAuthSourceByName(name string) (*AuthSource, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("认证源名称不能为空")
}
var source AuthSource
if err := db.DB(context.Background()).First(&source, "name = ?", name).Error; err != nil {
return nil, err
}
source.ClientSecretConfigured = source.ClientSecret != ""
return &source, nil
}
func CreateAuthSource(source *AuthSource) error {
if err := source.Validate(); err != nil {
return err
}
return db.DB(context.Background()).Create(source).Error
}
func UpdateAuthSource(source *AuthSource, keepSecret bool) error {
if source.ID == 0 {
return errors.New("认证源 ID 不能为空")
}
var current AuthSource
if err := db.DB(context.Background()).First(&current, "id = ?", source.ID).Error; err != nil {
return err
}
if keepSecret {
source.ClientSecret = current.ClientSecret
}
if err := source.Validate(); err != nil {
return err
}
return db.DB(context.Background()).Model(&current).Updates(map[string]any{
"name": source.Name,
"type": source.Type,
"display_name": source.DisplayName,
"is_active": source.IsActive,
"client_id": source.ClientID,
"client_secret": source.ClientSecret,
"openid_discovery_url": source.OpenIDDiscoveryURL,
"scopes": source.Scopes,
"icon_url": source.IconURL,
}).Error
}
func ToggleAuthSource(id uint64, isActive bool) error {
source, err := GetAuthSourceByID(id)
if err != nil {
return err
}
source.IsActive = isActive
if err := source.Validate(); err != nil {
return err
}
return db.DB(context.Background()).Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error
}
func DeleteAuthSource(id uint64) error {
if id == 0 {
return errors.New("认证源 ID 不能为空")
}
return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil {
return err
}
return tx.Delete(&AuthSource{}, "id = ?", id).Error
})
}
func FindExternalAccount(sourceID uint64, externalID string) (*ExternalAccount, error) {
var account ExternalAccount
if err := db.DB(context.Background()).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func BindExternalAccount(account *ExternalAccount) error {
if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" {
return errors.New("外部账号绑定信息不完整")
}
account.ExternalID = strings.TrimSpace(account.ExternalID)
account.ExternalUsername = strings.TrimSpace(account.ExternalUsername)
account.Email = strings.TrimSpace(account.Email)
return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error {
var current ExternalAccount
err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(&current).Error
if err == nil {
if current.UserID != account.UserID {
return errors.New("该外部账号已绑定到其他用户")
}
return tx.Model(&current).Updates(map[string]any{
"external_username": account.ExternalUsername,
"email": account.Email,
}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return tx.Create(account).Error
})
}
func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) {
if userID == 0 {
return nil, errors.New("用户 ID 不能为空")
}
var accounts []ExternalAccount
if err := db.DB(context.Background()).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
return nil, err
}
views := make([]ExternalAccountView, 0, len(accounts))
for _, account := range accounts {
var name, sourceType, label string
if account.AuthSourceID == 0 {
name = "default"
sourceType = "oidc"
label = "默认认证源"
} else {
source, err := GetAuthSourceByID(account.AuthSourceID)
if err != nil {
continue
}
name = source.Name
sourceType = source.Type
label = source.DisplayName
if label == "" {
label = source.Name
}
}
views = append(views, ExternalAccountView{
ID: account.ID,
AuthSourceID: account.AuthSourceID,
AuthSourceName: name,
AuthSourceType: sourceType,
AuthSourceLabel: label,
ExternalUsername: account.ExternalUsername,
Email: account.Email,
CreatedAt: account.CreatedAt,
})
}
return views, nil
}
func DeleteExternalAccountForUser(id uint64, userID uint64) error {
if id == 0 || userID == 0 {
return errors.New("绑定记录 ID 不能为空")
}
return db.DB(context.Background()).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error
}
+3
View File
@@ -33,7 +33,10 @@ import (
const (
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
ConfigKeySiteName = "site_name" // 站点名称
ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册
ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量
)
+30 -9
View File
@@ -43,13 +43,15 @@ const (
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
type OAuthUserInfo struct {
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
TrustLevel TrustLevel `json:"trust_level"`
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
TrustLevel TrustLevel `json:"trust_level"`
}
// GetID 获取用户 ID
@@ -88,6 +90,7 @@ type LeaderboardUser struct {
type User struct {
ID uint64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
TrustLevel TrustLevel `json:"trust_level" gorm:"index"`
@@ -107,6 +110,26 @@ type User struct {
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (u *User) SetPassword(password string) error {
if password == "" {
u.Password = ""
return nil
}
hashed, err := util.HashPassword(password)
if err != nil {
return err
}
u.Password = hashed
return nil
}
func (u *User) CheckPassword(password string) bool {
if u.Password == "" || password == "" {
return false
}
return util.CheckPasswordHash(u.Password, password)
}
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
return err
@@ -123,8 +146,6 @@ func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) {
return users, nil
}
func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) {
if u.Username == "dev_user" {
var response UserGamificationScoreResponse
+17 -1
View File
@@ -28,11 +28,13 @@ import (
"time"
"github.com/linux-do/credit/internal/apps/admin"
admin_auth_source "github.com/linux-do/credit/internal/apps/admin/auth_source"
admin_task "github.com/linux-do/credit/internal/apps/admin/task"
admin_user "github.com/linux-do/credit/internal/apps/admin/user"
publicconfig "github.com/linux-do/credit/internal/apps/config"
"github.com/linux-do/credit/internal/apps/health"
"github.com/linux-do/credit/internal/apps/upload"
"github.com/linux-do/credit/internal/apps/user"
"github.com/linux-do/credit/internal/util"
"github.com/gin-contrib/sessions"
@@ -109,15 +111,22 @@ func Serve() {
apiV1Router.GET("/health", health.Health)
// OAuth
apiV1Router.GET("/oauth/sources", oauth.GetLoginSources)
apiV1Router.GET("/oauth/login", oauth.GetLoginURL)
apiV1Router.GET("/oauth/:source/authorize", oauth.Authorize)
apiV1Router.GET("/oauth/logout", oauth.Logout)
apiV1Router.POST("/oauth/callback", oauth.Callback)
apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo)
apiV1Router.GET("/oauth/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
apiV1Router.POST("/oauth/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
// User
userRouter := apiV1Router.Group("/user")
userRouter.Use(oauth.LoginRequired())
{
userRouter.POST("/login", user.Login)
userRouter.POST("/register", user.Register)
userRouter.GET("/logout", user.Logout)
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
}
// Upload
@@ -155,6 +164,13 @@ func Serve() {
systemConfigRouter.PUT("", system_config.UpdateSystemConfig)
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
}
// Auth Sources
adminRouter.GET("/auth-sources", admin_auth_source.ListAuthSources)
adminRouter.POST("/auth-sources", admin_auth_source.CreateAuthSource)
adminRouter.PUT("/auth-sources/:id", admin_auth_source.UpdateAuthSource)
adminRouter.PUT("/auth-sources/:id/toggle", admin_auth_source.ToggleAuthSource)
adminRouter.DELETE("/auth-sources/:id", admin_auth_source.DeleteAuthSource)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package testhelper
import (
"context"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/glebarez/sqlite"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations,
// starts miniredis, and overrides the global db/Redis clients. It returns a cleanup function.
func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
// Initialize GORM in-memory SQLite
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("failed to open in-memory SQLite db: %v", err)
}
// AutoMigrate all tables
err = sqliteDB.AutoMigrate(
&model.User{},
&model.AuthSource{},
&model.ExternalAccount{},
&model.SystemConfig{},
&model.Upload{},
)
if err != nil {
t.Fatalf("failed to auto migrate tables: %v", err)
}
// Set global db
db.SetDB(sqliteDB)
// Start miniredis
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
// Hook up Redis Client to miniredis
redisClient := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
db.Redis = redisClient
// Hook up AsynqClient to miniredis
scheduler.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
Addr: mr.Addr(),
})
// Seed default configurations
seedDefaultConfigs(t, sqliteDB)
// Cleanup function
cleanup := func() {
redisClient.Close()
mr.Close()
// Reset database and Redis references
db.SetDB(nil)
db.Redis = nil
scheduler.AsynqClient = nil
}
return sqliteDB, mr, cleanup
}
func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
defaultConfigs := []model.SystemConfig{
{
Key: model.ConfigKeyUploadAllowedExtensions,
Value: "jpg,png,webp",
Type: "system",
Description: "允许上传的图片扩展名(逗号分隔)",
},
{
Key: model.ConfigKeySiteName,
Value: "Antigravity Project",
Type: "system",
Description: "系统平台的展示名称",
},
{
Key: model.ConfigKeyPasswordLoginEnabled,
Value: "true",
Type: "system",
Description: "是否允许使用账号密码登录",
},
{
Key: model.ConfigKeyRegistrationEnabled,
Value: "true",
Type: "system",
Description: "控制普通用户是否可以自主注册(true/false)",
},
{
Key: model.ConfigKeyPasswordRegisterEnabled,
Value: "true",
Type: "system",
Description: "是否允许通过密码创建本地账号",
},
{
Key: model.ConfigKeyOIDCLoginEnabled,
Value: "true",
Type: "system",
Description: "是否允许使用第三方 OIDC 认证源登录",
},
{
Key: model.ConfigKeyMaxAPIKeysPerUser,
Value: "5",
Type: "business",
Description: "限制每个普通用户可以创建的 API Key 最大数量",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
t.Fatalf("failed to seed default system configs: %v", err)
}
// Also seed these in miniredis context if required, but they are stored in postgres first.
// We'll write configs to miniredis in actual handlers.
for _, config := range defaultConfigs {
_ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, config.Key, &config)
}
}
+4
View File
@@ -47,6 +47,10 @@ var httpClient = &http.Client{
}),
}
func SetHTTPClient(c *http.Client) {
httpClient = c
}
func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
+15
View File
@@ -0,0 +1,15 @@
package util
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPasswordHash(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+9
View File
@@ -20,6 +20,15 @@ import "github.com/linux-do/credit/internal/cmd"
// @title LINUX DO Credit
// @version 1.0.0
// @description LINUX DO Credit 平台后端 API,提供用户认证、商户 API Key 管理、系统配置等功能。
// @contact.name LINUX DO Credit
// @contact.url https://linux.do
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /
// @securityDefinitions.apikey SessionCookie
// @in cookie
// @name session
func main() {
cmd.Execute()
}