From cd3d0c9f82c9e25c4bff04328813101f7af445f5 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 20:08:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agents.md | 5 +- CLAUDE.md | 5 +- docs/docs.go | 173 ++- docs/swagger.json | 173 ++- docs/swagger.yaml | 107 +- frontend/app/(main)/admin/settings/page.tsx | 4 +- frontend/components/common/admin/system.tsx | 30 +- .../common/general/manage-pannel.tsx | 58 +- .../components/common/settings/info-tab.tsx | 68 + .../components/common/settings/other-tab.tsx | 168 +++ .../common/settings/security-tab.tsx | 476 +++++++ .../components/common/settings/security.tsx | 1109 ----------------- .../common/settings/system-settings.tsx | 116 ++ .../components/common/settings/system-tab.tsx | 372 ++++++ frontend/contexts/admin-context.tsx | 18 +- frontend/lib/services/admin/admin.service.ts | 17 +- internal/apps/admin/auth_source/routers.go | 5 +- internal/apps/admin/errs.go | 5 +- internal/apps/admin/logs/routers.go | 3 +- internal/apps/admin/system_config/routers.go | 46 - .../apps/admin/system_config/routers_test.go | 43 - internal/apps/admin/task/routers.go | 5 +- internal/apps/cap/errs.go | 22 + internal/apps/cap/middleware.go | 4 +- internal/apps/oauth/constants.go | 10 +- internal/apps/oauth/errs.go | 15 +- internal/apps/oauth/oauth_test.go | 194 +-- internal/apps/oauth/sources.go | 26 +- internal/apps/upload/errs.go | 19 + internal/apps/upload/routers.go | 32 +- internal/apps/upload/tasks.go | 2 +- internal/apps/upload/utils.go | 8 +- internal/apps/user/access_tokens.go | 18 +- internal/apps/user/controllers.go | 224 ++-- internal/apps/user/errs.go | 40 +- internal/apps/user/tasks.go | 12 +- internal/db/errs.go | 24 + internal/db/redis.go | 10 +- internal/logger/errs.go | 21 + internal/logger/utils.go | 2 +- internal/model/auth_source.go | 26 +- internal/model/errs.go | 43 + internal/model/system_configs.go | 10 +- internal/model/templates.go | 33 +- internal/model/users.go | 15 +- internal/router/frontend_embedded.go | 3 +- internal/router/router.go | 1 - internal/storage/errs.go | 17 +- internal/storage/s3.go | 10 +- internal/task/errs.go | 30 + internal/task/executor.go | 20 +- internal/task/scheduler/errs.go | 21 + internal/task/scheduler/scheduler.go | 2 +- internal/util/cap/cap.go | 18 +- internal/util/cap/errs.go | 28 + internal/util/crypto.go | 24 +- internal/util/custom_types.go | 2 +- internal/util/errs.go | 31 + internal/util/http_clients.go | 4 +- internal/util/mail/errs.go | 28 + internal/util/mail/mail.go | 16 +- 61 files changed, 2180 insertions(+), 1891 deletions(-) create mode 100644 frontend/components/common/settings/info-tab.tsx create mode 100644 frontend/components/common/settings/other-tab.tsx create mode 100644 frontend/components/common/settings/security-tab.tsx delete mode 100644 frontend/components/common/settings/security.tsx create mode 100644 frontend/components/common/settings/system-settings.tsx create mode 100644 frontend/components/common/settings/system-tab.tsx create mode 100644 internal/apps/cap/errs.go create mode 100644 internal/db/errs.go create mode 100644 internal/logger/errs.go create mode 100644 internal/model/errs.go create mode 100644 internal/task/errs.go create mode 100644 internal/task/scheduler/errs.go create mode 100644 internal/util/cap/errs.go create mode 100644 internal/util/errs.go create mode 100644 internal/util/mail/errs.go diff --git a/Agents.md b/Agents.md index 8cd1ae98..677fccaa 100644 --- a/Agents.md +++ b/Agents.md @@ -274,9 +274,8 @@ components/common/ │ # 是否显示通知铃铛图标,通过 Context 持久化偏好 ├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方 │ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源) - └── security.tsx # SecurityMain — 系统安全设置页面(管理员专属),包含系统登录与 - # 注册控制(密码登录/注册/密码注册/OIDC 登录四个开关)、 - # 认证源管理(新增、编辑、启用/禁用、删除 OIDC 认证源) + ├── system-settings.tsx # SystemSettingsMain — 系统设置主页面(管理员专属),包含系统安全与登录控制、 + # 认证源管理、人机验证配置、邮件服务 (SMTP) 设置以及菜单显示控制 ``` --- diff --git a/CLAUDE.md b/CLAUDE.md index 8cd1ae98..677fccaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,9 +274,8 @@ components/common/ │ # 是否显示通知铃铛图标,通过 Context 持久化偏好 ├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方 │ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源) - └── security.tsx # SecurityMain — 系统安全设置页面(管理员专属),包含系统登录与 - # 注册控制(密码登录/注册/密码注册/OIDC 登录四个开关)、 - # 认证源管理(新增、编辑、启用/禁用、删除 OIDC 认证源) + ├── system-settings.tsx # SystemSettingsMain — 系统设置主页面(管理员专属),包含系统安全与登录控制、 + # 认证源管理、人机验证配置、邮件服务 (SMTP) 设置以及菜单显示控制 ``` --- diff --git a/docs/docs.go b/docs/docs.go index 6acf8328..fb3e3a31 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { @@ -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": { "post": { "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", @@ -3756,20 +3746,38 @@ const docTemplate = `{ "avatar_url": { "type": "string" }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, "id": { "type": "integer" }, "is_admin": { "type": "boolean" }, + "location": { + "type": "string" + }, "need_change_password": { "type": "boolean" }, "nickname": { "type": "string" }, + "phone": { + "type": "string" + }, "username": { "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": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 1d10e6f1..be2a0be3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { @@ -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": { "post": { "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", @@ -3749,20 +3739,38 @@ "avatar_url": { "type": "string" }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, "id": { "type": "integer" }, "is_admin": { "type": "boolean" }, + "location": { + "type": "string" + }, "need_change_password": { "type": "boolean" }, "nickname": { "type": "string" }, + "phone": { + "type": "string" + }, "username": { "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": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e01ab3e5..4335a4b5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -355,16 +355,28 @@ definitions: properties: avatar_url: type: string + bio: + type: string + email: + type: string + gender: + type: string id: type: integer is_admin: type: boolean + location: + type: string need_change_password: type: boolean nickname: type: string + phone: + type: string username: type: string + website: + type: string type: object oauth.CallbackRequest: properties: @@ -722,6 +734,25 @@ definitions: token: type: string 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: properties: is_active: @@ -1186,47 +1217,6 @@ paths: tags: - admin /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: description: 根据配置键获取对应的系统配置详情,需要管理员权限 parameters: @@ -2602,6 +2592,41 @@ paths: summary: 用户退出登录 tags: - 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: post: consumes: diff --git a/frontend/app/(main)/admin/settings/page.tsx b/frontend/app/(main)/admin/settings/page.tsx index 481bd97d..4120557b 100644 --- a/frontend/app/(main)/admin/settings/page.tsx +++ b/frontend/app/(main)/admin/settings/page.tsx @@ -1,8 +1,8 @@ "use client" -import {SecurityMain} from "@/components/common/settings/security" +import {SystemSettingsMain} from "@/components/common/settings/system-settings" /* 系统设置页面 */ export default function AdminSettingsPage() { - return + return } diff --git a/frontend/components/common/admin/system.tsx b/frontend/components/common/admin/system.tsx index e77c3735..de125033 100644 --- a/frontend/components/common/admin/system.tsx +++ b/frontend/components/common/admin/system.tsx @@ -1,20 +1,20 @@ "use client" import * as React from "react" -import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" -import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import {Input} from "@/components/ui/input" +import {Switch} from "@/components/ui/switch" +import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel" +import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs" -import { formatDateTime } from "@/lib/utils" -import type { SystemConfig } from "@/lib/services" -import { useAdmin } from "@/contexts/admin-context" +import {formatDateTime} from "@/lib/utils" +import type {SystemConfig} from "@/lib/services" +import {useAdmin} from "@/contexts/admin-context" /** * 系统配置 * 显示系统配置的详细信息和编辑面板 - * + * * @example * ```tsx * {config?.type === 'system' ? '系统配置' : '业务配置'} @@ -149,7 +149,7 @@ function SystemConfigDetailPanel({ /** * 系统配置管理组件 - * + * * @example * ```tsx * @@ -162,8 +162,7 @@ export function SystemConfigs() { systemConfigsLoading: loading, systemConfigsError: error, refetchSystemConfigs, - updateSystemConfig, - deleteSystemConfig + updateSystemConfig } = useAdmin() 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 ( title="系统配置" @@ -199,7 +194,6 @@ export function SystemConfigs() { onReload={() => refetchSystemConfigs(activeTab)} getInitialEditData={getInitialEditData} onSave={handleSave} - onDelete={handleDelete} getId={(config) => config.key} emptyDescription="未发现系统配置" loadingDescription="配置加载中" diff --git a/frontend/components/common/general/manage-pannel.tsx b/frontend/components/common/general/manage-pannel.tsx index 710523df..c05ed989 100644 --- a/frontend/components/common/general/manage-pannel.tsx +++ b/frontend/components/common/general/manage-pannel.tsx @@ -1,11 +1,11 @@ -import { useState } from "react" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { Spinner } from "@/components/ui/spinner" -import { ErrorInline } from "@/components/layout/error" -import { EmptyStateWithBorder } from "@/components/layout/empty" -import { LoadingStateWithBorder } from "@/components/layout/loading" -import { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react" +import {useState} from "react" +import {toast} from "sonner" +import {Button} from "@/components/ui/button" +import {Spinner} from "@/components/ui/spinner" +import {ErrorInline} from "@/components/layout/error" +import {EmptyStateWithBorder} from "@/components/layout/empty" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react" import { AlertDialog, AlertDialogAction, @@ -16,8 +16,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" -import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table" -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" +import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" +import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area" interface ManagePageProps { @@ -30,7 +30,7 @@ interface ManagePageProps { /** 获取初始编辑数据 */ getInitialEditData: (item: T) => Partial onSave: (item: T, editData: Partial) => Promise - onDelete: (item: T) => Promise + onDelete?: (item: T) => Promise /** 渲染表格 (Config-Driven) */ columns: { @@ -134,7 +134,7 @@ export function ManagePage({ } const handleConfirmDelete = async () => { - if (!deletingItem) return + if (!deletingItem || !onDelete) return try { await onDelete(deletingItem) toast.success('删除成功') @@ -191,7 +191,7 @@ export function ManagePage({ hovered={hoveredItem} onSelect={handleSelect} onHover={handleHover} - onDelete={handleDeleteClick} + onDelete={onDelete ? handleDeleteClick : undefined} getId={getId} /> ) @@ -322,7 +322,7 @@ export function ManageTable({ hovered: T | null onSelect: (item: T) => void onHover: (item: T | null) => void - onDelete: (item: T) => void + onDelete?: (item: T) => void getId: (item: T) => string | number }) { return ( @@ -340,7 +340,7 @@ export function ManageTable({ {col.header} ))} - 操作 + {onDelete && 操作} @@ -370,19 +370,21 @@ export function ManageTable({ {col.cell(item)} ))} - - - + {onDelete && ( + + + + )} ) })} diff --git a/frontend/components/common/settings/info-tab.tsx b/frontend/components/common/settings/info-tab.tsx new file mode 100644 index 00000000..f50be326 --- /dev/null +++ b/frontend/components/common/settings/info-tab.tsx @@ -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 ( +
+ {label} + {value || "-"} +
+ ) +} + +interface InfoTabProps { + systemConfigsLength: number + authSourcesLength: number +} + +export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps) { + return ( +
+ + +
+
+ +
+
+ 应用信息 + 当前前端应用的版本与构建信息 +
+
+
+ + + + + + + +
+ + + +
+
+ +
+
+ 服务连接 + 前端 API 客户端的基础连接参数 +
+
+
+ + + + + + + +
+
+ ) +} diff --git a/frontend/components/common/settings/other-tab.tsx b/frontend/components/common/settings/other-tab.tsx new file mode 100644 index 00000000..a8777293 --- /dev/null +++ b/frontend/components/common/settings/other-tab.tsx @@ -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 + 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 + try { + return JSON.parse(raw) as Record + } catch { + return {} as Record + } + }, [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 ( + + +
+
+ +
+
+ 目录显示管理 + + 配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。 + +
+
+
+ + {MENU_GROUPS.map((group) => ( +
+
+ + {group.name} + +
+
+
+ {group.items.map((item) => { + const Icon = item.icon + const isReadOnly = "readOnly" in item && item.readOnly + const checked = menuDisplayConfig[item.path] !== false + + return ( +
+
+
+ {Icon && } + {item.label} + {isReadOnly && ( + + 不可隐藏 + + )} +
+

+ {item.description} +

+
+
+ handleMenuToggle(item.path, val)} + /> +
+
+ ) + })} +
+
+ ))} + +
+ +
+ 安全提示: + 为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。 +
+
+ + + ) +} diff --git a/frontend/components/common/settings/security-tab.tsx b/frontend/components/common/settings/security-tab.tsx new file mode 100644 index 00000000..8d10a5c9 --- /dev/null +++ b/frontend/components/common/settings/security-tab.tsx @@ -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 + systemConfigsQuery: any +} + +export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { + const queryClient = useQueryClient() + const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false) + const [selectedSource, setSelectedSource] = useState(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 ( +
+ {/* 系统登录与注册控制 */} + + +
+
+ +
+
+ 系统安全与注册控制 + 配置系统的登录限制与用户自主注册权限 +
+
+
+ +
+ {SECURITY_KEYS.map((item) => { + const config = configs[item.key] + const checked = config ? config.value === "true" : false + const Icon = item.icon + return ( +
+
+
+ {Icon && } + {item.title} +
+

{item.description}

+
+ handleToggle(item.key, value)} + /> +
+ ) + })} +
+
+
+ + {/* 认证源配置管理 */} + + +
+
+ +
+
+ 认证源管理 + 添加、修改并启用系统自定义的 OIDC 认证源 +
+
+ +
+ + {authSourcesQuery.isPending ? ( +
+ +
+ ) : (authSourcesQuery.data ?? []).length > 0 ? ( + (authSourcesQuery.data ?? []).map((source) => ( +
+
+
+ {source.display_name || source.name} + + {source.is_active ? "已启用" : "已禁用"} + +
+
+ 标识符: {source.name} · 类型: {source.type.toUpperCase()} +
+
+
+ + +
+ toggleSourceMutation.mutate(source)} + /> + + +
+
+
+ )) + ) : ( +
+ 暂无配置的认证源,点击上方按钮新增 + +
+ )} +
+
+ + {/* 人机验证配置 (Cap CAPTCHA) */} + + +
+
+ +
+
+ 人机验证配置 (Cap CAPTCHA) + 配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击 +
+
+ handleToggle("cap_login_enabled", checked)} + /> +
+ + {/* 自动开始计算 Switch */} +
+
+

打开页面后自动开始计算

+
+ +
+
+
+
+ + setCapCount(e.target.value)} + placeholder="50" + className="bg-card border-dashed text-xs" + /> +

客户端需求解的难题总数。默认 1,推荐 1 至 5

+
+ +
+ + setCapDifficulty(e.target.value)} + placeholder="4" + className="bg-card border-dashed text-xs" + /> +

PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4

+
+ +
+ + setCapSize(e.target.value)} + placeholder="32" + className="bg-card border-dashed text-xs" + /> +

难题盐值混淆字符长度。默认 32

+
+ +
+ + setCapTTL(e.target.value)} + placeholder="600" + className="bg-card border-dashed text-xs" + /> +

难题有效期限。默认 600 秒 (10 分钟)

+
+ +
+ + setCapTokenTTL(e.target.value)} + placeholder="1200" + className="bg-card border-dashed text-xs" + /> +

PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)

+
+
+ +
+ +
+
+
+
+ + setAuthSourceModalOpen(false)} + onChanged={async () => { + await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] }) + await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] }) + await authSourcesQuery.refetch() + }} + /> +
+ ) +} diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx deleted file mode 100644 index e2ddc197..00000000 --- a/frontend/components/common/settings/security.tsx +++ /dev/null @@ -1,1109 +0,0 @@ -"use client" - -import {useEffect, useMemo, useState} from "react" -import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" -import { - CreditCard, - FileText, - Fingerprint, - FolderOpen, - Globe, - Home, - Info, - Layers, - LayoutList, - Loader2, - Lock, - Mail, - Palette, - Pencil, - Plus, - Server, - Settings, - Shield, - ShieldCheck, - Terminal, - Trash2, - UserPlus, - UserRound -} from "lucide-react" -import {useRouter} from "next/navigation" -import {motion} from "motion/react" -import packageJson from "../../../package.json" - -import {Button} from "@/components/ui/button" -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Switch} from "@/components/ui/switch" -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" -import {Input} from "@/components/ui/input" -import {Label} from "@/components/ui/label" -import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog" -import {useAuth} from "@/components/providers/auth-provider" -import {AuthSourceModal} from "@/components/common/settings/auth-source-modal" -import {TemplatesManager} from "@/components/common/settings/templates" -import {AdminService, apiConfig} from "@/lib/services" -import type {AuthSource, SystemConfig} from "@/lib/services/admin" -import {toast} from "sonner" -import {SystemStatusManager} from "@/components/common/admin/status" - -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 - -function systemConfigMap(configs: SystemConfig[]) { - return configs.reduce>((accumulator, config) => { - accumulator[config.key] = config - return accumulator - }, {}) -} - -function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- {label} - {value || "-"} -
- ) -} - - - -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 - -export function SecurityMain() { - const queryClient = useQueryClient() - const { user, loading } = useAuth() - const router = useRouter() - const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false) - const [selectedSource, setSelectedSource] = useState(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 [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(null) - const [smtpTestError, setSmtpTestError] = useState("") - - const systemConfigsQuery = useQuery({ - queryKey: ["admin", "system-configs"], - queryFn: () => AdminService.listSystemConfigs("system"), - enabled: !!user?.is_admin, - }) - - const authSourcesQuery = useQuery({ - queryKey: ["auth", "sources"], - queryFn: () => AdminService.listAuthSources(), - enabled: !!user?.is_admin, - }) - - const configs = useMemo( - () => systemConfigMap(systemConfigsQuery.data ?? []), - [systemConfigsQuery.data], - ) - - useEffect(() => { - if (!loading && (!user || !user.is_admin)) { - router.replace("/settings/profile") - } - }, [user, loading, router]) - - useEffect(() => { - if (systemConfigsQuery.data) { - const cfgMap = systemConfigMap(systemConfigsQuery.data) - 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") - setServerAddress(cfgMap["server_address"]?.value || "") - setSmtpHost(cfgMap["smtp_host"]?.value || "") - setSmtpPort(cfgMap["smtp_port"]?.value || "587") - setSmtpUsername(cfgMap["smtp_username"]?.value || "") - setSmtpPassword(cfgMap["smtp_password"]?.value || "") - } - }, [systemConfigsQuery.data]) - - 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 menuDisplayConfig = useMemo(() => { - const raw = configs["menu_display_config"]?.value - if (!raw) return {} as Record - try { - return JSON.parse(raw) as Record - } catch { - return {} as Record - } - }, [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 }) - } - - 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() - } - - 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() - } - - if (loading || !user || !user.is_admin) { - return ( -
- -
- ) - } - - return ( - - - - - 安全设置 - - - 运营设置 - - - 系统设置 - - - 其他设置 - - - 系统状态 - - - 系统信息 - - - - -
- {/* 系统登录与注册控制 */} - - -
-
- -
-
- 系统安全与注册控制 - 配置系统的登录限制与用户自主注册权限 -
-
-
- -
- {SECURITY_KEYS.map((item) => { - const config = configs[item.key] - const checked = config ? config.value === "true" : false - const Icon = item.icon - return ( -
-
-
- {Icon && } - {item.title} -
-

{item.description}

-
- handleToggle(item.key, value)} - /> -
- ) - })} -
-
-
- - {/* 认证源配置管理 */} - - -
-
- -
-
- 认证源管理 - 添加、修改并启用系统自定义的 OIDC 认证源 -
-
- -
- - {authSourcesQuery.isPending ? ( -
- -
- ) : (authSourcesQuery.data ?? []).length > 0 ? ( - (authSourcesQuery.data ?? []).map((source) => ( -
-
-
- {source.display_name || source.name} - - {source.is_active ? "已启用" : "已禁用"} - -
-
- 标识符: {source.name} · 类型: {source.type.toUpperCase()} -
-
-
- - -
- toggleSourceMutation.mutate(source)} - /> - - -
-
-
- )) - ) : ( -
- 暂无配置的认证源,点击上方按钮新增 - -
- )} -
-
- - {/* 人机验证配置 (Cap CAPTCHA) */} - - -
-
- -
-
- 人机验证配置 (Cap CAPTCHA) - 配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击 -
-
- handleToggle("cap_login_enabled", checked)} - /> -
- - {/* 自动开始计算 Switch */} -
-
-

打开页面后自动开始计算

-
- -
-
-
-
- - setCapCount(e.target.value)} - placeholder="50" - className="bg-card border-dashed text-xs" - /> -

客户端需求解的难题总数。默认 1,推荐 1 至 5

-
- -
- - setCapDifficulty(e.target.value)} - placeholder="4" - className="bg-card border-dashed text-xs" - /> -

PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4

-
- -
- - setCapSize(e.target.value)} - placeholder="32" - className="bg-card border-dashed text-xs" - /> -

难题盐值混淆字符长度。默认 32

-
- -
- - setCapTTL(e.target.value)} - placeholder="600" - className="bg-card border-dashed text-xs" - /> -

难题有效期限。默认 600 秒 (10 分钟)

-
- -
- - setCapTokenTTL(e.target.value)} - placeholder="1200" - className="bg-card border-dashed text-xs" - /> -

PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)

-
-
- -
- -
-
-
-
-
-
- - - - -
- {/* 通用设置 */} - - -
-
- -
-
- 通用设置 - 配置系统的全局通用参数 -
-
-
- -
-
- - setServerAddress(e.target.value)} - placeholder="例如: https://example.com" - className="bg-card border-dashed text-xs" - /> -

- 这里可以编辑更改服务器地址。默认不设定,允许从任意源(*)访问 API,此时存在跨域安全风险;如果手动设置服务器地址,CORS 允许源将更新为该地址,消除跨域安全隐患。 -

-
-
- -
-
-
-
- - {/* SMTP 邮件设置 */} - - -
-
- -
-
- SMTP 邮件设置 - 配置系统的邮件发送服务 (SMTP) -
-
-
- -
-
-
- - setSmtpHost(e.target.value)} - placeholder="例如: smtp.example.com" - className="bg-card border-dashed text-xs" - /> -
- -
- - setSmtpPort(e.target.value)} - placeholder="例如: 587 或 465" - className="bg-card border-dashed text-xs" - /> -
- -
- - setSmtpUsername(e.target.value)} - placeholder="例如: sender@example.com" - className="bg-card border-dashed text-xs" - /> -
- -
- - setSmtpPassword(e.target.value)} - placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"} - className="bg-card border-dashed text-xs" - /> -
-
- -
- - -
-
-
-
-
-
- - - - - - -
-
- -
-
- 目录显示管理 - - 配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。 - -
-
-
- - {MENU_GROUPS.map((group) => ( -
-
- - {group.name} - -
-
-
- {group.items.map((item) => { - const Icon = item.icon - const isReadOnly = "readOnly" in item && item.readOnly - const checked = menuDisplayConfig[item.path] !== false - - return ( -
-
-
- {Icon && } - {item.label} - {isReadOnly && ( - - 不可隐藏 - - )} -
-

- {item.description} -

-
-
- handleMenuToggle(item.path, val)} - /> -
-
- ) - })} -
-
- ))} - -
- -
- 安全提示: - 为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。 -
-
- - - - -
- - -
-
- -
-
- 应用信息 - 当前前端应用的版本与构建信息 -
-
-
- - - - - - - -
- - - -
-
- -
-
- 服务连接 - 前端 API 客户端的基础连接参数 -
-
-
- - - - - - - -
-
-
- - - setAuthSourceModalOpen(false)} - onChanged={async () => { - await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] }) - await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] }) - await authSourcesQuery.refetch() - }} - /> - - - - - SMTP 发件测试 - - 输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。 - - - -
-
- - setSmtpTestTo(e.target.value)} - placeholder="例如: receiver@example.com" - className="bg-card border-dashed text-xs" - disabled={testSmtpMutation.isPending} - /> -
- - {smtpTestLog && ( -
- -
-                  {smtpTestLog}
-                
-
- )} - - {smtpTestSuccess === true && ( -
- 测试成功!邮件已顺利发出。 -
- )} - - {smtpTestSuccess === false && ( -
- 测试失败:{smtpTestError} -
- )} - - - - - -
-
-
- - ) -} diff --git a/frontend/components/common/settings/system-settings.tsx b/frontend/components/common/settings/system-settings.tsx new file mode 100644 index 00000000..173016fd --- /dev/null +++ b/frontend/components/common/settings/system-settings.tsx @@ -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>((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 ( +
+ +
+ ) + } + + return ( + + + + + 安全设置 + + + 运营设置 + + + 系统设置 + + + 其他设置 + + + 系统状态 + + + 系统信息 + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/components/common/settings/system-tab.tsx b/frontend/components/common/settings/system-tab.tsx new file mode 100644 index 00000000..6adff59d --- /dev/null +++ b/frontend/components/common/settings/system-tab.tsx @@ -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 + 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(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 ( +
+ {/* 通用设置 */} + + +
+
+ +
+
+ 通用设置 + 配置系统的全局通用参数 +
+
+
+ +
+
+ + setServerAddress(e.target.value)} + placeholder="例如: https://example.com" + className="bg-card border-dashed text-xs" + /> +

+ 这里可以编辑更改服务器地址。默认不设定,允许从任意源(*)访问 API,此时存在跨域安全风险;如果手动设置服务器地址,CORS 允许源将更新为该地址,消除跨域安全隐患。 +

+
+
+ +
+
+
+
+ + {/* SMTP 邮件设置 */} + + +
+
+ +
+
+ SMTP 邮件设置 + 配置系统的邮件发送服务 (SMTP) +
+
+
+ +
+
+
+ + setSmtpHost(e.target.value)} + placeholder="例如: smtp.example.com" + className="bg-card border-dashed text-xs" + /> +
+ +
+ + setSmtpPort(e.target.value)} + placeholder="例如: 587 或 465" + className="bg-card border-dashed text-xs" + /> +
+ +
+ + setSmtpUsername(e.target.value)} + placeholder="例如: sender@example.com" + className="bg-card border-dashed text-xs" + /> +
+ +
+ + setSmtpPassword(e.target.value)} + placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"} + className="bg-card border-dashed text-xs" + /> +
+
+ +
+ + +
+
+
+
+ + + + + SMTP 发件测试 + + 输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。 + + + +
+
+ + setSmtpTestTo(e.target.value)} + placeholder="例如: receiver@example.com" + className="bg-card border-dashed text-xs" + disabled={testSmtpMutation.isPending} + /> +
+ + {smtpTestLog && ( +
+ +
+                  {smtpTestLog}
+                
+
+ )} + + {smtpTestSuccess === true && ( +
+ 测试成功!邮件已顺利发出。 +
+ )} + + {smtpTestSuccess === false && ( +
+ 测试失败:{smtpTestError} +
+ )} + + + + + +
+
+
+
+ ) +} diff --git a/frontend/contexts/admin-context.tsx b/frontend/contexts/admin-context.tsx index e3ef5e03..9ab22f50 100644 --- a/frontend/contexts/admin-context.tsx +++ b/frontend/contexts/admin-context.tsx @@ -1,11 +1,11 @@ "use client" 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 type { SystemConfig, UpdateSystemConfigRequest } from "@/lib/services" -import { handleContextError } from "@/lib/utils/error-handling" +import {handleContextError} from "@/lib/utils/error-handling" /** Admin 上下文状态接口 */ @@ -15,7 +15,6 @@ export interface AdminContextState { systemConfigsError: Error | null refetchSystemConfigs: (type?: 'system' | 'business') => Promise updateSystemConfig: (key: string, data: UpdateSystemConfigRequest) => Promise - deleteSystemConfig: (key: string) => Promise } const AdminContext = createContext(null) @@ -23,7 +22,7 @@ const AdminContext = createContext(null) /** * Admin Provider * 提供 admin 相关的数据状态管理 - * + * * @example * ```tsx * @@ -78,19 +77,12 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { } }, [refetchSystemConfigs]) - /** 删除系统配置 */ - const deleteSystemConfig = useCallback(async (key: string) => { - await services.admin.deleteSystemConfig(key) - await refetchSystemConfigs(lastConfigTypeRef.current) - }, [refetchSystemConfigs]) - const value: AdminContextState = { systemConfigs, systemConfigsLoading, systemConfigsError, refetchSystemConfigs, updateSystemConfig, - deleteSystemConfig, } return ( @@ -102,7 +94,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) { /** * 使用 Admin 上下文 - * + * * @example * ```tsx * const { systemConfigs } = useAdmin() diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index 275cee22..b22319f5 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -130,22 +130,7 @@ export class AdminService extends BaseService { 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 { - return this.delete(`/system-configs/${ key }`); - } + // ==================== 认证源管理 ==================== diff --git a/internal/apps/admin/auth_source/routers.go b/internal/apps/admin/auth_source/routers.go index 7db2f93e..57a71d61 100644 --- a/internal/apps/admin/auth_source/routers.go +++ b/internal/apps/admin/auth_source/routers.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" + "github.com/Rain-kl/Wavelet/internal/apps/admin" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" @@ -217,7 +218,7 @@ func DeleteAuthSource(c *gin.Context) { func parseSourceID(c *gin.Context) (uint64, error) { raw := c.Param("id") if raw == "" { - return 0, errors.New("认证源 ID 无效") + return 0, errors.New(admin.InvalidAuthSourceID) } source, err := model.GetAuthSourceByName(raw) if err == nil { @@ -225,7 +226,7 @@ func parseSourceID(c *gin.Context) (uint64, error) { } var id uint64 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 } diff --git a/internal/apps/admin/errs.go b/internal/apps/admin/errs.go index 1e0ce1eb..15c9668f 100644 --- a/internal/apps/admin/errs.go +++ b/internal/apps/admin/errs.go @@ -18,5 +18,8 @@ limitations under the License. package admin const ( - AdminRequired = "未经授权访问" + AdminRequired = "未经授权访问" + InvalidAuthSourceID = "认证源 ID 无效" + InvalidCursorParam = "无效的 cursor 参数" + InvalidTaskExecutionID = "无效的任务执行记录 ID" ) diff --git a/internal/apps/admin/logs/routers.go b/internal/apps/admin/logs/routers.go index b4a17e58..66396c91 100644 --- a/internal/apps/admin/logs/routers.go +++ b/internal/apps/admin/logs/routers.go @@ -21,6 +21,7 @@ import ( "encoding/json" "net/http" + "github.com/Rain-kl/Wavelet/internal/apps/admin" "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" @@ -53,7 +54,7 @@ func GetLogs(c *gin.Context) { var cursor, limit int if _, err := parsePositiveInt(cursorStr, &cursor); err != nil { - c.JSON(http.StatusBadRequest, util.Err("无效的 cursor 参数")) + c.JSON(http.StatusBadRequest, util.Err(admin.InvalidCursorParam)) return } if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 { diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index ebad97aa..f51d9fe1 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -227,52 +227,6 @@ func UpdateSystemConfig(c *gin.Context) { 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 配置请求 type TestSMTPRequest struct { SMTPHost string `json:"smtp_host" binding:"required,max=255"` diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index dc966959..4040f4ad 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -56,7 +56,6 @@ func setupTestRouter(authUser *model.User) *gin.Engine { { systemConfigRouter.GET("", GetSystemConfig) systemConfigRouter.PUT("", UpdateSystemConfig) - systemConfigRouter.DELETE("", DeleteSystemConfig) } 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) { _, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 286b1224..6dc1d786 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -24,6 +24,7 @@ import ( "strings" "time" + "github.com/Rain-kl/Wavelet/internal/apps/admin" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" @@ -156,7 +157,7 @@ func ListTaskExecutions(c *gin.Context) { func GetTaskExecution(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID")) + c.JSON(http.StatusBadRequest, util.Err(admin.InvalidTaskExecutionID)) return } @@ -186,7 +187,7 @@ func GetTaskExecution(c *gin.Context) { func RetryTask(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID")) + c.JSON(http.StatusBadRequest, util.Err(admin.InvalidTaskExecutionID)) return } diff --git a/internal/apps/cap/errs.go b/internal/apps/cap/errs.go new file mode 100644 index 00000000..8a521e14 --- /dev/null +++ b/internal/apps/cap/errs.go @@ -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 = "验证码校验失败或已过期,请重试" +) diff --git a/internal/apps/cap/middleware.go b/internal/apps/cap/middleware.go index 7025f0e7..e60231e5 100644 --- a/internal/apps/cap/middleware.go +++ b/internal/apps/cap/middleware.go @@ -35,13 +35,13 @@ func VerifyMiddleware(mgr *caputil.Manager, scope string, enabledFunc func() boo token := c.GetHeader("X-Cap-Token") if token == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码验证失败,缺少验证码凭证")) + c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(errCapTokenMissing)) return } valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) if err != nil || !valid { - c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败或已过期,请重试")) + c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(errCapTokenInvalidOrExpired)) return } diff --git a/internal/apps/oauth/constants.go b/internal/apps/oauth/constants.go index 9afb689b..8f586963 100644 --- a/internal/apps/oauth/constants.go +++ b/internal/apps/oauth/constants.go @@ -23,9 +23,13 @@ import ( ) const ( - UserNameKey = "username" - UserIDKey = "user_id" - UserObjKey = "user_obj" + UserNameKey = "username" + UserIDKey = "user_id" + UserObjKey = "user_obj" + PendingOAuthSourceIDKey = "pending_oauth_source_id" + PendingOAuthExternalIDKey = "pending_oauth_external_id" + PendingOAuthExternalUsernameKey = "pending_oauth_external_username" + PendingOAuthEmailKey = "pending_oauth_email" ) const ( diff --git a/internal/apps/oauth/errs.go b/internal/apps/oauth/errs.go index 98aba25a..690f6a82 100644 --- a/internal/apps/oauth/errs.go +++ b/internal/apps/oauth/errs.go @@ -18,7 +18,16 @@ limitations under the License. package oauth const ( - InvalidState = "非法登录请求" - IDTokenVerifyFailed = "ID Token 验证失败" - NonceMismatch = "nonce 不匹配,可能存在重放攻击" + InvalidState = "非法登录请求" + IDTokenVerifyFailed = "ID Token 验证失败" + IDTokenVerifyFailedFormat = "%s: %w" + NonceMismatch = "nonce 不匹配,可能存在重放攻击" + NoActiveAuthSource = "未配置可用认证源" + ServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试" + AuthSourceRequired = "认证源不能为空" + DiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" + UsernameGenerateFailed = "无法生成可用用户名" + UsernameFromSourceFailed = "无法从认证源获取用户名" + AuthSourceDisabled = "认证源未启用" + InvalidExternalAccountBindingID = "绑定记录 ID 无效" ) diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go index aed6a1d9..1b58b2f1 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/internal/apps/oauth/oauth_test.go @@ -267,7 +267,50 @@ func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string) // ----------------------------------------------------------------------------- // 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 { dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -593,29 +636,7 @@ func TestCallbackLoginAndUserInfo(t *testing.T) { state := uuid.NewString() // 1. Mock the outgoing HTTP client for token exchange and user info fetching - httpMock := &http.Client{ - 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) - }, - }, - } + httpMock := newMockOIDCClient(testIssuerURL, testClientID, state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User") util.SetHTTPClient(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) // Callback with same username but different external ID (99999) - httpMock2 := &http.Client{ - 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") - }, - }, - } + httpMock2 := newMockOIDCClient(testIssuerURL, testClientID, state2, "99999", "test_oauth_user", "another@linux.do", "Another User") util.SetHTTPClient(httpMock2) // 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) - httpMock4 := &http.Client{ - 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") - }, - }, - } + httpMock4 := newMockOIDCClient(testIssuerURL, testClientID, state4, "77777", "need_bind_user", "needbind@linux.do", "Need Bind User") util.SetHTTPClient(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) // Mock OIDC discovery, JWKS, and Token exchange for custom source (GitHub) - httpMock := &http.Client{ - 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) - }, - }, - } + httpMock := newMockOIDCClient("https://github.com", "gh_client", state, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester") util.SetHTTPClient(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) // Re-sign token for new state (since state serves as OIDC Nonce) - httpMock3 := &http.Client{ - 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") - }, - }, - } + httpMock3 := newMockOIDCClient("https://github.com", "gh_client", state3, "github_user_123", "github_tester", "tester@github.com", "GitHub Tester") util.SetHTTPClient(httpMock3) router3 := setupTestRouter(dbConn, mockRedis, httpMock3) diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index a38b9010..6a9f7539 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -87,7 +87,7 @@ func resolveAuthSource(sourceName string) (*model.AuthSource, error) { return nil, err } if len(sources) == 0 { - return nil, errors.New("未配置可用认证源") + return nil, errors.New(NoActiveAuthSource) } return &sources[0], nil } @@ -122,18 +122,18 @@ func activeLoginSources() []AuthSourceView { func getFrontendLoginRedirectURL(ctx context.Context) (string, error) { var sc model.SystemConfig 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 } func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { if source == nil { - return nil, nil, errors.New("认证源不能为空") + return nil, nil, errors.New(AuthSourceRequired) } 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) @@ -194,7 +194,7 @@ func uniqueUsername(ctx context.Context, base string) (string, error) { } 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) { @@ -213,7 +213,7 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri if rawIDToken, ok := token.Extra("id_token").(string); ok { idToken, verifyErr := verifier.Verify(ctx, rawIDToken) if verifyErr != nil { - return nil, fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr) + return nil, fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr) } if nonce != "" && idToken.Nonce != nonce { return nil, errors.New(NonceMismatch) @@ -257,7 +257,7 @@ func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error { userInfo.Username = userInfo.Sub } if userInfo.Username == "" { - return errors.New("无法从认证源获取用户名") + return errors.New(UsernameFromSourceFailed) } if userInfo.Name == "" { userInfo.Name = userInfo.Username @@ -359,7 +359,7 @@ func Authorize(c *gin.Context) { return } if !source.IsActive { - c.JSON(http.StatusBadRequest, util.Err("认证源未启用")) + c.JSON(http.StatusBadRequest, util.Err(AuthSourceDisabled)) return } purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose"))) @@ -490,10 +490,10 @@ func Callback(c *gin.Context) { if !registrationEnabled { // 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态 session := sessions.Default(c) - session.Set("pending_oauth_source_id", source.ID) - session.Set("pending_oauth_external_id", userInfo.Sub) - session.Set("pending_oauth_external_username", userInfo.Username) - session.Set("pending_oauth_email", userInfo.Email) + session.Set(PendingOAuthSourceIDKey, source.ID) + session.Set(PendingOAuthExternalIDKey, userInfo.Sub) + session.Set(PendingOAuthExternalUsernameKey, userInfo.Username) + session.Set(PendingOAuthEmailKey, userInfo.Email) if err := session.Save(); err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return @@ -577,7 +577,7 @@ func DeleteExternalAccount(c *gin.Context) { rawID := strings.TrimSpace(c.Param("id")) id, err := strconv.ParseUint(rawID, 10, 64) if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, util.Err("绑定记录 ID 无效")) + c.JSON(http.StatusBadRequest, util.Err(InvalidExternalAccountBindingID)) return } if err := model.DeleteExternalAccountForUser(id, userID); err != nil { diff --git a/internal/apps/upload/errs.go b/internal/apps/upload/errs.go index 01cc9248..855e1271 100644 --- a/internal/apps/upload/errs.go +++ b/internal/apps/upload/errs.go @@ -30,4 +30,23 @@ const ( ErrInvalidFilePath = "非法文件路径" ErrSaveUploadRecordFailed = "保存上传记录失败" 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" ) diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index eec15bc3..ec8a120a 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -92,7 +92,7 @@ func UploadFile(c *gin.Context) { // 校验大小 if header.Size > maxUploadSize { - c.JSON(http.StatusOK, util.Err("文件大小不能超过 32MB")) + c.JSON(http.StatusOK, util.Err(ErrGenericFileTooLarge)) return } @@ -145,7 +145,7 @@ func UploadFile(c *gin.Context) { } } if isImageExt && !strings.HasPrefix(mimeType, "image/") { - c.JSON(http.StatusOK, util.Err("文件内容与扩展名不匹配,可能包含安全风险")) + c.JSON(http.StatusOK, util.Err(ErrFileContentExtensionMismatch)) return } @@ -179,7 +179,7 @@ func UploadFile(c *gin.Context) { c.JSON(http.StatusOK, util.OK(newUpload)) return } else if !errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusOK, util.Err("文件校验失败")) + c.JSON(http.StatusOK, util.Err(ErrFileValidationFailed)) return } @@ -188,7 +188,7 @@ func UploadFile(c *gin.Context) { var meta model.UploadMetadata if metadataStr != "" { if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil { - c.JSON(http.StatusOK, util.Err("元数据 JSON 格式不合法")) + c.JSON(http.StatusOK, util.Err(ErrInvalidMetadataJSON)) return } } @@ -281,7 +281,7 @@ func DownloadFile(c *gin.Context) { idStr := c.Param("id") uploadID, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusOK, util.Err("无效的文件 ID")) + c.JSON(http.StatusOK, util.Err(ErrInvalidFileID)) return } @@ -291,7 +291,7 @@ func DownloadFile(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) return } - c.JSON(http.StatusOK, util.Err("查询文件记录失败")) + c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) return } @@ -339,7 +339,7 @@ func BatchDownloadFiles(c *gin.Context) { var req batchDownloadRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusOK, util.Err("参数绑定失败,请传入有效的文件 ID 数组")) + c.JSON(http.StatusOK, util.Err(ErrInvalidBatchDownloadRequest)) return } @@ -348,7 +348,7 @@ func BatchDownloadFiles(c *gin.Context) { for _, idStr := range req.IDs { id, err := strconv.ParseUint(idStr, 10, 64) 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 } ids = append(ids, id) @@ -357,12 +357,12 @@ func BatchDownloadFiles(c *gin.Context) { // 查库获取所有匹配且正常的文件记录 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 { - c.JSON(http.StatusOK, util.Err("检索文件记录失败")) + c.JSON(http.StatusOK, util.Err(ErrRetrieveUploadRecordsFailed)) return } if len(uploads) == 0 { - c.JSON(http.StatusOK, util.Err("没有找到任何有效的文件记录进行打包")) + c.JSON(http.StatusOK, util.Err(ErrNoValidFilesForArchive)) return } @@ -458,7 +458,7 @@ func ListMyFiles(c *gin.Context) { var req listMyFilesRequest if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusOK, util.Err("参数错误")) + c.JSON(http.StatusOK, util.Err(ErrInvalidParams)) return } if req.Page <= 0 { @@ -483,14 +483,14 @@ func ListMyFiles(c *gin.Context) { var total int64 if err := query.Count(&total).Error; err != nil { - c.JSON(http.StatusOK, util.Err("查询文件数量失败")) + c.JSON(http.StatusOK, util.Err(ErrQueryFileCountFailed)) return } var items []model.Upload offset := (req.Page - 1) * req.PageSize 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 } @@ -520,7 +520,7 @@ func DeleteFile(c *gin.Context) { idStr := c.Param("id") uploadID, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusOK, util.Err("无效的文件 ID")) + c.JSON(http.StatusOK, util.Err(ErrInvalidFileID)) return } @@ -530,7 +530,7 @@ func DeleteFile(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) return } - c.JSON(http.StatusOK, util.Err("查询文件记录失败")) + c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) return } @@ -541,7 +541,7 @@ func DeleteFile(c *gin.Context) { } 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 } diff --git a/internal/apps/upload/tasks.go b/internal/apps/upload/tasks.go index 41256a3c..cb2ffd12 100644 --- a/internal/apps/upload/tasks.go +++ b/internal/apps/upload/tasks.go @@ -53,7 +53,7 @@ func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byt Limit(batchSize). Find(&unusedUploads).Error; err != nil { task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err) - return nil, fmt.Errorf("查询未使用的上传文件失败: %w", err) + return nil, fmt.Errorf(ErrQueryUnusedUploadsFailed, err) } // 没有更多数据,退出循环 diff --git a/internal/apps/upload/utils.go b/internal/apps/upload/utils.go index e0a3a27a..578e1a60 100644 --- a/internal/apps/upload/utils.go +++ b/internal/apps/upload/utils.go @@ -27,19 +27,19 @@ const maxS3KeyLength = 1024 // ValidateS3Key validates an S3 object key for safety. func ValidateS3Key(key string) error { if key == "" { - return fmt.Errorf("s3 key must not be empty") + return fmt.Errorf(ErrS3KeyRequired) } if len(key) > maxS3KeyLength { - return fmt.Errorf("s3 key exceeds maximum length of %d", maxS3KeyLength) + return fmt.Errorf(ErrS3KeyTooLongFormat, maxS3KeyLength) } if strings.HasPrefix(key, "/") { - return fmt.Errorf("s3 key must not start with /") + return fmt.Errorf(ErrS3KeyStartsWithSlash) } if strings.Contains(key, "\x00") { - return fmt.Errorf("s3 key must not contain null bytes") + return fmt.Errorf(ErrS3KeyContainsNullBytes) } return nil diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go index 3128d0e3..a465c7e0 100644 --- a/internal/apps/user/access_tokens.go +++ b/internal/apps/user/access_tokens.go @@ -78,13 +78,13 @@ func CreateAccessToken(c *gin.Context) { var req createTokenRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusOK, util.Err("参数绑定失败")) + c.JSON(http.StatusOK, util.Err(errBindParamsFailed)) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { - c.JSON(http.StatusOK, util.Err("令牌名称不能为空")) + c.JSON(http.StatusOK, util.Err(errTokenNameRequired)) return } @@ -101,14 +101,14 @@ func CreateAccessToken(c *gin.Context) { } if int(count) >= maxLimit { - c.JSON(http.StatusOK, util.Err("已达到访问令牌最大创建数量限制")) + c.JSON(http.StatusOK, util.Err(errAccessTokenLimitReached)) return } // 生成 Token tokenStr, err := model.GenerateTokenString() if err != nil { - c.JSON(http.StatusOK, util.Err("生成令牌失败")) + c.JSON(http.StatusOK, util.Err(errGenerateTokenFailed)) return } @@ -150,7 +150,7 @@ func DeleteAccessToken(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusOK, util.Err("无效的令牌ID")) + c.JSON(http.StatusOK, util.Err(errInvalidTokenID)) return } @@ -161,7 +161,7 @@ func DeleteAccessToken(c *gin.Context) { } if tx.RowsAffected == 0 { - c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作")) + c.JSON(http.StatusOK, util.Err(errTokenNotFoundOrForbidden)) return } @@ -185,20 +185,20 @@ func RotateAccessToken(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusOK, util.Err("无效的令牌ID")) + c.JSON(http.StatusOK, util.Err(errInvalidTokenID)) return } var tokenRecord model.AccessToken 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 } // 生成新的 Token newTokenStr, err := model.GenerateTokenString() if err != nil { - c.JSON(http.StatusOK, util.Err("生成令牌失败")) + c.JSON(http.StatusOK, util.Err(errGenerateTokenFailed)) return } diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go index 13573436..18b92176 100644 --- a/internal/apps/user/controllers.go +++ b/internal/apps/user/controllers.go @@ -77,6 +77,64 @@ func generateVerificationCode() string { 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 { enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled) if err != nil { @@ -124,7 +182,7 @@ func setLoginSession(c *gin.Context, user *model.User) error { // @Router /api/v1/user/login [post] func Login(c *gin.Context) { if !isPasswordLoginEnabled() { - c.JSON(http.StatusOK, util.Err("管理员关闭了密码登录")) + c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled)) return } var req loginRequest @@ -134,14 +192,14 @@ func Login(c *gin.Context) { } req.Username = strings.TrimSpace(req.Username) if req.Username == "" || req.Password == "" { - c.JSON(http.StatusOK, util.Err("无效的参数")) + c.JSON(http.StatusOK, util.Err(errInvalidParams)) return } var user model.User ctx := c.Request.Context() 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 } 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) { - c.JSON(http.StatusOK, util.Err("用户名或密码错误")) + c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong)) return } if isEmailLoginVerificationEnabled() { if user.Email == "" { - c.JSON(http.StatusOK, util.Err("该账号未绑定邮箱,请联系管理员绑定邮箱后再登录")) + c.JSON(http.StatusOK, util.Err(errLoginEmailMissing)) return } if req.Code == "" { // 校验 Redis 发送冷却时间 - cooldownKey := fmt.Sprintf("email_code:cooldown:%s", user.Email) + cooldownKey := getEmailCooldownKey(user.Email) var temp string err := db.GetJSON(ctx, cooldownKey, &temp) if err != nil { // 没有冷却,触发验证码发送 - code := generateVerificationCode() - codeKey := fmt.Sprintf("email_code:login:%s", user.Email) - // 存验证码,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("

Wavelet 登录验证

您的登录验证码为:%s,5分钟内有效,请勿将验证码泄露给他人。

", 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("投递验证邮件发送任务失败,请重试")) + if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) return } } // 脱敏邮箱并返回错误,提示前端需要输入验证码 maskedEmail := util.MaskEmail(user.Email) - c.JSON(http.StatusOK, util.Err("need_email_code:"+maskedEmail)) + c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail)) return } // 校验验证码 - codeKey := fmt.Sprintf("email_code:login:%s", user.Email) - var storedCode string - if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + if !verifyEmailCode(ctx, user.Email, "login", req.Code) { + c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired)) return } - if storedCode != req.Code { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) - return - } - - // 验证成功,删除验证码 - _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() } session := sessions.Default(c) @@ -232,7 +254,7 @@ func Login(c *gin.Context) { if isPlaintext { if err := user.SetEncryptedPassword(req.Password); 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 } needChangePassword = true @@ -247,15 +269,15 @@ func Login(c *gin.Context) { return } if err := setLoginSession(c, &user); err != nil { - c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试")) + c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } - // 检查是否有未完成的 OAuth/OIDC 绑定 - pendingSourceID := session.Get("pending_oauth_source_id") - pendingExternalID := session.Get("pending_oauth_external_id") - pendingExternalUsername := session.Get("pending_oauth_external_username") - pendingEmail := session.Get("pending_oauth_email") + // 检查是否有未完成 of OAuth/OIDC 绑定 + pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey) + pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey) + pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey) + pendingEmail := session.Get(oauth.PendingOAuthEmailKey) if pendingSourceID != nil && pendingExternalID != nil { var sourceID uint64 @@ -281,10 +303,10 @@ func Login(c *gin.Context) { }) } // 清除 pending 信息 - session.Delete("pending_oauth_source_id") - session.Delete("pending_oauth_external_id") - session.Delete("pending_oauth_external_username") - session.Delete("pending_oauth_email") + session.Delete(oauth.PendingOAuthSourceIDKey) + session.Delete(oauth.PendingOAuthExternalIDKey) + session.Delete(oauth.PendingOAuthExternalUsernameKey) + session.Delete(oauth.PendingOAuthEmailKey) _ = session.Save() } @@ -304,7 +326,7 @@ func Login(c *gin.Context) { // @Router /api/v1/user/register [post] func Register(c *gin.Context) { if !isRegistrationEnabled() || !isPasswordRegisterEnabled() { - c.JSON(http.StatusOK, util.Err("管理员关闭了注册")) + c.JSON(http.StatusOK, util.Err(errRegistrationDisabled)) return } @@ -322,11 +344,11 @@ func Register(c *gin.Context) { req.Code = strings.TrimSpace(req.Code) if req.Username == "" || req.Password == "" { - c.JSON(http.StatusOK, util.Err("无效的参数")) + c.JSON(http.StatusOK, util.Err(errInvalidParams)) return } if len(req.Password) < 8 { - c.JSON(http.StatusOK, util.Err("密码长度不能少于 8 位")) + c.JSON(http.StatusOK, util.Err(errPasswordTooShort)) return } @@ -335,23 +357,14 @@ func Register(c *gin.Context) { // 邮箱注册验证校验 if isEmailRegisterVerificationEnabled() { if req.Email == "" || req.Code == "" { - c.JSON(http.StatusOK, util.Err("邮箱或验证码未填写")) + c.JSON(http.StatusOK, util.Err(errEmailOrCodeRequired)) return } - codeKey := fmt.Sprintf("email_code:register:%s", req.Email) - var storedCode string - if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + if !verifyEmailCode(ctx, req.Email, "register", req.Code) { + c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired)) return } - if storedCode != req.Code { - c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) - return - } - - // 验证通过,删除 Redis 中的验证码 - _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() } user := model.User{ @@ -380,7 +393,7 @@ func Register(c *gin.Context) { } if err := setLoginSession(c, &user); err != nil { - c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试")) + c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } @@ -434,36 +447,36 @@ func ChangePassword(c *gin.Context) { req.NewPassword = strings.TrimSpace(req.NewPassword) if req.OldPassword == "" || req.NewPassword == "" { - c.JSON(http.StatusOK, util.Err("无效的参数")) + c.JSON(http.StatusOK, util.Err(errInvalidParams)) return } if len(req.NewPassword) < 8 { - c.JSON(http.StatusOK, util.Err("新密码长度不能少于 8 位")) + c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort)) return } userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) if userObj == nil { - c.JSON(http.StatusUnauthorized, util.Err("请先登录")) + c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired)) return } ctx := c.Request.Context() var dbUser model.User 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 } // 校验旧密码 if !dbUser.CheckPassword(req.OldPassword) { - c.JSON(http.StatusOK, util.Err("原密码不正确")) + c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect)) return } // 加密并更新为新密码 if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil { - c.JSON(http.StatusOK, util.Err("密码加密失败,请重试")) + c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed)) return } @@ -499,12 +512,12 @@ func SendEmailCode(c *gin.Context) { req.Email = strings.TrimSpace(req.Email) if req.Email == "" { - c.JSON(http.StatusOK, util.Err("邮箱地址不能为空")) + c.JSON(http.StatusOK, util.Err(errEmailRequired)) return } if req.Scene != "register" { - c.JSON(http.StatusOK, util.Err("不支持的验证场景")) + c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene)) return } @@ -517,47 +530,22 @@ func SendEmailCode(c *gin.Context) { return } if count > 0 { - c.JSON(http.StatusOK, util.Err("该邮箱已被注册")) + c.JSON(http.StatusOK, util.Err(errEmailAlreadyRegistered)) return } // 2. 校验 Redis 发送冷却时间 - cooldownKey := fmt.Sprintf("email_code:cooldown:%s", req.Email) + cooldownKey := getEmailCooldownKey(req.Email) var temp string err := db.GetJSON(ctx, cooldownKey, &temp) if err == nil { - c.JSON(http.StatusOK, util.Err("验证码发送频繁,请稍后再试")) + c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown)) return } - // 3. 生成并缓存验证码 - code := generateVerificationCode() - codeKey := fmt.Sprintf("email_code:register:%s", req.Email) - 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("

Wavelet 注册验证

您的注册验证码为:%s,5分钟内有效,请勿泄露给他人。

", 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("投递验证邮件发送任务失败,请重试")) + // 3. 发送验证码 + if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) return } @@ -595,14 +583,14 @@ func UpdateProfile(c *gin.Context) { userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) if userObj == nil { - c.JSON(http.StatusUnauthorized, util.Err("请先登录")) + c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired)) return } ctx := c.Request.Context() var dbUser model.User 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 } @@ -610,7 +598,7 @@ func UpdateProfile(c *gin.Context) { req.Email = strings.TrimSpace(req.Email) if req.Email != "" && req.Email != dbUser.Email { if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { - c.JSON(http.StatusOK, util.Err("邮箱格式不正确")) + c.JSON(http.StatusOK, util.Err(errEmailFormatInvalid)) return } @@ -620,7 +608,7 @@ func UpdateProfile(c *gin.Context) { return } if count > 0 { - c.JSON(http.StatusOK, util.Err("该邮箱已被其他账号绑定")) + c.JSON(http.StatusOK, util.Err(errEmailAlreadyBound)) return } } diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index 27b4e083..059a9c3c 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -17,4 +17,42 @@ limitations under the License. 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" +) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 0c1cfac0..e43b882d 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -44,12 +44,12 @@ type SendEmailHandler struct{} // 校验并标准化邮件发送参数,框架在 Admin 下发时自动调用 func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") + return nil, errors.New(errTaskPayloadRequired) } var req SendEmailPayload 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) @@ -57,7 +57,7 @@ func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { req.Body = strings.TrimSpace(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) @@ -68,7 +68,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T var req SendEmailPayload if err := json.Unmarshal(payload, &req); err != nil { 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) @@ -94,7 +94,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T } if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" { - err := errors.New("系统 SMTP 邮件服务配置不完整") + err := errors.New(errSMTPConfigIncomplete) task.AppendLog(ctx, "发送失败: %v", 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) if err != nil { task.AppendLog(ctx, "邮件发送失败: %v", err) - return nil, fmt.Errorf("发送邮件失败: %w", err) + return nil, fmt.Errorf(errSendMailFailed, err) } msg := fmt.Sprintf("邮件成功发送至: %s", req.To) diff --git a/internal/db/errs.go b/internal/db/errs.go new file mode 100644 index 00000000..b7c1f624 --- /dev/null +++ b/internal/db/errs.go @@ -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" +) diff --git a/internal/db/redis.go b/internal/db/redis.go index 99fe3e99..3987f55f 100644 --- a/internal/db/redis.go +++ b/internal/db/redis.go @@ -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 { - return fmt.Errorf("failed to set redis hash: %w", err) + return fmt.Errorf(errRedisHashSetFailed, err) } 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 { - return fmt.Errorf("failed to unmarshal data: %w", err) + return fmt.Errorf(errUnmarshalDataFailed, err) } 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 { - return fmt.Errorf("failed to unmarshal data: %w", err) + return fmt.Errorf(errUnmarshalDataFailed, err) } 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 { jsonData, err := json.Marshal(data) 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 { - return fmt.Errorf("failed to set redis key: %w", err) + return fmt.Errorf(errRedisKeySetFailed, err) } return nil diff --git a/internal/logger/errs.go b/internal/logger/errs.go new file mode 100644 index 00000000..4b5dc948 --- /dev/null +++ b/internal/logger/errs.go @@ -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" +) diff --git a/internal/logger/utils.go b/internal/logger/utils.go index e24e52cc..d2b1f91d 100644 --- a/internal/logger/utils.go +++ b/internal/logger/utils.go @@ -55,7 +55,7 @@ func initWriter() (zapcore.WriteSyncer, error) { logPath := logConfig.FilePath logDir := filepath.Dir(logPath) 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) } // 配置日志轮转 diff --git a/internal/model/auth_source.go b/internal/model/auth_source.go index ae8c0d52..836d54d7 100644 --- a/internal/model/auth_source.go +++ b/internal/model/auth_source.go @@ -91,19 +91,19 @@ func (source *AuthSource) Normalize() { func (source *AuthSource) Validate() error { source.Normalize() if source.Name == "" { - return errors.New("认证源名称不能为空") + return errors.New(errAuthSourceNameRequired) } if !authSourceNamePattern.MatchString(source.Name) { - return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头") + return errors.New(errAuthSourceNameInvalid) } if source.Type != AuthSourceTypeOIDC { - return errors.New("认证源类型仅支持 oidc") + return errors.New(errAuthSourceTypeUnsupported) } if source.OpenIDDiscoveryURL == "" { - return errors.New("OIDC 认证源必须配置 Discovery URL") + return errors.New(errAuthSourceDiscoveryURLRequired) } if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { - return errors.New("启用认证源前必须配置 Client ID 和 Client Secret") + return errors.New(errAuthSourceClientCredentialsRequired) } return nil } @@ -137,7 +137,7 @@ func GetActiveAuthSources() ([]AuthSource, error) { func GetAuthSourceByID(id uint64) (*AuthSource, error) { if id == 0 { - return nil, errors.New("认证源 ID 不能为空") + return nil, errors.New(errAuthSourceIDRequired) } var source AuthSource 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) { name = strings.TrimSpace(name) if name == "" { - return nil, errors.New("认证源名称不能为空") + return nil, errors.New(errAuthSourceNameRequired) } var source AuthSource 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 { if source.ID == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } var current AuthSource 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 { if id == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error { 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 { if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { - return errors.New("外部账号绑定信息不完整") + return errors.New(errExternalAccountBindingIncomplete) } account.ExternalID = strings.TrimSpace(account.ExternalID) 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 if err == nil { if current.UserID != account.UserID { - return errors.New("该外部账号已绑定到其他用户") + return errors.New(errExternalAccountAlreadyBoundToAnother) } return tx.Model(¤t).Updates(map[string]any{ "external_username": account.ExternalUsername, @@ -255,7 +255,7 @@ func BindExternalAccount(account *ExternalAccount) error { func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) { if userID == 0 { - return nil, errors.New("用户 ID 不能为空") + return nil, errors.New(errUserIDRequired) } var accounts []ExternalAccount 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 { 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 } diff --git a/internal/model/errs.go b/internal/model/errs.go new file mode 100644 index 00000000..83aa71c6 --- /dev/null +++ b/internal/model/errs.go @@ -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 不能为空" +) diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index dc086e20..eebea932 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -85,7 +85,7 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { // 查数据库 database := db.DB(ctx) if database == nil { - return errors.New("database not initialized") + return errors.New(errDatabaseNotInitialized) } 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) 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 @@ -125,7 +125,7 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal. value, err := decimal.NewFromString(sc.Value) 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) 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 @@ -160,7 +160,7 @@ func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) { } 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 diff --git a/internal/model/templates.go b/internal/model/templates.go index 77fc0c1a..bd2e7b69 100644 --- a/internal/model/templates.go +++ b/internal/model/templates.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "errors" + "fmt" "strings" "text/template" "time" @@ -55,13 +56,13 @@ func (t *Template) Normalize() { func (t *Template) Validate() error { t.Normalize() if t.Key == "" { - return errors.New("模板标识符不能为空") + return errors.New(errTemplateKeyRequired) } if t.Name == "" { - return errors.New("模板名称不能为空") + return errors.New(errTemplateNameRequired) } if t.Content == "" { - return errors.New("模板内容不能为空") + return errors.New(errTemplateContentRequired) } return nil } @@ -94,26 +95,16 @@ func (t *Template) Render(data any) (string, string, error) { return subject, bodyBuf.String(), nil } -// RenderTemplate 渲染模板的高级包装。如果读取或渲染失败,将使用 fallbackSubject 和 fallbackBody 进行解析和返回。 -func RenderTemplate(ctx context.Context, key string, data any, fallbackSubject, fallbackBody string) (string, string) { +// RenderTemplate 渲染指定模板。模板不存在或渲染失败时返回错误,由调用方决定如何处理。 +func RenderTemplate(ctx context.Context, key string, data any) (string, string, error) { var t Template - if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err == nil { - subject, body, err := t.Render(data) - if err == nil { - return subject, body - } + if err := db.DB(ctx).Where("key = ?", key).First(&t).Error; err != nil { + return "", "", fmt.Errorf(errTemplateUnavailable, key, err) } - // 降级使用传入的默认模板内容渲染 - tFallback := Template{ - Key: key + "_fallback", - Subject: fallbackSubject, - Content: fallbackBody, + subject, body, err := t.Render(data) + if err != nil { + return "", "", fmt.Errorf(errTemplateRenderFailed, key, err) } - subject, body, err := tFallback.Render(data) - if err == nil { - return subject, body - } - - return fallbackSubject, fallbackBody + return subject, body, nil } diff --git a/internal/model/users.go b/internal/model/users.go index 816ae18b..bb21836f 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -92,12 +92,15 @@ func (u *User) SetEncryptedPassword(password string) error { 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 { if u.Password == "" || password == "" { return false } - isBcrypt := strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$") - if isBcrypt { + if u.IsPasswordEncrypted() { return util.CheckPasswordHash(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 { enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled) if err == nil && !enabled { - return errors.New("注册已关闭") + return errors.New(errRegistrationDisabled) } 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 { enabled, err := GetBoolByKey(ctx, ConfigKeyRegistrationEnabled) 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 } 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 } if emailCount > 0 { - return errors.New("该邮箱已被其他账号绑定") + return errors.New(errEmailAlreadyBound) } } diff --git a/internal/router/frontend_embedded.go b/internal/router/frontend_embedded.go index 7e94ab56..657dbf60 100644 --- a/internal/router/frontend_embedded.go +++ b/internal/router/frontend_embedded.go @@ -1,8 +1,7 @@ //go:build embed_frontend /* -Copyright 2025 linux.do -Modified by Arctel.net, 2026 +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. diff --git a/internal/router/router.go b/internal/router/router.go index 05a8bc70..5797d732 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -215,7 +215,6 @@ func Serve() { { systemConfigRouter.GET("", system_config.GetSystemConfig) systemConfigRouter.PUT("", system_config.UpdateSystemConfig) - systemConfigRouter.DELETE("", system_config.DeleteSystemConfig) } // Templates diff --git a/internal/storage/errs.go b/internal/storage/errs.go index dfb5061e..e91be4bd 100644 --- a/internal/storage/errs.go +++ b/internal/storage/errs.go @@ -1,6 +1,5 @@ /* -Copyright 2025 linux.do -Modified by Arctel.net, 2026 +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. @@ -20,11 +19,21 @@ package storage type ErrS3InitializationFailed struct{} func (e ErrS3InitializationFailed) Error() string { - return "S3存储初始化失败" + return errS3InitializationFailed } type LocalCacheError struct{} 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" +) diff --git a/internal/storage/s3.go b/internal/storage/s3.go index a0d4f930..f41292ee 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -146,7 +146,7 @@ func putObjectDefault(ctx context.Context, key string, body io.Reader, size int6 _, err := client.PutObject(ctx, input) if err != nil { 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 } @@ -181,7 +181,7 @@ func getObjectDefault(ctx context.Context, key string) (*ObjectInfo, error) { }) if err != nil { 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" @@ -223,13 +223,13 @@ func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) { resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil) if err != nil { 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 { resp.Body.Close() 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") @@ -265,7 +265,7 @@ func deleteObjectDefault(ctx context.Context, key string) error { }) if err != nil { 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 } diff --git a/internal/task/errs.go b/internal/task/errs.go new file mode 100644 index 00000000..153cd998 --- /dev/null +++ b/internal/task/errs.go @@ -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" +) diff --git a/internal/task/executor.go b/internal/task/executor.go index 585dfd0c..aee277ec 100644 --- a/internal/task/executor.go +++ b/internal/task/executor.go @@ -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) { meta := GetTaskMeta(taskType) if meta == nil { - return "", fmt.Errorf("未知的任务类型: %s", taskType) + return "", fmt.Errorf(errUnknownTaskType, taskType) } // 生成唯一的 TaskID @@ -119,7 +119,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere } if err := model.CreateTaskExecution(ctx, execution); err != nil { - return "", fmt.Errorf("创建任务执行记录失败: %w", err) + return "", fmt.Errorf(errCreateTaskExecutionFailed, err) } // 入队 Asynq @@ -137,7 +137,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere execution.StartedAt = &now execution.FinishedAt = &now _ = model.UpdateTaskExecution(ctx, execution) - return "", fmt.Errorf("任务入队失败: %w", err) + return "", fmt.Errorf(errTaskEnqueueFailed, err) } 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) { execution, err := model.GetTaskExecutionByID(ctx, id) if err != nil { - return "", fmt.Errorf("任务执行记录不存在: %w", err) + return "", fmt.Errorf(errTaskExecutionNotFound, err) } if execution.Status != model.TaskExecutionStatusFailed { - return "", fmt.Errorf("只有失败的任务才能重试,当前状态: %s", execution.Status) + return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) } if !execution.Retryable { - return "", fmt.Errorf("该任务不支持重试") + return "", fmt.Errorf(errTaskNotRetryable) } if execution.RetryCount >= execution.MaxRetry { - return "", fmt.Errorf("已达到最大重试次数 %d", execution.MaxRetry) + return "", fmt.Errorf(errTaskMaxRetryExceeded, execution.MaxRetry) } // 生成新的 TaskID @@ -179,7 +179,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { } if err := model.CreateTaskExecution(ctx, newExecution); err != nil { - return "", fmt.Errorf("创建重试任务执行记录失败: %w", err) + return "", fmt.Errorf(errCreateRetryExecutionFailed, err) } // 入队 Asynq @@ -196,7 +196,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { newExecution.StartedAt = &now newExecution.FinishedAt = &now _ = model.UpdateTaskExecution(ctx, newExecution) - return "", fmt.Errorf("重试任务入队失败: %w", err) + return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) } return newTaskID, nil @@ -224,7 +224,7 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error { // 查找处理器 handler, ok := getHandler(t.Type()) if !ok { - err := fmt.Errorf("未注册的任务处理器: %s", t.Type()) + err := fmt.Errorf(errUnregisteredTaskHandler, t.Type()) logger.ErrorF(ctx, "[TaskExecutor] %v", err) span.SetStatus(codes.Error, err.Error()) return err diff --git a/internal/task/scheduler/errs.go b/internal/task/scheduler/errs.go new file mode 100644 index 00000000..27cbc96c --- /dev/null +++ b/internal/task/scheduler/errs.go @@ -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" +) diff --git a/internal/task/scheduler/scheduler.go b/internal/task/scheduler/scheduler.go index fbc0e12a..0a5669ba 100644 --- a/internal/task/scheduler/scheduler.go +++ b/internal/task/scheduler/scheduler.go @@ -48,7 +48,7 @@ func StartScheduler() error { schedulerOnce.Do(func() { location, locErr := time.LoadLocation("Asia/Shanghai") if locErr != nil { - err = fmt.Errorf("failed to load location: %w", locErr) + err = fmt.Errorf(errLoadLocationFailed, locErr) return } scheduler = asynq.NewScheduler( diff --git a/internal/util/cap/cap.go b/internal/util/cap/cap.go index 76790e2a..acd5be50 100644 --- a/internal/util/cap/cap.go +++ b/internal/util/cap/cap.go @@ -105,10 +105,10 @@ func jwtSign(payload []byte, secret []byte) string { func jwtVerify(token string, secret []byte) ([]byte, error) { parts := strings.Split(token, ".") if len(parts) != 3 { - return nil, errors.New("invalid token format") + return nil, errors.New(errInvalidTokenFormat) } if parts[0] != jwtHeaderB64 { - return nil, errors.New("invalid header") + return nil, errors.New(errInvalidHeader) } sigInput := parts[0] + "." + parts[1] @@ -122,7 +122,7 @@ func jwtVerify(token string, secret []byte) ([]byte, error) { } if !hmac.Equal(expectedSig, actualSig) { - return nil, errors.New("signature mismatch") + return nil, errors.New(errSignatureMismatch) } 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) { payloadBytes, err := jwtVerify(token, secret) if err != nil { - return nil, errors.New("invalid_token") + return nil, errors.New(errInvalidToken) } var payload ChallengePayload if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return nil, errors.New("invalid_token") + return nil, errors.New(errInvalidToken) } if expectedScope != "" && payload.Scope != expectedScope { - return nil, errors.New("scope_mismatch") + return nil, errors.New(errScopeMismatch) } now := time.Now().UnixNano() / int64(time.Millisecond) if payload.Expires < now { - return nil, errors.New("expired") + return nil, errors.New(errExpired) } if len(solutions) != payload.Count { - return nil, errors.New("invalid_solutions") + return nil, errors.New(errInvalidSolutions) } tokenFnv := fnv1a(token) @@ -229,7 +229,7 @@ func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expe hashHex := hex.EncodeToString(hashBytes[:]) if !strings.HasPrefix(hashHex, target) { - return nil, errors.New("invalid_solution") + return nil, errors.New(errInvalidSolution) } } diff --git a/internal/util/cap/errs.go b/internal/util/cap/errs.go new file mode 100644 index 00000000..7507c6af --- /dev/null +++ b/internal/util/cap/errs.go @@ -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" +) diff --git a/internal/util/crypto.go b/internal/util/crypto.go index 16fd66a4..12894d13 100644 --- a/internal/util/crypto.go +++ b/internal/util/crypto.go @@ -54,28 +54,28 @@ func encryptBytes(signKey string, plaintext []byte) (string, error) { // 将 hex 编码的密钥转换为字节 key, err := hex.DecodeString(signKey) if err != nil { - return "", fmt.Errorf("invalid sign key: %w", err) + return "", fmt.Errorf(errInvalidSignKey, err) } if len(key) != 32 { - return "", errors.New("sign key must be 32 bytes (64 hex characters)") + return "", errors.New(errSignKeyLengthInvalid) } // 创建 AES cipher block, err := aes.NewCipher(key) if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) + return "", fmt.Errorf(errCreateCipherFailed, err) } // 使用 GCM 模式(Galois/Counter Mode) gcm, err := cipher.NewGCM(block) if err != nil { - return "", fmt.Errorf("failed to create GCM: %w", err) + return "", fmt.Errorf(errCreateGCMFailed, err) } // 生成随机 nonce nonce := make([]byte, gcm.NonceSize()) 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 编码的密钥转换为字节 key, err := hex.DecodeString(signKey) if err != nil { - return nil, fmt.Errorf("invalid sign key: %w", err) + return nil, fmt.Errorf(errInvalidSignKey, err) } if len(key) != 32 { - return nil, errors.New("sign key must be 32 bytes (64 hex characters)") + return nil, errors.New(errSignKeyLengthInvalid) } // 解码 base64 密文 data, err := Base64Decode(ciphertext) if err != nil { - return nil, fmt.Errorf("failed to decode ciphertext: %w", err) + return nil, fmt.Errorf(errDecodeCiphertextFailed, err) } // 创建 AES cipher block, err := aes.NewCipher(key) if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) + return nil, fmt.Errorf(errCreateCipherFailed, err) } // 使用 GCM 模式 gcm, err := cipher.NewGCM(block) if err != nil { - return nil, fmt.Errorf("failed to create GCM: %w", err) + return nil, fmt.Errorf(errCreateGCMFailed, err) } // 提取 nonce nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return nil, errors.New("ciphertext too short") + return nil, errors.New(errCiphertextTooShort) } 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) if err != nil { - return nil, fmt.Errorf("failed to decrypt: %w", err) + return nil, fmt.Errorf(errDecryptFailed, err) } return plaintext, nil diff --git a/internal/util/custom_types.go b/internal/util/custom_types.go index 145c9547..b817b437 100644 --- a/internal/util/custom_types.go +++ b/internal/util/custom_types.go @@ -29,7 +29,7 @@ type StringArray []string func (sa *StringArray) Scan(value interface{}) error { bytesValue, ok := value.([]byte) if !ok { - return fmt.Errorf("invalid value: %v", value) + return fmt.Errorf(errInvalidCustomValue, value) } return json.Unmarshal(bytesValue, sa) } diff --git a/internal/util/errs.go b/internal/util/errs.go new file mode 100644 index 00000000..2793e7d3 --- /dev/null +++ b/internal/util/errs.go @@ -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" +) diff --git a/internal/util/http_clients.go b/internal/util/http_clients.go index 207a5acb..b18db98d 100644 --- a/internal/util/http_clients.go +++ b/internal/util/http_clients.go @@ -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) { req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { - return nil, fmt.Errorf("创建HTTP请求失败: %w", err) + return nil, fmt.Errorf(errCreateHTTPRequestFailed, err) } 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) if err != nil { - return nil, fmt.Errorf("请求%s接口失败: %w", url, err) + return nil, fmt.Errorf(errHTTPRequestFailed, url, err) } return resp, nil diff --git a/internal/util/mail/errs.go b/internal/util/mail/errs.go new file mode 100644 index 00000000..75c3690d --- /dev/null +++ b/internal/util/mail/errs.go @@ -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" +) diff --git a/internal/util/mail/mail.go b/internal/util/mail/mail.go index 43179d7c..02a0c0d3 100644 --- a/internal/util/mail/mail.go +++ b/internal/util/mail/mail.go @@ -69,38 +69,38 @@ func SendMailHTML(cfg Config, to string, subject, body string) error { dialer := &net.Dialer{Timeout: 5 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) if err != nil { - return fmt.Errorf("dial tls failed: %w", err) + return fmt.Errorf(errDialTLSFailed, err) } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) client, err := smtp.NewClient(conn, cfg.Host) if err != nil { - return fmt.Errorf("smtp client creation failed: %w", err) + return fmt.Errorf(errSMTPClientCreationFailed, err) } defer client.Close() 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 { - return fmt.Errorf("smtp mail command failed: %w", err) + return fmt.Errorf(errSMTPMailCommandFailed, err) } 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() if err != nil { - return fmt.Errorf("smtp data command failed: %w", err) + return fmt.Errorf(errSMTPDataCommandFailed, err) } defer w.Close() _, err = w.Write([]byte(message)) if err != nil { - return fmt.Errorf("smtp writing body failed: %w", err) + return fmt.Errorf(errSMTPWritingBodyFailed, err) } 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) err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message)) if err != nil { - return fmt.Errorf("send mail failed: %w", err) + return fmt.Errorf(errSendMailFailed, err) } return nil