mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
重构
This commit is contained in:
@@ -274,9 +274,8 @@ components/common/
|
|||||||
│ # 是否显示通知铃铛图标,通过 Context 持久化偏好
|
│ # 是否显示通知铃铛图标,通过 Context 持久化偏好
|
||||||
├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方
|
├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方
|
||||||
│ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源)
|
│ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源)
|
||||||
└── security.tsx # SecurityMain — 系统安全设置页面(管理员专属),包含系统登录与
|
├── system-settings.tsx # SystemSettingsMain — 系统设置主页面(管理员专属),包含系统安全与登录控制、
|
||||||
# 注册控制(密码登录/注册/密码注册/OIDC 登录四个开关)、
|
# 认证源管理、人机验证配置、邮件服务 (SMTP) 设置以及菜单显示控制
|
||||||
# 认证源管理(新增、编辑、启用/禁用、删除 OIDC 认证源)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -274,9 +274,8 @@ components/common/
|
|||||||
│ # 是否显示通知铃铛图标,通过 Context 持久化偏好
|
│ # 是否显示通知铃铛图标,通过 Context 持久化偏好
|
||||||
├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方
|
├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方
|
||||||
│ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源)
|
│ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源)
|
||||||
└── security.tsx # SecurityMain — 系统安全设置页面(管理员专属),包含系统登录与
|
├── system-settings.tsx # SystemSettingsMain — 系统设置主页面(管理员专属),包含系统安全与登录控制、
|
||||||
# 注册控制(密码登录/注册/密码注册/OIDC 登录四个开关)、
|
# 认证源管理、人机验证配置、邮件服务 (SMTP) 设置以及菜单显示控制
|
||||||
# 认证源管理(新增、编辑、启用/禁用、删除 OIDC 认证源)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+105
-68
@@ -926,74 +926,6 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"SessionCookie": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限",
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"summary": "删除系统配置",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"description": "配置键",
|
|
||||||
"name": "key",
|
|
||||||
"in": "path",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "删除成功",
|
|
||||||
"schema": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"data": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "未登录",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "无管理员权限",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "配置不存在",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "内部错误",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/admin/tasks/dispatch": {
|
"/api/v1/admin/tasks/dispatch": {
|
||||||
@@ -3082,6 +3014,64 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/user/profile": {
|
||||||
|
"put": {
|
||||||
|
"description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"summary": "修改当前登录用户的个人资料",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "更新请求参数",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/user.updateProfileRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "修改成功,返回更新后的用户信息",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/oauth.BasicUserInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "邮箱已被占用或参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/user/register": {
|
"/api/v1/user/register": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。",
|
"description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。",
|
||||||
@@ -3756,20 +3746,38 @@ const docTemplate = `{
|
|||||||
"avatar_url": {
|
"avatar_url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"is_admin": {
|
"is_admin": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"need_change_password": {
|
"need_change_password": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"nickname": {
|
"nickname": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4296,6 +4304,35 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"user.updateProfileRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avatar_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.updateUserStatusRequest": {
|
"user.updateUserStatusRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+105
-68
@@ -919,74 +919,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"SessionCookie": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限",
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"summary": "删除系统配置",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"description": "配置键",
|
|
||||||
"name": "key",
|
|
||||||
"in": "path",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "删除成功",
|
|
||||||
"schema": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"data": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "未登录",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "无管理员权限",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "配置不存在",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "内部错误",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/admin/tasks/dispatch": {
|
"/api/v1/admin/tasks/dispatch": {
|
||||||
@@ -3075,6 +3007,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/user/profile": {
|
||||||
|
"put": {
|
||||||
|
"description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"summary": "修改当前登录用户的个人资料",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "更新请求参数",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/user.updateProfileRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "修改成功,返回更新后的用户信息",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/oauth.BasicUserInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "邮箱已被占用或参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/user/register": {
|
"/api/v1/user/register": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。",
|
"description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。",
|
||||||
@@ -3749,20 +3739,38 @@
|
|||||||
"avatar_url": {
|
"avatar_url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"is_admin": {
|
"is_admin": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"need_change_password": {
|
"need_change_password": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"nickname": {
|
"nickname": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4289,6 +4297,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"user.updateProfileRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avatar_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.updateUserStatusRequest": {
|
"user.updateUserStatusRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+66
-41
@@ -355,16 +355,28 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
avatar_url:
|
avatar_url:
|
||||||
type: string
|
type: string
|
||||||
|
bio:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
gender:
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
is_admin:
|
is_admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
need_change_password:
|
need_change_password:
|
||||||
type: boolean
|
type: boolean
|
||||||
nickname:
|
nickname:
|
||||||
type: string
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
|
website:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
oauth.CallbackRequest:
|
oauth.CallbackRequest:
|
||||||
properties:
|
properties:
|
||||||
@@ -722,6 +734,25 @@ definitions:
|
|||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
user.updateProfileRequest:
|
||||||
|
properties:
|
||||||
|
avatar_url:
|
||||||
|
type: string
|
||||||
|
bio:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
gender:
|
||||||
|
type: string
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
|
nickname:
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
website:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
user.updateUserStatusRequest:
|
user.updateUserStatusRequest:
|
||||||
properties:
|
properties:
|
||||||
is_active:
|
is_active:
|
||||||
@@ -1186,47 +1217,6 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- admin
|
- admin
|
||||||
/api/v1/admin/system-configs/{key}:
|
/api/v1/admin/system-configs/{key}:
|
||||||
delete:
|
|
||||||
description: 根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限
|
|
||||||
parameters:
|
|
||||||
- description: 配置键
|
|
||||||
in: path
|
|
||||||
name: key
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: 删除成功
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/definitions/util.ResponseAny'
|
|
||||||
- properties:
|
|
||||||
data:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
"401":
|
|
||||||
description: 未登录
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
|
||||||
"403":
|
|
||||||
description: 无管理员权限
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
|
||||||
"404":
|
|
||||||
description: 配置不存在
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
|
||||||
"500":
|
|
||||||
description: 内部错误
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
|
||||||
security:
|
|
||||||
- SessionCookie: []
|
|
||||||
summary: 删除系统配置
|
|
||||||
tags:
|
|
||||||
- admin
|
|
||||||
get:
|
get:
|
||||||
description: 根据配置键获取对应的系统配置详情,需要管理员权限
|
description: 根据配置键获取对应的系统配置详情,需要管理员权限
|
||||||
parameters:
|
parameters:
|
||||||
@@ -2602,6 +2592,41 @@ paths:
|
|||||||
summary: 用户退出登录
|
summary: 用户退出登录
|
||||||
tags:
|
tags:
|
||||||
- user
|
- user
|
||||||
|
/api/v1/user/profile:
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
|
||||||
|
parameters:
|
||||||
|
- description: 更新请求参数
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.updateProfileRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 修改成功,返回更新后的用户信息
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/oauth.BasicUserInfo'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: 邮箱已被占用或参数错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
summary: 修改当前登录用户的个人资料
|
||||||
|
tags:
|
||||||
|
- user
|
||||||
/api/v1/user/register:
|
/api/v1/user/register:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {SecurityMain} from "@/components/common/settings/security"
|
import {SystemSettingsMain} from "@/components/common/settings/system-settings"
|
||||||
|
|
||||||
/* 系统设置页面 */
|
/* 系统设置页面 */
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
return <SecurityMain />
|
return <SystemSettingsMain />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Input } from "@/components/ui/input"
|
import {Input} from "@/components/ui/input"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import {Switch} from "@/components/ui/switch"
|
||||||
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
|
import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel"
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||||
|
|
||||||
import { formatDateTime } from "@/lib/utils"
|
import {formatDateTime} from "@/lib/utils"
|
||||||
import type { SystemConfig } from "@/lib/services"
|
import type {SystemConfig} from "@/lib/services"
|
||||||
import { useAdmin } from "@/contexts/admin-context"
|
import {useAdmin} from "@/contexts/admin-context"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统配置
|
* 系统配置
|
||||||
* 显示系统配置的详细信息和编辑面板
|
* 显示系统配置的详细信息和编辑面板
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <SystemConfigDetailPanel
|
* <SystemConfigDetailPanel
|
||||||
@@ -127,8 +127,8 @@ function SystemConfigDetailPanel({
|
|||||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||||
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
|
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
|
||||||
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
|
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
config?.type === 'system'
|
config?.type === 'system'
|
||||||
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
||||||
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||||
}`}>
|
}`}>
|
||||||
{config?.type === 'system' ? '系统配置' : '业务配置'}
|
{config?.type === 'system' ? '系统配置' : '业务配置'}
|
||||||
@@ -149,7 +149,7 @@ function SystemConfigDetailPanel({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统配置管理组件
|
* 系统配置管理组件
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <SystemConfigs />
|
* <SystemConfigs />
|
||||||
@@ -162,8 +162,7 @@ export function SystemConfigs() {
|
|||||||
systemConfigsLoading: loading,
|
systemConfigsLoading: loading,
|
||||||
systemConfigsError: error,
|
systemConfigsError: error,
|
||||||
refetchSystemConfigs,
|
refetchSystemConfigs,
|
||||||
updateSystemConfig,
|
updateSystemConfig
|
||||||
deleteSystemConfig
|
|
||||||
} = useAdmin()
|
} = useAdmin()
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
|
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
|
||||||
@@ -186,10 +185,6 @@ export function SystemConfigs() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (config: SystemConfig) => {
|
|
||||||
await deleteSystemConfig(config.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePage<SystemConfig>
|
<ManagePage<SystemConfig>
|
||||||
title="系统配置"
|
title="系统配置"
|
||||||
@@ -199,7 +194,6 @@ export function SystemConfigs() {
|
|||||||
onReload={() => refetchSystemConfigs(activeTab)}
|
onReload={() => refetchSystemConfigs(activeTab)}
|
||||||
getInitialEditData={getInitialEditData}
|
getInitialEditData={getInitialEditData}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={handleDelete}
|
|
||||||
getId={(config) => config.key}
|
getId={(config) => config.key}
|
||||||
emptyDescription="未发现系统配置"
|
emptyDescription="未发现系统配置"
|
||||||
loadingDescription="配置加载中"
|
loadingDescription="配置加载中"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react"
|
import {useState} from "react"
|
||||||
import { toast } from "sonner"
|
import {toast} from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import {Spinner} from "@/components/ui/spinner"
|
||||||
import { ErrorInline } from "@/components/layout/error"
|
import {ErrorInline} from "@/components/layout/error"
|
||||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||||
import { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
|
import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
|
|
||||||
interface ManagePageProps<T> {
|
interface ManagePageProps<T> {
|
||||||
@@ -30,7 +30,7 @@ interface ManagePageProps<T> {
|
|||||||
/** 获取初始编辑数据 */
|
/** 获取初始编辑数据 */
|
||||||
getInitialEditData: (item: T) => Partial<T>
|
getInitialEditData: (item: T) => Partial<T>
|
||||||
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
||||||
onDelete: (item: T) => Promise<void>
|
onDelete?: (item: T) => Promise<void>
|
||||||
|
|
||||||
/** 渲染表格 (Config-Driven) */
|
/** 渲染表格 (Config-Driven) */
|
||||||
columns: {
|
columns: {
|
||||||
@@ -134,7 +134,7 @@ export function ManagePage<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!deletingItem) return
|
if (!deletingItem || !onDelete) return
|
||||||
try {
|
try {
|
||||||
await onDelete(deletingItem)
|
await onDelete(deletingItem)
|
||||||
toast.success('删除成功')
|
toast.success('删除成功')
|
||||||
@@ -191,7 +191,7 @@ export function ManagePage<T>({
|
|||||||
hovered={hoveredItem}
|
hovered={hoveredItem}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onDelete={handleDeleteClick}
|
onDelete={onDelete ? handleDeleteClick : undefined}
|
||||||
getId={getId}
|
getId={getId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -322,7 +322,7 @@ export function ManageTable<T>({
|
|||||||
hovered: T | null
|
hovered: T | null
|
||||||
onSelect: (item: T) => void
|
onSelect: (item: T) => void
|
||||||
onHover: (item: T | null) => void
|
onHover: (item: T | null) => void
|
||||||
onDelete: (item: T) => void
|
onDelete?: (item: T) => void
|
||||||
getId: (item: T) => string | number
|
getId: (item: T) => string | number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -340,7 +340,7 @@ export function ManageTable<T>({
|
|||||||
{col.header}
|
{col.header}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>
|
{onDelete && <TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="animate-in fade-in duration-200">
|
<TableBody className="animate-in fade-in duration-200">
|
||||||
@@ -370,19 +370,21 @@ export function ManageTable<T>({
|
|||||||
{col.cell(item)}
|
{col.cell(item)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell className="text-xs py-1 text-center">
|
{onDelete && (
|
||||||
<Button
|
<TableCell className="text-xs py-1 text-center">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
onDelete(item)
|
e.stopPropagation()
|
||||||
}}
|
onDelete(item)
|
||||||
>
|
}}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</TableCell>
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {Info, Server} from "lucide-react"
|
||||||
|
import packageJson from "../../../package.json"
|
||||||
|
import {apiConfig} from "@/lib/services"
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoTabProps {
|
||||||
|
systemConfigsLength: number
|
||||||
|
authSourcesLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<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-muted text-muted-foreground">
|
||||||
|
<Info className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">应用信息</CardTitle>
|
||||||
|
<CardDescription className="text-xs">当前前端应用的版本与构建信息</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="应用名称" value={packageJson.name} />
|
||||||
|
<InfoRow label="版本号" value={packageJson.version} />
|
||||||
|
<InfoRow label="构建时间" value={packageJson.buildDate} />
|
||||||
|
<InfoRow label="Next.js" value={(packageJson as any).dependencies.next} />
|
||||||
|
<InfoRow label="React" value={(packageJson as any).dependencies.react} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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-muted text-muted-foreground">
|
||||||
|
<Server className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">服务连接</CardTitle>
|
||||||
|
<CardDescription className="text-xs">前端 API 客户端的基础连接参数</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="API Base URL" value={apiConfig.baseURL || "同源"} />
|
||||||
|
<InfoRow label="请求超时" value={`${apiConfig.timeout}ms`} />
|
||||||
|
<InfoRow label="携带凭证" value={apiConfig.withCredentials ? "是" : "否"} />
|
||||||
|
<InfoRow label="系统配置项" value={`${systemConfigsLength} 项`} />
|
||||||
|
<InfoRow label="认证源数量" value={`${authSourcesLength} 个`} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useMemo} from "react"
|
||||||
|
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Home,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
LayoutList,
|
||||||
|
Palette,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Terminal,
|
||||||
|
UserRound
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
|
import {Switch} from "@/components/ui/switch"
|
||||||
|
import {AdminService} from "@/lib/services"
|
||||||
|
import type {SystemConfig} from "@/lib/services/admin"
|
||||||
|
import {toast} from "sonner"
|
||||||
|
|
||||||
|
const MENU_GROUPS = [
|
||||||
|
{
|
||||||
|
name: "基础菜单",
|
||||||
|
items: [
|
||||||
|
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "管理菜单",
|
||||||
|
items: [
|
||||||
|
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
|
||||||
|
{ path: "/admin/files", label: "文件管理", description: "查看用户上传文件并清理多余文件", icon: FolderOpen },
|
||||||
|
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
|
||||||
|
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
|
||||||
|
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
|
||||||
|
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "文档菜单",
|
||||||
|
items: [
|
||||||
|
{ path: "/components", label: "组件库", description: "内置 UI 组件的展示、调试与参考", icon: Palette },
|
||||||
|
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
|
||||||
|
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface OtherTabProps {
|
||||||
|
configs: Record<string, SystemConfig>
|
||||||
|
systemConfigsQuery: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtherTab({ configs, systemConfigsQuery }: OtherTabProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const menuDisplayConfig = useMemo(() => {
|
||||||
|
const raw = configs["menu_display_config"]?.value
|
||||||
|
if (!raw) return {} as Record<string, boolean>
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Record<string, boolean>
|
||||||
|
} catch {
|
||||||
|
return {} as Record<string, boolean>
|
||||||
|
}
|
||||||
|
}, [configs])
|
||||||
|
|
||||||
|
const updateMenuConfigMutation = useMutation({
|
||||||
|
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
|
||||||
|
const newConfig = { ...menuDisplayConfig, [path]: enabled }
|
||||||
|
const currentCfg = configs["menu_display_config"]
|
||||||
|
await AdminService.updateSystemConfig("menu_display_config", {
|
||||||
|
value: JSON.stringify(newConfig),
|
||||||
|
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||||
|
toast.success("目录显示配置已更新")
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "更新配置失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMenuToggle = (path: string, checked: boolean) => {
|
||||||
|
updateMenuConfigMutation.mutate({ path, enabled: checked })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<LayoutList className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-6">
|
||||||
|
{MENU_GROUPS.map((group) => (
|
||||||
|
<div key={group.name} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<div className="h-px bg-border/40 flex-1" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isReadOnly = "readOnly" in item && item.readOnly
|
||||||
|
const checked = menuDisplayConfig[item.path] !== false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
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.5 flex-1 min-w-0 pr-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
|
||||||
|
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||||
|
{isReadOnly && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||||
|
不可隐藏
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
disabled={isReadOnly || updateMenuConfigMutation.isPending}
|
||||||
|
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
|
||||||
|
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
<span className="font-semibold text-foreground">安全提示:</span>
|
||||||
|
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useEffect, useState} from "react"
|
||||||
|
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {Fingerprint, Globe, Loader2, Lock, Mail, Pencil, Plus, Settings, Shield, Trash2, UserPlus} from "lucide-react"
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
|
import {Switch} from "@/components/ui/switch"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
|
import {Label} from "@/components/ui/label"
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email_login_verification_enabled",
|
||||||
|
title: "邮箱登录验证",
|
||||||
|
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email_register_verification_enabled",
|
||||||
|
title: "邮箱注册验证",
|
||||||
|
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface SecurityTabProps {
|
||||||
|
configs: Record<string, SystemConfig>
|
||||||
|
systemConfigsQuery: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||||
|
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||||
|
|
||||||
|
const [capCount, setCapCount] = useState("")
|
||||||
|
const [capDifficulty, setCapDifficulty] = useState("")
|
||||||
|
const [capSize, setCapSize] = useState("")
|
||||||
|
const [capTTL, setCapTTL] = useState("")
|
||||||
|
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||||
|
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||||
|
|
||||||
|
const authSourcesQuery = useQuery({
|
||||||
|
queryKey: ["auth", "sources"],
|
||||||
|
queryFn: () => AdminService.listAuthSources(),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemConfigsQuery.data) {
|
||||||
|
const cfgMap = configs
|
||||||
|
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
|
||||||
|
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
|
||||||
|
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
|
||||||
|
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
|
||||||
|
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||||
|
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||||
|
}
|
||||||
|
}, [systemConfigsQuery.data, configs])
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: async ({ key, value }: { key: string; 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"] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||||
|
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: string, checked: boolean) => {
|
||||||
|
updateConfigMutation.mutate({ key, value: checked })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCapMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const updates = [
|
||||||
|
{ key: "cap_challenge_count", value: capCount },
|
||||||
|
{ key: "cap_challenge_difficulty", value: capDifficulty },
|
||||||
|
{ key: "cap_challenge_size", value: capSize },
|
||||||
|
{ key: "cap_challenge_ttl_seconds", value: capTTL },
|
||||||
|
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
|
||||||
|
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const currentCfg = configs[update.key]
|
||||||
|
await AdminService.updateSystemConfig(update.key, {
|
||||||
|
value: update.value,
|
||||||
|
description: currentCfg?.description || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||||
|
toast.success("人机验证配置已成功保存")
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "保存配置失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCapSave = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
saveCapMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 人机验证配置 (Cap CAPTCHA) */}
|
||||||
|
<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">
|
||||||
|
<Shield className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
|
||||||
|
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={configs["cap_login_enabled"]?.value === "true"}
|
||||||
|
disabled={updateConfigMutation.isPending}
|
||||||
|
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{/* 自动开始计算 Switch */}
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={capAutoSolve}
|
||||||
|
onCheckedChange={setCapAutoSolve}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCapSave} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
|
||||||
|
<Input
|
||||||
|
id="cap_challenge_count"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={capCount}
|
||||||
|
onChange={(e) => setCapCount(e.target.value)}
|
||||||
|
placeholder="50"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
|
||||||
|
<Input
|
||||||
|
id="cap_challenge_difficulty"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={capDifficulty}
|
||||||
|
onChange={(e) => setCapDifficulty(e.target.value)}
|
||||||
|
placeholder="4"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
|
||||||
|
<Input
|
||||||
|
id="cap_challenge_size"
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={64}
|
||||||
|
value={capSize}
|
||||||
|
onChange={(e) => setCapSize(e.target.value)}
|
||||||
|
placeholder="32"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="cap_challenge_ttl"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
value={capTTL}
|
||||||
|
onChange={(e) => setCapTTL(e.target.value)}
|
||||||
|
placeholder="600"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="cap_token_ttl"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
value={capTokenTTL}
|
||||||
|
onChange={(e) => setCapTokenTTL(e.target.value)}
|
||||||
|
placeholder="1200"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={saveCapMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveCapMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"保存配置"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useEffect, useMemo} from "react"
|
||||||
|
import {useQuery} from "@tanstack/react-query"
|
||||||
|
import {Loader2} from "lucide-react"
|
||||||
|
import {useRouter} from "next/navigation"
|
||||||
|
import {motion} from "motion/react"
|
||||||
|
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||||
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
|
import {TemplatesManager} from "@/components/common/settings/templates"
|
||||||
|
import {AdminService} from "@/lib/services"
|
||||||
|
import type {SystemConfig} from "@/lib/services/admin"
|
||||||
|
import {SystemStatusManager} from "@/components/common/admin/status"
|
||||||
|
|
||||||
|
import {SecurityTab} from "./security-tab"
|
||||||
|
import {SystemTab} from "./system-tab"
|
||||||
|
import {OtherTab} from "./other-tab"
|
||||||
|
import {InfoTab} from "./info-tab"
|
||||||
|
|
||||||
|
function systemConfigMap(configs: SystemConfig[]) {
|
||||||
|
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||||
|
accumulator[config.key] = config
|
||||||
|
return accumulator
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemSettingsMain() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="security" className="w-full">
|
||||||
|
<TabsList className="w-full overflow-x-auto">
|
||||||
|
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
安全设置
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
运营设置
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
系统设置
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
其他设置
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
系统状态
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
|
||||||
|
系统信息
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="security" className="pt-4">
|
||||||
|
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="operation" className="pt-4">
|
||||||
|
<TemplatesManager />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="system" className="pt-4">
|
||||||
|
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="status" className="pt-4">
|
||||||
|
<SystemStatusManager />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="other" className="pt-4">
|
||||||
|
<OtherTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="info" className="pt-4">
|
||||||
|
<InfoTab
|
||||||
|
systemConfigsLength={systemConfigsQuery.data?.length ?? 0}
|
||||||
|
authSourcesLength={authSourcesQuery.data?.length ?? 0}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useEffect, useState} from "react"
|
||||||
|
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {Loader2, Mail, Server} from "lucide-react"
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
|
import {Label} from "@/components/ui/label"
|
||||||
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||||
|
import {AdminService} from "@/lib/services"
|
||||||
|
import type {SystemConfig} from "@/lib/services/admin"
|
||||||
|
import {toast} from "sonner"
|
||||||
|
|
||||||
|
interface SystemTabProps {
|
||||||
|
configs: Record<string, SystemConfig>
|
||||||
|
systemConfigsQuery: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [serverAddress, setServerAddress] = useState("")
|
||||||
|
const [smtpHost, setSmtpHost] = useState("")
|
||||||
|
const [smtpPort, setSmtpPort] = useState("")
|
||||||
|
const [smtpUsername, setSmtpUsername] = useState("")
|
||||||
|
const [smtpPassword, setSmtpPassword] = useState("")
|
||||||
|
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
|
||||||
|
const [smtpTestTo, setSmtpTestTo] = useState("")
|
||||||
|
const [smtpTestLog, setSmtpTestLog] = useState("")
|
||||||
|
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
|
||||||
|
const [smtpTestError, setSmtpTestError] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemConfigsQuery.data) {
|
||||||
|
setServerAddress(configs["server_address"]?.value || "")
|
||||||
|
setSmtpHost(configs["smtp_host"]?.value || "")
|
||||||
|
setSmtpPort(configs["smtp_port"]?.value || "587")
|
||||||
|
setSmtpUsername(configs["smtp_username"]?.value || "")
|
||||||
|
setSmtpPassword(configs["smtp_password"]?.value || "")
|
||||||
|
}
|
||||||
|
}, [systemConfigsQuery.data, configs])
|
||||||
|
|
||||||
|
const saveSystemMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const currentCfg = configs["server_address"]
|
||||||
|
await AdminService.updateSystemConfig("server_address", {
|
||||||
|
value: serverAddress,
|
||||||
|
description: currentCfg?.description || "服务器地址",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||||
|
toast.success("通用配置已成功保存")
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "保存配置失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSystemSave = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
saveSystemMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSmtpMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const updates = [
|
||||||
|
{ key: "smtp_host", value: smtpHost },
|
||||||
|
{ key: "smtp_port", value: smtpPort },
|
||||||
|
{ key: "smtp_username", value: smtpUsername },
|
||||||
|
{ key: "smtp_password", value: smtpPassword },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const currentCfg = configs[update.key]
|
||||||
|
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
|
||||||
|
// If already configured and sent empty or mask, skip updating it (keep existing)
|
||||||
|
if (currentCfg && currentCfg.value === "******") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await AdminService.updateSystemConfig(update.key, {
|
||||||
|
value: update.value,
|
||||||
|
description: currentCfg?.description || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||||
|
toast.success("SMTP 邮件配置已成功保存")
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "保存配置失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSmtpSave = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
saveSmtpMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmtpMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
setSmtpTestLog("正在发起连接测试...\n")
|
||||||
|
setSmtpTestSuccess(null)
|
||||||
|
setSmtpTestError("")
|
||||||
|
|
||||||
|
const res = await AdminService.testSMTP({
|
||||||
|
smtp_host: smtpHost,
|
||||||
|
smtp_port: parseInt(smtpPort, 10) || 587,
|
||||||
|
smtp_username: smtpUsername,
|
||||||
|
smtp_password: smtpPassword,
|
||||||
|
to: smtpTestTo,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setSmtpTestLog(data.log)
|
||||||
|
if (data.success) {
|
||||||
|
setSmtpTestSuccess(true)
|
||||||
|
toast.success("测试邮件发送成功")
|
||||||
|
} else {
|
||||||
|
setSmtpTestSuccess(false)
|
||||||
|
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
|
||||||
|
toast.error("测试邮件发送失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setSmtpTestSuccess(false)
|
||||||
|
setSmtpTestError(error.message || "请求发送失败")
|
||||||
|
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
|
||||||
|
toast.error(error.message || "测试请求发送失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!smtpTestTo) {
|
||||||
|
toast.error("请输入目标邮箱地址")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testSmtpMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Server className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
|
||||||
|
<CardDescription className="text-xs">配置系统的全局通用参数</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSystemSave} className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="server_address" className="text-xs font-semibold">服务器地址</Label>
|
||||||
|
<Input
|
||||||
|
id="server_address"
|
||||||
|
type="text"
|
||||||
|
value={serverAddress}
|
||||||
|
onChange={(e) => setServerAddress(e.target.value)}
|
||||||
|
placeholder="例如: https://example.com"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
这里可以编辑更改服务器地址。默认不设定,允许从任意源(*)访问 API,此时存在跨域安全风险;如果手动设置服务器地址,CORS 允许源将更新为该地址,消除跨域安全隐患。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={saveSystemMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveSystemMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"保存配置"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* SMTP 邮件设置 */}
|
||||||
|
<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">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
|
||||||
|
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSmtpSave} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp_host"
|
||||||
|
type="text"
|
||||||
|
value={smtpHost}
|
||||||
|
onChange={(e) => setSmtpHost(e.target.value)}
|
||||||
|
placeholder="例如: smtp.example.com"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp_port"
|
||||||
|
type="number"
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={(e) => setSmtpPort(e.target.value)}
|
||||||
|
placeholder="例如: 587 或 465"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp_username"
|
||||||
|
type="text"
|
||||||
|
value={smtpUsername}
|
||||||
|
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||||
|
placeholder="例如: sender@example.com"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp_password"
|
||||||
|
type="password"
|
||||||
|
value={smtpPassword}
|
||||||
|
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||||
|
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSmtpTestOpen(true)
|
||||||
|
setSmtpTestTo("")
|
||||||
|
setSmtpTestLog("")
|
||||||
|
setSmtpTestSuccess(null)
|
||||||
|
setSmtpTestError("")
|
||||||
|
}}
|
||||||
|
disabled={saveSmtpMutation.isPending}
|
||||||
|
>
|
||||||
|
测试发件
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={saveSmtpMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveSmtpMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"保存配置"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
|
||||||
|
<DialogContent className="max-w-lg border border-dashed">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSmtpTestSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="smtp_test_to" className="text-xs font-semibold">目标邮箱地址</Label>
|
||||||
|
<Input
|
||||||
|
id="smtp_test_to"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={smtpTestTo}
|
||||||
|
onChange={(e) => setSmtpTestTo(e.target.value)}
|
||||||
|
placeholder="例如: receiver@example.com"
|
||||||
|
className="bg-card border-dashed text-xs"
|
||||||
|
disabled={testSmtpMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{smtpTestLog && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold">连接与传输日志</Label>
|
||||||
|
<pre className="bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed">
|
||||||
|
{smtpTestLog}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{smtpTestSuccess === true && (
|
||||||
|
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
|
||||||
|
测试成功!邮件已顺利发出。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{smtpTestSuccess === false && (
|
||||||
|
<div className="p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all">
|
||||||
|
测试失败:{smtpTestError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSmtpTestOpen(false)}
|
||||||
|
disabled={testSmtpMutation.isPending}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={testSmtpMutation.isPending}
|
||||||
|
>
|
||||||
|
{testSmtpMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
测试中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"开始测试"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { createContext, useContext, useState, useRef, useCallback } from "react"
|
import {createContext, useCallback, useContext, useRef, useState} from "react"
|
||||||
|
|
||||||
|
import type {SystemConfig, UpdateSystemConfigRequest} from "@/lib/services"
|
||||||
import services from "@/lib/services"
|
import services from "@/lib/services"
|
||||||
import type { SystemConfig, UpdateSystemConfigRequest } from "@/lib/services"
|
import {handleContextError} from "@/lib/utils/error-handling"
|
||||||
import { handleContextError } from "@/lib/utils/error-handling"
|
|
||||||
|
|
||||||
|
|
||||||
/** Admin 上下文状态接口 */
|
/** Admin 上下文状态接口 */
|
||||||
@@ -15,7 +15,6 @@ export interface AdminContextState {
|
|||||||
systemConfigsError: Error | null
|
systemConfigsError: Error | null
|
||||||
refetchSystemConfigs: (type?: 'system' | 'business') => Promise<void>
|
refetchSystemConfigs: (type?: 'system' | 'business') => Promise<void>
|
||||||
updateSystemConfig: (key: string, data: UpdateSystemConfigRequest) => Promise<void>
|
updateSystemConfig: (key: string, data: UpdateSystemConfigRequest) => Promise<void>
|
||||||
deleteSystemConfig: (key: string) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminContext = createContext<AdminContextState | null>(null)
|
const AdminContext = createContext<AdminContextState | null>(null)
|
||||||
@@ -23,7 +22,7 @@ const AdminContext = createContext<AdminContextState | null>(null)
|
|||||||
/**
|
/**
|
||||||
* Admin Provider
|
* Admin Provider
|
||||||
* 提供 admin 相关的数据状态管理
|
* 提供 admin 相关的数据状态管理
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <AdminProvider>
|
* <AdminProvider>
|
||||||
@@ -78,19 +77,12 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [refetchSystemConfigs])
|
}, [refetchSystemConfigs])
|
||||||
|
|
||||||
/** 删除系统配置 */
|
|
||||||
const deleteSystemConfig = useCallback(async (key: string) => {
|
|
||||||
await services.admin.deleteSystemConfig(key)
|
|
||||||
await refetchSystemConfigs(lastConfigTypeRef.current)
|
|
||||||
}, [refetchSystemConfigs])
|
|
||||||
|
|
||||||
const value: AdminContextState = {
|
const value: AdminContextState = {
|
||||||
systemConfigs,
|
systemConfigs,
|
||||||
systemConfigsLoading,
|
systemConfigsLoading,
|
||||||
systemConfigsError,
|
systemConfigsError,
|
||||||
refetchSystemConfigs,
|
refetchSystemConfigs,
|
||||||
updateSystemConfig,
|
updateSystemConfig,
|
||||||
deleteSystemConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +94,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 Admin 上下文
|
* 使用 Admin 上下文
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* const { systemConfigs } = useAdmin()
|
* const { systemConfigs } = useAdmin()
|
||||||
|
|||||||
@@ -130,22 +130,7 @@ export class AdminService extends BaseService {
|
|||||||
return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request);
|
return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除系统配置
|
|
||||||
* @param key - 配置键
|
|
||||||
* @returns void
|
|
||||||
* @throws {UnauthorizedError} 当未登录时
|
|
||||||
* @throws {ForbiddenError} 当无管理员权限时
|
|
||||||
* @throws {NotFoundError} 当配置不存在时
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* await AdminService.deleteSystemConfig('app.version');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
static async deleteSystemConfig(key: string): Promise<void> {
|
|
||||||
return this.delete<void>(`/system-configs/${ key }`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 认证源管理 ====================
|
// ==================== 认证源管理 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/util"
|
"github.com/Rain-kl/Wavelet/internal/util"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -217,7 +218,7 @@ func DeleteAuthSource(c *gin.Context) {
|
|||||||
func parseSourceID(c *gin.Context) (uint64, error) {
|
func parseSourceID(c *gin.Context) (uint64, error) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return 0, errors.New("认证源 ID 无效")
|
return 0, errors.New(admin.InvalidAuthSourceID)
|
||||||
}
|
}
|
||||||
source, err := model.GetAuthSourceByName(raw)
|
source, err := model.GetAuthSourceByName(raw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -225,7 +226,7 @@ func parseSourceID(c *gin.Context) (uint64, error) {
|
|||||||
}
|
}
|
||||||
var id uint64
|
var id uint64
|
||||||
if _, scanErr := fmt.Sscanf(raw, "%d", &id); scanErr != nil || id == 0 {
|
if _, scanErr := fmt.Sscanf(raw, "%d", &id); scanErr != nil || id == 0 {
|
||||||
return 0, errors.New("认证源 ID 无效")
|
return 0, errors.New(admin.InvalidAuthSourceID)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@ limitations under the License.
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AdminRequired = "未经授权访问"
|
AdminRequired = "未经授权访问"
|
||||||
|
InvalidAuthSourceID = "认证源 ID 无效"
|
||||||
|
InvalidCursorParam = "无效的 cursor 参数"
|
||||||
|
InvalidTaskExecutionID = "无效的任务执行记录 ID"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||||
"github.com/Rain-kl/Wavelet/internal/logger"
|
"github.com/Rain-kl/Wavelet/internal/logger"
|
||||||
"github.com/Rain-kl/Wavelet/internal/util"
|
"github.com/Rain-kl/Wavelet/internal/util"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -53,7 +54,7 @@ func GetLogs(c *gin.Context) {
|
|||||||
|
|
||||||
var cursor, limit int
|
var cursor, limit int
|
||||||
if _, err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
if _, err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, util.Err("无效的 cursor 参数"))
|
c.JSON(http.StatusBadRequest, util.Err(admin.InvalidCursorParam))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
|
if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
|
||||||
|
|||||||
@@ -227,52 +227,6 @@ func UpdateSystemConfig(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, util.OKNil())
|
c.JSON(http.StatusOK, util.OKNil())
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSystemConfig 删除系统配置
|
|
||||||
// @Summary 删除系统配置
|
|
||||||
// @Description 根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限
|
|
||||||
// @Tags admin
|
|
||||||
// @Produce json
|
|
||||||
// @Security SessionCookie
|
|
||||||
// @Param key path string true "配置键"
|
|
||||||
// @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")
|
|
||||||
|
|
||||||
// 检查配置是否存在
|
|
||||||
var config model.SystemConfig
|
|
||||||
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&config).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
c.JSON(http.StatusNotFound, util.Err(SystemConfigNotFound))
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 删除配置
|
|
||||||
if err := tx.Delete(&config).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Redis.HDel(c.Request.Context(), db.PrefixedKey(model.SystemConfigRedisHashKey), key).Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OKNil())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSMTPRequest 测试 SMTP 配置请求
|
// TestSMTPRequest 测试 SMTP 配置请求
|
||||||
type TestSMTPRequest struct {
|
type TestSMTPRequest struct {
|
||||||
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
|
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
|
|||||||
{
|
{
|
||||||
systemConfigRouter.GET("", GetSystemConfig)
|
systemConfigRouter.GET("", GetSystemConfig)
|
||||||
systemConfigRouter.PUT("", UpdateSystemConfig)
|
systemConfigRouter.PUT("", UpdateSystemConfig)
|
||||||
systemConfigRouter.DELETE("", DeleteSystemConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -263,48 +262,6 @@ func TestUpdateSystemConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteSystemConfig(t *testing.T) {
|
|
||||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTestSMTP(t *testing.T) {
|
func TestTestSMTP(t *testing.T) {
|
||||||
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/internal/task"
|
"github.com/Rain-kl/Wavelet/internal/task"
|
||||||
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
||||||
@@ -156,7 +157,7 @@ func ListTaskExecutions(c *gin.Context) {
|
|||||||
func GetTaskExecution(c *gin.Context) {
|
func GetTaskExecution(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID"))
|
c.JSON(http.StatusBadRequest, util.Err(admin.InvalidTaskExecutionID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ func GetTaskExecution(c *gin.Context) {
|
|||||||
func RetryTask(c *gin.Context) {
|
func RetryTask(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID"))
|
c.JSON(http.StatusBadRequest, util.Err(admin.InvalidTaskExecutionID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 cap
|
||||||
|
|
||||||
|
const (
|
||||||
|
errCapTokenMissing = "验证码验证失败,缺少验证码凭证"
|
||||||
|
errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试"
|
||||||
|
)
|
||||||
@@ -35,13 +35,13 @@ func VerifyMiddleware(mgr *caputil.Manager, scope string, enabledFunc func() boo
|
|||||||
|
|
||||||
token := c.GetHeader("X-Cap-Token")
|
token := c.GetHeader("X-Cap-Token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码验证失败,缺少验证码凭证"))
|
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(errCapTokenMissing))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
|
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败或已过期,请重试"))
|
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(errCapTokenInvalidOrExpired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UserNameKey = "username"
|
UserNameKey = "username"
|
||||||
UserIDKey = "user_id"
|
UserIDKey = "user_id"
|
||||||
UserObjKey = "user_obj"
|
UserObjKey = "user_obj"
|
||||||
|
PendingOAuthSourceIDKey = "pending_oauth_source_id"
|
||||||
|
PendingOAuthExternalIDKey = "pending_oauth_external_id"
|
||||||
|
PendingOAuthExternalUsernameKey = "pending_oauth_external_username"
|
||||||
|
PendingOAuthEmailKey = "pending_oauth_email"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -18,7 +18,16 @@ limitations under the License.
|
|||||||
package oauth
|
package oauth
|
||||||
|
|
||||||
const (
|
const (
|
||||||
InvalidState = "非法登录请求"
|
InvalidState = "非法登录请求"
|
||||||
IDTokenVerifyFailed = "ID Token 验证失败"
|
IDTokenVerifyFailed = "ID Token 验证失败"
|
||||||
NonceMismatch = "nonce 不匹配,可能存在重放攻击"
|
IDTokenVerifyFailedFormat = "%s: %w"
|
||||||
|
NonceMismatch = "nonce 不匹配,可能存在重放攻击"
|
||||||
|
NoActiveAuthSource = "未配置可用认证源"
|
||||||
|
ServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试"
|
||||||
|
AuthSourceRequired = "认证源不能为空"
|
||||||
|
DiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
|
||||||
|
UsernameGenerateFailed = "无法生成可用用户名"
|
||||||
|
UsernameFromSourceFailed = "无法从认证源获取用户名"
|
||||||
|
AuthSourceDisabled = "认证源未启用"
|
||||||
|
InvalidExternalAccountBindingID = "绑定记录 ID 无效"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -267,7 +267,50 @@ func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string)
|
|||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Test Helpers
|
// Test Helpers
|
||||||
// -----------------------------------------------------------------------------
|
func newMockOIDCClient(issuer, clientID, expectedState, sub, username, email, name string) *http.Client {
|
||||||
|
cleanIssuer := strings.TrimRight(issuer, "/")
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &mockRoundTripper{
|
||||||
|
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
||||||
|
urlStr := req.URL.String()
|
||||||
|
if req.Method == http.MethodGet && strings.Contains(urlStr, "/.well-known/openid-configuration") {
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"issuer": %q,
|
||||||
|
"authorization_endpoint": %q,
|
||||||
|
"token_endpoint": %q,
|
||||||
|
"jwks_uri": %q,
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"id_token_signing_alg_values_supported": ["RS256"]
|
||||||
|
}`, cleanIssuer, cleanIssuer+"/oauth2/authorize", cleanIssuer+"/oauth2/token", cleanIssuer+"/oauth2/keys")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodGet && (strings.Contains(urlStr, "/keys") || strings.Contains(urlStr, "/jwks")) {
|
||||||
|
jwksJSON, _ := json.Marshal(testJWKS)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(jwksJSON)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && (strings.Contains(urlStr, "/token") || strings.Contains(urlStr, "/access_token")) {
|
||||||
|
idToken := generateMockIDToken(issuer, sub, clientID, expectedState, username, email, name)
|
||||||
|
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected mock request: %s %s", req.Method, req.URL)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *gorm.DB {
|
func setupTestDB(t *testing.T) *gorm.DB {
|
||||||
dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -593,29 +636,7 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
state := uuid.NewString()
|
state := uuid.NewString()
|
||||||
|
|
||||||
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
||||||
httpMock := &http.Client{
|
httpMock := newMockOIDCClient(testIssuerURL, testClientID, state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||||
Transport: &mockRoundTripper{
|
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
|
||||||
return oidcDiscoveryResponse(), nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodGet && req.URL.String() == testJWKSURL {
|
|
||||||
return jwksResponse(), nil
|
|
||||||
}
|
|
||||||
// Handle Token Exchange
|
|
||||||
if req.Method == http.MethodPost && req.URL.String() == testTokenURL {
|
|
||||||
idToken := generateMockIDToken(testIssuerURL, "88888", testClientID, state, "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
|
||||||
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Header: make(http.Header),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected outgoing request: %s %s", req.Method, req.URL)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
util.SetHTTPClient(httpMock)
|
util.SetHTTPClient(httpMock)
|
||||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||||
|
|
||||||
@@ -679,27 +700,7 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state2)), payloadValue, OAuthStateCacheKeyExpiration)
|
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state2)), payloadValue, OAuthStateCacheKeyExpiration)
|
||||||
|
|
||||||
// Callback with same username but different external ID (99999)
|
// Callback with same username but different external ID (99999)
|
||||||
httpMock2 := &http.Client{
|
httpMock2 := newMockOIDCClient(testIssuerURL, testClientID, state2, "99999", "test_oauth_user", "another@linux.do", "Another User")
|
||||||
Transport: &mockRoundTripper{
|
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
|
||||||
return oidcDiscoveryResponse(), nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodGet && req.URL.String() == testJWKSURL {
|
|
||||||
return jwksResponse(), nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodPost && req.URL.String() == testTokenURL {
|
|
||||||
idToken := generateMockIDToken(testIssuerURL, "99999", testClientID, state2, "test_oauth_user", "another@linux.do", "Another User")
|
|
||||||
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected outgoing request")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
util.SetHTTPClient(httpMock2)
|
util.SetHTTPClient(httpMock2)
|
||||||
|
|
||||||
// Create another router for this mock client
|
// Create another router for this mock client
|
||||||
@@ -740,28 +741,7 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state4)), payloadValue4, OAuthStateCacheKeyExpiration)
|
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state4)), payloadValue4, OAuthStateCacheKeyExpiration)
|
||||||
|
|
||||||
httpMock4 := &http.Client{
|
httpMock4 := newMockOIDCClient(testIssuerURL, testClientID, state4, "77777", "need_bind_user", "needbind@linux.do", "Need Bind User")
|
||||||
Transport: &mockRoundTripper{
|
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
|
||||||
return oidcDiscoveryResponse(), nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodGet && req.URL.String() == testJWKSURL {
|
|
||||||
return jwksResponse(), nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodPost && req.URL.String() == testTokenURL {
|
|
||||||
idToken := generateMockIDToken(testIssuerURL, "77777", testClientID, state4, "need_bind_user", "needbind@linux.do", "Need Bind User")
|
|
||||||
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Header: make(http.Header),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected request")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
util.SetHTTPClient(httpMock4)
|
util.SetHTTPClient(httpMock4)
|
||||||
router4 := setupTestRouter(dbConn, mockRedis, httpMock4)
|
router4 := setupTestRouter(dbConn, mockRedis, httpMock4)
|
||||||
|
|
||||||
@@ -824,47 +804,7 @@ func TestCallbackBind(t *testing.T) {
|
|||||||
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration)
|
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration)
|
||||||
|
|
||||||
// Mock OIDC discovery, JWKS, and Token exchange for custom source (GitHub)
|
// Mock OIDC discovery, JWKS, and Token exchange for custom source (GitHub)
|
||||||
httpMock := &http.Client{
|
httpMock := newMockOIDCClient("https://github.com", "gh_client", state, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester")
|
||||||
Transport: &mockRoundTripper{
|
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
|
||||||
// Handle OIDC Discovery Document
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
|
||||||
body := `{
|
|
||||||
"issuer": "https://github.com",
|
|
||||||
"authorization_endpoint": "https://github.com/login/oauth/authorize",
|
|
||||||
"token_endpoint": "https://github.com/login/oauth/access_token",
|
|
||||||
"jwks_uri": "https://github.com/oauth/keys",
|
|
||||||
"response_types_supported": ["code"],
|
|
||||||
"subject_types_supported": ["public"],
|
|
||||||
"id_token_signing_alg_values_supported": ["RS256"]
|
|
||||||
}`
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// Handle JWKS Key Set Fetch
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/oauth/keys") {
|
|
||||||
jwksJSON, _ := json.Marshal(testJWKS)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewReader(jwksJSON)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// Handle Token Exchange
|
|
||||||
if req.Method == http.MethodPost && strings.Contains(req.URL.String(), "/login/oauth/access_token") {
|
|
||||||
// Generate signed RS256 token matching issuer and aud
|
|
||||||
idToken := generateMockIDToken("https://github.com", "github_user_123", "gh_client", state, "github_tester", "tester@github.com", "GitHub Tester")
|
|
||||||
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","id_token":"%s"}`, idToken)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected request: %s", req.URL)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
util.SetHTTPClient(httpMock)
|
util.SetHTTPClient(httpMock)
|
||||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||||
|
|
||||||
@@ -951,43 +891,7 @@ func TestCallbackBind(t *testing.T) {
|
|||||||
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state3)), payloadValue, OAuthStateCacheKeyExpiration)
|
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state3)), payloadValue, OAuthStateCacheKeyExpiration)
|
||||||
|
|
||||||
// Re-sign token for new state (since state serves as OIDC Nonce)
|
// Re-sign token for new state (since state serves as OIDC Nonce)
|
||||||
httpMock3 := &http.Client{
|
httpMock3 := newMockOIDCClient("https://github.com", "gh_client", state3, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester")
|
||||||
Transport: &mockRoundTripper{
|
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
|
||||||
body := `{
|
|
||||||
"issuer": "https://github.com",
|
|
||||||
"authorization_endpoint": "https://github.com/login/oauth/authorize",
|
|
||||||
"token_endpoint": "https://github.com/login/oauth/access_token",
|
|
||||||
"jwks_uri": "https://github.com/oauth/keys",
|
|
||||||
"response_types_supported": ["code"],
|
|
||||||
"subject_types_supported": ["public"],
|
|
||||||
"id_token_signing_alg_values_supported": ["RS256"]
|
|
||||||
}`
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/oauth/keys") {
|
|
||||||
jwksJSON, _ := json.Marshal(testJWKS)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(bytes.NewReader(jwksJSON)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if req.Method == http.MethodPost && strings.Contains(req.URL.String(), "/login/oauth/access_token") {
|
|
||||||
idToken := generateMockIDToken("https://github.com", "github_user_123", "gh_client", state3, "github_tester", "tester@github.com", "GitHub Tester")
|
|
||||||
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","id_token":"%s"}`, idToken)
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected request")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
util.SetHTTPClient(httpMock3)
|
util.SetHTTPClient(httpMock3)
|
||||||
router3 := setupTestRouter(dbConn, mockRedis, httpMock3)
|
router3 := setupTestRouter(dbConn, mockRedis, httpMock3)
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func resolveAuthSource(sourceName string) (*model.AuthSource, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil, errors.New("未配置可用认证源")
|
return nil, errors.New(NoActiveAuthSource)
|
||||||
}
|
}
|
||||||
return &sources[0], nil
|
return &sources[0], nil
|
||||||
}
|
}
|
||||||
@@ -122,18 +122,18 @@ func activeLoginSources() []AuthSourceView {
|
|||||||
func getFrontendLoginRedirectURL(ctx context.Context) (string, error) {
|
func getFrontendLoginRedirectURL(ctx context.Context) (string, error) {
|
||||||
var sc model.SystemConfig
|
var sc model.SystemConfig
|
||||||
if err := sc.GetByKey(ctx, model.ConfigKeyServerAddress); err != nil || strings.TrimSpace(sc.Value) == "" {
|
if err := sc.GetByKey(ctx, model.ConfigKeyServerAddress); err != nil || strings.TrimSpace(sc.Value) == "" {
|
||||||
return "", errors.New("服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试")
|
return "", errors.New(ServerAddressMissing)
|
||||||
}
|
}
|
||||||
return strings.TrimRight(sc.Value, "/") + "/login", nil
|
return strings.TrimRight(sc.Value, "/") + "/login", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
|
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
|
||||||
if source == nil {
|
if source == nil {
|
||||||
return nil, nil, errors.New("认证源不能为空")
|
return nil, nil, errors.New(AuthSourceRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
if source.OpenIDDiscoveryURL == "" {
|
if source.OpenIDDiscoveryURL == "" {
|
||||||
return nil, nil, errors.New("OIDC 认证源必须配置 Discovery URL")
|
return nil, nil, errors.New(DiscoveryURLRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake)
|
// Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake)
|
||||||
@@ -194,7 +194,7 @@ func uniqueUsername(ctx context.Context, base string) (string, error) {
|
|||||||
}
|
}
|
||||||
candidate = fmt.Sprintf("%s-%d", base, i+1)
|
candidate = fmt.Sprintf("%s-%d", base, i+1)
|
||||||
}
|
}
|
||||||
return "", errors.New("无法生成可用用户名")
|
return "", errors.New(UsernameGenerateFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) {
|
func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) {
|
||||||
@@ -213,7 +213,7 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
|||||||
if rawIDToken, ok := token.Extra("id_token").(string); ok {
|
if rawIDToken, ok := token.Extra("id_token").(string); ok {
|
||||||
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
|
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
|
||||||
if verifyErr != nil {
|
if verifyErr != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
|
return nil, fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr)
|
||||||
}
|
}
|
||||||
if nonce != "" && idToken.Nonce != nonce {
|
if nonce != "" && idToken.Nonce != nonce {
|
||||||
return nil, errors.New(NonceMismatch)
|
return nil, errors.New(NonceMismatch)
|
||||||
@@ -257,7 +257,7 @@ func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
|
|||||||
userInfo.Username = userInfo.Sub
|
userInfo.Username = userInfo.Sub
|
||||||
}
|
}
|
||||||
if userInfo.Username == "" {
|
if userInfo.Username == "" {
|
||||||
return errors.New("无法从认证源获取用户名")
|
return errors.New(UsernameFromSourceFailed)
|
||||||
}
|
}
|
||||||
if userInfo.Name == "" {
|
if userInfo.Name == "" {
|
||||||
userInfo.Name = userInfo.Username
|
userInfo.Name = userInfo.Username
|
||||||
@@ -359,7 +359,7 @@ func Authorize(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !source.IsActive {
|
if !source.IsActive {
|
||||||
c.JSON(http.StatusBadRequest, util.Err("认证源未启用"))
|
c.JSON(http.StatusBadRequest, util.Err(AuthSourceDisabled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
|
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
|
||||||
@@ -490,10 +490,10 @@ func Callback(c *gin.Context) {
|
|||||||
if !registrationEnabled {
|
if !registrationEnabled {
|
||||||
// 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态
|
// 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
session.Set("pending_oauth_source_id", source.ID)
|
session.Set(PendingOAuthSourceIDKey, source.ID)
|
||||||
session.Set("pending_oauth_external_id", userInfo.Sub)
|
session.Set(PendingOAuthExternalIDKey, userInfo.Sub)
|
||||||
session.Set("pending_oauth_external_username", userInfo.Username)
|
session.Set(PendingOAuthExternalUsernameKey, userInfo.Username)
|
||||||
session.Set("pending_oauth_email", userInfo.Email)
|
session.Set(PendingOAuthEmailKey, userInfo.Email)
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -577,7 +577,7 @@ func DeleteExternalAccount(c *gin.Context) {
|
|||||||
rawID := strings.TrimSpace(c.Param("id"))
|
rawID := strings.TrimSpace(c.Param("id"))
|
||||||
id, err := strconv.ParseUint(rawID, 10, 64)
|
id, err := strconv.ParseUint(rawID, 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
c.JSON(http.StatusBadRequest, util.Err("绑定记录 ID 无效"))
|
c.JSON(http.StatusBadRequest, util.Err(InvalidExternalAccountBindingID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := model.DeleteExternalAccountForUser(id, userID); err != nil {
|
if err := model.DeleteExternalAccountForUser(id, userID); err != nil {
|
||||||
|
|||||||
@@ -30,4 +30,23 @@ const (
|
|||||||
ErrInvalidFilePath = "非法文件路径"
|
ErrInvalidFilePath = "非法文件路径"
|
||||||
ErrSaveUploadRecordFailed = "保存上传记录失败"
|
ErrSaveUploadRecordFailed = "保存上传记录失败"
|
||||||
ErrQueryHistoryUploadFailed = "查询历史上传记录失败"
|
ErrQueryHistoryUploadFailed = "查询历史上传记录失败"
|
||||||
|
ErrGenericFileTooLarge = "文件大小不能超过 32MB"
|
||||||
|
ErrFileContentExtensionMismatch = "文件内容与扩展名不匹配,可能包含安全风险"
|
||||||
|
ErrFileValidationFailed = "文件校验失败"
|
||||||
|
ErrInvalidMetadataJSON = "元数据 JSON 格式不合法"
|
||||||
|
ErrInvalidFileID = "无效的文件 ID"
|
||||||
|
ErrQueryUploadRecordFailed = "查询文件记录失败"
|
||||||
|
ErrInvalidBatchDownloadRequest = "参数绑定失败,请传入有效的文件 ID 数组"
|
||||||
|
ErrInvalidIDValueFormat = "无效的 ID 值: %s"
|
||||||
|
ErrRetrieveUploadRecordsFailed = "检索文件记录失败"
|
||||||
|
ErrNoValidFilesForArchive = "没有找到任何有效的文件记录进行打包"
|
||||||
|
ErrInvalidParams = "参数错误"
|
||||||
|
ErrQueryFileCountFailed = "查询文件数量失败"
|
||||||
|
ErrQueryFileListFailed = "查询文件列表失败"
|
||||||
|
ErrDeleteFileFailed = "删除文件失败"
|
||||||
|
ErrS3KeyRequired = "s3 key must not be empty"
|
||||||
|
ErrS3KeyTooLongFormat = "s3 key exceeds maximum length of %d"
|
||||||
|
ErrS3KeyStartsWithSlash = "s3 key must not start with /"
|
||||||
|
ErrS3KeyContainsNullBytes = "s3 key must not contain null bytes"
|
||||||
|
ErrQueryUnusedUploadsFailed = "查询未使用的上传文件失败: %w"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func UploadFile(c *gin.Context) {
|
|||||||
|
|
||||||
// 校验大小
|
// 校验大小
|
||||||
if header.Size > maxUploadSize {
|
if header.Size > maxUploadSize {
|
||||||
c.JSON(http.StatusOK, util.Err("文件大小不能超过 32MB"))
|
c.JSON(http.StatusOK, util.Err(ErrGenericFileTooLarge))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func UploadFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isImageExt && !strings.HasPrefix(mimeType, "image/") {
|
if isImageExt && !strings.HasPrefix(mimeType, "image/") {
|
||||||
c.JSON(http.StatusOK, util.Err("文件内容与扩展名不匹配,可能包含安全风险"))
|
c.JSON(http.StatusOK, util.Err(ErrFileContentExtensionMismatch))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ func UploadFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, util.OK(newUpload))
|
c.JSON(http.StatusOK, util.OK(newUpload))
|
||||||
return
|
return
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusOK, util.Err("文件校验失败"))
|
c.JSON(http.StatusOK, util.Err(ErrFileValidationFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func UploadFile(c *gin.Context) {
|
|||||||
var meta model.UploadMetadata
|
var meta model.UploadMetadata
|
||||||
if metadataStr != "" {
|
if metadataStr != "" {
|
||||||
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
|
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("元数据 JSON 格式不合法"))
|
c.JSON(http.StatusOK, util.Err(ErrInvalidMetadataJSON))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ func DownloadFile(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
|
c.JSON(http.StatusOK, util.Err(ErrInvalidFileID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ func DownloadFile(c *gin.Context) {
|
|||||||
c.AbortWithStatus(http.StatusNotFound)
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
|
c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ func BatchDownloadFiles(c *gin.Context) {
|
|||||||
|
|
||||||
var req batchDownloadRequest
|
var req batchDownloadRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("参数绑定失败,请传入有效的文件 ID 数组"))
|
c.JSON(http.StatusOK, util.Err(ErrInvalidBatchDownloadRequest))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ func BatchDownloadFiles(c *gin.Context) {
|
|||||||
for _, idStr := range req.IDs {
|
for _, idStr := range req.IDs {
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err(fmt.Sprintf("无效的 ID 值: %s", idStr)))
|
c.JSON(http.StatusOK, util.Err(fmt.Sprintf(ErrInvalidIDValueFormat, idStr)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
@@ -357,12 +357,12 @@ func BatchDownloadFiles(c *gin.Context) {
|
|||||||
// 查库获取所有匹配且正常的文件记录
|
// 查库获取所有匹配且正常的文件记录
|
||||||
var uploads []model.Upload
|
var uploads []model.Upload
|
||||||
if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
|
if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("检索文件记录失败"))
|
c.JSON(http.StatusOK, util.Err(ErrRetrieveUploadRecordsFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(uploads) == 0 {
|
if len(uploads) == 0 {
|
||||||
c.JSON(http.StatusOK, util.Err("没有找到任何有效的文件记录进行打包"))
|
c.JSON(http.StatusOK, util.Err(ErrNoValidFilesForArchive))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +458,7 @@ func ListMyFiles(c *gin.Context) {
|
|||||||
|
|
||||||
var req listMyFilesRequest
|
var req listMyFilesRequest
|
||||||
if err := c.ShouldBindQuery(&req); err != nil {
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("参数错误"))
|
c.JSON(http.StatusOK, util.Err(ErrInvalidParams))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Page <= 0 {
|
if req.Page <= 0 {
|
||||||
@@ -483,14 +483,14 @@ func ListMyFiles(c *gin.Context) {
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("查询文件数量失败"))
|
c.JSON(http.StatusOK, util.Err(ErrQueryFileCountFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []model.Upload
|
var items []model.Upload
|
||||||
offset := (req.Page - 1) * req.PageSize
|
offset := (req.Page - 1) * req.PageSize
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("查询文件列表失败"))
|
c.JSON(http.StatusOK, util.Err(ErrQueryFileListFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ func DeleteFile(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
|
c.JSON(http.StatusOK, util.Err(ErrInvalidFileID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ func DeleteFile(c *gin.Context) {
|
|||||||
c.AbortWithStatus(http.StatusNotFound)
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
|
c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ func DeleteFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
|
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("删除文件失败"))
|
c.JSON(http.StatusOK, util.Err(ErrDeleteFileFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byt
|
|||||||
Limit(batchSize).
|
Limit(batchSize).
|
||||||
Find(&unusedUploads).Error; err != nil {
|
Find(&unusedUploads).Error; err != nil {
|
||||||
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
|
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
|
||||||
return nil, fmt.Errorf("查询未使用的上传文件失败: %w", err)
|
return nil, fmt.Errorf(ErrQueryUnusedUploadsFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有更多数据,退出循环
|
// 没有更多数据,退出循环
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ const maxS3KeyLength = 1024
|
|||||||
// ValidateS3Key validates an S3 object key for safety.
|
// ValidateS3Key validates an S3 object key for safety.
|
||||||
func ValidateS3Key(key string) error {
|
func ValidateS3Key(key string) error {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return fmt.Errorf("s3 key must not be empty")
|
return fmt.Errorf(ErrS3KeyRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(key) > maxS3KeyLength {
|
if len(key) > maxS3KeyLength {
|
||||||
return fmt.Errorf("s3 key exceeds maximum length of %d", maxS3KeyLength)
|
return fmt.Errorf(ErrS3KeyTooLongFormat, maxS3KeyLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(key, "/") {
|
if strings.HasPrefix(key, "/") {
|
||||||
return fmt.Errorf("s3 key must not start with /")
|
return fmt.Errorf(ErrS3KeyStartsWithSlash)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(key, "\x00") {
|
if strings.Contains(key, "\x00") {
|
||||||
return fmt.Errorf("s3 key must not contain null bytes")
|
return fmt.Errorf(ErrS3KeyContainsNullBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ func CreateAccessToken(c *gin.Context) {
|
|||||||
|
|
||||||
var req createTokenRequest
|
var req createTokenRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("参数绑定失败"))
|
c.JSON(http.StatusOK, util.Err(errBindParamsFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("令牌名称不能为空"))
|
c.JSON(http.StatusOK, util.Err(errTokenNameRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +101,14 @@ func CreateAccessToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if int(count) >= maxLimit {
|
if int(count) >= maxLimit {
|
||||||
c.JSON(http.StatusOK, util.Err("已达到访问令牌最大创建数量限制"))
|
c.JSON(http.StatusOK, util.Err(errAccessTokenLimitReached))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 Token
|
// 生成 Token
|
||||||
tokenStr, err := model.GenerateTokenString()
|
tokenStr, err := model.GenerateTokenString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
|
c.JSON(http.StatusOK, util.Err(errGenerateTokenFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ func DeleteAccessToken(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
|
c.JSON(http.StatusOK, util.Err(errInvalidTokenID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ func DeleteAccessToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tx.RowsAffected == 0 {
|
if tx.RowsAffected == 0 {
|
||||||
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
|
c.JSON(http.StatusOK, util.Err(errTokenNotFoundOrForbidden))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,20 +185,20 @@ func RotateAccessToken(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
|
c.JSON(http.StatusOK, util.Err(errInvalidTokenID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenRecord model.AccessToken
|
var tokenRecord model.AccessToken
|
||||||
if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil {
|
if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
|
c.JSON(http.StatusOK, util.Err(errTokenNotFoundOrForbidden))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的 Token
|
// 生成新的 Token
|
||||||
newTokenStr, err := model.GenerateTokenString()
|
newTokenStr, err := model.GenerateTokenString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
|
c.JSON(http.StatusOK, util.Err(errGenerateTokenFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-118
@@ -77,6 +77,64 @@ func generateVerificationCode() string {
|
|||||||
return fmt.Sprintf("%06d", n.Int64()+100000)
|
return fmt.Sprintf("%06d", n.Int64()+100000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEmailCodeKey(scene, email string) string {
|
||||||
|
return fmt.Sprintf("email_code:%s:%s", scene, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmailCooldownKey(email string) string {
|
||||||
|
return fmt.Sprintf("email_code:cooldown:%s", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
|
||||||
|
code := generateVerificationCode()
|
||||||
|
codeKey := getEmailCodeKey(scene, email)
|
||||||
|
cooldownKey := getEmailCooldownKey(email)
|
||||||
|
|
||||||
|
// 使用模板管理获取并渲染邮件标题和正文。模板缺失或渲染失败时不发送验证码。
|
||||||
|
emailSubject, emailBody, err := model.RenderTemplate(
|
||||||
|
ctx,
|
||||||
|
templateName,
|
||||||
|
map[string]any{"Code": code},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(errRenderEmailTemplateFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存验证码,5分钟有效
|
||||||
|
if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil {
|
||||||
|
return fmt.Errorf(errGenerateEmailCodeFailed)
|
||||||
|
}
|
||||||
|
// 存冷却,60秒有效
|
||||||
|
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
||||||
|
|
||||||
|
// 构建异步邮件发送任务
|
||||||
|
payload := SendEmailPayload{
|
||||||
|
To: email,
|
||||||
|
Subject: emailSubject,
|
||||||
|
Body: emailBody,
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(errDispatchEmailTaskFailed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
|
||||||
|
codeKey := getEmailCodeKey(scene, email)
|
||||||
|
var storedCode string
|
||||||
|
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if storedCode != code {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 验证成功,删除验证码
|
||||||
|
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func isPasswordLoginEnabled() bool {
|
func isPasswordLoginEnabled() bool {
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +182,7 @@ func setLoginSession(c *gin.Context, user *model.User) error {
|
|||||||
// @Router /api/v1/user/login [post]
|
// @Router /api/v1/user/login [post]
|
||||||
func Login(c *gin.Context) {
|
func Login(c *gin.Context) {
|
||||||
if !isPasswordLoginEnabled() {
|
if !isPasswordLoginEnabled() {
|
||||||
c.JSON(http.StatusOK, util.Err("管理员关闭了密码登录"))
|
c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
@@ -134,14 +192,14 @@ func Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的参数"))
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
|
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
|
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !user.IsActive {
|
if !user.IsActive {
|
||||||
@@ -150,79 +208,43 @@ func Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 判定是否是明文密码存储
|
// 判定是否是明文密码存储
|
||||||
isPlaintext := !(strings.HasPrefix(user.Password, "$2a$") || strings.HasPrefix(user.Password, "$2b$") || strings.HasPrefix(user.Password, "$2y$"))
|
isPlaintext := !user.IsPasswordEncrypted()
|
||||||
|
|
||||||
if !user.CheckPassword(req.Password) {
|
if !user.CheckPassword(req.Password) {
|
||||||
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
|
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEmailLoginVerificationEnabled() {
|
if isEmailLoginVerificationEnabled() {
|
||||||
if user.Email == "" {
|
if user.Email == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("该账号未绑定邮箱,请联系管理员绑定邮箱后再登录"))
|
c.JSON(http.StatusOK, util.Err(errLoginEmailMissing))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Code == "" {
|
if req.Code == "" {
|
||||||
// 校验 Redis 发送冷却时间
|
// 校验 Redis 发送冷却时间
|
||||||
cooldownKey := fmt.Sprintf("email_code:cooldown:%s", user.Email)
|
cooldownKey := getEmailCooldownKey(user.Email)
|
||||||
var temp string
|
var temp string
|
||||||
err := db.GetJSON(ctx, cooldownKey, &temp)
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 没有冷却,触发验证码发送
|
// 没有冷却,触发验证码发送
|
||||||
code := generateVerificationCode()
|
if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil {
|
||||||
codeKey := fmt.Sprintf("email_code:login:%s", user.Email)
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
// 存验证码,5分钟有效
|
|
||||||
if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("生成验证码失败,请重试"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 存冷却,60秒有效
|
|
||||||
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
|
||||||
|
|
||||||
// 使用模板管理获取并渲染邮件标题和正文
|
|
||||||
emailSubject, emailBody := model.RenderTemplate(
|
|
||||||
ctx,
|
|
||||||
"login_email",
|
|
||||||
map[string]any{"Code": code},
|
|
||||||
"Wavelet 登录验证码",
|
|
||||||
fmt.Sprintf("<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>%s</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>", code),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 构建异步邮件发送任务
|
|
||||||
payload := SendEmailPayload{
|
|
||||||
To: user.Email,
|
|
||||||
Subject: emailSubject,
|
|
||||||
Body: emailBody,
|
|
||||||
}
|
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
|
||||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 脱敏邮箱并返回错误,提示前端需要输入验证码
|
// 脱敏邮箱并返回错误,提示前端需要输入验证码
|
||||||
maskedEmail := util.MaskEmail(user.Email)
|
maskedEmail := util.MaskEmail(user.Email)
|
||||||
c.JSON(http.StatusOK, util.Err("need_email_code:"+maskedEmail))
|
c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验验证码
|
// 校验验证码
|
||||||
codeKey := fmt.Sprintf("email_code:login:%s", user.Email)
|
if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
|
||||||
var storedCode string
|
c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
|
||||||
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("验证码错误或已过期"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if storedCode != req.Code {
|
|
||||||
c.JSON(http.StatusOK, util.Err("验证码错误或已过期"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证成功,删除验证码
|
|
||||||
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
@@ -232,7 +254,7 @@ func Login(c *gin.Context) {
|
|||||||
if isPlaintext {
|
if isPlaintext {
|
||||||
if err := user.SetEncryptedPassword(req.Password); err == nil {
|
if err := user.SetEncryptedPassword(req.Password); err == nil {
|
||||||
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
|
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("升级密码安全算法失败,请重试"))
|
c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
needChangePassword = true
|
needChangePassword = true
|
||||||
@@ -247,15 +269,15 @@ func Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := setLoginSession(c, &user); err != nil {
|
if err := setLoginSession(c, &user); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
|
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有未完成的 OAuth/OIDC 绑定
|
// 检查是否有未完成 of OAuth/OIDC 绑定
|
||||||
pendingSourceID := session.Get("pending_oauth_source_id")
|
pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
|
||||||
pendingExternalID := session.Get("pending_oauth_external_id")
|
pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
|
||||||
pendingExternalUsername := session.Get("pending_oauth_external_username")
|
pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
|
||||||
pendingEmail := session.Get("pending_oauth_email")
|
pendingEmail := session.Get(oauth.PendingOAuthEmailKey)
|
||||||
|
|
||||||
if pendingSourceID != nil && pendingExternalID != nil {
|
if pendingSourceID != nil && pendingExternalID != nil {
|
||||||
var sourceID uint64
|
var sourceID uint64
|
||||||
@@ -281,10 +303,10 @@ func Login(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 清除 pending 信息
|
// 清除 pending 信息
|
||||||
session.Delete("pending_oauth_source_id")
|
session.Delete(oauth.PendingOAuthSourceIDKey)
|
||||||
session.Delete("pending_oauth_external_id")
|
session.Delete(oauth.PendingOAuthExternalIDKey)
|
||||||
session.Delete("pending_oauth_external_username")
|
session.Delete(oauth.PendingOAuthExternalUsernameKey)
|
||||||
session.Delete("pending_oauth_email")
|
session.Delete(oauth.PendingOAuthEmailKey)
|
||||||
_ = session.Save()
|
_ = session.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +326,7 @@ func Login(c *gin.Context) {
|
|||||||
// @Router /api/v1/user/register [post]
|
// @Router /api/v1/user/register [post]
|
||||||
func Register(c *gin.Context) {
|
func Register(c *gin.Context) {
|
||||||
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
|
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
|
||||||
c.JSON(http.StatusOK, util.Err("管理员关闭了注册"))
|
c.JSON(http.StatusOK, util.Err(errRegistrationDisabled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,11 +344,11 @@ func Register(c *gin.Context) {
|
|||||||
req.Code = strings.TrimSpace(req.Code)
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的参数"))
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
c.JSON(http.StatusOK, util.Err("密码长度不能少于 8 位"))
|
c.JSON(http.StatusOK, util.Err(errPasswordTooShort))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,23 +357,14 @@ func Register(c *gin.Context) {
|
|||||||
// 邮箱注册验证校验
|
// 邮箱注册验证校验
|
||||||
if isEmailRegisterVerificationEnabled() {
|
if isEmailRegisterVerificationEnabled() {
|
||||||
if req.Email == "" || req.Code == "" {
|
if req.Email == "" || req.Code == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("邮箱或验证码未填写"))
|
c.JSON(http.StatusOK, util.Err(errEmailOrCodeRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
codeKey := fmt.Sprintf("email_code:register:%s", req.Email)
|
if !verifyEmailCode(ctx, req.Email, "register", req.Code) {
|
||||||
var storedCode string
|
c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
|
||||||
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("验证码错误或已过期"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if storedCode != req.Code {
|
|
||||||
c.JSON(http.StatusOK, util.Err("验证码错误或已过期"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证通过,删除 Redis 中的验证码
|
|
||||||
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -380,7 +393,7 @@ func Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := setLoginSession(c, &user); err != nil {
|
if err := setLoginSession(c, &user); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
|
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,36 +447,36 @@ func ChangePassword(c *gin.Context) {
|
|||||||
req.NewPassword = strings.TrimSpace(req.NewPassword)
|
req.NewPassword = strings.TrimSpace(req.NewPassword)
|
||||||
|
|
||||||
if req.OldPassword == "" || req.NewPassword == "" {
|
if req.OldPassword == "" || req.NewPassword == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的参数"))
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < 8 {
|
if len(req.NewPassword) < 8 {
|
||||||
c.JSON(http.StatusOK, util.Err("新密码长度不能少于 8 位"))
|
c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
if userObj == nil {
|
if userObj == nil {
|
||||||
c.JSON(http.StatusUnauthorized, util.Err("请先登录"))
|
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
var dbUser model.User
|
var dbUser model.User
|
||||||
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("未找到该用户"))
|
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验旧密码
|
// 校验旧密码
|
||||||
if !dbUser.CheckPassword(req.OldPassword) {
|
if !dbUser.CheckPassword(req.OldPassword) {
|
||||||
c.JSON(http.StatusOK, util.Err("原密码不正确"))
|
c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密并更新为新密码
|
// 加密并更新为新密码
|
||||||
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
|
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("密码加密失败,请重试"))
|
c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,12 +512,12 @@ func SendEmailCode(c *gin.Context) {
|
|||||||
|
|
||||||
req.Email = strings.TrimSpace(req.Email)
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
if req.Email == "" {
|
if req.Email == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("邮箱地址不能为空"))
|
c.JSON(http.StatusOK, util.Err(errEmailRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Scene != "register" {
|
if req.Scene != "register" {
|
||||||
c.JSON(http.StatusOK, util.Err("不支持的验证场景"))
|
c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,47 +530,22 @@ func SendEmailCode(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
c.JSON(http.StatusOK, util.Err("该邮箱已被注册"))
|
c.JSON(http.StatusOK, util.Err(errEmailAlreadyRegistered))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 校验 Redis 发送冷却时间
|
// 2. 校验 Redis 发送冷却时间
|
||||||
cooldownKey := fmt.Sprintf("email_code:cooldown:%s", req.Email)
|
cooldownKey := getEmailCooldownKey(req.Email)
|
||||||
var temp string
|
var temp string
|
||||||
err := db.GetJSON(ctx, cooldownKey, &temp)
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.JSON(http.StatusOK, util.Err("验证码发送频繁,请稍后再试"))
|
c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 生成并缓存验证码
|
// 3. 发送验证码
|
||||||
code := generateVerificationCode()
|
if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
|
||||||
codeKey := fmt.Sprintf("email_code:register:%s", req.Email)
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("生成验证码失败,请重试"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second)
|
|
||||||
|
|
||||||
// 使用模板管理获取并渲染邮件标题和正文
|
|
||||||
emailSubject, emailBody := model.RenderTemplate(
|
|
||||||
ctx,
|
|
||||||
"register_email",
|
|
||||||
map[string]any{"Code": code},
|
|
||||||
"Wavelet 注册验证码",
|
|
||||||
fmt.Sprintf("<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>%s</strong>,5分钟内有效,请勿泄露给他人。</p>", code),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 4. 投递异步邮件发送任务
|
|
||||||
payload := SendEmailPayload{
|
|
||||||
To: req.Email,
|
|
||||||
Subject: emailSubject,
|
|
||||||
Body: emailBody,
|
|
||||||
}
|
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
|
||||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,14 +583,14 @@ func UpdateProfile(c *gin.Context) {
|
|||||||
|
|
||||||
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
if userObj == nil {
|
if userObj == nil {
|
||||||
c.JSON(http.StatusUnauthorized, util.Err("请先登录"))
|
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
var dbUser model.User
|
var dbUser model.User
|
||||||
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, util.Err("未找到该用户"))
|
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +598,7 @@ func UpdateProfile(c *gin.Context) {
|
|||||||
req.Email = strings.TrimSpace(req.Email)
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
if req.Email != "" && req.Email != dbUser.Email {
|
if req.Email != "" && req.Email != dbUser.Email {
|
||||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||||
c.JSON(http.StatusOK, util.Err("邮箱格式不正确"))
|
c.JSON(http.StatusOK, util.Err(errEmailFormatInvalid))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +608,7 @@ func UpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
c.JSON(http.StatusOK, util.Err("该邮箱已被其他账号绑定"))
|
c.JSON(http.StatusOK, util.Err(errEmailAlreadyBound))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,42 @@ limitations under the License.
|
|||||||
|
|
||||||
package user
|
package user
|
||||||
|
|
||||||
const ()
|
const (
|
||||||
|
errBindParamsFailed = "参数绑定失败"
|
||||||
|
errInvalidParams = "无效的参数"
|
||||||
|
errPasswordLoginDisabled = "管理员关闭了密码登录"
|
||||||
|
errUsernameOrPasswordWrong = "用户名或密码错误"
|
||||||
|
errLoginEmailMissing = "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录"
|
||||||
|
errNeedEmailCodePrefix = "need_email_code:"
|
||||||
|
errEmailCodeInvalidOrExpired = "验证码错误或已过期"
|
||||||
|
errPasswordUpgradeFailed = "升级密码安全算法失败,请重试"
|
||||||
|
errSaveSessionFailed = "无法保存会话信息,请重试"
|
||||||
|
errRegistrationDisabled = "管理员关闭了注册"
|
||||||
|
errPasswordTooShort = "密码长度不能少于 8 位"
|
||||||
|
errEmailOrCodeRequired = "邮箱或验证码未填写"
|
||||||
|
errNewPasswordTooShort = "新密码长度不能少于 8 位"
|
||||||
|
errLoginRequired = "请先登录"
|
||||||
|
errUserNotFound = "未找到该用户"
|
||||||
|
errOldPasswordIncorrect = "原密码不正确"
|
||||||
|
errPasswordEncryptFailed = "密码加密失败,请重试"
|
||||||
|
errEmailRequired = "邮箱地址不能为空"
|
||||||
|
errUnsupportedEmailScene = "不支持的验证场景"
|
||||||
|
errEmailAlreadyRegistered = "该邮箱已被注册"
|
||||||
|
errEmailCodeCooldown = "验证码发送频繁,请稍后再试"
|
||||||
|
errEmailFormatInvalid = "邮箱格式不正确"
|
||||||
|
errEmailAlreadyBound = "该邮箱已被其他账号绑定"
|
||||||
|
errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w"
|
||||||
|
errGenerateEmailCodeFailed = "生成验证码失败,请重试"
|
||||||
|
errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试"
|
||||||
|
errTokenNameRequired = "令牌名称不能为空"
|
||||||
|
errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制"
|
||||||
|
errGenerateTokenFailed = "生成令牌失败"
|
||||||
|
errInvalidTokenID = "无效的令牌ID"
|
||||||
|
errTokenNotFoundOrForbidden = "令牌不存在或无权操作"
|
||||||
|
errTaskPayloadRequired = "任务参数不能为空"
|
||||||
|
errInvalidJSONFormat = "无效的 JSON 格式: %w"
|
||||||
|
errEmailTaskFieldsRequired = "to、subject、body 不能为空"
|
||||||
|
errParseEmailPayloadFailed = "解析邮件发送参数失败: %w"
|
||||||
|
errSMTPConfigIncomplete = "系统 SMTP 邮件服务配置不完整"
|
||||||
|
errSendMailFailed = "发送邮件失败: %w"
|
||||||
|
)
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ type SendEmailHandler struct{}
|
|||||||
// 校验并标准化邮件发送参数,框架在 Admin 下发时自动调用
|
// 校验并标准化邮件发送参数,框架在 Admin 下发时自动调用
|
||||||
func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return nil, errors.New("任务参数不能为空")
|
return nil, errors.New(errTaskPayloadRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
var req SendEmailPayload
|
var req SendEmailPayload
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
|
return nil, fmt.Errorf(errInvalidJSONFormat, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.To = strings.TrimSpace(req.To)
|
req.To = strings.TrimSpace(req.To)
|
||||||
@@ -57,7 +57,7 @@ func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|||||||
req.Body = strings.TrimSpace(req.Body)
|
req.Body = strings.TrimSpace(req.Body)
|
||||||
|
|
||||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||||
return nil, errors.New("to、subject、body 不能为空")
|
return nil, errors.New(errEmailTaskFieldsRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(req)
|
return json.Marshal(req)
|
||||||
@@ -68,7 +68,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
|
|||||||
var req SendEmailPayload
|
var req SendEmailPayload
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
task.AppendLog(ctx, "解析邮件发送参数失败: %v", err)
|
task.AppendLog(ctx, "解析邮件发送参数失败: %v", err)
|
||||||
return nil, fmt.Errorf("解析邮件发送参数失败: %w", err)
|
return nil, fmt.Errorf(errParseEmailPayloadFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
task.AppendLog(ctx, "开始准备发送邮件到: %s, 主题: %s", req.To, req.Subject)
|
task.AppendLog(ctx, "开始准备发送邮件到: %s, 主题: %s", req.To, req.Subject)
|
||||||
@@ -94,7 +94,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" {
|
if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" {
|
||||||
err := errors.New("系统 SMTP 邮件服务配置不完整")
|
err := errors.New(errSMTPConfigIncomplete)
|
||||||
task.AppendLog(ctx, "发送失败: %v", err)
|
task.AppendLog(ctx, "发送失败: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
|
|||||||
err = mail.SendMailHTML(cfg, req.To, req.Subject, req.Body)
|
err = mail.SendMailHTML(cfg, req.To, req.Subject, req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.AppendLog(ctx, "邮件发送失败: %v", err)
|
task.AppendLog(ctx, "邮件发送失败: %v", err)
|
||||||
return nil, fmt.Errorf("发送邮件失败: %w", err)
|
return nil, fmt.Errorf(errSendMailFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
|
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 db
|
||||||
|
|
||||||
|
const (
|
||||||
|
errRedisHashSetFailed = "failed to set redis hash: %w"
|
||||||
|
errUnmarshalDataFailed = "failed to unmarshal data: %w"
|
||||||
|
errMarshalDataFailed = "failed to marshal data: %w"
|
||||||
|
errRedisKeySetFailed = "failed to set redis key: %w"
|
||||||
|
)
|
||||||
@@ -123,7 +123,7 @@ func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil {
|
if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil {
|
||||||
return fmt.Errorf("failed to set redis hash: %w", err)
|
return fmt.Errorf(errRedisHashSetFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -141,7 +141,7 @@ func HGetJSON[T any](ctx context.Context, hashKey, fieldKey string, data *T) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(val), data); err != nil {
|
if err := json.Unmarshal([]byte(val), data); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal data: %w", err)
|
return fmt.Errorf(errUnmarshalDataFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -158,7 +158,7 @@ func GetJSON[T any](ctx context.Context, key string, data *T) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(val, data); err != nil {
|
if err := json.Unmarshal(val, data); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal data: %w", err)
|
return fmt.Errorf(errUnmarshalDataFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -172,11 +172,11 @@ func GetJSON[T any](ctx context.Context, key string, data *T) error {
|
|||||||
func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error {
|
func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error {
|
||||||
jsonData, err := json.Marshal(data)
|
jsonData, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal data: %w", err)
|
return fmt.Errorf(errMarshalDataFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil {
|
if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil {
|
||||||
return fmt.Errorf("failed to set redis key: %w", err)
|
return fmt.Errorf(errRedisKeySetFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 logger
|
||||||
|
|
||||||
|
const (
|
||||||
|
errCreateLogFileDirFailed = "[Logger] create log file dir err: %w"
|
||||||
|
)
|
||||||
@@ -55,7 +55,7 @@ func initWriter() (zapcore.WriteSyncer, error) {
|
|||||||
logPath := logConfig.FilePath
|
logPath := logConfig.FilePath
|
||||||
logDir := filepath.Dir(logPath)
|
logDir := filepath.Dir(logPath)
|
||||||
if err := os.MkdirAll(logDir, 0750); err != nil {
|
if err := os.MkdirAll(logDir, 0750); err != nil {
|
||||||
return nil, fmt.Errorf("[Logger] create log file dir err: %w", err)
|
return nil, fmt.Errorf(errCreateLogFileDirFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置日志轮转
|
// 配置日志轮转
|
||||||
|
|||||||
@@ -91,19 +91,19 @@ func (source *AuthSource) Normalize() {
|
|||||||
func (source *AuthSource) Validate() error {
|
func (source *AuthSource) Validate() error {
|
||||||
source.Normalize()
|
source.Normalize()
|
||||||
if source.Name == "" {
|
if source.Name == "" {
|
||||||
return errors.New("认证源名称不能为空")
|
return errors.New(errAuthSourceNameRequired)
|
||||||
}
|
}
|
||||||
if !authSourceNamePattern.MatchString(source.Name) {
|
if !authSourceNamePattern.MatchString(source.Name) {
|
||||||
return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头")
|
return errors.New(errAuthSourceNameInvalid)
|
||||||
}
|
}
|
||||||
if source.Type != AuthSourceTypeOIDC {
|
if source.Type != AuthSourceTypeOIDC {
|
||||||
return errors.New("认证源类型仅支持 oidc")
|
return errors.New(errAuthSourceTypeUnsupported)
|
||||||
}
|
}
|
||||||
if source.OpenIDDiscoveryURL == "" {
|
if source.OpenIDDiscoveryURL == "" {
|
||||||
return errors.New("OIDC 认证源必须配置 Discovery URL")
|
return errors.New(errAuthSourceDiscoveryURLRequired)
|
||||||
}
|
}
|
||||||
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
|
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
|
||||||
return errors.New("启用认证源前必须配置 Client ID 和 Client Secret")
|
return errors.New(errAuthSourceClientCredentialsRequired)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func GetActiveAuthSources() ([]AuthSource, error) {
|
|||||||
|
|
||||||
func GetAuthSourceByID(id uint64) (*AuthSource, error) {
|
func GetAuthSourceByID(id uint64) (*AuthSource, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, errors.New("认证源 ID 不能为空")
|
return nil, errors.New(errAuthSourceIDRequired)
|
||||||
}
|
}
|
||||||
var source AuthSource
|
var source AuthSource
|
||||||
if err := db.DB(context.Background()).First(&source, "id = ?", id).Error; err != nil {
|
if err := db.DB(context.Background()).First(&source, "id = ?", id).Error; err != nil {
|
||||||
@@ -150,7 +150,7 @@ func GetAuthSourceByID(id uint64) (*AuthSource, error) {
|
|||||||
func GetAuthSourceByName(name string) (*AuthSource, error) {
|
func GetAuthSourceByName(name string) (*AuthSource, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, errors.New("认证源名称不能为空")
|
return nil, errors.New(errAuthSourceNameRequired)
|
||||||
}
|
}
|
||||||
var source AuthSource
|
var source AuthSource
|
||||||
if err := db.DB(context.Background()).First(&source, "name = ?", name).Error; err != nil {
|
if err := db.DB(context.Background()).First(&source, "name = ?", name).Error; err != nil {
|
||||||
@@ -169,7 +169,7 @@ func CreateAuthSource(source *AuthSource) error {
|
|||||||
|
|
||||||
func UpdateAuthSource(source *AuthSource, keepSecret bool) error {
|
func UpdateAuthSource(source *AuthSource, keepSecret bool) error {
|
||||||
if source.ID == 0 {
|
if source.ID == 0 {
|
||||||
return errors.New("认证源 ID 不能为空")
|
return errors.New(errAuthSourceIDRequired)
|
||||||
}
|
}
|
||||||
var current AuthSource
|
var current AuthSource
|
||||||
if err := db.DB(context.Background()).First(¤t, "id = ?", source.ID).Error; err != nil {
|
if err := db.DB(context.Background()).First(¤t, "id = ?", source.ID).Error; err != nil {
|
||||||
@@ -208,7 +208,7 @@ func ToggleAuthSource(id uint64, isActive bool) error {
|
|||||||
|
|
||||||
func DeleteAuthSource(id uint64) error {
|
func DeleteAuthSource(id uint64) error {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return errors.New("认证源 ID 不能为空")
|
return errors.New(errAuthSourceIDRequired)
|
||||||
}
|
}
|
||||||
return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error {
|
return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil {
|
if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil {
|
||||||
@@ -228,7 +228,7 @@ func FindExternalAccount(sourceID uint64, externalID string) (*ExternalAccount,
|
|||||||
|
|
||||||
func BindExternalAccount(account *ExternalAccount) error {
|
func BindExternalAccount(account *ExternalAccount) error {
|
||||||
if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" {
|
if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" {
|
||||||
return errors.New("外部账号绑定信息不完整")
|
return errors.New(errExternalAccountBindingIncomplete)
|
||||||
}
|
}
|
||||||
account.ExternalID = strings.TrimSpace(account.ExternalID)
|
account.ExternalID = strings.TrimSpace(account.ExternalID)
|
||||||
account.ExternalUsername = strings.TrimSpace(account.ExternalUsername)
|
account.ExternalUsername = strings.TrimSpace(account.ExternalUsername)
|
||||||
@@ -239,7 +239,7 @@ func BindExternalAccount(account *ExternalAccount) error {
|
|||||||
err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error
|
err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if current.UserID != account.UserID {
|
if current.UserID != account.UserID {
|
||||||
return errors.New("该外部账号已绑定到其他用户")
|
return errors.New(errExternalAccountAlreadyBoundToAnother)
|
||||||
}
|
}
|
||||||
return tx.Model(¤t).Updates(map[string]any{
|
return tx.Model(¤t).Updates(map[string]any{
|
||||||
"external_username": account.ExternalUsername,
|
"external_username": account.ExternalUsername,
|
||||||
@@ -255,7 +255,7 @@ func BindExternalAccount(account *ExternalAccount) error {
|
|||||||
|
|
||||||
func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) {
|
func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) {
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
return nil, errors.New("用户 ID 不能为空")
|
return nil, errors.New(errUserIDRequired)
|
||||||
}
|
}
|
||||||
var accounts []ExternalAccount
|
var accounts []ExternalAccount
|
||||||
if err := db.DB(context.Background()).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
|
if err := db.DB(context.Background()).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
|
||||||
@@ -296,7 +296,7 @@ func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error)
|
|||||||
|
|
||||||
func DeleteExternalAccountForUser(id uint64, userID uint64) error {
|
func DeleteExternalAccountForUser(id uint64, userID uint64) error {
|
||||||
if id == 0 || userID == 0 {
|
if id == 0 || userID == 0 {
|
||||||
return errors.New("绑定记录 ID 不能为空")
|
return errors.New(errExternalAccountBindingIDRequired)
|
||||||
}
|
}
|
||||||
return db.DB(context.Background()).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error
|
return db.DB(context.Background()).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 model
|
||||||
|
|
||||||
|
const (
|
||||||
|
errRegistrationDisabled = "注册已关闭"
|
||||||
|
errDatabaseNotInitialized = "database not initialized"
|
||||||
|
errUsernameExists = "用户名已存在"
|
||||||
|
errEmailAlreadyBound = "该邮箱已被其他账号绑定"
|
||||||
|
errConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w"
|
||||||
|
errConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w"
|
||||||
|
errConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w"
|
||||||
|
errParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w"
|
||||||
|
errTemplateKeyRequired = "模板标识符不能为空"
|
||||||
|
errTemplateNameRequired = "模板名称不能为空"
|
||||||
|
errTemplateContentRequired = "模板内容不能为空"
|
||||||
|
errTemplateUnavailable = "模板 %s 不存在或不可用: %w"
|
||||||
|
errTemplateRenderFailed = "模板 %s 渲染失败: %w"
|
||||||
|
errAuthSourceNameRequired = "认证源名称不能为空"
|
||||||
|
errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头"
|
||||||
|
errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc"
|
||||||
|
errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
|
||||||
|
errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret"
|
||||||
|
errAuthSourceIDRequired = "认证源 ID 不能为空"
|
||||||
|
errExternalAccountBindingIncomplete = "外部账号绑定信息不完整"
|
||||||
|
errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户"
|
||||||
|
errUserIDRequired = "用户 ID 不能为空"
|
||||||
|
errExternalAccountBindingIDRequired = "绑定记录 ID 不能为空"
|
||||||
|
)
|
||||||
@@ -85,7 +85,7 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
|
|||||||
// 查数据库
|
// 查数据库
|
||||||
database := db.DB(ctx)
|
database := db.DB(ctx)
|
||||||
if database == nil {
|
if database == nil {
|
||||||
return errors.New("database not initialized")
|
return errors.New(errDatabaseNotInitialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.Where("key = ?", key).First(sc).Error; err != nil {
|
if err := database.Where("key = ?", key).First(sc).Error; err != nil {
|
||||||
@@ -109,7 +109,7 @@ func GetIntByKey(ctx context.Context, key string) (int, error) {
|
|||||||
|
|
||||||
value, err := strconv.Atoi(sc.Value)
|
value, err := strconv.Atoi(sc.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("配置 %s 的值 '%s' 无法转换为整数: %w", key, sc.Value, err)
|
return 0, fmt.Errorf(errConfigIntParseFailed, key, sc.Value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value, nil
|
return value, nil
|
||||||
@@ -125,7 +125,7 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.
|
|||||||
|
|
||||||
value, err := decimal.NewFromString(sc.Value)
|
value, err := decimal.NewFromString(sc.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return decimal.Zero, fmt.Errorf("配置 %s 的值 '%s' 无法转换为decimal: %w", key, sc.Value, err)
|
return decimal.Zero, fmt.Errorf(errConfigDecimalParseFailed, key, sc.Value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 裁剪到指定小数位数
|
// 裁剪到指定小数位数
|
||||||
@@ -141,7 +141,7 @@ func GetBoolByKey(ctx context.Context, key string) (bool, error) {
|
|||||||
|
|
||||||
value, err := strconv.ParseBool(sc.Value)
|
value, err := strconv.ParseBool(sc.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("配置 %s 的值 '%s' 无法转换为布尔值: %w", key, sc.Value, err)
|
return false, fmt.Errorf(errConfigBoolParseFailed, key, sc.Value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value, nil
|
return value, nil
|
||||||
@@ -160,7 +160,7 @@ func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(sc.Value), &config); err != nil {
|
if err := json.Unmarshal([]byte(sc.Value), &config); err != nil {
|
||||||
return nil, fmt.Errorf("解析目录显示配置失败: %w", err)
|
return nil, fmt.Errorf(errParseMenuDisplayConfigFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
|
|||||||
+12
-21
@@ -20,6 +20,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -55,13 +56,13 @@ func (t *Template) Normalize() {
|
|||||||
func (t *Template) Validate() error {
|
func (t *Template) Validate() error {
|
||||||
t.Normalize()
|
t.Normalize()
|
||||||
if t.Key == "" {
|
if t.Key == "" {
|
||||||
return errors.New("模板标识符不能为空")
|
return errors.New(errTemplateKeyRequired)
|
||||||
}
|
}
|
||||||
if t.Name == "" {
|
if t.Name == "" {
|
||||||
return errors.New("模板名称不能为空")
|
return errors.New(errTemplateNameRequired)
|
||||||
}
|
}
|
||||||
if t.Content == "" {
|
if t.Content == "" {
|
||||||
return errors.New("模板内容不能为空")
|
return errors.New(errTemplateContentRequired)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -94,26 +95,16 @@ func (t *Template) Render(data any) (string, string, error) {
|
|||||||
return subject, bodyBuf.String(), nil
|
return subject, bodyBuf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderTemplate 渲染模板的高级包装。如果读取或渲染失败,将使用 fallbackSubject 和 fallbackBody 进行解析和返回。
|
// RenderTemplate 渲染指定模板。模板不存在或渲染失败时返回错误,由调用方决定如何处理。
|
||||||
func RenderTemplate(ctx context.Context, key string, data any, fallbackSubject, fallbackBody string) (string, string) {
|
func RenderTemplate(ctx context.Context, key string, data any) (string, string, error) {
|
||||||
var t Template
|
var t Template
|
||||||
if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err == nil {
|
if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err != nil {
|
||||||
subject, body, err := t.Render(data)
|
return "", "", fmt.Errorf(errTemplateUnavailable, key, err)
|
||||||
if err == nil {
|
|
||||||
return subject, body
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 降级使用传入的默认模板内容渲染
|
subject, body, err := t.Render(data)
|
||||||
tFallback := Template{
|
if err != nil {
|
||||||
Key: key + "_fallback",
|
return "", "", fmt.Errorf(errTemplateRenderFailed, key, err)
|
||||||
Subject: fallbackSubject,
|
|
||||||
Content: fallbackBody,
|
|
||||||
}
|
}
|
||||||
subject, body, err := tFallback.Render(data)
|
return subject, body, nil
|
||||||
if err == nil {
|
|
||||||
return subject, body
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackSubject, fallbackBody
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,12 +92,15 @@ func (u *User) SetEncryptedPassword(password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) IsPasswordEncrypted() bool {
|
||||||
|
return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
|
||||||
|
}
|
||||||
|
|
||||||
func (u *User) CheckPassword(password string) bool {
|
func (u *User) CheckPassword(password string) bool {
|
||||||
if u.Password == "" || password == "" {
|
if u.Password == "" || password == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
isBcrypt := strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
|
if u.IsPasswordEncrypted() {
|
||||||
if isBcrypt {
|
|
||||||
return util.CheckPasswordHash(u.Password, password)
|
return util.CheckPasswordHash(u.Password, password)
|
||||||
}
|
}
|
||||||
return u.Password == password
|
return u.Password == password
|
||||||
@@ -132,7 +135,7 @@ func (u *User) CheckActive() error {
|
|||||||
func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
|
func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
|
||||||
enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled)
|
enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled)
|
||||||
if err == nil && !enabled {
|
if err == nil && !enabled {
|
||||||
return errors.New("注册已关闭")
|
return errors.New(errRegistrationDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -158,7 +161,7 @@ func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUser
|
|||||||
func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error {
|
func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error {
|
||||||
enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled)
|
enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled)
|
||||||
if err == nil && !enabled {
|
if err == nil && !enabled {
|
||||||
return errors.New("注册已关闭")
|
return errors.New(errRegistrationDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户名冲突
|
// 检查用户名冲突
|
||||||
@@ -167,7 +170,7 @@ func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return errors.New("用户名已存在")
|
return errors.New(errUsernameExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查邮箱冲突
|
// 检查邮箱冲突
|
||||||
@@ -177,7 +180,7 @@ func (u *User) RegisterUser(ctx context.Context, tx *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if emailCount > 0 {
|
if emailCount > 0 {
|
||||||
return errors.New("该邮箱已被其他账号绑定")
|
return errors.New(errEmailAlreadyBound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//go:build embed_frontend
|
//go:build embed_frontend
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2026 Arctel.net
|
||||||
Modified by Arctel.net, 2026
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ func Serve() {
|
|||||||
{
|
{
|
||||||
systemConfigRouter.GET("", system_config.GetSystemConfig)
|
systemConfigRouter.GET("", system_config.GetSystemConfig)
|
||||||
systemConfigRouter.PUT("", system_config.UpdateSystemConfig)
|
systemConfigRouter.PUT("", system_config.UpdateSystemConfig)
|
||||||
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2026 Arctel.net
|
||||||
Modified by Arctel.net, 2026
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -20,11 +19,21 @@ package storage
|
|||||||
type ErrS3InitializationFailed struct{}
|
type ErrS3InitializationFailed struct{}
|
||||||
|
|
||||||
func (e ErrS3InitializationFailed) Error() string {
|
func (e ErrS3InitializationFailed) Error() string {
|
||||||
return "S3存储初始化失败"
|
return errS3InitializationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalCacheError struct{}
|
type LocalCacheError struct{}
|
||||||
|
|
||||||
func (e LocalCacheError) Error() string {
|
func (e LocalCacheError) Error() string {
|
||||||
return "本地缓存错误"
|
return errLocalCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
errS3InitializationFailed = "S3存储初始化失败"
|
||||||
|
errLocalCache = "本地缓存错误"
|
||||||
|
errS3PutObjectFailed = "s3 put object failed: %w"
|
||||||
|
errS3GetObjectFailed = "s3 get object failed: %w"
|
||||||
|
errCDNRequestFailed = "cdn request failed: %w"
|
||||||
|
errCDNStatusFailed = "cdn returned status %d"
|
||||||
|
errS3DeleteObjectFailed = "s3 delete object failed: %w"
|
||||||
|
)
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func putObjectDefault(ctx context.Context, key string, body io.Reader, size int6
|
|||||||
_, err := client.PutObject(ctx, input)
|
_, err := client.PutObject(ctx, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, fmt.Sprintf("S3 put object failed: %v", err))
|
span.SetStatus(codes.Error, fmt.Sprintf("S3 put object failed: %v", err))
|
||||||
return fmt.Errorf("s3 put object failed: %w", err)
|
return fmt.Errorf(errS3PutObjectFailed, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func getObjectDefault(ctx context.Context, key string) (*ObjectInfo, error) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, fmt.Sprintf("S3 get object failed: %v", err))
|
span.SetStatus(codes.Error, fmt.Sprintf("S3 get object failed: %v", err))
|
||||||
return nil, fmt.Errorf("s3 get object failed: %w", err)
|
return nil, fmt.Errorf(errS3GetObjectFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := "application/octet-stream"
|
contentType := "application/octet-stream"
|
||||||
@@ -223,13 +223,13 @@ func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) {
|
|||||||
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
|
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, fmt.Sprintf("cdn request failed: %v", err))
|
span.SetStatus(codes.Error, fmt.Sprintf("cdn request failed: %v", err))
|
||||||
return nil, fmt.Errorf("cdn request failed: %w", err)
|
return nil, fmt.Errorf(errCDNRequestFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
span.SetStatus(codes.Error, fmt.Sprintf("cdn returned status %d", resp.StatusCode))
|
span.SetStatus(codes.Error, fmt.Sprintf("cdn returned status %d", resp.StatusCode))
|
||||||
return nil, fmt.Errorf("cdn returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf(errCDNStatusFailed, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
@@ -265,7 +265,7 @@ func deleteObjectDefault(ctx context.Context, key string) error {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, fmt.Sprintf("S3 delete object failed: %v", err))
|
span.SetStatus(codes.Error, fmt.Sprintf("S3 delete object failed: %v", err))
|
||||||
return fmt.Errorf("s3 delete object failed: %w", err)
|
return fmt.Errorf(errS3DeleteObjectFailed, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const (
|
||||||
|
errUnknownTaskType = "未知的任务类型: %s"
|
||||||
|
errCreateTaskExecutionFailed = "创建任务执行记录失败: %w"
|
||||||
|
errTaskEnqueueFailed = "任务入队失败: %w"
|
||||||
|
errTaskExecutionNotFound = "任务执行记录不存在: %w"
|
||||||
|
errRetryOnlyFailedTask = "只有失败的任务才能重试,当前状态: %s"
|
||||||
|
errTaskNotRetryable = "该任务不支持重试"
|
||||||
|
errTaskMaxRetryExceeded = "已达到最大重试次数 %d"
|
||||||
|
errCreateRetryExecutionFailed = "创建重试任务执行记录失败: %w"
|
||||||
|
errRetryTaskEnqueueFailed = "重试任务入队失败: %w"
|
||||||
|
errUnregisteredTaskHandler = "未注册的任务处理器: %s"
|
||||||
|
)
|
||||||
+10
-10
@@ -99,7 +99,7 @@ func AppendLog(ctx context.Context, format string, args ...interface{}) {
|
|||||||
func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||||
meta := GetTaskMeta(taskType)
|
meta := GetTaskMeta(taskType)
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
return "", fmt.Errorf("未知的任务类型: %s", taskType)
|
return "", fmt.Errorf(errUnknownTaskType, taskType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一的 TaskID
|
// 生成唯一的 TaskID
|
||||||
@@ -119,7 +119,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := model.CreateTaskExecution(ctx, execution); err != nil {
|
if err := model.CreateTaskExecution(ctx, execution); err != nil {
|
||||||
return "", fmt.Errorf("创建任务执行记录失败: %w", err)
|
return "", fmt.Errorf(errCreateTaskExecutionFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 入队 Asynq
|
// 入队 Asynq
|
||||||
@@ -137,7 +137,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
|||||||
execution.StartedAt = &now
|
execution.StartedAt = &now
|
||||||
execution.FinishedAt = &now
|
execution.FinishedAt = &now
|
||||||
_ = model.UpdateTaskExecution(ctx, execution)
|
_ = model.UpdateTaskExecution(ctx, execution)
|
||||||
return "", fmt.Errorf("任务入队失败: %w", err)
|
return "", fmt.Errorf(errTaskEnqueueFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return taskID, nil
|
return taskID, nil
|
||||||
@@ -147,19 +147,19 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
|||||||
func RetryTask(ctx context.Context, id uint64) (string, error) {
|
func RetryTask(ctx context.Context, id uint64) (string, error) {
|
||||||
execution, err := model.GetTaskExecutionByID(ctx, id)
|
execution, err := model.GetTaskExecutionByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("任务执行记录不存在: %w", err)
|
return "", fmt.Errorf(errTaskExecutionNotFound, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if execution.Status != model.TaskExecutionStatusFailed {
|
if execution.Status != model.TaskExecutionStatusFailed {
|
||||||
return "", fmt.Errorf("只有失败的任务才能重试,当前状态: %s", execution.Status)
|
return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !execution.Retryable {
|
if !execution.Retryable {
|
||||||
return "", fmt.Errorf("该任务不支持重试")
|
return "", fmt.Errorf(errTaskNotRetryable)
|
||||||
}
|
}
|
||||||
|
|
||||||
if execution.RetryCount >= execution.MaxRetry {
|
if execution.RetryCount >= execution.MaxRetry {
|
||||||
return "", fmt.Errorf("已达到最大重试次数 %d", execution.MaxRetry)
|
return "", fmt.Errorf(errTaskMaxRetryExceeded, execution.MaxRetry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的 TaskID
|
// 生成新的 TaskID
|
||||||
@@ -179,7 +179,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := model.CreateTaskExecution(ctx, newExecution); err != nil {
|
if err := model.CreateTaskExecution(ctx, newExecution); err != nil {
|
||||||
return "", fmt.Errorf("创建重试任务执行记录失败: %w", err)
|
return "", fmt.Errorf(errCreateRetryExecutionFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 入队 Asynq
|
// 入队 Asynq
|
||||||
@@ -196,7 +196,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) {
|
|||||||
newExecution.StartedAt = &now
|
newExecution.StartedAt = &now
|
||||||
newExecution.FinishedAt = &now
|
newExecution.FinishedAt = &now
|
||||||
_ = model.UpdateTaskExecution(ctx, newExecution)
|
_ = model.UpdateTaskExecution(ctx, newExecution)
|
||||||
return "", fmt.Errorf("重试任务入队失败: %w", err)
|
return "", fmt.Errorf(errRetryTaskEnqueueFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newTaskID, nil
|
return newTaskID, nil
|
||||||
@@ -224,7 +224,7 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
|||||||
// 查找处理器
|
// 查找处理器
|
||||||
handler, ok := getHandler(t.Type())
|
handler, ok := getHandler(t.Type())
|
||||||
if !ok {
|
if !ok {
|
||||||
err := fmt.Errorf("未注册的任务处理器: %s", t.Type())
|
err := fmt.Errorf(errUnregisteredTaskHandler, t.Type())
|
||||||
logger.ErrorF(ctx, "[TaskExecutor] %v", err)
|
logger.ErrorF(ctx, "[TaskExecutor] %v", err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 scheduler
|
||||||
|
|
||||||
|
const (
|
||||||
|
errLoadLocationFailed = "failed to load location: %w"
|
||||||
|
)
|
||||||
@@ -48,7 +48,7 @@ func StartScheduler() error {
|
|||||||
schedulerOnce.Do(func() {
|
schedulerOnce.Do(func() {
|
||||||
location, locErr := time.LoadLocation("Asia/Shanghai")
|
location, locErr := time.LoadLocation("Asia/Shanghai")
|
||||||
if locErr != nil {
|
if locErr != nil {
|
||||||
err = fmt.Errorf("failed to load location: %w", locErr)
|
err = fmt.Errorf(errLoadLocationFailed, locErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scheduler = asynq.NewScheduler(
|
scheduler = asynq.NewScheduler(
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ func jwtSign(payload []byte, secret []byte) string {
|
|||||||
func jwtVerify(token string, secret []byte) ([]byte, error) {
|
func jwtVerify(token string, secret []byte) ([]byte, error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, errors.New("invalid token format")
|
return nil, errors.New(errInvalidTokenFormat)
|
||||||
}
|
}
|
||||||
if parts[0] != jwtHeaderB64 {
|
if parts[0] != jwtHeaderB64 {
|
||||||
return nil, errors.New("invalid header")
|
return nil, errors.New(errInvalidHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
sigInput := parts[0] + "." + parts[1]
|
sigInput := parts[0] + "." + parts[1]
|
||||||
@@ -122,7 +122,7 @@ func jwtVerify(token string, secret []byte) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !hmac.Equal(expectedSig, actualSig) {
|
if !hmac.Equal(expectedSig, actualSig) {
|
||||||
return nil, errors.New("signature mismatch")
|
return nil, errors.New(errSignatureMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := b64urlDecode(parts[1])
|
payload, err := b64urlDecode(parts[1])
|
||||||
@@ -195,25 +195,25 @@ func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*Chal
|
|||||||
func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) {
|
func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) {
|
||||||
payloadBytes, err := jwtVerify(token, secret)
|
payloadBytes, err := jwtVerify(token, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("invalid_token")
|
return nil, errors.New(errInvalidToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload ChallengePayload
|
var payload ChallengePayload
|
||||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||||
return nil, errors.New("invalid_token")
|
return nil, errors.New(errInvalidToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
if expectedScope != "" && payload.Scope != expectedScope {
|
if expectedScope != "" && payload.Scope != expectedScope {
|
||||||
return nil, errors.New("scope_mismatch")
|
return nil, errors.New(errScopeMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UnixNano() / int64(time.Millisecond)
|
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||||
if payload.Expires < now {
|
if payload.Expires < now {
|
||||||
return nil, errors.New("expired")
|
return nil, errors.New(errExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(solutions) != payload.Count {
|
if len(solutions) != payload.Count {
|
||||||
return nil, errors.New("invalid_solutions")
|
return nil, errors.New(errInvalidSolutions)
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenFnv := fnv1a(token)
|
tokenFnv := fnv1a(token)
|
||||||
@@ -229,7 +229,7 @@ func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expe
|
|||||||
hashHex := hex.EncodeToString(hashBytes[:])
|
hashHex := hex.EncodeToString(hashBytes[:])
|
||||||
|
|
||||||
if !strings.HasPrefix(hashHex, target) {
|
if !strings.HasPrefix(hashHex, target) {
|
||||||
return nil, errors.New("invalid_solution")
|
return nil, errors.New(errInvalidSolution)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 cap
|
||||||
|
|
||||||
|
const (
|
||||||
|
errInvalidTokenFormat = "invalid token format"
|
||||||
|
errInvalidHeader = "invalid header"
|
||||||
|
errSignatureMismatch = "signature mismatch"
|
||||||
|
errInvalidToken = "invalid_token"
|
||||||
|
errScopeMismatch = "scope_mismatch"
|
||||||
|
errExpired = "expired"
|
||||||
|
errInvalidSolutions = "invalid_solutions"
|
||||||
|
errInvalidSolution = "invalid_solution"
|
||||||
|
)
|
||||||
+12
-12
@@ -54,28 +54,28 @@ func encryptBytes(signKey string, plaintext []byte) (string, error) {
|
|||||||
// 将 hex 编码的密钥转换为字节
|
// 将 hex 编码的密钥转换为字节
|
||||||
key, err := hex.DecodeString(signKey)
|
key, err := hex.DecodeString(signKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid sign key: %w", err)
|
return "", fmt.Errorf(errInvalidSignKey, err)
|
||||||
}
|
}
|
||||||
if len(key) != 32 {
|
if len(key) != 32 {
|
||||||
return "", errors.New("sign key must be 32 bytes (64 hex characters)")
|
return "", errors.New(errSignKeyLengthInvalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 AES cipher
|
// 创建 AES cipher
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
return "", fmt.Errorf(errCreateCipherFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 GCM 模式(Galois/Counter Mode)
|
// 使用 GCM 模式(Galois/Counter Mode)
|
||||||
gcm, err := cipher.NewGCM(block)
|
gcm, err := cipher.NewGCM(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
return "", fmt.Errorf(errCreateGCMFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成随机 nonce
|
// 生成随机 nonce
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
return "", fmt.Errorf(errGenerateNonceFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密数据
|
// 加密数据
|
||||||
@@ -90,34 +90,34 @@ func decryptBytes(signKey string, ciphertext string) ([]byte, error) {
|
|||||||
// 将 hex 编码的密钥转换为字节
|
// 将 hex 编码的密钥转换为字节
|
||||||
key, err := hex.DecodeString(signKey)
|
key, err := hex.DecodeString(signKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid sign key: %w", err)
|
return nil, fmt.Errorf(errInvalidSignKey, err)
|
||||||
}
|
}
|
||||||
if len(key) != 32 {
|
if len(key) != 32 {
|
||||||
return nil, errors.New("sign key must be 32 bytes (64 hex characters)")
|
return nil, errors.New(errSignKeyLengthInvalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码 base64 密文
|
// 解码 base64 密文
|
||||||
data, err := Base64Decode(ciphertext)
|
data, err := Base64Decode(ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode ciphertext: %w", err)
|
return nil, fmt.Errorf(errDecodeCiphertextFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 AES cipher
|
// 创建 AES cipher
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
return nil, fmt.Errorf(errCreateCipherFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 GCM 模式
|
// 使用 GCM 模式
|
||||||
gcm, err := cipher.NewGCM(block)
|
gcm, err := cipher.NewGCM(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
return nil, fmt.Errorf(errCreateGCMFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 nonce
|
// 提取 nonce
|
||||||
nonceSize := gcm.NonceSize()
|
nonceSize := gcm.NonceSize()
|
||||||
if len(data) < nonceSize {
|
if len(data) < nonceSize {
|
||||||
return nil, errors.New("ciphertext too short")
|
return nil, errors.New(errCiphertextTooShort)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||||
@@ -125,7 +125,7 @@ func decryptBytes(signKey string, ciphertext string) ([]byte, error) {
|
|||||||
// 解密数据
|
// 解密数据
|
||||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
return nil, fmt.Errorf(errDecryptFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return plaintext, nil
|
return plaintext, nil
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type StringArray []string
|
|||||||
func (sa *StringArray) Scan(value interface{}) error {
|
func (sa *StringArray) Scan(value interface{}) error {
|
||||||
bytesValue, ok := value.([]byte)
|
bytesValue, ok := value.([]byte)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid value: %v", value)
|
return fmt.Errorf(errInvalidCustomValue, value)
|
||||||
}
|
}
|
||||||
return json.Unmarshal(bytesValue, sa)
|
return json.Unmarshal(bytesValue, sa)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 util
|
||||||
|
|
||||||
|
const (
|
||||||
|
errCreateHTTPRequestFailed = "创建HTTP请求失败: %w"
|
||||||
|
errHTTPRequestFailed = "请求%s接口失败: %w"
|
||||||
|
errInvalidCustomValue = "invalid value: %v"
|
||||||
|
errInvalidSignKey = "invalid sign key: %w"
|
||||||
|
errSignKeyLengthInvalid = "sign key must be 32 bytes (64 hex characters)"
|
||||||
|
errCreateCipherFailed = "failed to create cipher: %w"
|
||||||
|
errCreateGCMFailed = "failed to create GCM: %w"
|
||||||
|
errGenerateNonceFailed = "failed to generate nonce: %w"
|
||||||
|
errDecodeCiphertextFailed = "failed to decode ciphertext: %w"
|
||||||
|
errCiphertextTooShort = "ciphertext too short"
|
||||||
|
errDecryptFailed = "failed to decrypt: %w"
|
||||||
|
)
|
||||||
@@ -55,7 +55,7 @@ func SetHTTPClient(c *http.Client) {
|
|||||||
func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) {
|
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)
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
|
return nil, fmt.Errorf(errCreateHTTPRequestFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookies != nil {
|
if cookies != nil {
|
||||||
@@ -72,7 +72,7 @@ func Request(ctx context.Context, method, url string, body io.Reader, headers, c
|
|||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("请求%s接口失败: %w", url, err)
|
return nil, fmt.Errorf(errHTTPRequestFailed, url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Arctel.net
|
||||||
|
|
||||||
|
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 mail
|
||||||
|
|
||||||
|
const (
|
||||||
|
errDialTLSFailed = "dial tls failed: %w"
|
||||||
|
errSMTPClientCreationFailed = "smtp client creation failed: %w"
|
||||||
|
errSMTPAuthFailed = "smtp auth failed: %w"
|
||||||
|
errSMTPMailCommandFailed = "smtp mail command failed: %w"
|
||||||
|
errSMTPRcptCommandFailed = "smtp rcpt command failed: %w"
|
||||||
|
errSMTPDataCommandFailed = "smtp data command failed: %w"
|
||||||
|
errSMTPWritingBodyFailed = "smtp writing body failed: %w"
|
||||||
|
errSendMailFailed = "send mail failed: %w"
|
||||||
|
)
|
||||||
@@ -69,38 +69,38 @@ func SendMailHTML(cfg Config, to string, subject, body string) error {
|
|||||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dial tls failed: %w", err)
|
return fmt.Errorf(errDialTLSFailed, err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
|
||||||
client, err := smtp.NewClient(conn, cfg.Host)
|
client, err := smtp.NewClient(conn, cfg.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("smtp client creation failed: %w", err)
|
return fmt.Errorf(errSMTPClientCreationFailed, err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
if err = client.Auth(auth); err != nil {
|
if err = client.Auth(auth); err != nil {
|
||||||
return fmt.Errorf("smtp auth failed: %w", err)
|
return fmt.Errorf(errSMTPAuthFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = client.Mail(cfg.Username); err != nil {
|
if err = client.Mail(cfg.Username); err != nil {
|
||||||
return fmt.Errorf("smtp mail command failed: %w", err)
|
return fmt.Errorf(errSMTPMailCommandFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = client.Rcpt(to); err != nil {
|
if err = client.Rcpt(to); err != nil {
|
||||||
return fmt.Errorf("smtp rcpt command failed: %w", err)
|
return fmt.Errorf(errSMTPRcptCommandFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := client.Data()
|
w, err := client.Data()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("smtp data command failed: %w", err)
|
return fmt.Errorf(errSMTPDataCommandFailed, err)
|
||||||
}
|
}
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
|
|
||||||
_, err = w.Write([]byte(message))
|
_, err = w.Write([]byte(message))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("smtp writing body failed: %w", err)
|
return fmt.Errorf(errSMTPWritingBodyFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -109,7 +109,7 @@ func SendMailHTML(cfg Config, to string, subject, body string) error {
|
|||||||
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
|
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
|
||||||
err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message))
|
err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send mail failed: %w", err)
|
return fmt.Errorf(errSendMailFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user