feat(cordis): add OpenFlare Cordis 架构改造设计

docs(changelog): 修正表述笔误

refactor(cordis): 磁盘缓存改用上上游能力并清理本地副本

按上游/下游归属规约:类型断言守卫已回流 Wavelet(f3d85d5,附回归用例),
本仓库删除 OpenFlare/plugins/server/pkg/cache 整包并改 import 到
Wavelet/pkg/cache/disk,同步后与上游零漂移。

验证:go build 通过;go test ./... exit 0(137 包 ok);256 条路由对拍与
232 条 swagger 操作均零差异;make build-all 四进制;前端零改动。

docs(cordis): 记录 T1 清理结果与五个复用阻塞点

refactor(cordis): server 复用上游 pkg 能力并删除等价本地副本

按上游/下游归属规约清理重复实现,删除 7 个与上游等价的本地包并改 import:
shared/response→pkg/response、pkg/{logger,mail,trace,httppool,cache/ram}→
上游同名包、infra/persistence/batchwriter→pkg/batchwriter。逐项核过差异:
httppool 逐字节相同;logger 的 Config 字段完全一致;response 的 7 个 Abort*
一致;cache/ram 换过去顺带把裸 go 变回带 panic 恢复的 util.Go。

两处非等价差异按语义处理:
- batchwriter.Stats 与 status DTO 原为类型别名,改为消费侧逐字段转换,
  避免 model 反向依赖基础设施类型;
- 上游 pkg/idgen 要求显式 Init(本地副本为懒加载自动初始化),本次保留本地
  副本,待与 infra 初始化一并迁移(已登记在清理计划)。

验证:go build 通过;go test ./... exit 0(138 包 ok);256 条路由对拍零差异;
make swagger 232 条操作零增减,且归一化后与旧文档深度相等——差异仅为
response.Any / logger.LogEntry 两个定义名随包路径改名,接口形状未变。

chore(cordis): 回流内核与 pkg/util 通用能力并清理 vendoring 污染

按新增的上游/下游归属规约:HandleRaw/BasePath 与版本比较、网络、格式化助手
属通用能力,已提交到 Wavelet 分支 feat/cordis-router-raw-routes,本仓库改为
纯同步获取(pkg/util 已零漂移),补丁登记保留至上游合并。

同时修掉我此前 git add -A 造成的污染:首次 vendoring 把上游工作区里被
gitignore 的运行期产物一起提交进来(upload 的 diskcache 缓存块 650 个与
driver_http/dist 前端构建物 380 个,共 12872 行/1030 文件)。sync-upstream.sh
现显式排除 uploads/dist/data/*.db,.gitignore 补上对应兜底规则。

AGENTS.md 增加上游/下游改动归属规约,并把仍指向前 Cordis 布局的硬性约束
(internal/router + Serve、internal/repository/logstore、internal/platform/bootstrap、
internal/cmd)改到当前插件路径。

验证:go build 通过;go test ./... exit 0(144 包 ok);make swagger 232 条
操作与基线逐条一致;make build-all 四进制;gofmt 干净。

feat(cordis): server 插件化并改由内核挂载控制面路由

新增 plugins/server/plugin.go:Apply 以 ctx.Router().Group(app.api_prefix)
声明根级与 /v1 全部路由;33 个注册函数由 *gin.RouterGroup 改为
core.RouterExtension,RegisterCollection 改用内核新增的 HandleRaw 保留
尾部斜杠变体,AdminMiddlewares 返回 []any(Go 不允许把 []T 展开为 ...any)。
删除 router.Serve 与 registerRoutes,装配根改为 core.App +
driver_http.New(WithEngine(router.BuildEngine())),监听、信号与优雅退出归内核;
前端 SPA 的 NoRoute 兜底因内核暂无贡献点而保留在引擎层。

路由保真证据:plugin_parity_test 对拍 baseline/routes-engine.txt 的 256 条
(方法 路径) 零差异;go test ./... exit 0(144 包 ok,含真实 handler 的
openflare/integration 用例走同一条挂载路径);make swagger 232 条操作与基线
逐条一致;golangci-lint 0 issues;make build-all 四进制;embed_frontend
标签编译通过;前端零改动。

已知待补:带 Redis 的实机 HTTP 冒烟(本机 6379 未启动,session store 与
改造前一样在建店阶段即 fatal),以及 bootstrap 的任务/设置/迁移注册迁入 Apply。

feat(core): RouterExtension 增加 HandleRaw 与 BasePath 以保真尾部斜杠路由

server 插件化的前置:Handle 经 cleanPath 会剥掉尾部斜杠,无法表达
/resource 与 /resource/ 两条不同路由,而 OpenFlare 有 20 个历史 list
端点两者都注册且部署关闭了 RedirectTrailingSlash,缺失即 404。新增
HandleRaw 与 BasePath(作用域包装器同样登记反注册),补 extpoints 用例;
并把 router.Serve 拆出 BuildEngine 以便交给 driver_http.WithEngine 复用,
新增路由表导出 harness,固化 256 条 (方法 路径) 基线供插件化对拍。
上游补丁登记于 backend/OpenFlare/upstream-patches.md,同步脚本改为按目录
前缀输出差异并在同步后提醒确认补丁是否仍在。

验证:go build 通过;go test ./... exit 0(143 包 ok);gofmt 干净。

docs(cordis): 记录 server 插件接入内核的可行路径与内核能力缺口

feat(cordis): agent/relay/flared 落地为内核驱动插件

三个边缘守护进程各新增 plugin.go,实现 core.Plugin + core.Driver
(自定义 DriverType 与同名 profile),装配与生命周期从 main 迁入
Apply/Start/Stop:Apply 负责 JSON 配置加载、运行环境与用户确保、
openresty/frps/frpc 管理器与各服务装配;Start 以 util.Go 拉起阻塞式
runner 与 GeoIP 周期更新;Stop 收敛主循环结果并在超时时报错而非静默。

入口改为 core.NewApp(core.WithProfile(...)) + Prepare/Run,保持
-config 旗标、默认路径、退出码与启动/停止日志不变。

验证:go build 通过;go test ./... exit 0(143 包 ok,含 3 个插件身份
与配置失败路径测试);make build-all 四进制产出;三进制实跑缺失配置
均 exit 1 且错误链保留 load {agent,relay,flared} config 原因;gofmt 干净。

refactor(cordis): 按功能职责拆分为 4 个插件与 share 共享层

backend/OpenFlare 不再平铺遗留分层,改为 plugins/{server,agent,relay,flared}
加 share/:控制面业务(openflare/admin/oauth/user/upload/cap/config/health 与
repository/model/infra/router 等支撑层)归 server;三个边缘守护进程各自成插件;
被两个以上插件消费的 protocol/geoip/wsclient/render/pagesarchive/edge 归 share。
同时把 pkg/util 与 buildinfo 合并回上游 pkg(上游已覆盖全部符号,仅 8 个函数与
2 个类型为 OpenFlare 独有,已一并迁入),装配根统一到 backend/cmd(含三个 daemon
入口),Dockerfile 与 release 工作流的构建路径和 -X 注入路径同步更新。

验证:go build 通过;go test ./... exit 0(141 包 ok);make swagger exit 0 且
232 条 API 操作与基线逐条一致;make build-all 产出 4 进制;-X 注入经二进制
strings 实测生效;日志后端直连门禁改写为按 server 插件业务域扫描并在扫描数为 0
时报错(防门禁静默失效);前端零改动。

feat(cordis): 落地 backend/share 共享层与上游同步脚本

跨插件共享资源(控制消息协议、GeoIP+iputil、边缘守护进程日志)从下游包
移入 backend/share,并声明其只能依赖 core/pkg 与标准/第三方库,禁止反向
引用下游业务与具体插件实现;新增 scripts/sync-upstream.sh 只覆盖
backend/{core,pkg,plugins},同步后 --check 报告零差异,证明与上游逐字一致。

go build 通过,go test ./... exit 0(142 包 ok),前端零改动。

refactor(cordis): 采用与 Wavelet 同构的单模块布局并引入上游内核

按上游结构落位:backend/{core,pkg,plugins} 为 Wavelet 上游拷贝,OpenFlare
全部业务收拢到上游 downstream 所对应的位置 backend/OpenFlare/,模块名保持
Wavelet 以保证上游 import 路径逐字一致、同步零改写;三个 daemon 入口移至
backend/OpenFlare/cmd,backend/cmd 与 main.go 作为控制面装配根。

行为不变:go build 通过,142 个测试包全绿(含上游插件测试),232 条 API
操作与改造前逐条一致,四进制产物正常,前端零改动。swagger 暂只扫描下游代码,
待 P4 挂载上游路由后再纳入 plugins/。

style: 修正模块路径改写导致的 import 分组排序漂移

refactor(layout): Go 代码迁入 backend/ 并将模块名简化为 OpenFlare

对齐上游 Wavelet 的仓库布局,为以第二 module 形态 vendoring Cordis 内核与
平台插件做准备:模块路径整体改写为 OpenFlare,Go 目标加 cd backend,
swaggo 产物移至 backend/docs 并把 json/yaml 复制回 docs/ 供站点消费,
Dockerfile 与 release 工作流的构建目录、ldflags 模块路径同步更新。

行为保持不变:232 条路由与改造前逐条一致,95 个测试包全绿,
四进制产物正常,前端零改动。

chore(cordis): 落地改造计划与 schema/路由基线

新增 legacy_dump_test 迁移快照 harness:在临时 sqlite 库上按生产顺序
(goose.UpTo → zone 导入 → goose.Up)跑完 76 个历史迁移并导出 schema 与
版本序列,作为改造前后一致性门禁的唯一事实来源。同时记录 232 条路由清单
与 foundation 实施计划。

docs(cordis): add OpenFlare Cordis 架构改造设计

明确上游以第二 module 形态 vendoring 进 backend/Wavelet、4 个插件
(server/agent/relay/flared) 全部装载内核,并规定保留 76 个历史 goose
迁移 + 一次性版本 stamp 桥接的迁移方案,配套三方 schema 一致性门禁,
确保已部署库不重跑历史、不丢数据。
This commit is contained in:
ryan
2026-08-29 19:28:39 +08:00
parent 9f79fb9969
commit dbaa3bf140
1327 changed files with 91634 additions and 4157 deletions
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package admin
import "Wavelet/plugins/domain/admin/model"
// DatabaseConfig aliases model.DatabaseConfig.
type DatabaseConfig = model.DatabaseConfig
// ClickHouseConfig aliases model.ClickHouseConfig.
type ClickHouseConfig = model.ClickHouseConfig
+246
View File
@@ -0,0 +1,246 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package errs defines error constants, sentinels, and error helpers for the admin domain.
package errs
import (
"errors"
"strings"
)
// 管理后台公共错误常量
const (
AdminRequired = "未经授权访问"
TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
InvalidAuthSourceID = "认证源 ID 无效"
ErrInvalidAuthSourceID = "无效的认证源 ID"
InvalidCursorParam = "无效的 cursor 参数"
InvalidTaskExecutionID = "无效的任务执行记录 ID"
InvalidParams = "无效的参数"
InternalServerError = "内部服务器错误"
InvalidScheduleID = "无效的定时任务ID"
)
// 依赖服务未就绪错误常量
const (
DatabaseNotInitialized = "数据库未初始化"
ErrDatabaseServiceNotAvailable = "database service not available"
ErrDatabaseNotInitialized = "database not initialized"
ErrCacheServiceNotInitialized = "cache service is not initialized"
UserServiceUnavailable = "用户服务未就绪"
AuthServiceUnavailable = "认证服务未就绪"
TaskServiceUnavailable = "task service not available"
LogStoreUnavailable = "日志存储服务未初始化"
)
// 系统配置错误消息常量
const (
SystemConfigNotFound = "系统配置不存在"
ConfigKeyRequired = "配置键不能为空"
ConfigValueRequired = "配置值不能为空"
ConfigKeyExists = "配置键已存在"
ProtectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改"
StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎"
ErrConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w"
ErrConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w"
ErrConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w"
ErrParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w"
ErrCheckExistingUploadsFailed = "检查存量文件失败: %w"
ErrParseCurrentStorageConfigFailed = "解析当前存储配置失败: %w"
ErrParseTargetStorageConfigFailed = "解析目标存储配置失败: %w"
ErrSerializeStorageConfigFailed = "序列化存储配置失败: %w"
ErrAutoResolveMigrationTaskFailed = "自动更新迁移任务状态失败: %v"
StorageMigrationTaskType = "storage:migrate"
StorageDriverResolvedResult = "存储配置直接更新,故障迁移任务自动标记为已解决"
)
// 存储配置校验错误前缀,用于区分参数校验失败与内部错误。
var storageValidationErrPrefixes = []string{
"解析", "验证", "初始化测试", "存储连通性", "序列化", "检查存量文件",
}
// 模板管理相关错误消息常量
const (
TemplateNotFound = "模板不存在"
TemplateKeyRequired = "模板标识符不能为空"
TemplateNameRequired = "模板名称不能为空"
TemplateContentRequired = "模板内容不能为空"
TemplateKeyExists = "模板标识符已存在"
SystemTemplateCannotDelete = "系统预置模板不可删除"
SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符"
)
// 任务调度相关错误消息常量
const (
InvalidTaskType = "无效的任务类型"
InvalidTimeRange = "无效的时间范围"
TaskDispatchFailed = "任务下发失败"
UserIDRequired = "用户ID必填"
TaskNotFound = "任务执行记录不存在"
TaskNotRetryable = "该任务不支持重试"
TaskNotFailed = "只有失败的任务才能重试"
TaskMaxRetryExceeded = "已达到最大重试次数"
TaskRetryFailed = "任务重试失败"
ScheduleSaveFailed = "保存定时任务失败"
ScheduleDeleteFailed = "删除定时任务失败"
InvalidCronExpression = "无效的 Cron 表达式"
ScheduleNotFound = "定时任务不存在"
// 任务契约实现返回的远端错误文案,用于状态码归类。
RemoteTaskNotFoundMsg = "不存在"
RemoteTaskNotFailedMsg = "只有失败的任务"
RemoteTaskNotRetryableMsg = "不支持重试"
RemoteTaskMaxRetryMsg = "已达到最大重试"
)
// 数据库管理相关错误消息常量
const (
InvalidSQLStatement = "SQL 语句不能为空"
ErrOpenDatabaseFileFailed = "无法打开数据库文件"
ErrReadDatabaseFileInfoFailed = "无法读取数据库文件信息"
ErrPgDumpUnavailable = "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具"
)
// 访问日志相关错误消息常量
const (
ErrQueryUserFailed = "查询用户信息失败: %w"
ErrQueryAccessTrendFailed = "查询访问趋势失败: "
)
// 日志库切换相关错误消息常量
const (
ErrReadLogDatabaseFailed = "读取日志主库失败: %w"
ErrLogDatabaseEmpty = "日志主库配置为空"
ErrSameLogTarget = "目标日志库与当前日志库相同,无需迁移"
ErrClickHouseNotEnabled = "ClickHouse 未启用,无法迁移到 ClickHouse"
ErrPostgresNotEnabled = "PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL"
ErrSQLiteNotAllowedAsLogDB = "当前主库为 PostgreSQL,日志库不能设置为 SQLite"
)
// 应用更新相关错误消息常量
const (
ErrInvalidRepository = "上游仓库地址无效"
ErrReleaseRequestFailed = "获取上游版本失败"
ErrReleaseResponseInvalid = "上游版本响应无效"
ErrNoCompatibleRelease = "未找到兼容的 Release"
ErrNoCompatibleAsset = "未找到当前系统对应的 Release 资产"
ErrDevelopmentBuild = "开发版本无法执行自动升级"
ErrAlreadyUpToDate = "当前已是最新版本"
ErrUpgradeAlreadyRunning = "已有升级任务正在执行"
ErrAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制"
ErrReleaseAssetSizeInvalid = "release 资产大小无效: %d"
ErrCreateUpgradeRequestFailed = "创建升级下载请求失败: %w"
ErrDownloadUpgradeAssetFailed = "下载升级资产失败: %w"
ErrUpgradeAssetHTTPFailed = "下载升级资产失败: HTTP %d"
ErrCreateUpgradeArchiveFailed = "创建升级归档失败: %w"
ErrWriteUpgradeArchiveFailed = "写入升级归档失败: %w"
ErrCloseUpgradeArchiveFailed = "关闭升级归档失败: %w"
ErrUpgradeArchiveSizeMismatch = "升级归档大小不匹配: got %d, want %d"
ErrArchiveContainsIllegalPath = "归档包含非法路径: %s"
ErrArchivePathOutOfDestination = "归档路径越界: %s"
ErrExtractedBinaryTooLarge = "解压后的程序文件超过大小限制"
ErrLocateExecutableFailed = "定位当前程序失败: %w"
ErrResolveExecutablePathFailed = "解析当前程序路径失败: %w"
ErrCreateUpgradeDirFailed = "创建升级目录失败: %w"
ErrExtractUpgradeAssetFailed = "解压升级资产失败: %w"
)
// 用户管理(管理员视角)错误消息常量
const (
UserNotFound = "用户不存在"
CannotDisable = "不能禁用管理员账号"
CannotDelete = "不能删除管理员账号"
CannotDeleteSelf = "不能删除当前登录账号"
UsernameRequired = "用户名不能为空"
EmailRequired = "邮箱不能为空"
PasswordTooShort = "密码长度不能少于 8 位" //nolint:gosec // error message, not hardcoded credentials
UsernameExists = "用户名已存在"
EmailExists = "邮箱已被使用"
CannotRevokeSelfAdmin = "不能取消自身的管理员权限"
UpdateUserFailed = "更新用户状态失败"
DeleteUserFailed = "删除用户失败"
UpdateUserInfoFailed = "更新用户信息失败"
ListAdminUsersFailed = "获取用户列表失败"
)
// 认证源管理相关错误消息常量
const (
ListAuthSourcesFailed = "获取认证源列表失败"
CreateAuthSourceFailed = "创建认证源失败: "
ToggleAuthSourceFailed = "切换认证源状态失败: "
DeleteAuthSourceFailed = "删除认证源失败: "
)
// 层边界哨兵错误:Service/Repository 层返回、Handler 层据以选择信封状态码。
var (
// ErrDatabaseUninitialized 表示数据库服务尚未注入。
ErrDatabaseUninitialized = errors.New(DatabaseNotInitialized)
// ErrSystemConfigNotFound 表示系统配置键不存在。
ErrSystemConfigNotFound = errors.New(SystemConfigNotFound)
// ErrConfigKeyExists 表示系统配置键已存在。
ErrConfigKeyExists = errors.New(ConfigKeyExists)
// ErrProtectedConfigKey 表示配置键由系统任务托管,禁止手动修改。
ErrProtectedConfigKey = errors.New(ProtectedConfigKeyMessage)
// ErrTemplateNotFound 表示模板标识符不存在。
ErrTemplateNotFound = errors.New(TemplateNotFound)
// ErrTemplateKeyExists 表示模板标识符已被占用。
ErrTemplateKeyExists = errors.New(TemplateKeyExists)
// ErrSystemTemplateCannotDelete 表示系统预置模板不可删除。
ErrSystemTemplateCannotDelete = errors.New(SystemTemplateCannotDelete)
// ErrUserNotFound 表示目标用户不存在。
ErrUserNotFound = errors.New(UserNotFound)
// ErrUserServiceUnavailable 表示用户契约服务尚未注入。
ErrUserServiceUnavailable = errors.New(UserServiceUnavailable)
// ErrAuthServiceUnavailable 表示认证契约服务尚未注入。
ErrAuthServiceUnavailable = errors.New(AuthServiceUnavailable)
// ErrTaskServiceUnavailable 表示任务契约服务尚未注入。
ErrTaskServiceUnavailable = errors.New(TaskServiceUnavailable)
// ErrLogStoreUnavailable 表示日志分析契约服务尚未注入。
ErrLogStoreUnavailable = errors.New(LogStoreUnavailable)
// ErrScheduleNotFound 表示定时任务不存在。
ErrScheduleNotFound = errors.New(ScheduleNotFound)
// ErrInvalidCronExpression 表示 Cron 表达式无法解析。
ErrInvalidCronExpression = errors.New(InvalidCronExpression)
// ErrInvalidTaskType 表示任务类型未在任务注册表中声明。
ErrInvalidTaskType = errors.New(InvalidTaskType)
)
// InvalidInputError marks a failure caused by caller supplied content (an unusable SQL
// statement, a rejected task payload, ...). It carries no HTTP semantics; the handler
// layer decides how such errors surface to the client.
type InvalidInputError struct {
Msg string
}
func (e *InvalidInputError) Error() string { return e.Msg }
// NewInvalidInputError builds an invalid input failure preserving the original message.
func NewInvalidInputError(msg string) error {
return &InvalidInputError{Msg: msg}
}
// AsInvalidInput reports whether err was caused by rejected caller input.
func AsInvalidInput(err error) (string, bool) {
var target *InvalidInputError
if errors.As(err, &target) {
return target.Msg, true
}
return "", false
}
// IsStorageConfigValidationError 判定错误是否属于存储配置参数校验失败。
func IsStorageConfigValidationError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
if msg == StorageDriverSwitchRequiresMigration {
return true
}
for _, prefix := range storageValidationErrPrefixes {
if strings.HasPrefix(msg, prefix) {
return true
}
}
return false
}
@@ -0,0 +1,106 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/service"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ListAuthSources lists all configured authentication sources.
func ListAuthSources(c *gin.Context) {
views, err := service.ListAuthSources(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(views))
}
// CreateAuthSource creates a new authentication source.
func CreateAuthSource(c *gin.Context) {
var source contracts.AuthSourceDTO
if err := c.ShouldBindJSON(&source); err != nil {
response.AbortBadRequest(c, errs.InvalidParams)
return
}
created, err := service.CreateAuthSource(c.Request.Context(), source)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(created))
}
// UpdateAuthSource updates an authentication source.
func UpdateAuthSource(c *gin.Context) {
id, ok := parseAuthSourceID(c)
if !ok {
return
}
var req contracts.AuthSourceDTO
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errs.InvalidParams)
return
}
updated, err := service.UpdateAuthSource(c.Request.Context(), id, req)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(updated))
}
// ToggleAuthSource toggles the active state of an auth source.
func ToggleAuthSource(c *gin.Context) {
id, ok := parseAuthSourceID(c)
if !ok {
return
}
toggled, err := service.ToggleAuthSource(c.Request.Context(), id)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(gin.H{"is_active": toggled.IsActive}))
}
// DeleteAuthSource deletes an authentication source.
func DeleteAuthSource(c *gin.Context) {
id, ok := parseAuthSourceID(c)
if !ok {
return
}
if err := service.DeleteAuthSource(c.Request.Context(), id); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// parseAuthSourceID reads the numeric auth source path parameter.
func parseAuthSourceID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidAuthSourceID)
return 0, false
}
return id, true
}
@@ -0,0 +1,76 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"net/http"
"github.com/gin-gonic/gin"
)
// GetCacheStatus 获取磁盘缓存状态与当前统计数据
// @Summary 获取缓存状态
// @Description 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=disk.Status} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/cache/status [get]
func GetCacheStatus(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.DiskCacheStatus()))
}
// UpdateCacheConfig 更新磁盘缓存策略配置
// @Summary 更新缓存配置
// @Description 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新
// @Tags admin
// @Accept json
// @Produce json
// @Param request body model.UpdateCacheConfigRequest true "缓存配置请求体"
// @Security SessionCookie
// @Success 200 {object} response.Any "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "服务内部错误"
// @Router /api/v1/admin/cache/config [post]
func UpdateCacheConfig(c *gin.Context) {
var req model.UpdateCacheConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := service.UpdateDiskCachePolicy(c.Request.Context(), req); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ClearCache 一键清空所有磁盘缓存数据
// @Summary 清空缓存
// @Description 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any "清理成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "服务内部错误"
// @Router /api/v1/admin/cache/clear [post]
func ClearCache(c *gin.Context) {
if err := service.ClearDiskCache(); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -0,0 +1,187 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// GetPublicConfig 获取公共配置
// @Summary 获取公共配置
// @Description 返回系统配置表中 visibility 为 1 的配置键值集合
// @Tags config
// @Accept json
// @Produce json
// @Success 200 {object} response.Any
// @Router /api/v1/config/public [get]
func GetPublicConfig(c *gin.Context) {
resp, err := service.PublicSystemConfigs(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(resp))
}
// GetRobotsTXT 动态生成 robots.txt
// @Summary 获取 robots.txt
// @Description 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容
// @Tags config
// @Produce text/plain
// @Success 200 {string} string "robots.txt 内容"
// @Router /robots.txt [get]
func GetRobotsTXT(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(service.RobotsTxtBody(c.Request.Context())))
}
// CreateSystemConfig 创建系统配置
// @Summary 创建系统配置
// @Description 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.CreateSystemConfigRequest true "创建请求参数"
// @Success 200 {object} response.Any{data=string} "创建成功"
// @Failure 400 {object} response.Any "参数错误或配置键已存在"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/system-configs [post]
func CreateSystemConfig(c *gin.Context) {
var req model.CreateSystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := service.CreateAdminSystemConfig(c.Request.Context(), req); err != nil {
if errors.Is(err, errs.ErrProtectedConfigKey) || errors.Is(err, errs.ErrConfigKeyExists) {
response.AbortBadRequest(c, err.Error())
return
}
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListSystemConfigs 获取系统配置列表
// @Summary 获取系统配置列表
// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param type query string false "配置类型(system/business)"
// @Success 200 {object} response.Any{data=[]model.SystemConfig} "系统配置列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/system-configs [get]
func ListSystemConfigs(c *gin.Context) {
configs, err := service.ListAdminSystemConfigs(c.Request.Context(), c.Query("type"))
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(configs))
}
// GetSystemConfig 获取单个系统配置
// @Summary 获取单个系统配置
// @Description 根据配置键获取对应的系统配置详情,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param key path string true "配置键"
// @Success 200 {object} response.Any{data=model.SystemConfig} "系统配置详情"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "配置不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/system-configs/{key} [get]
func GetSystemConfig(c *gin.Context) {
config, err := service.GetAdminSystemConfig(c.Request.Context(), c.Param("key"))
if err != nil {
if errors.Is(err, errs.ErrSystemConfigNotFound) {
response.AbortNotFound(c, errs.SystemConfigNotFound)
return
}
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(config))
}
// UpdateSystemConfig 更新系统配置
// @Summary 更新系统配置
// @Description 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param key path string true "配置键"
// @Param request body model.UpdateSystemConfigRequest true "更新请求参数"
// @Success 200 {object} response.Any{data=string} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "配置不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/system-configs/{key} [put]
func UpdateSystemConfig(c *gin.Context) {
var req model.UpdateSystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
key := c.Param("key")
if err := service.UpdateAdminSystemConfig(c.Request.Context(), key, req); err != nil {
if errors.Is(err, errs.ErrSystemConfigNotFound) {
response.AbortNotFound(c, errs.SystemConfigNotFound)
return
}
if errors.Is(err, errs.ErrProtectedConfigKey) || errs.IsStorageConfigValidationError(err) {
response.AbortBadRequest(c, err.Error())
return
}
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// TestSMTP 测试 SMTP 邮件发送
// @Summary 测试 SMTP 邮件发送
// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.TestSMTPRequest true "测试请求参数"
// @Success 200 {object} response.Any{data=model.TestSMTPResponse} "测试执行完毕"
// @Failure 400 {object} response.Any "参数错误"
// @Router /api/v1/admin/system-configs/smtp/test [post]
func TestSMTP(c *gin.Context) {
var req model.TestSMTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(service.TestSMTP(c.Request.Context(), req)))
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// GetDBOverview 获取数据库运行概览
// @Summary 获取数据库运行概览
// @Description 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.DBOverviewResponse} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/db-manage/overview [get]
func GetDBOverview(c *gin.Context) {
overview, err := service.DatabaseOverview(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(overview))
}
// ListDBTables 获取数据库所有表名
// @Summary 获取数据库所有表名
// @Description 返回当前数据库的所有用户自定义表名称列表,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]string} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/db-manage/tables [get]
func ListDBTables(c *gin.Context) {
tables, err := service.DatabaseTableNames(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(tables))
}
// GetDBTableData 获取数据表 data
func GetDBTableData(c *gin.Context) {
var req model.GetTableDataRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
data, err := service.DatabaseTableData(c.Request.Context(), req)
if err != nil {
if msg, ok := errs.AsInvalidInput(err); ok {
response.AbortBadRequest(c, msg)
return
}
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(data))
}
// ExecuteSQL 执行 SQL 查询
// @Summary 执行 SQL 查询
// @Description 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.ExecuteSQLRequest true "SQL 请求参数"
// @Success 200 {object} response.Any{data=model.ExecuteSQLResponse} "执行完毕"
// @Failure 400 {object} response.Any "SQL 语句错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/db-manage/query [post]
func ExecuteSQL(c *gin.Context) {
var req model.ExecuteSQLRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
trimmedSQL := strings.TrimSpace(req.SQL)
if trimmedSQL == "" {
response.AbortBadRequest(c, errs.InvalidSQLStatement)
return
}
resp, err := service.ExecuteCustomSQL(c.Request.Context(), trimmedSQL)
if err != nil {
if errors.Is(err, errs.ErrDatabaseUninitialized) {
response.AbortInternal(c, err.Error())
return
}
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(resp))
}
// GetDatabaseInfo 获取当前数据库类型及版本信息
// @Summary 获取数据库信息
// @Description 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.DatabaseInfoResponse} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/db-info [get]
func GetDatabaseInfo(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.DatabaseInfo(c.Request.Context())))
}
// ExportDatabase 导出数据库
// @Summary 导出数据库
// @Description SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限
// @Tags admin
// @Produce application/octet-stream
// @Security SessionCookie
// @Success 200 {file} binary "数据库文件"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "导出失败"
// @Router /api/v1/admin/db-export [get]
func ExportDatabase(c *gin.Context) {
if !service.GetDBConfig().Enabled {
exportSQLite(c)
} else {
exportPostgres(c)
}
}
func exportSQLite(c *gin.Context) {
f, fi, err := service.OpenSQLiteExportFile()
if err != nil {
response.AbortInternal(c, err.Error())
return
}
defer func() {
_ = f.Close()
}()
c.Header("Content-Disposition", `attachment; filename="wavelet.db"`)
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", fmt.Sprintf("%d", fi.Size()))
c.Status(http.StatusOK)
http.ServeContent(c.Writer, c.Request, "wavelet.db", fi.ModTime(), f)
}
func exportPostgres(c *gin.Context) {
cmd, fileName, err := service.NewPgDumpCommand(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.Header("Content-Disposition", `attachment; filename="`+fileName+`"`)
c.Header("Content-Type", "application/octet-stream")
c.Status(http.StatusOK)
cmd.Stdout = c.Writer
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
logger.ErrorF(c.Request.Context(), "[db-export] pg_dump failed: %v", err)
}
}
@@ -0,0 +1,205 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const (
defaultLimit = 200
maxLimit = 500
maxPageSize = 100
)
// wsMessage WebSocket 消息格式
type wsMessage struct {
Type string `json:"type"` // "log" | "error"
Data json.RawMessage `json:"data"`
}
// GetLogs 获取历史日志
// @Summary 获取系统日志
// @Description 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param cursor query int false "日志游标,0=获取最新" default(0)
// @Param limit query int false "每页条数" default(200)
// @Success 200 {object} response.Any{data=model.LogsResponse} "日志列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/logs [get]
func GetLogs(c *gin.Context) {
cursorStr := c.DefaultQuery("cursor", "0")
limitStr := c.DefaultQuery("limit", "200")
var cursor, limit int
if err := parsePositiveInt(cursorStr, &cursor); err != nil {
response.AbortWithError(c, http.StatusBadRequest, errs.InvalidCursorParam)
return
}
if err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
c.JSON(http.StatusOK, response.OK(service.RecentSystemLogs(cursor, limit)))
}
// HandleLogWebSocket WebSocket 端点,实时推送系统日志
// @Summary 系统日志实时推送
// @Description 通过 WebSocket 实时推送系统日志,需要管理员权限
// @Tags admin
// @Router /api/v1/admin/logs/ws [get]
func HandleLogWebSocket(c *gin.Context) {
upgrader := getUpgrader()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
ch := logger.GlobalRingBuffer.Subscribe()
defer logger.GlobalRingBuffer.Unsubscribe(ch)
done := make(chan struct{})
util.Go(func() {
defer close(done)
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
for {
select {
case <-done:
return
case entry, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(entry)
msg := wsMessage{Type: "log", Data: data}
payload, _ := json.Marshal(msg)
if err := conn.WriteMessage(1, payload); err != nil {
return
}
}
}
}
// GetAccessLogs 获取 ClickHouse 异步采集的访问日志
// @Summary 获取用户访问日志
// @Description 分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限)
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页条数" default(20)
// @Param username query string false "用户名模糊搜索"
// @Param path query string false "接口路径模糊搜索"
// @Param start_time query string false "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)"
// @Param end_time query string false "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)"
// @Success 200 {object} response.Any{data=model.AccessLogsResponse} "访问日志列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/logs/access [get]
func GetAccessLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 {
pageSize = 20
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
resp, err := service.AccessLogs(c.Request.Context(), model.AccessLogQuery{
Username: c.Query("username"),
Path: c.Query("path"),
StartTime: c.Query("start_time"),
EndTime: c.Query("end_time"),
Page: page,
PageSize: pageSize,
})
if err != nil {
response.AbortWithError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(resp))
}
// GetLogsAnalytics 获取 ClickHouse 访问日志图表聚合指标
// @Summary 获取访问日志分析数据
// @Description 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限)
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.LogsAnalyticsResponse} "分析统计数据"
// @Failure 500 {object} response.Any "内部错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/logs/analytics [get]
func GetLogsAnalytics(c *gin.Context) {
resp, err := service.AccessLogAnalytics(c.Request.Context())
if err != nil {
response.AbortWithError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(resp))
}
func getUpgrader() *websocket.Upgrader {
return &websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return service.IsAllowedLogOrigin(r.Context(), r.Header.Get("Origin"), r.Host)
},
}
}
// errNegativeParam 表示查询参数解析出了负数。
var errNegativeParam = errors.New("parameter must not be negative")
// parsePositiveInt 解析非负整数查询参数;返回错误时 result 保持调用前的值。
func parsePositiveInt(s string, result *int) error {
if s == "" {
*result = 0
return nil
}
n, err := strconv.Atoi(s)
if err != nil {
return err
}
if n < 0 {
return errNegativeParam
}
*result = n
return nil
}
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"testing"
)
func TestParsePositiveInt(t *testing.T) {
const untouched = 77
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "empty means zero", input: "", want: 0},
{name: "zero accepted", input: "0", want: 0},
{name: "positive accepted", input: "42", want: 42},
{name: "negative rejected", input: "-5", want: untouched, wantErr: true},
{name: "oversized rejected", input: "99999999999999999999", want: untouched, wantErr: true},
{name: "non numeric rejected", input: "abc", want: untouched, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := untouched
err := parsePositiveInt(tt.input, &got)
if (err != nil) != tt.wantErr {
t.Fatalf("parsePositiveInt(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("parsePositiveInt(%q) left result %d, want %d", tt.input, got, tt.want)
}
})
}
}
@@ -0,0 +1,46 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/pkg/trace"
"Wavelet/plugins/domain/admin/errs"
"github.com/gin-gonic/gin"
)
// LoginAdminRequired 返回管理员权限校验中间件
func LoginAdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
ctx, span := trace.Start(c.Request.Context(), "LoginAdminRequired")
defer span.End()
user, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
if user == nil {
response.AbortNotFound(c, errs.AdminRequired)
return
}
// 如果是通过 Access Token 鉴权,需要检查令牌本身是否具有管理员权限
if tokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey); tokenAuth {
tokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey)
if !tokenAdmin {
response.AbortNotFound(c, errs.TokenAdminRequired)
return
}
}
if !user.IsAdmin {
response.AbortNotFound(c, errs.AdminRequired)
return
}
logger.InfoF(ctx, "[LoginAdminRequired] %d %s", user.ID, user.Username)
c.Next()
}
}
@@ -0,0 +1,122 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package handler provides HTTP routing and handlers for the admin domain.
package handler
import (
"Wavelet/core/extpoints"
)
// RegisterRoutes mounts all admin console endpoints under the provided admin router group.
func RegisterRoutes(adminRouter extpoints.RouterExtension) {
// Status & Diagnostics
adminRouter.GET("/status", GetSystemStatus)
adminRouter.GET("/status/log-database", GetLogDatabaseStatus)
adminRouter.GET("/db-info", GetDatabaseInfo)
adminRouter.GET("/db-export", ExportDatabase)
// DB Management
dbGroup := adminRouter.Group("/db-manage")
{
dbGroup.GET("/overview", GetDBOverview)
dbGroup.GET("/tables", ListDBTables)
dbGroup.GET("/table-data", GetDBTableData)
dbGroup.POST("/query", ExecuteSQL)
}
// Cache Management
cacheGroup := adminRouter.Group("/cache")
{
cacheGroup.GET("/status", GetCacheStatus)
cacheGroup.POST("/config", UpdateCacheConfig)
cacheGroup.POST("/clear", ClearCache)
}
// Updater
updateGroup := adminRouter.Group("/update")
{
updateGroup.GET("", GetUpdateStatus)
updateGroup.POST("/apply", ApplyUpdate)
}
// Logs
logsGroup := adminRouter.Group("/logs")
{
logsGroup.GET("", GetLogs)
logsGroup.GET("/access", GetAccessLogs)
logsGroup.GET("/analytics", GetLogsAnalytics)
logsGroup.GET("/ws", HandleLogWebSocket)
}
// Users
usersGroup := adminRouter.Group("/users")
{
usersGroup.GET("", ListUsers)
usersGroup.POST("", CreateUser)
usersGroup.GET("/:id", GetUser)
usersGroup.PUT("/:id/status", UpdateUserStatus)
usersGroup.PUT("/:id", UpdateUser)
usersGroup.DELETE("/:id", DeleteUser)
}
// Auth Sources
authSourcesGroup := adminRouter.Group("/auth-sources")
{
authSourcesGroup.GET("", ListAuthSources)
authSourcesGroup.POST("", CreateAuthSource)
authSourcesGroup.PUT("/:id", UpdateAuthSource)
authSourcesGroup.PUT("/:id/toggle", ToggleAuthSource)
authSourcesGroup.DELETE("/:id", DeleteAuthSource)
}
// System Configs
configGroup := adminRouter.Group("/system-configs")
{
configGroup.GET("", ListSystemConfigs)
configGroup.POST("", CreateSystemConfig)
configGroup.POST("/smtp/test", TestSMTP)
keyGroup := configGroup.Group("/:key")
{
keyGroup.GET("", GetSystemConfig)
keyGroup.PUT("", UpdateSystemConfig)
}
}
// Templates
templateGroup := adminRouter.Group("/templates")
{
templateGroup.GET("", ListTemplates)
templateGroup.POST("", CreateTemplate)
keyGroup := templateGroup.Group("/:key")
{
keyGroup.GET("", GetTemplate)
keyGroup.PUT("", UpdateTemplate)
keyGroup.DELETE("", DeleteTemplate)
}
}
// Tasks
taskGroup := adminRouter.Group("/tasks")
{
taskGroup.GET("/types", ListTaskTypes)
taskGroup.POST("/dispatch", DispatchTask)
executions := taskGroup.Group("/executions")
{
executions.GET("", ListTaskExecutions)
executions.GET("/:id", GetTaskExecution)
executions.POST("/:id/retry", RetryTask)
}
schedules := taskGroup.Group("/schedules")
{
schedules.GET("", ListSchedules)
schedules.POST("", CreateSchedule)
schedules.PUT("/:id", UpdateSchedule)
schedules.DELETE("/:id", DeleteSchedule)
}
}
}
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/service"
"net/http"
"github.com/gin-gonic/gin"
)
// GetSystemStatus 获取系统状态信息
// @Summary 获取系统状态信息
// @Description 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.SystemStatusResponse} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/status [get]
func GetSystemStatus(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.CollectSystemStatus()))
}
// GetLogDatabaseStatus 返回当前日志库状态。
// @Summary 获取日志数据库状态
// @Description 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.LogDatabaseStatus} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/status/log-database [get]
func GetLogDatabaseStatus(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.LogDatabaseStatus(c.Request.Context())))
}
@@ -0,0 +1,318 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// abortTaskLogicError maps a task service failure onto the unified response envelope.
func abortTaskLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, errs.ErrTaskServiceUnavailable) || errors.Is(err, errs.ErrScheduleNotFound) {
response.AbortInternal(c, err.Error())
return true
}
msg := err.Error()
if errors.Is(err, errs.ErrInvalidCronExpression) || errors.Is(err, errs.ErrInvalidTaskType) {
response.AbortBadRequest(c, msg)
return true
}
if text, ok := errs.AsInvalidInput(err); ok {
response.AbortBadRequest(c, text)
return true
}
response.AbortInternal(c, msg)
return true
}
// ListTaskTypes 获取支持的任务类型列表
// @Summary 获取支持的任务类型
// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]contracts.TaskMetaDTO} "任务类型列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/tasks/types [get]
func ListTaskTypes(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.ListTaskTypes()))
}
// DispatchTask 下发任务
// @Summary 下发异步任务
// @Description 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.DispatchTaskRequest true "任务请求参数"
// @Success 200 {object} response.Any{data=string} "任务已入队"
// @Failure 400 {object} response.Any "任务类型不存在或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "任务入队失败"
// @Router /api/v1/admin/tasks/dispatch [post]
func DispatchTask(c *gin.Context) {
var req model.DispatchTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
taskID, err := service.DispatchTask(c.Request.Context(), req)
if err != nil {
switch {
case errors.Is(err, errs.ErrTaskServiceUnavailable):
response.AbortInternal(c, err.Error())
case errors.Is(err, errs.ErrInvalidTaskType):
response.AbortBadRequest(c, errs.InvalidTaskType)
default:
if text, ok := errs.AsInvalidInput(err); ok {
response.AbortBadRequest(c, text)
return
}
response.AbortInternal(c, err.Error())
}
return
}
c.JSON(http.StatusOK, response.OK(taskID))
}
// ListTaskExecutions 查询任务执行记录列表
// @Summary 查询任务执行记录
// @Description 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param status query string false "状态筛选 (pending/running/succeeded/failed)"
// @Param task_type query string false "任务类型筛选"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页条数" default(20)
// @Success 200 {object} response.Any{data=object} "任务执行记录列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/tasks/executions [get]
func ListTaskExecutions(c *gin.Context) {
var req model.ListTaskExecutionsRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
executions, total, err := service.ListTaskExecutions(c.Request.Context(), req)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(gin.H{
"items": executions,
"total": total,
"page": req.Page,
"page_size": req.PageSize,
}))
}
// GetTaskExecution 查询单条任务执行详情
// @Summary 查询任务执行详情
// @Description 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "任务执行记录 ID"
// @Success 200 {object} response.Any{data=model.TaskExecution} "任务执行详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Router /api/v1/admin/tasks/executions/{id} [get]
func GetTaskExecution(c *gin.Context) {
id, err := parseUintParam(c, errs.InvalidTaskExecutionID)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
execution, err := service.TaskExecution(c.Request.Context(), id)
if err != nil {
response.AbortNotFound(c, errs.TaskNotFound)
return
}
c.JSON(http.StatusOK, response.OK(execution))
}
// RetryTask 重试失败的任务
// @Summary 重试失败任务
// @Description 重新下发一条失败的任务,创建新的执行记录,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "任务执行记录 ID"
// @Success 200 {object} response.Any{data=string} "新任务的 TaskID"
// @Failure 400 {object} response.Any "任务不支持重试或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "重试失败"
// @Router /api/v1/admin/tasks/executions/{id}/retry [post]
func RetryTask(c *gin.Context) {
id, err := parseUintParam(c, errs.InvalidTaskExecutionID)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
newTaskID, err := service.RetryTask(c.Request.Context(), id)
if err != nil {
switch {
case errors.Is(err, errs.ErrTaskServiceUnavailable):
response.AbortInternal(c, err.Error())
case service.IsRetryMissingError(err):
response.AbortNotFound(c, err.Error())
case service.IsRetryConflictError(err):
response.AbortBadRequest(c, err.Error())
default:
response.AbortInternal(c, err.Error())
}
return
}
c.JSON(http.StatusOK, response.OK(newTaskID))
}
// ListSchedules 获取定时任务列表
// @Summary 获取定时任务列表
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.Schedule} "定时任务列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/tasks/schedules [get]
func ListSchedules(c *gin.Context) {
schedules, err := service.ListSchedules(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(schedules))
}
// CreateSchedule 创建定时任务
// @Summary 创建定时任务
// @Description 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.CreateScheduleRequest true "创建定时任务请求参数"
// @Success 200 {object} response.Any{data=model.Schedule} "创建成功的定时任务信息"
// @Failure 400 {object} response.Any "Cron 表达式无效、异步任务类型不存在或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "保存定时任务失败"
// @Router /api/v1/admin/tasks/schedules [post]
func CreateSchedule(c *gin.Context) {
var req model.CreateScheduleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
schedule, err := service.CreateSchedule(c.Request.Context(), req)
if abortTaskLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(schedule))
}
// UpdateSchedule 修改定时任务
// @Summary 修改定时任务
// @Description 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "定时任务 ID"
// @Param request body model.UpdateScheduleRequest true "修改定时任务请求参数"
// @Success 200 {object} response.Any{data=model.Schedule} "修改后的定时任务信息"
// @Failure 400 {object} response.Any "Cron 表达式无效、参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "定时任务不存在"
// @Failure 500 {object} response.Any "修改定时任务失败"
// @Router /api/v1/admin/tasks/schedules/{id} [put]
func UpdateSchedule(c *gin.Context) {
id, err := parseUintParam(c, errs.InvalidScheduleID)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
var req model.UpdateScheduleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
schedule, err := service.UpdateSchedule(c.Request.Context(), id, req)
if abortTaskLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(schedule))
}
// DeleteSchedule 删除定时任务
// @Summary 删除定时任务
// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "定时任务 ID"
// @Success 200 {object} response.Any{data=string} "删除结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "删除定时任务失败"
// @Router /api/v1/admin/tasks/schedules/{id} [delete]
func DeleteSchedule(c *gin.Context) {
id, err := parseUintParam(c, errs.InvalidScheduleID)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := service.DeleteSchedule(c.Request.Context(), id); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// parseUintParam reads a positive numeric path parameter.
func parseUintParam(c *gin.Context, invalidMsg string) (uint64, error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return 0, errors.New(invalidMsg)
}
return id, nil
}
@@ -0,0 +1,97 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler_test
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/admin/handler"
"Wavelet/plugins/domain/admin/service"
"Wavelet/plugins/drivers/driver_asynq_worker"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type listTaskTypesResponse struct {
ErrorMsg string `json:"error_msg"`
Data []contracts.TaskMetaDTO `json:"data"`
}
func TestListTaskTypesHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(t.Context())
worker := driver_asynq_worker.New()
require.NoError(t, worker.Apply(ctx))
ctx.Task().Register("logs:db_switch", func(_ any) error { return nil },
extpoints.WithTaskType("logs_db_switch"),
extpoints.WithTaskName("切换日志数据库"),
extpoints.WithTaskDescription("复制迁移用户访问日志并在成功后切换日志主库"),
extpoints.WithTaskCategory("system"),
extpoints.WithTaskRetry(3),
extpoints.WithTaskQueue("default"),
extpoints.WithTaskRetryable(true),
extpoints.WithTaskParams(contracts.TaskParamDTO{
Name: "target",
Label: "目标日志库",
Type: "string",
Required: true,
Placeholder: "postgres|sqlite|clickhouse",
Description: "迁移目标",
}),
)
taskSvc, err := core.Inject[contracts.TaskService](ctx)
require.NoError(t, err)
service.SetTaskService(taskSvc)
r := gin.New()
r.GET("/api/v1/admin/tasks/types", handler.ListTaskTypes)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tasks/types", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp listTaskTypesResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Empty(t, resp.ErrorMsg)
require.NotEmpty(t, resp.Data)
var found bool
for _, task := range resp.Data {
if task.Type == "logs_db_switch" {
found = true
assert.Equal(t, "logs:db_switch", task.AsynqTask)
assert.Equal(t, "切换日志数据库", task.Name)
assert.Equal(t, "复制迁移用户访问日志并在成功后切换日志主库", task.Description)
assert.Equal(t, "system", task.Category)
assert.Equal(t, 3, task.MaxRetry)
assert.Equal(t, "default", task.Queue)
assert.True(t, task.Retryable)
require.Len(t, task.Params, 1)
assert.Equal(t, "target", task.Params[0].Name)
assert.Equal(t, "目标日志库", task.Params[0].Label)
assert.Equal(t, "string", task.Params[0].Type)
assert.True(t, task.Params[0].Required)
assert.Equal(t, "postgres|sqlite|clickhouse", task.Params[0].Placeholder)
}
}
assert.True(t, found, "expected logs_db_switch in task types")
// Ensure NO task in the list has an empty Type or Name, which breaks SelectItem key/value in frontend
for _, task := range resp.Data {
assert.NotEmpty(t, task.Type, "task.type must never be empty")
assert.NotEmpty(t, task.Name, "task.name must never be empty")
}
}
@@ -0,0 +1,159 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// abortTemplateLogicError maps the template service outcome onto the response envelope.
func abortTemplateLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, errs.ErrTemplateNotFound) {
response.AbortNotFound(c, errs.TemplateNotFound)
return true
}
msg := err.Error()
switch msg {
case errs.TemplateKeyExists, errs.SystemTemplateCannotDelete:
response.AbortBadRequest(c, msg)
return true
}
response.AbortInternal(c, msg)
return true
}
// CreateTemplate 创建模板
// @Summary 创建模板
// @Description 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.CreateTemplateRequest true "创建请求参数"
// @Success 200 {object} response.Any{data=string} "创建成功"
// @Failure 400 {object} response.Any "参数错误或模板标识符已存在"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/templates [post]
func CreateTemplate(c *gin.Context) {
var req model.CreateTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
tmpl, err := service.CreateTemplate(c.Request.Context(), req)
if abortTemplateLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(tmpl))
}
// ListTemplates 获取模板列表
// @Summary 获取模板列表
// @Description 返回所有通知模板列表,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.Template} "模板列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/templates [get]
func ListTemplates(c *gin.Context) {
templates, err := service.ListTemplates(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(templates))
}
// GetTemplate 获取单个模板
// @Summary 获取单个模板
// @Description 根据模板标识符获取对应的模板详情,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param key path string true "模板标识符"
// @Success 200 {object} response.Any{data=model.Template} "模板详情"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "模板不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/templates/{key} [get]
func GetTemplate(c *gin.Context) {
tmpl, err := service.GetTemplate(c.Request.Context(), c.Param("key"))
if abortTemplateLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(tmpl))
}
// UpdateTemplate 更新模板
// @Summary 更新模板
// @Description 根据模板标识符更新对应的模板内容,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param key path string true "模板标识符"
// @Param request body model.UpdateTemplateRequest true "更新请求参数"
// @Success 200 {object} response.Any{data=model.Template} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "模板不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/templates/{key} [put]
func UpdateTemplate(c *gin.Context) {
var req model.UpdateTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
tmpl, err := service.UpdateTemplate(c.Request.Context(), c.Param("key"), req)
if abortTemplateLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(tmpl))
}
// DeleteTemplate 删除模板
// @Summary 删除模板
// @Description 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param key path string true "模板标识符"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "不可删除系统模板"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "模板不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/templates/{key} [delete]
func DeleteTemplate(c *gin.Context) {
if err := service.DeleteTemplate(c.Request.Context(), c.Param("key")); abortTemplateLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -0,0 +1,69 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/admin/service"
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// GetUpdateStatus 获取应用更新状态
// @Summary 获取应用更新状态
// @Description 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.UpdaterStatus} "更新状态"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "查询失败"
// @Router /api/v1/admin/update [get]
func GetUpdateStatus(c *gin.Context) {
status, err := service.GetUpdateStatus(c.Request.Context())
if err != nil {
logger.ErrorF(c.Request.Context(), "[Updater] check release failed: %v", err)
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(status))
}
// ApplyUpdate 下载并应用应用更新
// @Summary 下载并应用应用更新
// @Description 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any "升级已准备并即将重启"
// @Failure 400 {object} response.Any "当前版本不可升级"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "升级准备失败"
// @Router /api/v1/admin/update/apply [post]
func ApplyUpdate(c *gin.Context) {
executable, stagedBinary, err := service.DefaultUpdaterManager.PrepareUpgrade(c.Request.Context())
if err != nil {
logger.ErrorF(c.Request.Context(), "[Updater] prepare upgrade failed: %v", err)
response.AbortBadRequest(c, err.Error())
return
}
logger.InfoF(c.Request.Context(), "[Updater] upgrade prepared; restarting with %s", stagedBinary)
c.JSON(http.StatusOK, response.OKNil())
util.Go(func() {
time.Sleep(time.Second)
if err := service.ReplaceAndRestart(executable, stagedBinary); err != nil {
service.DefaultUpdaterManager.FinishUpgrade()
logger.ErrorF(context.Background(), "[Updater] replace and restart failed: %v", err)
}
})
}
@@ -0,0 +1,294 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func parseUserID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.AbortBadRequest(c, errs.UserNotFound)
return 0, false
}
return id, true
}
// abortUserLogicError maps the user service outcome onto the unified response envelope.
func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbiddenMsgs, badRequestMsgs []string) bool {
if err == nil {
return false
}
if errors.Is(err, errs.ErrUserServiceUnavailable) {
response.AbortInternal(c, err.Error())
return true
}
if errors.Is(err, errs.ErrUserNotFound) {
response.AbortNotFound(c, notFoundMsg)
return true
}
msg := err.Error()
for _, m := range badRequestMsgs {
if msg == m {
response.AbortBadRequest(c, msg)
return true
}
}
for _, m := range forbiddenMsgs {
if msg == m {
response.AbortForbidden(c, msg)
return true
}
}
logger.ErrorF(c.Request.Context(), "Admin user error: %v", err)
response.AbortInternal(c, errs.InternalServerError)
return true
}
// ListUsers 获取用户列表
// @Summary 获取用户列表
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param request query model.ListUsersRequest true "查询参数"
// @Success 200 {object} response.Any{data=model.ListUsersResponse} "用户列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users [get]
func ListUsers(c *gin.Context) {
var req model.ListUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
total, dtos, err := service.AdminListUsers(c.Request.Context(), contracts.AdminListUsersFilter{
Page: req.Page,
PageSize: req.PageSize,
UserID: req.UserID,
Username: req.Username,
Email: req.Email,
})
if err != nil {
response.AbortInternal(c, err.Error())
return
}
users := make([]model.UserResponse, 0, len(dtos))
for _, dto := range dtos {
users = append(users, service.ToUserResponse(dto))
}
c.JSON(http.StatusOK, response.OK(model.ListUsersResponse{
Users: users,
Total: total,
}))
}
// GetUser 获取用户详情
// @Summary 获取用户详情
// @Description 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Success 200 {object} response.Any{data=model.UserResponse} "用户详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "用户不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users/{id} [get]
func GetUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
targetUser, err := service.AdminGetUser(c.Request.Context(), id)
if abortUserLogicError(c, err, errs.UserNotFound, nil, nil) {
return
}
c.JSON(http.StatusOK, response.OK(service.ToUserResponse(targetUser)))
}
// UpdateUserStatus 更新用户状态(启用/禁用)
// @Summary 更新用户状态
// @Description 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Param request body model.UpdateUserStatusRequest true "状态参数"
// @Success 200 {object} response.Any{data=string} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限或尝试禁用管理员"
// @Failure 404 {object} response.Any "用户不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users/{id}/status [put]
func UpdateUserStatus(c *gin.Context) {
var req model.UpdateUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
id, ok := parseUserID(c)
if !ok {
return
}
if err := service.AdminUpdateUserStatus(c.Request.Context(), id, req.IsActive); err != nil {
if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotDisable}, nil) {
return
}
response.AbortInternal(c, errs.UpdateUserFailed)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限、尝试删除管理员或当前用户"
// @Failure 404 {object} response.Any "用户不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users/{id} [delete]
func DeleteUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
if currUser == nil {
response.AbortUnauthorized(c, errs.AdminRequired)
return
}
if err := service.AdminDeleteUser(c.Request.Context(), currUser.ID, id); err != nil {
if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotDelete, errs.CannotDeleteSelf}, nil) {
return
}
response.AbortInternal(c, errs.DeleteUserFailed)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// CreateUser 创建用户
// @Summary 创建用户
// @Description 创建一个本地密码登录的新用户,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.CreateUserRequest true "创建用户参数"
// @Success 200 {object} response.Any{data=model.UserResponse} "创建成功"
// @Failure 400 {object} response.Any "参数错误或用户名已存在"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users [post]
func CreateUser(c *gin.Context) {
var req model.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
newUser, err := service.AdminCreateUser(c.Request.Context(), contracts.AdminCreateUserRequest{
Username: req.Username,
Password: req.Password,
Nickname: req.Nickname,
Email: req.Email,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
})
if abortUserLogicError(c, err, "", nil, []string{errs.UsernameRequired, errs.EmailRequired, errs.PasswordTooShort, errs.UsernameExists, errs.EmailExists}) {
return
}
c.JSON(http.StatusOK, response.OK(service.ToUserResponse(newUser)))
}
// UpdateUser 更新用户信息
// @Summary 更新用户信息
// @Description 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "用户 ID"
// @Param request body model.UpdateUserRequest true "更新参数"
// @Success 200 {object} response.Any{data=string} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限或尝试修改自身权限"
// @Failure 404 {object} response.Any "用户不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/users/{id} [put]
func UpdateUser(c *gin.Context) {
var req model.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
id, ok := parseUserID(c)
if !ok {
return
}
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
if currUser == nil {
response.AbortUnauthorized(c, errs.AdminRequired)
return
}
err := service.AdminUpdateUser(c.Request.Context(), currUser.ID, contracts.AdminUpdateUserRequest{
ID: id,
Nickname: req.Nickname,
Email: req.Email,
IsAdmin: req.IsAdmin,
Password: req.Password,
})
if err != nil {
if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotRevokeSelfAdmin}, []string{errs.EmailRequired, errs.EmailExists, errs.PasswordTooShort}) {
return
}
response.AbortInternal(c, errs.UpdateUserInfoFailed)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -0,0 +1,131 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_system_configs (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'system',
visibility INTEGER NOT NULL DEFAULT 0,
description VARCHAR(255),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS w_templates (
id BIGINT PRIMARY KEY,
key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'email',
subject VARCHAR(255),
content TEXT NOT NULL,
description VARCHAR(255),
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_templates_is_system ON w_templates (is_system);
CREATE INDEX IF NOT EXISTS idx_w_templates_created_at ON w_templates (created_at);
CREATE INDEX IF NOT EXISTS idx_w_templates_updated_at ON w_templates (updated_at);
CREATE TABLE IF NOT EXISTS w_schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active);
-- Seed initial cleanup task
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '系统定期垃圾清理', 'system_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS w_task_executions (
id BIGINT PRIMARY KEY,
task_id VARCHAR(128) NOT NULL UNIQUE,
task_type VARCHAR(64) NOT NULL,
task_name VARCHAR(128),
status VARCHAR(32) NOT NULL,
retryable BOOLEAN NOT NULL DEFAULT FALSE,
max_retry INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
log TEXT,
error_message TEXT,
result TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
duration BIGINT,
payload TEXT,
triggered_by VARCHAR(32) NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_task_type ON w_task_executions (task_type);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at);
-- Seed system configs (all default platform configs)
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES
('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_max_size_mb', '1024', 'system', 0, '磁盘缓存最大空间大小(MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_ttl_minutes', '1440', 'system', 0, '磁盘缓存默认有效期(分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('file_access_whitelist', '["avatar"]', 'system', 0, '免登录访问的文件业务类型白名单 (JSON 数组)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('login_session_ttl_hours', '168', 'system', 0, '登录会话过期时间(小时)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
INSERT INTO w_templates (id, key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES
(1, 'login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 'register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM w_templates WHERE key IN ('login_email', 'register_email');
DELETE FROM w_system_configs WHERE key IN (
'cap_login_enabled', 'cap_auto_solve', 'cap_challenge_count', 'cap_challenge_size',
'cap_challenge_difficulty', 'cap_challenge_ttl_seconds', 'cap_token_ttl_seconds',
'server_address', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'upload_allowed_extensions', 'site_name', 'password_login_enabled', 'registration_enabled',
'password_register_enabled', 'oidc_login_enabled', 'max_api_keys_per_user',
'email_login_verification_enabled', 'email_register_verification_enabled',
'menu_display_config', 'search_engine_indexing_enabled', 'update_upstream_repository',
'storage_config', 'disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled',
'file_access_whitelist', 'login_session_ttl_hours', 'log_database', 'log_db_migration'
);
DROP TABLE IF EXISTS w_task_executions;
DROP TABLE IF EXISTS w_schedules;
DROP TABLE IF EXISTS w_templates;
DROP TABLE IF EXISTS w_system_configs;
-- +goose StatementEnd
@@ -0,0 +1,131 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_system_configs (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'system',
visibility INTEGER NOT NULL DEFAULT 0,
description VARCHAR(255),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS w_templates (
id BIGINT PRIMARY KEY,
key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'email',
subject VARCHAR(255),
content TEXT NOT NULL,
description VARCHAR(255),
is_system BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_templates_is_system ON w_templates (is_system);
CREATE INDEX IF NOT EXISTS idx_w_templates_created_at ON w_templates (created_at);
CREATE INDEX IF NOT EXISTS idx_w_templates_updated_at ON w_templates (updated_at);
CREATE TABLE IF NOT EXISTS w_schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active);
-- Seed initial cleanup task
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '系统定期垃圾清理', 'system_cleanup', '0 3 * * *', '{}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS w_task_executions (
id BIGINT PRIMARY KEY,
task_id VARCHAR(128) NOT NULL UNIQUE,
task_type VARCHAR(64) NOT NULL,
task_name VARCHAR(128),
status VARCHAR(32) NOT NULL,
retryable BOOLEAN NOT NULL DEFAULT 0,
max_retry INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
log TEXT,
error_message TEXT,
result TEXT,
started_at DATETIME,
finished_at DATETIME,
duration BIGINT,
payload TEXT,
triggered_by VARCHAR(32) NOT NULL DEFAULT 'system',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_task_type ON w_task_executions (task_type);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at);
-- Seed system configs (all default platform configs)
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES
('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_max_size_mb', '1024', 'system', 0, '磁盘缓存最大空间大小(MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_ttl_minutes', '1440', 'system', 0, '磁盘缓存默认有效期(分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('file_access_whitelist', '["avatar"]', 'system', 0, '免登录访问的文件业务类型白名单 (JSON 数组)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('login_session_ttl_hours', '168', 'system', 0, '登录会话过期时间(小时)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
INSERT INTO w_templates (id, key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES
(1, 'login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, 'register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM w_templates WHERE key IN ('login_email', 'register_email');
DELETE FROM w_system_configs WHERE key IN (
'cap_login_enabled', 'cap_auto_solve', 'cap_challenge_count', 'cap_challenge_size',
'cap_challenge_difficulty', 'cap_challenge_ttl_seconds', 'cap_token_ttl_seconds',
'server_address', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'upload_allowed_extensions', 'site_name', 'password_login_enabled', 'registration_enabled',
'password_register_enabled', 'oidc_login_enabled', 'max_api_keys_per_user',
'email_login_verification_enabled', 'email_register_verification_enabled',
'menu_display_config', 'search_engine_indexing_enabled', 'update_upstream_repository',
'storage_config', 'disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled',
'file_access_whitelist', 'login_session_ttl_hours', 'log_database', 'log_db_migration'
);
DROP TABLE IF EXISTS w_task_executions;
DROP TABLE IF EXISTS w_schedules;
DROP TABLE IF EXISTS w_templates;
DROP TABLE IF EXISTS w_system_configs;
-- +goose StatementEnd
@@ -0,0 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
// DatabaseConfig holds database configuration needed by the admin plugin.
type DatabaseConfig struct {
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
Host string `config:"host" env:"DB_HOST"`
Port int `config:"port" env:"DB_PORT" default:"5432"`
Database string `config:"database" env:"DB_DATABASE"`
Username string `config:"username" env:"DB_USERNAME"`
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
SQLitePath string `config:"sqlite_path" env:"DB_SQLITE_PATH" default:"./data/wavelet.db"`
}
// ClickHouseConfig holds clickhouse enablement status needed by admin log queries/switching.
type ClickHouseConfig struct {
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
}
+382
View File
@@ -0,0 +1,382 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"Wavelet/pkg/logger"
"fmt"
"math"
"time"
)
const (
binaryKB = 0
binaryMB = 1
binaryGB = 2
valueThreshold = 10
maxStringLength = 200
)
// FormatBytes renders a byte count using binary units.
func FormatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
value := float64(bytes) / float64(div)
var suffix string
switch exp {
case binaryKB:
suffix = "KiB"
case binaryMB:
suffix = "MiB"
case binaryGB:
suffix = "GiB"
default:
suffix = "TiB"
}
if value == math.Trunc(value) {
if value >= valueThreshold {
return fmt.Sprintf("%.0f %s", value, suffix)
}
return fmt.Sprintf("%.1f %s", value, suffix)
}
return fmt.Sprintf("%.1f %s", value, suffix)
}
// TruncateDisplayValue caps oversized cell values before they reach the console UI.
func TruncateDisplayValue(value string) string {
runes := []rune(value)
if len(runes) > maxStringLength {
return string(runes[:maxStringLength]) + "..."
}
return value
}
// DBOverviewResponse 数据库运行概览响应结构体
type DBOverviewResponse struct {
Type string `json:"type"`
Version string `json:"version"`
Name string `json:"name"`
Size string `json:"size"`
TableCount int64 `json:"table_count"`
Connections int64 `json:"connections"`
}
// GetTableDataRequest 分页拉取表数据请求结构体
type GetTableDataRequest struct {
Table string `form:"table" binding:"required"`
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=10"`
}
// TableDataResponse 动态数据表响应结构体
type TableDataResponse struct {
Columns []string `json:"columns"`
Total int64 `json:"total"`
Results []map[string]interface{} `json:"results"`
}
// ExecuteSQLRequest 执行自定义 SQL 请求结构体
type ExecuteSQLRequest struct {
SQL string `json:"sql" binding:"required"`
}
// ExecuteSQLResponse 执行自定义 SQL 响应结构体
type ExecuteSQLResponse struct {
Type string `json:"type"` // "select" 或 "exec"
Columns []string `json:"columns,omitempty"`
Results []map[string]interface{} `json:"results,omitempty"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMs int64 `json:"execution_time_ms"`
}
// DatabaseInfoResponse 数据库信息响应结构体
type DatabaseInfoResponse struct {
Type string `json:"type"`
Name string `json:"name"`
Version string `json:"version"`
}
// SystemStatusResponse 系统状态响应结构体
type SystemStatusResponse struct {
Uptime string `json:"uptime"`
NumGoroutine int `json:"num_goroutine"`
Alloc string `json:"alloc"`
TotalAlloc string `json:"total_alloc"`
Sys string `json:"sys"`
Lookups uint64 `json:"lookups"`
Mallocs uint64 `json:"mallocs"`
Frees uint64 `json:"frees"`
HeapAlloc string `json:"heap_alloc"`
HeapSys string `json:"heap_sys"`
HeapIdle string `json:"heap_idle"`
HeapInuse string `json:"heap_inuse"`
HeapReleased string `json:"heap_released"`
HeapObjects uint64 `json:"heap_objects"`
StackInuse string `json:"stack_inuse"`
StackSys string `json:"stack_sys"`
MSpanInuse string `json:"mspan_inuse"`
MSpanSys string `json:"mspan_sys"`
MCacheInuse string `json:"mcache_inuse"`
MCacheSys string `json:"mcache_sys"`
BuckHashSys string `json:"buck_hash_sys"`
GCSys string `json:"gc_sys"`
OtherSys string `json:"other_sys"`
NextGC string `json:"next_gc"`
LastGCTime string `json:"last_gc_time"`
PauseTotalNs string `json:"pause_total_ns"`
LastPause string `json:"last_pause"`
NumGC uint32 `json:"num_gc"`
}
// LogDatabaseStatus 日志库状态。
type LogDatabaseStatus struct {
ActiveDatabase string `json:"active_database"`
Migration string `json:"migration"`
RetentionDays map[string]int `json:"retention_days"`
AvailableTargets []string `json:"available_targets"`
}
// CreateSystemConfigRequest 创建系统配置请求
type CreateSystemConfigRequest struct {
Key string `json:"key" binding:"required,max=64"`
Value string `json:"value" binding:"required"`
Type string `json:"type" binding:"required,oneof=system business"`
Visibility int `json:"visibility" binding:"oneof=0 1"`
Description string `json:"description" binding:"max=255"`
}
// UpdateSystemConfigRequest 更新系统配置请求
type UpdateSystemConfigRequest struct {
Value string `json:"value" binding:"required"`
Visibility *int `json:"visibility" binding:"omitempty,oneof=0 1"`
Description string `json:"description" binding:"max=255"`
}
// TestSMTPRequest 测试 SMTP 配置请求
type TestSMTPRequest struct {
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
SMTPPort int `json:"smtp_port" binding:"required"`
SMTPUsername string `json:"smtp_username" binding:"required,max=255"`
SMTPPassword string `json:"smtp_password" binding:"required,max=255"`
To string `json:"to" binding:"required,email"`
}
// TestSMTPResponse 测试 SMTP 配置响应
type TestSMTPResponse struct {
Success bool `json:"success"`
Log string `json:"log"`
Error string `json:"error"`
}
// UpdateCacheConfigRequest 磁盘缓存策略更新请求
type UpdateCacheConfigRequest struct {
MaxSizeMB int64 `json:"max_size_mb" binding:"required,min=1"`
TTLMinutes int64 `json:"ttl_minutes" binding:"required,min=0"`
LRUEnabled bool `json:"lru_enabled"`
}
// CreateTemplateRequest 创建模板请求
type CreateTemplateRequest struct {
Key string `json:"key" binding:"required,max=80"`
Name string `json:"name" binding:"required,max=100"`
Type string `json:"type" binding:"required,max=20"`
Subject string `json:"subject" binding:"max=255"`
Content string `json:"content" binding:"required"`
Description string `json:"description" binding:"max=255"`
}
// UpdateTemplateRequest 更新模板请求
type UpdateTemplateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Type string `json:"type" binding:"required,max=20"`
Subject string `json:"subject" binding:"max=255"`
Content string `json:"content" binding:"required"`
Description string `json:"description" binding:"max=255"`
}
// DispatchTaskRequest 下发任务请求
type DispatchTaskRequest struct {
TaskType string `json:"task_type" binding:"required"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
UserID *uint64 `json:"user_id"`
Payload string `json:"payload"`
}
// CreateScheduleRequest 创建定时任务请求
type CreateScheduleRequest struct {
Name string `json:"name" binding:"required"`
TaskType string `json:"task_type" binding:"required"`
Cron string `json:"cron" binding:"required"`
Payload string `json:"payload"`
IsActive *bool `json:"is_active" binding:"required"`
}
// UpdateScheduleRequest 修改定时任务请求
type UpdateScheduleRequest struct {
Name string `json:"name" binding:"required"`
TaskType string `json:"task_type" binding:"required"`
Cron string `json:"cron" binding:"required"`
Payload string `json:"payload"`
IsActive *bool `json:"is_active" binding:"required"`
}
// ListTaskExecutionsRequest 分页查询任务执行记录请求参数
type ListTaskExecutionsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status string `form:"status"`
TaskType string `form:"task_type"`
TaskTypes string `form:"task_types"`
TaskTypePrefix string `form:"task_type_prefix"`
}
// ListUsersRequest 用户列表查询请求
type ListUsersRequest struct {
Page int `form:"page" binding:"min=1"`
PageSize int `form:"page_size" binding:"min=1,max=100"`
UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"`
Username string `form:"username"`
Email string `form:"email"`
}
// UserResponse 用户资料响应
type UserResponse struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListUsersResponse 用户列表响应
type ListUsersResponse struct {
Users []UserResponse `json:"users"`
Total int64 `json:"total"`
}
// UpdateUserStatusRequest 更新用户状态请求
type UpdateUserStatusRequest struct {
IsActive bool `json:"is_active"`
}
// CreateUserRequest 创建用户请求
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=64"`
Nickname string `json:"nickname" binding:"omitempty,max=64"`
Email string `json:"email" binding:"required,email,max=255"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
// UpdateUserRequest 更新用户信息请求
type UpdateUserRequest struct {
Nickname string `json:"nickname" binding:"max=64"`
Email string `json:"email" binding:"required,email,max=255"`
IsAdmin bool `json:"is_admin"`
Password string `json:"password" binding:"omitempty,min=8,max=64"`
}
// LogsResponse 历史日志查询响应
type LogsResponse struct {
Lines []logger.LogEntry `json:"lines"`
HasMore bool `json:"has_more"`
NextCursor int `json:"next_cursor"` // 用于加载更早日志的 cursor
}
// AccessLogItem 访问日志单条数据
type AccessLogItem struct {
ID uint64 `json:"id,string"`
TraceID string `json:"trace_id"`
UserID uint64 `json:"user_id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Path string `json:"path"`
Method string `json:"method"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Headers string `json:"headers"`
Status int32 `json:"status"`
Latency int64 `json:"latency"`
CreatedAt string `json:"created_at"`
}
// AccessLogsResponse 访问日志查询响应
type AccessLogsResponse struct {
Total uint64 `json:"total"`
List []AccessLogItem `json:"list"`
}
// TrendItem 趋势图数据点
type TrendItem struct {
Date string `json:"date"`
Count uint64 `json:"count"`
}
// BrowserItem 浏览器占比排行
type BrowserItem struct {
Browser string `json:"browser"`
Count uint64 `json:"count"`
}
// TopUserItem 活跃用户数据
type TopUserItem struct {
UserID uint64 `json:"user_id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Count uint64 `json:"count"`
}
// LogsAnalyticsResponse 访问日志数据分析结果
type LogsAnalyticsResponse struct {
Trend []TrendItem `json:"trend"`
Browsers []BrowserItem `json:"browsers"`
TopUsers []TopUserItem `json:"top_users"`
}
// AccessLogQuery carries the raw console filters before they become a contract filter.
type AccessLogQuery struct {
Username string
Path string
StartTime string
EndTime string
Page int
PageSize int
}
// UpdaterStatus describes the current build and the newest compatible upstream release.
type UpdaterStatus struct {
CurrentVersion string `json:"current_version"`
BuildTime string `json:"build_time"`
LatestVersion string `json:"latest_version"`
UpdateAvailable bool `json:"update_available"`
CanUpgrade bool `json:"can_upgrade"`
Prerelease bool `json:"prerelease"`
ReleaseName string `json:"release_name"`
ReleaseNotes string `json:"release_notes"`
ReleaseURL string `json:"release_url"`
PublishedAt string `json:"published_at"`
UpstreamRepository string `json:"upstream_repository"`
AssetName string `json:"asset_name"`
Platform string `json:"platform"`
}
@@ -0,0 +1,214 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package model contains database entities and data transfer objects for the admin domain.
package model
import (
"Wavelet/plugins/domain/admin/errs"
"bytes"
"errors"
"strings"
"text/template"
"time"
)
// 配置键常量 - 所有系统配置的 key 定义
const (
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
ConfigKeySiteName = "site_name" // 站点名称
ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册
ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" //nolint:gosec // false positive: config key name, not credentials
ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证
ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击)
ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5
ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度
ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度)
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" //nolint:gosec // false positive: config key name, not credentials
ConfigKeyServerAddress = "server_address" // 服务器地址
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
ConfigKeySMTPPort = "smtp_port" // SMTP 端口
ConfigKeySMTPUsername = "smtp_username" // SMTP 账户
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串)
ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索
ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式)
ConfigKeyDiskCacheMaxSizeMB = "disk_cache_max_size_mb" // 磁盘缓存最大空间大小 (MB)
ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟)
ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制
ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时)
ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库
ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON)
ConfigKeyLogDatabase = "log_database" // 当前日志主库(postgres/sqlite/clickhouse),受保护
ConfigKeyLogDBMigration = "log_db_migration" // 日志库迁移冻结标记(空/migrating),受保护
ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres" // PostgreSQL 用户访问日志保留天数
ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite" // SQLite 用户访问日志保留天数
ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse" // ClickHouse 用户访问日志保留天数
)
const (
// ConfigVisibilityHidden 表示配置不通过公共配置接口暴露
ConfigVisibilityHidden = 0
// ConfigVisibilityVisible 表示配置通过公共配置接口暴露
ConfigVisibilityVisible = 1
)
// SystemConfig 系统配置实体
type SystemConfig struct {
Key string `json:"key" gorm:"primaryKey;size:64;not null"`
Value string `json:"value" gorm:"type:text;not null"`
Type string `json:"type" gorm:"size:32;not null;default:'system'"`
Visibility int `json:"visibility" gorm:"not null;default:0"`
Description string `json:"description" gorm:"size:255"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// TableName 表名
func (SystemConfig) TableName() string {
return "w_system_configs"
}
// Template 邮件/消息模板实体
type Template struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Key string `json:"key" gorm:"uniqueIndex;size:80;not null"`
Name string `json:"name" gorm:"size:100;not null"`
Type string `json:"type" gorm:"size:20;not null;default:'email'"`
Subject string `json:"subject" gorm:"size:255"`
Content string `json:"content" gorm:"type:text;not null"`
Description string `json:"description" gorm:"size:255"`
IsSystem bool `json:"is_system" gorm:"index;not null;default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 表名
func (Template) TableName() string {
return "w_templates"
}
// TemplateTypeEmail 邮件模板类型
const TemplateTypeEmail = "email"
// Normalize 规范化模板字段
func (t *Template) Normalize() {
t.Key = strings.TrimSpace(t.Key)
t.Name = strings.TrimSpace(t.Name)
t.Type = strings.ToLower(strings.TrimSpace(t.Type))
t.Subject = strings.TrimSpace(t.Subject)
t.Content = strings.TrimSpace(t.Content)
t.Description = strings.TrimSpace(t.Description)
if t.Type == "" {
t.Type = TemplateTypeEmail
}
}
// Validate 校验模板必填字段
func (t *Template) Validate() error {
t.Normalize()
if t.Key == "" {
return errors.New(errs.TemplateKeyRequired)
}
if t.Name == "" {
return errors.New(errs.TemplateNameRequired)
}
if t.Content == "" {
return errors.New(errs.TemplateContentRequired)
}
return nil
}
// Render 渲染模板的 Subject 和 Content
func (t *Template) Render(data any) (string, string, error) {
var subject string
if t.Subject != "" {
tmplSubject, err := template.New(t.Key + "_subject").Parse(t.Subject)
if err != nil {
return "", "", err
}
var subBuf bytes.Buffer
if err := tmplSubject.Execute(&subBuf, data); err != nil {
return "", "", err
}
subject = subBuf.String()
}
tmplContent, err := template.New(t.Key + "_content").Parse(t.Content)
if err != nil {
return "", "", err
}
var bodyBuf bytes.Buffer
if err := tmplContent.Execute(&bodyBuf, data); err != nil {
return "", "", err
}
return subject, bodyBuf.String(), nil
}
// Schedule 定时任务配置表
type Schedule struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
TaskType string `json:"task_type" gorm:"size:64;not null"`
Cron string `json:"cron" gorm:"size:64;not null"`
Payload string `json:"payload" gorm:"type:text"`
IsActive bool `json:"is_active" gorm:"not null;default:true"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (Schedule) TableName() string {
return "w_schedules"
}
// TaskExecutionStatus 任务执行状态
type TaskExecutionStatus string
// 任务执行状态
const (
TaskExecutionStatusPending TaskExecutionStatus = "pending"
TaskExecutionStatusRunning TaskExecutionStatus = "running"
TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded"
TaskExecutionStatusFailed TaskExecutionStatus = "failed"
)
// TaskExecution 任务执行记录
type TaskExecution struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"`
TaskType string `json:"task_type" gorm:"size:64;index;not null"`
TaskName string `json:"task_name" gorm:"size:128"`
Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"`
Retryable bool `json:"retryable" gorm:"not null;default:false"`
MaxRetry int `json:"max_retry" gorm:"not null;default:0"`
RetryCount int `json:"retry_count" gorm:"not null;default:0"`
Log string `json:"log" gorm:"type:text"`
ErrorMessage string `json:"error_message" gorm:"type:text"`
Result string `json:"result" gorm:"type:text"`
StartedAt *time.Time `json:"started_at" gorm:"index"`
FinishedAt *time.Time `json:"finished_at"`
Duration int64 `json:"duration" gorm:"comment:耗时毫秒"`
Payload string `json:"payload" gorm:"type:text"`
TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (TaskExecution) TableName() string {
return "w_task_executions"
}
// TaskExecutionCleanupStats 任务日志清理结果统计
type TaskExecutionCleanupStats struct {
HighFrequencyDeleted int64 `json:"high_frequency_deleted"`
LowFrequencyDeleted int64 `json:"low_frequency_deleted"`
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package admin provides the system management console, diagnostics, audit logging, and configuration hot-reloading domain plugin for Cordis.
package admin
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/plugins/domain/admin/handler"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"context"
"embed"
"reflect"
"github.com/gin-gonic/gin"
)
// SystemConfig aliases model.SystemConfig for external compatibility.
type SystemConfig = model.SystemConfig
//go:embed migrations/*/*.sql
var adminMigrations embed.FS
// Option configures the admin plugin.
type Option func(*Plugin)
// Plugin implements core.Plugin to provide system administration and management APIs.
type Plugin struct{}
// New creates a new admin domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the admin domain plugin.
func (p *Plugin) Name() string {
return "admin"
}
// Inject declares required dependencies for the admin domain plugin.
func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{
reflect.TypeFor[contracts.DBService](),
reflect.TypeFor[contracts.CacheService](),
reflect.TypeFor[contracts.UserService](),
reflect.TypeFor[contracts.AuthService](),
}
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "admin",
Version: "1.0.0",
Description: "System administration console, diagnostic monitoring, and configuration hot-reload plugin",
Author: "Wavelet Team",
}
}
// DeclareConfig declares configuration bindings consumed by the admin plugin.
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{
{Prefix: "database", Target: &model.DatabaseConfig{}},
{Prefix: "clickhouse", Target: &model.ClickHouseConfig{}},
}
}
// Apply registers admin routes, tasks, schedules, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
var dbCfg model.DatabaseConfig
_ = ctx.Config().Bind("database", &dbCfg)
service.SetDBConfig(dbCfg)
var chCfg model.ClickHouseConfig
_ = ctx.Config().Bind("clickhouse", &chCfg)
service.SetClickHouseConfig(chCfg)
// 0. Bind Services reactively
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
service.SetDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
service.SetDBService(db)
})
}
if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil {
service.SetCacheService(cache)
} else {
core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) {
service.SetCacheService(cache)
})
}
if user, err := core.Inject[contracts.UserService](ctx); err == nil && user != nil {
service.SetUserService(user)
} else {
core.When[contracts.UserService](ctx, func(user contracts.UserService) {
service.SetUserService(user)
})
}
if auth, err := core.Inject[contracts.AuthService](ctx); err == nil && auth != nil {
service.SetAuthService(auth)
} else {
core.When[contracts.AuthService](ctx, func(auth contracts.AuthService) {
service.SetAuthService(auth)
})
}
if task, err := core.Inject[contracts.TaskService](ctx); err == nil && task != nil {
service.SetTaskService(task)
} else {
core.When[contracts.TaskService](ctx, func(task contracts.TaskService) {
service.SetTaskService(task)
})
}
if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil {
service.SetStorageService(storage)
} else {
core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) {
service.SetStorageService(storage)
})
}
if rc, err := core.Inject[contracts.RiskControlService](ctx); err == nil && rc != nil {
service.SetRiskControlService(rc)
} else {
core.When[contracts.RiskControlService](ctx, func(rc contracts.RiskControlService) {
service.SetRiskControlService(rc)
})
}
service.SetEventEmitter(ctx.Events().Emit)
ctx.OnDispose(func() error {
service.ResetServices()
return nil
})
// 0a. Dynamic Auth Middlewares
denyAuth := ginutil.AuthUnavailable()
var loginMW gin.HandlerFunc = func(c *gin.Context) {
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
mw(c)
return
}
}
denyAuth(c)
}
var adminMW gin.HandlerFunc = func(c *gin.Context) {
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok {
mw(c)
return
}
}
denyAuth(c)
}
// 0b. Register migrations
ctx.Migrations().Register("admin", adminMigrations)
// 1. Register Admin HTTP Routes
adminRouter := ctx.Router().Group("/api/v1/admin", loginMW, adminMW)
handler.RegisterRoutes(adminRouter)
// 2. Register Background Tasks
logSwitchHandler := &service.LogDBSwitchHandler{}
ctx.Task().Register(service.LogDBSwitchTask, func(c context.Context, payload []byte) error {
_, err := logSwitchHandler.Execute(c, payload)
return err
}, extpoints.WithTaskMeta(service.LogDBSwitchMeta))
ctx.Task().Register("admin:system_cleanup", func(_ context.Context, _ []byte) error {
return nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
extpoints.WithTaskDescription("定期清理未使用上传文件、历史推送记录和过期任务执行日志"),
extpoints.WithTaskCategory("maintenance"),
extpoints.WithTaskRetry(1),
extpoints.WithTaskQueue("default"),
extpoints.WithTaskRetryable(true),
)
// 3. Register Cron Schedules
ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"})
// 4. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "admin.system_cleanup_cron",
Default: "0 4 * * *",
Description: "Cron expression for nightly system logs and expired tokens cleanup",
Type: "string",
Category: "maintenance",
})
return nil
}
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package admin_test
import (
"Wavelet/core"
"Wavelet/plugins/domain/admin"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdminPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
p := admin.New()
assert.Equal(t, "admin", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
// Verify routes
routes := ctx.Router().Routes()
assert.NotEmpty(t, routes)
// Verify tasks
_, ok := ctx.Tasks().Get("admin:system_cleanup")
require.True(t, ok)
// Verify schedules
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", sched.Spec)
// Verify settings
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", setting.Default)
}
func TestAdminMigrationsIncludeTaskExecutionsAndSchedules(t *testing.T) {
ctx := core.NewContext(context.Background())
p := admin.New()
require.NoError(t, p.Apply(ctx))
entry, ok := ctx.Migrations().Get("admin")
require.True(t, ok, "admin plugin must register migrations")
assert.Equal(t, "admin", entry.PluginID)
// Verify sqlite migration files include w_schedules and w_task_executions
sqliteDir, err := entry.FS.Open("migrations/sqlite/00001_initial.sql")
require.NoError(t, err)
defer sqliteDir.Close()
stat, err := sqliteDir.Stat()
require.NoError(t, err)
buf := make([]byte, stat.Size())
_, err = sqliteDir.Read(buf)
require.NoError(t, err)
content := string(buf)
assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_task_executions")
assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_schedules")
assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_system_configs")
assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_templates")
// Verify postgres migration files include w_schedules and w_task_executions
pgDir, err := entry.FS.Open("migrations/postgres/00001_initial.sql")
require.NoError(t, err)
defer pgDir.Close()
stat, err = pgDir.Stat()
require.NoError(t, err)
buf = make([]byte, stat.Size())
_, err = pgDir.Read(buf)
require.NoError(t, err)
pgContent := string(buf)
assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_task_executions")
assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_schedules")
assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_system_configs")
assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_templates")
}
@@ -0,0 +1,144 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/pkg/cache/ram"
"Wavelet/plugins/domain/admin/model"
"context"
"encoding/json"
"errors"
"time"
"gorm.io/gorm"
)
const (
// SystemConfigBroadcastChannel broadcasts system config cache updates across nodes.
SystemConfigBroadcastChannel = "system:config_broadcast"
// SystemConfigInvalidationChannel is kept as an alias for backward compatibility.
SystemConfigInvalidationChannel = SystemConfigBroadcastChannel
// SystemConfigRedisHashKey is kept for backward compatibility in tests.
SystemConfigRedisHashKey = "system:system_configs"
// SystemConfigVisibleListRedisKey is kept for backward compatibility in tests.
SystemConfigVisibleListRedisKey = "system:visible_configs"
// ConfigCacheType is the cache type for all system configs.
ConfigCacheType = "config"
)
// ConfigLoader loads configuration data from the database.
type ConfigLoader struct{}
// LoadAll loads all system configs from database as CacheItems.
func (ConfigLoader) LoadAll(ctx context.Context, configType string) ([]ram.CacheItem, error) {
configs, err := PreheatSystemConfigs(ctx)
if err != nil {
return nil, err
}
items := make([]ram.CacheItem, len(configs))
for i, cfg := range configs {
valBytes, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
items[i] = ram.CacheItem{
Key: cfg.Key,
Value: string(valBytes),
Type: configType,
TTL: determineTTL(cfg.Key),
}
}
return items, nil
}
// LoadOne loads a single system config from database as CacheItem.
func (ConfigLoader) LoadOne(ctx context.Context, configType, key string) (ram.CacheItem, error) {
cfg, err := GetSystemConfigByKey(ctx, key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ram.CacheItem{}, ram.ErrNotFound
}
return ram.CacheItem{}, err
}
valBytes, err := json.Marshal(cfg)
if err != nil {
return ram.CacheItem{}, err
}
return ram.CacheItem{
Key: cfg.Key,
Value: string(valBytes),
Type: configType,
TTL: determineTTL(cfg.Key),
}, nil
}
// GetCachedSystemConfig retrieves a single system config with RAM L1 fallback to DB.
func GetCachedSystemConfig(ctx context.Context, key string) (*model.SystemConfig, error) {
if item, ok := ram.Get(ConfigCacheType, key); ok {
var cfg model.SystemConfig
if err := json.Unmarshal([]byte(item.Value), &cfg); err == nil {
return &cfg, nil
}
}
cfg, err := GetSystemConfigByKey(ctx, key)
if err != nil {
return nil, err
}
valBytes, err := json.Marshal(cfg)
if err == nil {
ram.Set(ram.CacheItem{
Key: cfg.Key,
Value: string(valBytes),
Type: ConfigCacheType,
TTL: determineTTL(key),
})
}
return &cfg, nil
}
// StopSystemConfigCacheListener stops the cache invalidation listener (kept for backward compatibility).
func StopSystemConfigCacheListener() {
}
// StartSystemConfigCacheListener starts the cache listener (kept for backward compatibility).
func StartSystemConfigCacheListener() {
}
func ensureSystemConfigCacheListener() {
}
func determineTTL(_ string) time.Duration {
return -1
}
// InvalidateSystemConfigCache triggers a broadcast to refresh the cache for key.
func InvalidateSystemConfigCache(ctx context.Context, key string) error {
ram.Delete(ConfigCacheType, key)
if cacheSvc := GetCache(ctx); cacheSvc != nil {
_ = cacheSvc.Delete(ctx, "system:config:"+key)
_ = cacheSvc.Delete(ctx, SystemConfigVisibleListRedisKey)
}
return nil
}
// InvalidateAllSystemConfigCaches triggers a broadcast to refresh the entire config cache.
func InvalidateAllSystemConfigCaches(ctx context.Context) error {
ram.UpdateTypeItems(ConfigCacheType, nil)
if cacheSvc := GetCache(ctx); cacheSvc != nil {
_ = cacheSvc.Delete(ctx, SystemConfigRedisHashKey)
_ = cacheSvc.Delete(ctx, SystemConfigVisibleListRedisKey)
}
return nil
}
// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache.
func ResetSystemConfigRAMCacheForTest() {
ram.ResetForTest()
}
@@ -0,0 +1,394 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"context"
"database/sql"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
)
const (
defaultSQLiteDBPath = "./data/wavelet.db"
logDBNameSQLite = "sqlite"
)
var (
dbConfigMu sync.RWMutex
dbConfig = model.DatabaseConfig{
SQLitePath: defaultSQLiteDBPath,
}
)
// SetDBConfig sets the database configuration.
func SetDBConfig(cfg model.DatabaseConfig) {
dbConfigMu.Lock()
defer dbConfigMu.Unlock()
dbConfig = cfg
}
// GetDBConfig gets the database configuration.
func GetDBConfig() model.DatabaseConfig {
dbConfigMu.RLock()
defer dbConfigMu.RUnlock()
return dbConfig
}
// sqliteDatabasePath resolves the effective SQLite file path from configuration.
func sqliteDatabasePath() string {
name := GetDBConfig().SQLitePath
if name == "" {
name = defaultSQLiteDBPath
}
return name
}
// QuoteTableName escapes a raw identifier for use inside a quoted SQL fragment.
func QuoteTableName(table string) string {
return `"` + strings.ReplaceAll(table, `"`, `""`) + `"`
}
// GetSQLiteOverview collects the SQLite runtime overview.
func GetSQLiteOverview(ctx context.Context) (model.DBOverviewResponse, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
}
name := sqliteDatabasePath()
var version string
var ver string
if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil {
version = "SQLite " + ver
} else {
version = "SQLite"
}
var sizeStr string
if fi, err := os.Stat(name); err == nil {
size := fi.Size()
if size < 0 {
size = 0
}
sizeStr = model.FormatBytes(uint64(size))
} else {
sizeStr = "0 B"
}
var tableCount int64
if err := gormDB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&tableCount).Error; err != nil {
tableCount = 0
}
var connCount int64
if sqlDB, err := gormDB.DB(); err == nil {
connCount = int64(sqlDB.Stats().OpenConnections)
} else {
connCount = 1
}
return model.DBOverviewResponse{
Type: logDBNameSQLite,
Version: version,
Name: name,
Size: sizeStr,
TableCount: tableCount,
Connections: connCount,
}, nil
}
// GetPostgresOverview collects the PostgreSQL runtime overview.
func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
}
name := GetDBConfig().Database
var version string
var ver string
if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil {
version = ver
} else {
version = "PostgreSQL"
}
var sizeStr string
var sizeBytes sql.NullInt64
if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid {
size := sizeBytes.Int64
if size < 0 {
size = 0
}
sizeStr = model.FormatBytes(uint64(size))
} else {
sizeStr = "0 B"
}
var tableCount int64
if err := gormDB.Raw("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()").Scan(&tableCount).Error; err != nil {
tableCount = 0
}
var connCount int64
var pgc sql.NullInt64
if err := gormDB.Raw("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()").Scan(&pgc).Error; err == nil && pgc.Valid {
connCount = pgc.Int64
} else {
if sqlDB, err := gormDB.DB(); err == nil {
connCount = int64(sqlDB.Stats().OpenConnections)
} else {
connCount = 1
}
}
return model.DBOverviewResponse{
Type: "postgres",
Version: version,
Name: name,
Size: sizeStr,
TableCount: tableCount,
Connections: connCount,
}, nil
}
// ListDatabaseTableNames returns every user table of the active database.
func ListDatabaseTableNames(ctx context.Context) ([]string, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return nil, errs.ErrDatabaseUninitialized
}
var tables []string
var err error
if !GetDBConfig().Enabled {
err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
} else {
err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error
}
if err != nil {
return nil, err
}
return tables, nil
}
// CountDatabaseTableRows counts the rows of the quoted table.
func CountDatabaseTableRows(ctx context.Context, quotedTable string) (int64, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return 0, errs.ErrDatabaseUninitialized
}
var total int64
if err := gormDB.Raw("SELECT count(*) FROM " + quotedTable).Scan(&total).Error; err != nil {
return 0, errs.NewInvalidInputError(err.Error())
}
return total, nil
}
// QueryDatabaseTableRows loads one page of raw rows from the quoted table.
func QueryDatabaseTableRows(
ctx context.Context,
quotedTable string,
limit int,
offset int,
) ([]string, []map[string]any, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return nil, nil, errs.ErrDatabaseUninitialized
}
rows, err := gormDB.Raw("SELECT * FROM "+quotedTable+" LIMIT ? OFFSET ?", limit, offset).Rows()
if err != nil {
return nil, nil, errs.NewInvalidInputError(err.Error())
}
defer func() {
_ = rows.Close()
}()
cols, err := rows.Columns()
if err != nil {
return nil, nil, err
}
results, err := scanTableRows(rows, cols)
if err != nil {
return nil, nil, err
}
return cols, results, nil
}
// RunSelectSQL executes an arbitrary select-like statement.
func RunSelectSQL(ctx context.Context, sqlStr string) ([]string, []map[string]any, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return nil, nil, errs.ErrDatabaseUninitialized
}
rows, err := gormDB.Raw(sqlStr).Rows()
if err != nil {
return nil, nil, errs.NewInvalidInputError(err.Error())
}
defer func() {
_ = rows.Close()
}()
cols, err := rows.Columns()
if err != nil {
return nil, nil, err
}
results, err := scanTableRows(rows, cols)
if err != nil {
return nil, nil, err
}
return cols, results, nil
}
// RunMutationSQL executes a non-query statement and reports affected rows.
func RunMutationSQL(ctx context.Context, sqlStr string) (int64, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return 0, errs.ErrDatabaseUninitialized
}
tx := gormDB.Exec(sqlStr)
if tx.Error != nil {
return 0, errs.NewInvalidInputError(tx.Error.Error())
}
return tx.RowsAffected, nil
}
// scanTableRows decodes every row of the result set into a column keyed map.
func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]any, error) {
results := make([]map[string]any, 0)
for rows.Next() {
row, err := scanRowAsMap(rows, cols)
if err != nil {
return nil, err
}
results = append(results, row)
}
return results, nil
}
// scanRowAsMap decodes a single row, normalising driver byte slices to strings.
func scanRowAsMap(rows *sql.Rows, cols []string) (map[string]any, error) {
columns := make([]any, len(cols))
columnPointers := make([]any, len(cols))
for i := range columns {
columnPointers[i] = &columns[i]
}
if err := rows.Scan(columnPointers...); err != nil {
return nil, err
}
rowMap := make(map[string]any)
for i, colName := range cols {
val := columns[i]
if b, ok := val.([]byte); ok {
rowMap[colName] = string(b)
continue
}
rowMap[colName] = val
}
return rowMap, nil
}
// GetSQLiteInfo collects the SQLite type/name/version triple.
func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
cfg := GetDBConfig()
info := model.DatabaseInfoResponse{
Type: logDBNameSQLite,
Name: cfg.SQLitePath,
Version: "SQLite",
}
if info.Name == "" {
info.Name = defaultSQLiteDBPath
}
gormDB := GetDB(ctx)
if gormDB == nil {
return info
}
var ver string
if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil && ver != "" {
info.Version = "SQLite " + ver
}
return info
}
// GetPostgresInfo collects the PostgreSQL type/name/version triple.
func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse {
cfg := GetDBConfig()
info := model.DatabaseInfoResponse{
Type: "postgres",
Name: cfg.Database,
Version: "PostgreSQL",
}
gormDB := GetDB(ctx)
if gormDB == nil {
return info
}
var ver string
if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil && ver != "" {
info.Version = ver
}
return info
}
// OpenSQLiteExportFile opens the active SQLite database file together with its stat info.
func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) {
// export db file path is trusted
f, err := os.Open(sqliteDatabasePath())
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", errs.ErrOpenDatabaseFileFailed, err)
}
fi, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, nil, fmt.Errorf("%s: %w", errs.ErrReadDatabaseFileInfoFailed, err)
}
return f, fi, nil
}
// NewPgDumpCommand builds the streaming pg_dump command for the active database.
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
dbCfg := GetDBConfig()
pgDumpPath, err := exec.LookPath("pg_dump")
if err != nil {
return nil, "", errors.New(errs.ErrPgDumpUnavailable)
}
args := []string{
"--no-password",
"-h", dbCfg.Host,
"-p", fmt.Sprintf("%d", dbCfg.Port),
"-U", dbCfg.Username,
dbCfg.Database,
}
//nolint:gosec // pg_dump args are constructed from validated db config
cmd := exec.CommandContext(ctx, pgDumpPath, args...)
if dbCfg.Password != "" {
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
} else {
cmd.Env = os.Environ()
}
fileName := fmt.Sprintf("wavelet_%s.sql", time.Now().Format("20060102_150405"))
return cmd, fileName, nil
}
@@ -0,0 +1,157 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository_test
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
cacheplugin "Wavelet/plugins/infra/cache"
)
// stubDBService 用内存 SQLite 满足 DBService 契约,隔离外部依赖。
type stubDBService struct{ db *gorm.DB }
func (s stubDBService) GORM() *gorm.DB { return s.db }
func (s stubDBService) DB(context.Context) *gorm.DB { return s.db }
func (s stubDBService) Named(string) *gorm.DB { return s.db }
// newFlushLogTestCache 构建真实多层缓存服务并注入 admin 插件上下文。
func newFlushLogTestCache(t *testing.T) (contracts.CacheService, *miniredis.Miniredis, func()) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr(), MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}})
p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64))
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(map[string]any{
"redis": map[string]any{
"enabled": true,
"addrs": []string{mr.Addr()},
},
}))
require.NoError(t, ctx.Config().Resolve())
require.NoError(t, p.Apply(ctx))
svc, err := core.Inject[contracts.CacheService](ctx)
require.NoError(t, err)
repository.SetCacheService(svc)
cleanup := func() {
repository.SetCacheService(nil)
_ = rdb.Close()
mr.Close()
}
return svc, mr, cleanup
}
// TestFlushTaskExecutionLogPropagatesCacheError 回归:缓存读取失败(非未命中)时,
// FlushTaskExecutionLog 必须返回错误而不是静默吞掉日志并误报成功(nilerr 修复)。
func TestFlushTaskExecutionLogPropagatesCacheError(t *testing.T) {
_, mr, cleanup := newFlushLogTestCache(t)
defer cleanup()
ctx := context.Background()
const taskID = "flush-err-task"
// 先缓冲一行日志
require.NoError(t, repository.AppendTaskExecutionLog(ctx, taskID, "step-1 ok"))
// 关闭 miniredis 模拟缓存基础设施故障(读取出错而非未命中)
mr.Close()
err := repository.FlushTaskExecutionLog(ctx, taskID)
assert.Error(t, err, "缓存故障时必须返回错误,防止缓冲日志被静默丢弃")
}
// TestFlushTaskExecutionLogCacheMissIsNoop 回归:任务无缓冲日志(未命中)时应为空操作成功。
func TestFlushTaskExecutionLogCacheMissIsNoop(t *testing.T) {
_, _, cleanup := newFlushLogTestCache(t)
defer cleanup()
ctx := context.Background()
assert.NoError(t, repository.FlushTaskExecutionLog(ctx, "missing-task"))
}
// TestFlushTaskExecutionLogPersistsAndClears 验证正常路径:缓冲日志写入执行记录后清理缓存。
func TestFlushTaskExecutionLogPersistsAndClears(t *testing.T) {
svc, _, cleanup := newFlushLogTestCache(t)
defer cleanup()
ctx := context.Background()
const taskID = "flush-ok-task"
require.NoError(t, repository.AppendTaskExecutionLog(ctx, taskID, "done"))
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.TaskExecution{}))
repository.SetDBService(stubDBService{db: sqliteDB})
defer repository.SetDBService(nil)
gormDB := sqliteDB
exec := &model.TaskExecution{TaskID: taskID, TaskType: "upload:test", TaskName: "t", Status: model.TaskExecutionStatusSucceeded}
require.NoError(t, gormDB.Create(exec).Error)
require.NoError(t, repository.FlushTaskExecutionLog(ctx, taskID))
var got model.TaskExecution
require.NoError(t, gormDB.First(&got, exec.ID).Error)
assert.Contains(t, got.Log, "done")
// 缓存中的缓冲日志应已被清理
var buf string
err = svc.Get(ctx, repository.TaskExecutionLogRedisKey(taskID), &buf)
assert.True(t, errors.Is(err, contracts.ErrCacheMiss), "flush 后缓存应清空, got %v", err)
}
// readFailCache 读取永远报错而写入成功,用于区分「未命中」与「缓存故障」两种语义。
type readFailCache struct {
writes []string
}
func (c *readFailCache) Get(context.Context, string, any) error {
return errors.New("cache unavailable")
}
func (c *readFailCache) Set(_ context.Context, key string, value any, _ time.Duration) error {
c.writes = append(c.writes, fmt.Sprintf("%s=%v", key, value))
return nil
}
func (c *readFailCache) Delete(context.Context, string) error { return nil }
func (c *readFailCache) GetOrSet(context.Context, string, any, time.Duration, func() (any, error)) error {
return errors.New("cache unavailable")
}
func (c *readFailCache) Invalidate(context.Context, string) error { return nil }
// TestAppendTaskExecutionLogKeepsBufferOnCacheReadError 回归:缓存读取失败(而非未命中)时
// 不得把「读不到」当成「没有缓冲」继续写入,否则整段任务日志会被最新一行覆盖丢失。
func TestAppendTaskExecutionLogKeepsBufferOnCacheReadError(t *testing.T) {
fake := &readFailCache{}
repository.SetCacheService(fake)
defer repository.SetCacheService(nil)
err := repository.AppendTaskExecutionLog(context.Background(), "append-err-task", "step-2")
assert.Error(t, err, "缓存故障必须上抛,而不是覆盖缓冲")
assert.Empty(t, fake.writes, "读取失败时不得写入,避免覆盖已缓冲日志")
}
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/pkg/util"
"context"
)
// UserDisplayName is the minimal user projection needed to decorate access log rows.
type UserDisplayName struct {
Username string
Nickname string
}
// SearchUserIDsByUsername is the database fallback used when the user contract is absent.
func SearchUserIDsByUsername(ctx context.Context, username string) ([]uint64, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return nil, nil
}
var ids []uint64
if err := gormDB.Table("w_users").
Where("username LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(username)+"%").
Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// LoadUserDisplayNames resolves usernames and nicknames for the given ids.
func LoadUserDisplayNames(ctx context.Context, userIDs []uint64) (map[uint64]UserDisplayName, error) {
result := make(map[uint64]UserDisplayName, len(userIDs))
gormDB := GetDB(ctx)
if gormDB == nil || len(userIDs) == 0 {
return result, nil
}
var users []struct {
ID uint64
Username string
Nickname string
}
if err := gormDB.Table("w_users").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
result[u.ID] = UserDisplayName{Username: u.Username, Nickname: u.Nickname}
}
return result, nil
}
@@ -0,0 +1,421 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package repository provides persistence operations for the admin domain.
package repository
import (
"Wavelet/core/contracts"
"Wavelet/pkg/cache/ram"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
const (
configTypeSystem = "system"
)
var (
repoMu sync.RWMutex
dbService contracts.DBService
cacheService contracts.CacheService
)
// SetDBService injects the DBService contract.
func SetDBService(s contracts.DBService) {
repoMu.Lock()
defer repoMu.Unlock()
dbService = s
}
// SetCacheService injects the CacheService contract.
func SetCacheService(s contracts.CacheService) {
repoMu.Lock()
defer repoMu.Unlock()
cacheService = s
}
// ResetServices clears injected persistence services.
func ResetServices() {
repoMu.Lock()
defer repoMu.Unlock()
dbService = nil
cacheService = nil
}
// GetDB returns the GORM DB instance bound to the context if available.
func GetDB(ctx context.Context) *gorm.DB {
repoMu.RLock()
defer repoMu.RUnlock()
if dbService == nil {
return nil
}
return dbService.DB(ctx)
}
// GetCache returns the unified CacheService instance.
func GetCache(_ context.Context) contracts.CacheService {
repoMu.RLock()
defer repoMu.RUnlock()
return cacheService
}
// PreheatSystemConfigs loads all system configs from database.
func PreheatSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) {
database := GetDB(ctx)
if database == nil {
return nil, errors.New(errs.ErrDatabaseNotInitialized)
}
var configs []model.SystemConfig
if err := database.Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// PreheatSystemConfigByKey loads a single config key from database.
func PreheatSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) {
database := GetDB(ctx)
if database == nil {
return model.SystemConfig{}, errors.New(errs.ErrDatabaseNotInitialized)
}
var sc model.SystemConfig
if err := database.Where("key = ?", key).First(&sc).Error; err != nil {
return model.SystemConfig{}, err
}
return sc, nil
}
// GetSystemConfigByGroup queries a configuration by Type and Key.
func GetSystemConfigByGroup(ctx context.Context, configType, key string) (model.SystemConfig, error) {
ensureSystemConfigCacheListener()
if item, ok := ram.Get(configType, key); ok {
var sc model.SystemConfig
if err := json.Unmarshal([]byte(item.Value), &sc); err == nil {
return sc, nil
}
}
database := GetDB(ctx)
if database == nil {
return model.SystemConfig{}, errors.New(errs.ErrDatabaseNotInitialized)
}
var sc model.SystemConfig
if err := database.Where("key = ?", key).First(&sc).Error; err != nil {
return model.SystemConfig{}, err
}
valBytes, err := json.Marshal(sc)
if err == nil {
ram.Set(ram.CacheItem{
Key: sc.Key,
Value: string(valBytes),
Type: configType,
TTL: determineTTL(sc.Key),
})
}
return sc, nil
}
// GetSystemConfigByKey queries config by key.
func GetSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) {
return GetSystemConfigByGroup(ctx, ConfigCacheType, key)
}
// ListSystemConfigsByKeys loads multiple config keys.
func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]model.SystemConfig, error) {
if len(keys) == 0 {
return map[string]model.SystemConfig{}, nil
}
ensureSystemConfigCacheListener()
result := make(map[string]model.SystemConfig, len(keys))
missing := make([]string, 0, len(keys))
for _, key := range keys {
if item, ok := ram.Get(ConfigCacheType, key); ok {
var sc model.SystemConfig
if err := json.Unmarshal([]byte(item.Value), &sc); err == nil {
result[key] = sc
continue
}
}
missing = append(missing, key)
}
if len(missing) == 0 {
return result, nil
}
database := GetDB(ctx)
if database == nil {
return nil, errors.New(errs.ErrDatabaseNotInitialized)
}
var configs []model.SystemConfig
if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil {
return nil, err
}
for i := range configs {
valBytes, err := json.Marshal(configs[i])
if err == nil {
ram.Set(ram.CacheItem{
Key: configs[i].Key,
Value: string(valBytes),
Type: ConfigCacheType,
TTL: determineTTL(configs[i].Key),
})
}
result[configs[i].Key] = configs[i]
}
return result, nil
}
// InvalidateVisibleSystemConfigsCache clears the cached public config list.
func InvalidateVisibleSystemConfigsCache(ctx context.Context) error {
return InvalidateAllSystemConfigCaches(ctx)
}
// ListVisibleSystemConfigs queries visible configs using local cache store.
func ListVisibleSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) {
ensureSystemConfigCacheListener()
items := ram.GetTypeItems(ConfigCacheType)
if len(items) > 0 {
var list []model.SystemConfig
for _, item := range items {
var sc model.SystemConfig
if err := json.Unmarshal([]byte(item.Value), &sc); err == nil {
if sc.Visibility == model.ConfigVisibilityVisible {
list = append(list, sc)
}
}
}
return list, nil
}
database := GetDB(ctx)
if database == nil {
return nil, errors.New(errs.ErrDatabaseNotInitialized)
}
var configs []model.SystemConfig
if err := database.Where("visibility = ?", model.ConfigVisibilityVisible).Find(&configs).Error; err != nil {
return nil, err
}
for _, cfg := range configs {
valBytes, err := json.Marshal(cfg)
if err == nil {
ram.Set(ram.CacheItem{
Key: cfg.Key,
Value: string(valBytes),
Type: ConfigCacheType,
TTL: determineTTL(cfg.Key),
})
}
}
return configs, nil
}
// GetIntByKey queries config and converts to int.
func GetIntByKey(ctx context.Context, key string) (int, error) {
sc, err := GetSystemConfigByKey(ctx, key)
if err != nil {
return 0, err
}
value, err := strconv.Atoi(sc.Value)
if err != nil {
return 0, fmt.Errorf(errs.ErrConfigIntParseFailed, key, sc.Value, err)
}
return value, nil
}
// GetDecimalByKey queries config and converts to decimal.Decimal.
func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) {
sc, err := GetSystemConfigByKey(ctx, key)
if err != nil {
return decimal.Zero, err
}
value, err := decimal.NewFromString(sc.Value)
if err != nil {
return decimal.Zero, fmt.Errorf(errs.ErrConfigDecimalParseFailed, key, sc.Value, err)
}
return value.Truncate(precision), nil
}
// GetBoolByKey queries config and converts to bool.
func GetBoolByKey(ctx context.Context, key string) (bool, error) {
sc, err := GetSystemConfigByKey(ctx, key)
if err != nil {
return false, err
}
value, err := strconv.ParseBool(sc.Value)
if err != nil {
return false, fmt.Errorf(errs.ErrConfigBoolParseFailed, key, sc.Value, err)
}
return value, nil
}
// GetMenuDisplayConfig queries and parses menu config.
func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) {
sc, err := GetSystemConfigByKey(ctx, model.ConfigKeyMenuDisplayConfig)
if err != nil {
return nil, err
}
config := make(map[string]bool)
if sc.Value == "" || sc.Value == "{}" {
return config, nil
}
if err := json.Unmarshal([]byte(sc.Value), &config); err != nil {
return nil, fmt.Errorf(errs.ErrParseMenuDisplayConfigFailed, err)
}
return config, nil
}
// ListAdminSystemConfigs returns all configs, optionally filtered by type.
func ListAdminSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) {
query := GetDB(ctx).Order("created_at DESC")
if configType != "" {
query = query.Where("type = ?", configType)
}
var configs []model.SystemConfig
if err := query.Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// GetAdminSystemConfigByKey loads a config directly from DB.
func GetAdminSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) {
var config model.SystemConfig
if err := GetDB(ctx).Where("key = ?", key).First(&config).Error; err != nil {
return model.SystemConfig{}, err
}
return config, nil
}
// SystemConfigExists reports whether a config key already exists.
func SystemConfigExists(ctx context.Context, key string) (bool, error) {
var existing model.SystemConfig
err := GetDB(ctx).Where("key = ?", key).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// CreateSystemConfigRecord persists a new system config row.
func CreateSystemConfigRecord(ctx context.Context, config *model.SystemConfig) error {
return GetDB(ctx).Create(config).Error
}
// UpdateSystemConfigFields applies partial updates to a system config row.
func UpdateSystemConfigFields(ctx context.Context, config *model.SystemConfig, updates map[string]any) error {
return GetDB(ctx).Model(config).Updates(updates).Error
}
// UpdateSystemConfigTx applies the config row updates inside a transaction and, when
// resolveTaskType is not empty, marks that task type's failed executions as succeeded
// within the same transaction.
func UpdateSystemConfigTx(
ctx context.Context,
config *model.SystemConfig,
updates map[string]any,
resolveTaskType string,
resolveResult string,
) error {
database := GetDB(ctx)
if database == nil {
return errors.New(errs.ErrDatabaseServiceNotAvailable)
}
return database.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(config).Updates(updates).Error; err != nil {
return err
}
if resolveTaskType == "" {
return nil
}
if err := MarkFailedTaskExecutionsSucceededTx(tx, resolveTaskType, resolveResult, time.Now()); err != nil {
logger.ErrorF(ctx, errs.ErrAutoResolveMigrationTaskFailed, err)
}
return nil
})
}
// SaveOrUpdateSystemConfig creates or updates a config row and invalidates cache.
func SaveOrUpdateSystemConfig(ctx context.Context, key, value string) error {
var sc model.SystemConfig
err := GetDB(ctx).Where("key = ?", key).First(&sc).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
sc = model.SystemConfig{
Key: key,
Value: value,
Type: configTypeSystem,
Visibility: model.ConfigVisibilityHidden,
}
if err := GetDB(ctx).Create(&sc).Error; err != nil {
return err
}
} else {
sc.Value = value
if err := GetDB(ctx).Save(&sc).Error; err != nil {
return err
}
}
return InvalidateSystemConfigCache(ctx, key)
}
// CountActiveUploads counts non-deleted rows of the storage upload table. A missing
// database handle yields zero, matching the pre-refactor guard behaviour.
func CountActiveUploads(ctx context.Context) (int64, error) {
gormDB := GetDB(ctx)
if gormDB == nil {
return 0, nil
}
var uploadCount int64
if err := gormDB.Table("w_uploads").
Where("status != ?", "deleted").
Count(&uploadCount).Error; err != nil {
return 0, fmt.Errorf(errs.ErrCheckExistingUploadsFailed, err)
}
return uploadCount, nil
}
@@ -0,0 +1,330 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/core/contracts"
"Wavelet/pkg/idgen"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
const (
taskExecutionLogRedisKeyPrefix = "task:execution:log:"
taskExecutionLogExpiration = 24 * time.Hour
)
// CreateScheduleRecord 创建定时任务
func CreateScheduleRecord(ctx context.Context, schedule *model.Schedule) error {
return GetDB(ctx).Create(schedule).Error
}
// UpdateScheduleRecord 更新定时任务
func UpdateScheduleRecord(ctx context.Context, schedule *model.Schedule) error {
return GetDB(ctx).Save(schedule).Error
}
// DeleteScheduleRecord 删除定时任务
func DeleteScheduleRecord(ctx context.Context, id uint64) error {
return GetDB(ctx).Delete(&model.Schedule{}, id).Error
}
// GetScheduleByID 根据 ID 获取定时任务
func GetScheduleByID(ctx context.Context, id uint64) (*model.Schedule, error) {
var schedule model.Schedule
if err := GetDB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil {
return nil, err
}
return &schedule, nil
}
// ListSchedulesRecord 获取所有定时任务
func ListSchedulesRecord(ctx context.Context) ([]model.Schedule, error) {
var schedules []model.Schedule
if err := GetDB(ctx).Order("id DESC").Find(&schedules).Error; err != nil {
return nil, err
}
return schedules, nil
}
// ListActiveSchedules 获取所有启用的定时任务
func ListActiveSchedules(ctx context.Context) ([]model.Schedule, error) {
var schedules []model.Schedule
if err := GetDB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil {
return nil, err
}
return schedules, nil
}
// CreateTaskExecutionRecord 创建任务执行记录
func CreateTaskExecutionRecord(ctx context.Context, execution *model.TaskExecution) error {
execution.ID = idgen.NextUint64ID()
return GetDB(ctx).Create(execution).Error
}
// UpdateTaskExecutionRecord 更新任务执行记录,忽略由 Redis 缓冲和归档流程管理的 log 字段。
func UpdateTaskExecutionRecord(ctx context.Context, execution *model.TaskExecution) error {
return GetDB(ctx).Omit("log").Save(execution).Error
}
// GetTaskExecutionByTaskID 根据 TaskID 获取执行记录
func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*model.TaskExecution, error) {
var execution model.TaskExecution
if err := GetDB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil {
return nil, err
}
loadTaskExecutionLog(ctx, &execution)
return &execution, nil
}
// GetTaskExecutionByID 根据 ID 获取执行记录
func GetTaskExecutionByID(ctx context.Context, id uint64) (*model.TaskExecution, error) {
var execution model.TaskExecution
if err := GetDB(ctx).Where("id = ?", id).First(&execution).Error; err != nil {
return nil, err
}
loadTaskExecutionLog(ctx, &execution)
return &execution, nil
}
// GetLatestTaskExecutionByTaskType returns the most recent execution for a task type.
func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*model.TaskExecution, bool, error) {
var execution model.TaskExecution
err := GetDB(ctx).
Where("task_type = ?", taskType).
Order("id DESC").
First(&execution).Error
if err == nil {
loadTaskExecutionLog(ctx, &execution)
return &execution, true, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
return nil, false, err
}
// AppendTaskExecutionLog 将日志追加到缓冲,任务完成后再持久化到数据库。
func AppendTaskExecutionLog(ctx context.Context, taskID, logLine string) error {
cacheSvc := GetCache(ctx)
if cacheSvc == nil {
return errors.New(errs.ErrCacheServiceNotInitialized)
}
now := time.Now().Format("15:04:05")
line := fmt.Sprintf("[%s] %s\n", now, logLine)
key := TaskExecutionLogRedisKey(taskID)
var existing string
if err := cacheSvc.Get(ctx, key, &existing); err != nil {
// 只有未命中才代表「尚无缓冲」;其余读取失败若被当作空缓冲继续写入,
// 会用这一行覆盖掉整段已缓冲的任务日志。
if !errors.Is(err, contracts.ErrCacheMiss) {
return fmt.Errorf("load buffered task execution log: %w", err)
}
}
return cacheSvc.Set(ctx, key, existing+line, taskExecutionLogExpiration)
}
// FlushTaskExecutionLog 将缓冲中的完整任务日志写入数据库,并在成功后清理缓存。
func FlushTaskExecutionLog(ctx context.Context, taskID string) error {
cacheSvc := GetCache(ctx)
if cacheSvc == nil {
return errors.New(errs.ErrCacheServiceNotInitialized)
}
key := TaskExecutionLogRedisKey(taskID)
var logText string
if err := cacheSvc.Get(ctx, key, &logText); err != nil {
// 缓存未命中属于正常情况(任务无输出),其余错误必须上抛,
// 否则缓冲日志会被静默丢弃并误报持久化成功。
if !errors.Is(err, contracts.ErrCacheMiss) {
return fmt.Errorf("load buffered task execution log: %w", err)
}
return nil
}
if logText == "" {
return nil
}
gormDB := GetDB(ctx)
if gormDB == nil {
return errors.New(errs.ErrDatabaseNotInitialized)
}
result := gormDB.Model(&model.TaskExecution{}).
Where("task_id = ?", taskID).
Update("log", logText)
if result.Error != nil {
return fmt.Errorf("persist task execution log: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("persist task execution log: task %q not found", taskID)
}
_ = cacheSvc.Delete(ctx, key)
return nil
}
// ListTaskExecutionRecords 分页查询任务执行记录
func ListTaskExecutionRecords(ctx context.Context, req model.ListTaskExecutionsRequest) ([]model.TaskExecution, int64, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
query := GetDB(ctx).Model(&model.TaskExecution{})
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.TaskType != "" {
query = query.Where("task_type = ?", req.TaskType)
} else if types := parseTaskTypesFilter(req.TaskTypes); len(types) > 0 {
query = query.Where("task_type IN ?", types)
} else if req.TaskTypePrefix != "" {
query = query.Where("task_type LIKE ? ESCAPE '\\'", util.EscapeLike(req.TaskTypePrefix)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var executions []model.TaskExecution
offset := (req.Page - 1) * req.PageSize
if err := query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&executions).Error; err != nil {
return nil, 0, err
}
loadTaskExecutionLogs(ctx, executions)
return executions, total, nil
}
func parseTaskTypesFilter(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// MarkFailedTaskExecutionsSucceededTx marks failed executions of a task type as succeeded within a transaction.
func MarkFailedTaskExecutionsSucceededTx(
tx *gorm.DB,
taskType string,
result string,
finishedAt time.Time,
) error {
return tx.Model(&model.TaskExecution{}).
Where("task_type = ? AND status = ?", taskType, model.TaskExecutionStatusFailed).
Updates(map[string]any{
"status": model.TaskExecutionStatusSucceeded,
"result": result,
"finished_at": finishedAt,
}).Error
}
// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention.
func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (model.TaskExecutionCleanupStats, error) {
const (
frequencyWindowDays = 30
highFrequencyThreshold = frequencyWindowDays
)
frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays)
highFrequencyCutoff := now.AddDate(0, 0, -3)
lowFrequencyCutoff := now.AddDate(0, 0, -30)
terminalStatuses := []model.TaskExecutionStatus{model.TaskExecutionStatusSucceeded, model.TaskExecutionStatusFailed}
var highFrequencyTaskTypes []string
if err := GetDB(ctx).
Model(&model.TaskExecution{}).
Select("task_type").
Where("created_at >= ?", frequencyWindowStart).
Group("task_type").
Having("COUNT(*) > ?", highFrequencyThreshold).
Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil {
return model.TaskExecutionCleanupStats{}, fmt.Errorf("query high-frequency task types: %w", err)
}
var highFrequencyDeleted int64
if len(highFrequencyTaskTypes) > 0 {
highFrequencyResult := GetDB(ctx).
Where("status IN ?", terminalStatuses).
Where("created_at < ?", highFrequencyCutoff).
Where("task_type IN ?", highFrequencyTaskTypes).
Delete(&model.TaskExecution{})
if highFrequencyResult.Error != nil {
return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete high-frequency task execution logs: %w", highFrequencyResult.Error)
}
highFrequencyDeleted = highFrequencyResult.RowsAffected
}
lowFrequencyQuery := GetDB(ctx).
Where("status IN ?", terminalStatuses).
Where("created_at < ?", lowFrequencyCutoff)
if len(highFrequencyTaskTypes) > 0 {
lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes)
}
lowFrequencyResult := lowFrequencyQuery.Delete(&model.TaskExecution{})
if lowFrequencyResult.Error != nil {
return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete low-frequency task execution logs: %w", lowFrequencyResult.Error)
}
return model.TaskExecutionCleanupStats{
HighFrequencyDeleted: highFrequencyDeleted,
LowFrequencyDeleted: lowFrequencyResult.RowsAffected,
}, nil
}
// TaskExecutionLogRedisKey builds the Redis key for task execution logs.
func TaskExecutionLogRedisKey(taskID string) string {
return taskExecutionLogRedisKeyPrefix + taskID
}
// loadTaskExecutionLog best-effort enriches an execution with its cached log;
// a cache miss or failure simply leaves the stored log column in place.
func loadTaskExecutionLog(ctx context.Context, execution *model.TaskExecution) {
cacheSvc := GetCache(ctx)
if cacheSvc == nil {
return
}
var logText string
if err := cacheSvc.Get(ctx, TaskExecutionLogRedisKey(execution.TaskID), &logText); err == nil && logText != "" {
execution.Log = logText
}
}
// loadTaskExecutionLogs best-effort enriches every execution with its cached log.
func loadTaskExecutionLogs(ctx context.Context, executions []model.TaskExecution) {
cacheSvc := GetCache(ctx)
if cacheSvc == nil || len(executions) == 0 {
return
}
for i := range executions {
var logText string
if err := cacheSvc.Get(ctx, TaskExecutionLogRedisKey(executions[i].TaskID), &logText); err == nil && logText != "" {
executions[i].Log = logText
}
}
}
@@ -0,0 +1,58 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/plugins/domain/admin/model"
"context"
"errors"
"gorm.io/gorm"
)
// ListTemplatesRecord returns all templates ordered by system flag and creation time.
func ListTemplatesRecord(ctx context.Context) ([]model.Template, error) {
var templates []model.Template
if err := GetDB(ctx).Order("is_system DESC, created_at DESC").Find(&templates).Error; err != nil {
return nil, err
}
return templates, nil
}
// GetTemplateByKey loads a template by its key.
func GetTemplateByKey(ctx context.Context, key string) (model.Template, error) {
var tmpl model.Template
if err := GetDB(ctx).Where("key = ?", key).First(&tmpl).Error; err != nil {
return model.Template{}, err
}
return tmpl, nil
}
// TemplateExistsByKey reports whether a template key is already taken.
func TemplateExistsByKey(ctx context.Context, key string) (bool, error) {
var existing model.Template
err := GetDB(ctx).Where("key = ?", key).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// CreateTemplateRecord persists a new template.
func CreateTemplateRecord(ctx context.Context, tmpl *model.Template) error {
return GetDB(ctx).Create(tmpl).Error
}
// SaveTemplateRecord updates an existing template.
func SaveTemplateRecord(ctx context.Context, tmpl *model.Template) error {
return GetDB(ctx).Save(tmpl).Error
}
// DeleteTemplateRecord removes a template record.
func DeleteTemplateRecord(ctx context.Context, tmpl *model.Template) error {
return GetDB(ctx).Delete(tmpl).Error
}
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"context"
"errors"
"fmt"
)
// ListAuthSources returns every configured authentication source.
func ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) {
authSvc, err := requireAuthService(ctx)
if err != nil {
return nil, err
}
views, err := authSvc.ListAuthSources(ctx)
if err != nil {
logger.ErrorF(ctx, "List auth sources failed: %v", err)
return nil, errors.New(errs.ListAuthSourcesFailed)
}
return views, nil
}
// CreateAuthSource registers a new authentication source.
func CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
authSvc, err := requireAuthService(ctx)
if err != nil {
return nil, err
}
created, err := authSvc.CreateAuthSource(ctx, source)
if err != nil {
return nil, fmt.Errorf("%s%w", errs.CreateAuthSourceFailed, err)
}
return created, nil
}
// UpdateAuthSource rewrites an existing authentication source.
func UpdateAuthSource(
ctx context.Context,
id uint64,
source contracts.AuthSourceDTO,
) (*contracts.AuthSourceDTO, error) {
authSvc, err := requireAuthService(ctx)
if err != nil {
return nil, err
}
updated, err := authSvc.UpdateAuthSource(ctx, id, source)
if err != nil {
return nil, err
}
return updated, nil
}
// ToggleAuthSource flips the active state of an authentication source.
func ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) {
authSvc, err := requireAuthService(ctx)
if err != nil {
return nil, err
}
toggled, err := authSvc.ToggleAuthSource(ctx, id)
if err != nil {
return nil, fmt.Errorf("%s%w", errs.ToggleAuthSourceFailed, err)
}
return toggled, nil
}
// DeleteAuthSource removes an authentication source.
func DeleteAuthSource(ctx context.Context, id uint64) error {
authSvc, err := requireAuthService(ctx)
if err != nil {
return err
}
if err := authSvc.DeleteAuthSource(ctx, id); err != nil {
return fmt.Errorf("%s%w", errs.DeleteAuthSourceFailed, err)
}
return nil
}
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"context"
"strconv"
pkgcache "Wavelet/pkg/cache/disk"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
)
// DiskCacheStatus reports the disk cache usage counters.
func DiskCacheStatus() pkgcache.Status {
return pkgcache.Default().Status()
}
// ClearDiskCache purges every cached object and resets the tracking counters.
func ClearDiskCache() error {
return pkgcache.Default().Clear()
}
// UpdateDiskCachePolicy persists the disk cache settings and applies them hot.
func UpdateDiskCachePolicy(ctx context.Context, req model.UpdateCacheConfigRequest) error {
if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil {
return err
}
if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil {
return err
}
if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil {
return err
}
pkgcache.Default().UpdatePolicy(req.MaxSizeMB, req.TTLMinutes, req.LRUEnabled)
return nil
}
func saveOrUpdateCacheConfig(ctx context.Context, key, value string) error {
return repository.SaveOrUpdateSystemConfig(ctx, key, value)
}
@@ -0,0 +1,299 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
mail "Wavelet/pkg/mail"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"encoding/json"
"errors"
"fmt"
)
const maskedConfigValue = "******"
// PublicSystemConfigs returns the key/value map exposed to unauthenticated clients.
func PublicSystemConfigs(ctx context.Context) (map[string]string, error) {
configs, err := repository.ListVisibleSystemConfigs(ctx)
if err != nil {
return nil, err
}
resp := make(map[string]string, len(configs))
for _, config := range configs {
resp[config.Key] = config.Value
}
return resp, nil
}
// ListAdminSystemConfigs returns every config, optionally filtered by type, with secrets masked.
func ListAdminSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) {
configs, err := repository.ListAdminSystemConfigs(ctx, configType)
if err != nil {
return nil, err
}
for i := range configs {
configs[i].Value = MaskSensitiveConfig(configs[i].Key, configs[i].Value)
}
return configs, nil
}
// GetAdminSystemConfig loads a single config with its secrets masked.
func GetAdminSystemConfig(ctx context.Context, key string) (model.SystemConfig, error) {
config, err := repository.GetAdminSystemConfigByKey(ctx, key)
if err != nil {
return model.SystemConfig{}, translateNotFound(err, errs.ErrSystemConfigNotFound)
}
config.Value = MaskSensitiveConfig(config.Key, config.Value)
return config, nil
}
// CreateAdminSystemConfig persists a new config key and refreshes the cache layer.
func CreateAdminSystemConfig(ctx context.Context, req model.CreateSystemConfigRequest) error {
if isProtectedConfigKey(req.Key) {
return errs.ErrProtectedConfigKey
}
exists, err := repository.SystemConfigExists(ctx, req.Key)
if err != nil {
return err
}
if exists {
return errs.ErrConfigKeyExists
}
config := model.SystemConfig{
Key: req.Key,
Value: req.Value,
Type: req.Type,
Visibility: req.Visibility,
Description: req.Description,
}
if err := repository.CreateSystemConfigRecord(ctx, &config); err != nil {
return err
}
invalidateSystemConfigCaches(ctx, req.Key)
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
}
return nil
}
// UpdateAdminSystemConfig applies an update to a protected-aware config key inside a transaction.
func UpdateAdminSystemConfig(ctx context.Context, key string, req model.UpdateSystemConfigRequest) error {
if isProtectedConfigKey(key) {
return errs.ErrProtectedConfigKey
}
config, err := repository.GetAdminSystemConfigByKey(ctx, key)
if err != nil {
return translateNotFound(err, errs.ErrSystemConfigNotFound)
}
var originalDriver contracts.StorageDriver
resolveTaskType := ""
resolveResult := ""
if key == model.ConfigKeyStorageConfig {
var currentCfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(config.Value), &currentCfg); err == nil {
originalDriver = currentCfg.Driver
}
validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value)
if err != nil {
return err
}
req.Value = validatedVal
var newCfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(req.Value), &newCfg); err == nil {
resolveTaskType, resolveResult = storageMigrationResolutionTask(originalDriver, newCfg.Driver)
}
}
updates := map[string]any{
"description": req.Description,
}
if req.Visibility != nil {
updates["visibility"] = *req.Visibility
config.Visibility = *req.Visibility
}
if key != model.ConfigKeySMTPPassword || req.Value != maskedConfigValue {
updates["value"] = req.Value
config.Value = req.Value
}
if err := repository.UpdateSystemConfigTx(ctx, &config, updates, resolveTaskType, resolveResult); err != nil {
return err
}
invalidateCachesAfterConfigUpdate(ctx, key)
return nil
}
// storageMigrationResolutionTask reports the failed-task resolution that a direct storage
// config rewrite implies. An empty task type means nothing has to be resolved.
func storageMigrationResolutionTask(
originalDriver contracts.StorageDriver,
newDriver contracts.StorageDriver,
) (string, string) {
if originalDriver == "" || newDriver != originalDriver {
return "", ""
}
return errs.StorageMigrationTaskType, errs.StorageDriverResolvedResult
}
func isProtectedConfigKey(key string) bool {
return key == model.ConfigKeyLogDatabase || key == model.ConfigKeyLogDBMigration
}
func invalidateSystemConfigCaches(ctx context.Context, key string) {
if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil {
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
}
_ = EmitEvent(ctx, contracts.EventTopicConfigChanged, contracts.ConfigChangedEvent{Key: key})
}
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
invalidateSystemConfigCaches(ctx, key)
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
}
}
// TestSMTP sends a probe mail, resolving a masked password from the stored config.
func TestSMTP(ctx context.Context, req model.TestSMTPRequest) model.TestSMTPResponse {
password := req.SMTPPassword
if password == maskedConfigValue {
if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword); err == nil {
password = sc.Value
}
}
cfg := mail.Config{
Host: req.SMTPHost,
Port: req.SMTPPort,
Username: req.SMTPUsername,
Password: password,
}
subject := "Wavelet SMTP Test Mail"
body := `<h3>SMTP Mail Connection Test</h3>
<p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p>
<p>Sent from Wavelet.</p>`
logs, err := mail.SendMailWithLog(ctx, cfg, req.To, subject, body)
resp := model.TestSMTPResponse{
Success: err == nil,
Log: logs,
}
if err != nil {
resp.Error = err.Error()
}
return resp
}
// MaskSensitiveConfig masks secret config values before exposing to clients.
func MaskSensitiveConfig(key, value string) string {
if value == "" {
return value
}
switch key {
case model.ConfigKeySMTPPassword:
return maskedConfigValue
case model.ConfigKeyStorageConfig:
return maskStorageConfig(value)
}
return value
}
func maskStorageConfig(value string) string {
var cfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
return value
}
if cfg.S3.SecretAccessKey != "" {
cfg.S3.SecretAccessKey = maskedConfigValue
}
if cfg.R2.SecretAccessKey != "" {
cfg.R2.SecretAccessKey = maskedConfigValue
}
if cfg.MinIO.SecretAccessKey != "" {
cfg.MinIO.SecretAccessKey = maskedConfigValue
}
if cfg.OSS.SecretAccessKey != "" {
cfg.OSS.SecretAccessKey = maskedConfigValue
}
if cfg.WebDAV.Password != "" {
cfg.WebDAV.Password = maskedConfigValue
}
val, err := json.Marshal(cfg)
if err != nil {
return value
}
return string(val)
}
// validateAndMergeStorageConfig parses, merges unmasked secrets, validates parameter values,
// and tests connectivity of the new storage configuration.
func validateAndMergeStorageConfig(ctx context.Context, value, currentConfig string) (string, error) {
var currentCfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(currentConfig), &currentCfg); err != nil {
return "", fmt.Errorf(errs.ErrParseCurrentStorageConfigFailed, err)
}
var newCfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(value), &newCfg); err != nil {
return "", fmt.Errorf(errs.ErrParseTargetStorageConfigFailed, err)
}
// 合并被掩码屏蔽的敏感信息,获取完整的真实配置
targetCfg := newCfg
if targetCfg.S3.SecretAccessKey == maskedConfigValue {
targetCfg.S3.SecretAccessKey = currentCfg.S3.SecretAccessKey
}
if targetCfg.R2.SecretAccessKey == maskedConfigValue {
targetCfg.R2.SecretAccessKey = currentCfg.R2.SecretAccessKey
}
if targetCfg.MinIO.SecretAccessKey == maskedConfigValue {
targetCfg.MinIO.SecretAccessKey = currentCfg.MinIO.SecretAccessKey
}
if targetCfg.OSS.SecretAccessKey == maskedConfigValue {
targetCfg.OSS.SecretAccessKey = currentCfg.OSS.SecretAccessKey
}
if targetCfg.WebDAV.Password == maskedConfigValue {
targetCfg.WebDAV.Password = currentCfg.WebDAV.Password
}
if err := validateMergedStorageConfig(ctx, currentCfg, newCfg, targetCfg); err != nil {
return "", err
}
// 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符
unmaskedVal, err := json.Marshal(targetCfg)
if err != nil {
return "", fmt.Errorf(errs.ErrSerializeStorageConfigFailed, err)
}
return string(unmaskedVal), nil
}
func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, _ contracts.StorageConfigDTO) error {
if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver {
uploadCount, err := repository.CountActiveUploads(ctx)
if err != nil {
return err
}
if uploadCount > 0 {
return errors.New(errs.StorageDriverSwitchRequiresMigration)
}
}
return nil
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"os"
"os/exec"
"strings"
"sync"
"time"
)
var (
dbConfigMu sync.RWMutex
dbConfig model.DatabaseConfig
chConfig model.ClickHouseConfig
)
// SetDBConfig sets the database configuration in service and repository.
func SetDBConfig(cfg model.DatabaseConfig) {
dbConfigMu.Lock()
defer dbConfigMu.Unlock()
dbConfig = cfg
repository.SetDBConfig(cfg)
}
// GetDBConfig returns the database configuration.
func GetDBConfig() model.DatabaseConfig {
dbConfigMu.RLock()
defer dbConfigMu.RUnlock()
return dbConfig
}
// SetClickHouseConfig sets the clickhouse configuration.
func SetClickHouseConfig(cfg model.ClickHouseConfig) {
dbConfigMu.Lock()
defer dbConfigMu.Unlock()
chConfig = cfg
}
// GetClickHouseConfig returns the clickhouse configuration.
func GetClickHouseConfig() model.ClickHouseConfig {
dbConfigMu.RLock()
defer dbConfigMu.RUnlock()
return chConfig
}
// selectSQLKeywords marks statements that return a result set instead of a row count.
var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"}
// DatabaseOverview collects the runtime overview of the active database.
func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) {
if !GetDBConfig().Enabled {
return repository.GetSQLiteOverview(ctx)
}
return repository.GetPostgresOverview(ctx)
}
// DatabaseTableNames returns every user table of the active database.
func DatabaseTableNames(ctx context.Context) ([]string, error) {
return repository.ListDatabaseTableNames(ctx)
}
// DatabaseTableData loads one page of a table with its column layout and total row count.
func DatabaseTableData(ctx context.Context, req model.GetTableDataRequest) (model.TableDataResponse, error) {
quotedTable := repository.QuoteTableName(req.Table)
total, err := repository.CountDatabaseTableRows(ctx, quotedTable)
if err != nil {
return model.TableDataResponse{}, err
}
offset := (req.Page - 1) * req.PageSize
if offset < 0 {
offset = 0
}
limit := req.PageSize
if limit <= 0 {
limit = 10
}
cols, results, err := repository.QueryDatabaseTableRows(ctx, quotedTable, limit, offset)
if err != nil {
return model.TableDataResponse{}, err
}
return model.TableDataResponse{
Columns: cols,
Total: total,
Results: truncateCellValues(results),
}, nil
}
// truncateCellValues caps oversized string cells before they reach the console grid.
func truncateCellValues(rows []map[string]any) []map[string]any {
for _, row := range rows {
for column, value := range row {
if str, ok := value.(string); ok {
row[column] = model.TruncateDisplayValue(str)
}
}
}
return rows
}
// ExecuteCustomSQL runs an arbitrary statement issued from the console SQL runner.
func ExecuteCustomSQL(ctx context.Context, trimmedSQL string) (model.ExecuteSQLResponse, error) {
startTime := time.Now()
if isSelectStatement(trimmedSQL) {
cols, results, err := repository.RunSelectSQL(ctx, trimmedSQL)
if err != nil {
return model.ExecuteSQLResponse{}, err
}
return model.ExecuteSQLResponse{
Type: "select",
Columns: cols,
Results: results,
AffectedRows: int64(len(results)),
ExecutionTimeMs: time.Since(startTime).Milliseconds(),
}, nil
}
affectedRows, err := repository.RunMutationSQL(ctx, trimmedSQL)
if err != nil {
return model.ExecuteSQLResponse{}, err
}
return model.ExecuteSQLResponse{
Type: "exec",
AffectedRows: affectedRows,
ExecutionTimeMs: time.Since(startTime).Milliseconds(),
}, nil
}
// isSelectStatement reports whether the statement yields a result set.
func isSelectStatement(trimmedSQL string) bool {
lowerSQL := strings.ToLower(trimmedSQL)
for _, kw := range selectSQLKeywords {
if strings.HasPrefix(lowerSQL, kw) {
return true
}
}
return false
}
// DatabaseInfo returns the active database type, name and version.
func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse {
if !GetDBConfig().Enabled {
return repository.GetSQLiteInfo(ctx)
}
return repository.GetPostgresInfo(ctx)
}
// OpenSQLiteExportFile opens the active SQLite database file together with its stat info.
func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) {
return repository.OpenSQLiteExportFile()
}
// NewPgDumpCommand builds the streaming pg_dump command for the active database.
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
return repository.NewPgDumpCommand(ctx)
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"fmt"
"net/url"
"strings"
"time"
)
const (
analyticsDays = 7
denyingRobotsFile = "User-Agent: *\nDisallow: /\n"
allowingRobotsFile = "User-Agent: *\nAllow: /\n"
)
// RecentSystemLogs reads a page of the process log ring buffer.
func RecentSystemLogs(cursor, limit int) model.LogsResponse {
entries, hasMore := logger.GlobalRingBuffer.Query(cursor, limit)
resp := model.LogsResponse{
Lines: entries,
HasMore: hasMore,
}
if len(entries) > 0 {
resp.NextCursor = entries[0].Index
}
return resp
}
// RobotsTxtBody resolves the robots.txt payload from the indexing setting.
func RobotsTxtBody(ctx context.Context) string {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled)
if err == nil && enabled {
return allowingRobotsFile
}
return denyingRobotsFile
}
// IsAllowedLogOrigin reports whether a WebSocket handshake origin may subscribe to logs.
func IsAllowedLogOrigin(ctx context.Context, origin, host string) bool {
if origin == "" {
return true
}
// 1. 同源检查 (Same-origin check)
u, err := url.Parse(origin)
if err == nil && strings.EqualFold(u.Host, host) {
return true
}
// 2. 检查配置的允许跨域 Origin (Check allowed origins in system config)
sc, cfgErr := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
if cfgErr != nil || sc.Value == "" {
return false
}
originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/")
for _, allowed := range strings.Split(sc.Value, ",") {
allowed = strings.TrimRight(strings.TrimSpace(allowed), "/")
if allowed != "" && strings.EqualFold(allowed, originToCheck) {
return true
}
}
return false
}
// AccessLogs queries the analytical access log store and decorates rows with user names.
func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsResponse, error) {
rc := GetRiskControlService()
if rc == nil {
return model.AccessLogsResponse{}, errs.ErrLogStoreUnavailable
}
filter, err := buildAccessLogFilter(ctx, q)
if err != nil {
return model.AccessLogsResponse{}, err
}
if filter.UserIDs != nil && len(filter.UserIDs) == 0 {
return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil
}
logs, total, err := rc.QueryAccessLogs(ctx, filter, q.Page, q.PageSize)
if err != nil {
return model.AccessLogsResponse{}, err
}
if total == 0 {
return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil
}
list := make([]model.AccessLogItem, len(logs))
for i, logItem := range logs {
list[i] = model.AccessLogItem{
ID: logItem.ID,
UserID: logItem.UserID,
Path: logItem.Path,
Method: logItem.Method,
IP: logItem.IP,
UserAgent: logItem.UserAgent,
Status: logItem.Status,
Latency: logItem.Latency,
CreatedAt: logItem.CreatedAt.Format(time.RFC3339),
}
}
enrichAccessLogsWithUsers(ctx, list)
return model.AccessLogsResponse{Total: total, List: list}, nil
}
// AccessLogAnalytics aggregates the daily trend of the access log store.
func AccessLogAnalytics(ctx context.Context) (model.LogsAnalyticsResponse, error) {
rc := GetRiskControlService()
if rc == nil {
return model.LogsAnalyticsResponse{}, errs.ErrLogStoreUnavailable
}
stats, err := rc.QueryAccessLogStats(ctx, analyticsDays)
if err != nil {
return model.LogsAnalyticsResponse{}, fmt.Errorf("%s%w", errs.ErrQueryAccessTrendFailed, err)
}
trendList := make([]model.TrendItem, len(stats))
for i, st := range stats {
trendList[i] = model.TrendItem{
Date: st.Date,
Count: st.PV,
}
}
return model.LogsAnalyticsResponse{
Trend: trendList,
Browsers: []model.BrowserItem{},
TopUsers: []model.TopUserItem{},
}, nil
}
// findUserIDsByUsername resolves the user id filter behind a username search term.
func findUserIDsByUsername(ctx context.Context, username string) ([]uint64, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
users, _, err := userSvc.ListUsers(ctx, 1, userQueryMaxLimit, username)
if err != nil {
return nil, fmt.Errorf(errs.ErrQueryUserFailed, err)
}
ids := make([]uint64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
return ids, nil
}
ids, err := repository.SearchUserIDsByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf(errs.ErrQueryUserFailed, err)
}
return ids, nil
}
const userQueryMaxLimit = 100
func buildAccessLogFilter(ctx context.Context, q model.AccessLogQuery) (contracts.AccessLogFilterDTO, error) {
filter := contracts.AccessLogFilterDTO{}
if q.Username != "" {
userIDs, err := findUserIDsByUsername(ctx, q.Username)
if err != nil {
return filter, err
}
filter.UserIDs = userIDs
}
if q.Path != "" {
filter.Path = q.Path
}
if q.StartTime != "" {
if t, err := parseAccessLogTime(q.StartTime); err == nil {
filter.StartTime = &t
}
}
if q.EndTime != "" {
if t, err := parseAccessLogTime(q.EndTime); err == nil {
filter.EndTime = &t
}
}
return filter, nil
}
func parseAccessLogTime(value string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t, nil
}
return time.Parse("2006-01-02 15:04:05", value)
}
// enrichAccessLogsWithUsers attaches usernames and nicknames to access log rows.
func enrichAccessLogsWithUsers(ctx context.Context, list []model.AccessLogItem) {
if len(list) == 0 {
return
}
userIDs := make([]uint64, 0, len(list))
seen := make(map[uint64]struct{}, len(list))
for _, item := range list {
if _, ok := seen[item.UserID]; ok {
continue
}
seen[item.UserID] = struct{}{}
userIDs = append(userIDs, item.UserID)
}
userMap := make(map[uint64]repository.UserDisplayName, len(userIDs))
if userSvc := GetUserService(ctx); userSvc != nil {
if users, err := userSvc.GetUsersByIDs(ctx, userIDs); err == nil {
for _, u := range users {
if u != nil {
userMap[u.ID] = repository.UserDisplayName{Username: u.Username, Nickname: u.Nickname}
}
}
}
} else if names, err := repository.LoadUserDisplayNames(ctx, userIDs); err == nil {
userMap = names
}
for i := range list {
if info, ok := userMap[list[i].UserID]; ok {
list[i].Username = info.Username
list[i].Nickname = info.Nickname
}
}
}
@@ -0,0 +1,185 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"encoding/json"
"errors"
"fmt"
)
const (
// LogDBSwitchTask 切换日志数据库任务标识。
LogDBSwitchTask = "logs:db_switch"
// TaskTypeLogDBSwitch 管理端任务类型。
TaskTypeLogDBSwitch = "logs_db_switch"
targetPostgres = "postgres"
targetSQLite = "sqlite"
targetClickHouse = "clickhouse"
errParseTaskPayloadFailed = "参数解析失败: %w"
errInvalidLogTarget = "目标日志库不合法: %s"
)
// LogDBSwitchMeta 描述切换日志数据库任务。
var LogDBSwitchMeta = contracts.TaskMetaDTO{
Type: TaskTypeLogDBSwitch,
AsynqTask: LogDBSwitchTask,
Name: "切换日志数据库",
DisplayName: "切换日志数据库",
Description: "复制迁移用户访问日志并在成功后切换日志主库(期间禁止日志写入)",
Category: "system",
SupportsTime: false,
MaxRetry: 3,
Queue: "default",
Retryable: true,
Params: []contracts.TaskParamDTO{
{
Name: "target",
Label: "目标日志库",
Type: "string",
Required: true,
Placeholder: "postgres|sqlite|clickhouse",
Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse",
},
},
}
type logDBSwitchPayload struct {
Target string `json:"target"`
}
// LogDBSwitchHandler 切换日志数据库任务处理器。
type LogDBSwitchHandler struct{}
// ValidatePayload 校验并规范化参数。
func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) {
var p logDBSwitchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf(errParseTaskPayloadFailed, err)
}
p.Target = normalizeTarget(p.Target)
if !validTarget(p.Target) {
return nil, fmt.Errorf(errInvalidLogTarget, p.Target)
}
out, err := json.Marshal(p)
if err != nil {
return nil, err
}
return out, nil
}
func normalizeTarget(v string) string {
switch v {
case targetPostgres, "postgresql":
return targetPostgres
case targetSQLite, "sqlite3":
return targetSQLite
case targetClickHouse, "ch":
return targetClickHouse
}
return v
}
func validTarget(v string) bool {
return v == targetPostgres || v == targetSQLite || v == targetClickHouse
}
// Execute 执行迁移。
func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
var p logDBSwitchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf(errParseTaskPayloadFailed, err)
}
p.Target = normalizeTarget(p.Target)
if err := validateSwitch(ctx, p.Target); err != nil {
return nil, err
}
source, err := currentLogDatabase(ctx)
if err != nil {
return nil, err
}
taskSvc := GetTaskService()
if taskSvc != nil {
taskSvc.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target)
}
if err := setMigrationFlag(ctx, logMigrationInProgress); err != nil {
return nil, err
}
defer func() {
if err := setMigrationFlag(ctx, ""); err != nil {
logger.ErrorF(ctx, "清除日志迁移冻结标记失败: %v", err)
}
}()
rc := GetRiskControlService()
if rc != nil {
if err := rc.SwitchLogEngine(ctx, p.Target); err != nil {
return nil, err
}
}
if err := flipLogDatabase(ctx, p.Target); err != nil {
return nil, err
}
if taskSvc != nil {
taskSvc.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target)
}
return &contracts.TaskResultDTO{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil
}
func validateSwitch(ctx context.Context, target string) error {
source, err := currentLogDatabase(ctx)
if err != nil {
return err
}
if source == target {
return errors.New(errs.ErrSameLogTarget)
}
switch target {
case targetClickHouse:
if !GetClickHouseConfig().Enabled {
return errors.New(errs.ErrClickHouseNotEnabled)
}
case targetPostgres:
if !GetDBConfig().Enabled {
return errors.New(errs.ErrPostgresNotEnabled)
}
case targetSQLite:
if GetDBConfig().Enabled {
return errors.New(errs.ErrSQLiteNotAllowedAsLogDB)
}
}
return nil
}
func currentLogDatabase(ctx context.Context) (string, error) {
cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase)
if err != nil {
return "", fmt.Errorf(errs.ErrReadLogDatabaseFailed, err)
}
if cfg.Value == "" {
return "", errors.New(errs.ErrLogDatabaseEmpty)
}
return cfg.Value, nil
}
func setMigrationFlag(ctx context.Context, v string) error {
return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDBMigration, v)
}
func flipLogDatabase(ctx context.Context, target string) error {
return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, target)
}
@@ -0,0 +1,50 @@
//go:build !windows
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/pkg/logger"
"context"
"fmt"
"os"
"path/filepath"
"syscall"
)
const installedBinaryMode = 0o755
// ReplaceAndRestart replaces the current executable binary with the staged binary and restarts via syscall.Exec.
func ReplaceAndRestart(executable, stagedBinary string) error {
ctx := context.Background()
logger.InfoF(ctx, "[Updater] Swapping executable: %s -> %s", executable, stagedBinary)
backup := executable + ".old"
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除旧备份失败: %w", err)
}
if err := os.Rename(executable, backup); err != nil {
return fmt.Errorf("备份当前程序失败: %w", err)
}
if err := os.Rename(stagedBinary, executable); err != nil {
_ = os.Rename(backup, executable)
return fmt.Errorf("替换当前程序失败: %w", err)
}
if err := os.Chmod(executable, installedBinaryMode); err != nil {
_ = os.Remove(executable)
_ = os.Rename(backup, executable)
return fmt.Errorf("设置程序执行权限失败: %w", err)
}
stagingDir := filepath.Dir(stagedBinary)
_ = os.RemoveAll(stagingDir)
logger.InfoF(ctx, "[Updater] Executing syscall.Exec to restart service: %s %v", executable, os.Args)
//nolint:gosec // restart process via exec with same binary and args
return syscall.Exec(executable, os.Args, os.Environ())
}
@@ -0,0 +1,16 @@
//go:build windows
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/plugins/domain/admin/errs"
"errors"
)
// ReplaceAndRestart is blocked on Windows.
func ReplaceAndRestart(_, _ string) error {
return errors.New(errs.ErrAutomaticUpgradeBlocked)
}
@@ -0,0 +1,204 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service provides business logic and orchestration for the admin domain.
package service
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/repository"
"context"
"errors"
"sync"
"gorm.io/gorm"
)
var (
servicesMu sync.RWMutex
dbService contracts.DBService
cacheService contracts.CacheService
userService contracts.UserService
authService contracts.AuthService
taskService contracts.TaskService
storageSvc contracts.StorageService
riskControlService contracts.RiskControlService
eventEmitter func(ctx context.Context, topic string, payload any) error
)
// SetDBService injects the DBService contract.
func SetDBService(s contracts.DBService) {
servicesMu.Lock()
defer servicesMu.Unlock()
dbService = s
repository.SetDBService(s)
}
// SetCacheService injects the CacheService contract.
func SetCacheService(s contracts.CacheService) {
servicesMu.Lock()
defer servicesMu.Unlock()
cacheService = s
repository.SetCacheService(s)
}
// SetUserService injects the UserService contract.
func SetUserService(s contracts.UserService) {
servicesMu.Lock()
defer servicesMu.Unlock()
userService = s
}
// SetAuthService injects the AuthService contract.
func SetAuthService(s contracts.AuthService) {
servicesMu.Lock()
defer servicesMu.Unlock()
authService = s
}
// SetTaskService injects the TaskService contract.
func SetTaskService(s contracts.TaskService) {
servicesMu.Lock()
defer servicesMu.Unlock()
taskService = s
}
// SetStorageService injects the StorageService contract.
func SetStorageService(s contracts.StorageService) {
servicesMu.Lock()
defer servicesMu.Unlock()
storageSvc = s
}
// SetRiskControlService injects the RiskControlService contract.
func SetRiskControlService(s contracts.RiskControlService) {
servicesMu.Lock()
defer servicesMu.Unlock()
riskControlService = s
}
// SetEventEmitter sets the event emission callback.
func SetEventEmitter(fn func(ctx context.Context, topic string, payload any) error) {
servicesMu.Lock()
defer servicesMu.Unlock()
eventEmitter = fn
}
// EmitEvent publishes a domain event if an emitter is registered.
func EmitEvent(ctx context.Context, topic string, payload any) error {
servicesMu.RLock()
defer servicesMu.RUnlock()
if eventEmitter == nil {
return nil
}
return eventEmitter(ctx, topic, payload)
}
// ResetServices clears all injected services (used on disposal and testing).
func ResetServices() {
servicesMu.Lock()
defer servicesMu.Unlock()
dbService = nil
cacheService = nil
userService = nil
authService = nil
taskService = nil
storageSvc = nil
riskControlService = nil
eventEmitter = nil
repository.ResetServices()
}
// GetDB returns the GORM DB instance bound to the context if available.
func GetDB(ctx context.Context) *gorm.DB {
servicesMu.RLock()
defer servicesMu.RUnlock()
if dbService == nil {
return nil
}
return dbService.DB(ctx)
}
// GetCache returns the unified CacheService instance.
func GetCache(_ context.Context) contracts.CacheService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return cacheService
}
// GetUserService returns the UserService instance.
func GetUserService(_ context.Context) contracts.UserService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return userService
}
// GetAuthService returns the AuthService instance.
func GetAuthService(_ context.Context) contracts.AuthService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return authService
}
// GetTaskService returns the TaskService instance.
func GetTaskService() contracts.TaskService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return taskService
}
// GetStorageService returns the StorageService instance.
func GetStorageService() contracts.StorageService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return storageSvc
}
// GetRiskControlService returns the RiskControlService instance.
func GetRiskControlService() contracts.RiskControlService {
servicesMu.RLock()
defer servicesMu.RUnlock()
return riskControlService
}
// translateNotFound collapses the persistence layer's record-not-found sentinel into
// the plugin's own domain error so that no layer above the repository has to import gorm.
func translateNotFound(err error, notFound error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return notFound
}
return err
}
// isRecordMissing reports whether err originates from a missing persistence row.
func isRecordMissing(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// requireUserService resolves the injected user contract service.
func requireUserService(ctx context.Context) (contracts.UserService, error) {
userSvc := GetUserService(ctx)
if userSvc == nil {
return nil, errs.ErrUserServiceUnavailable
}
return userSvc, nil
}
// requireAuthService resolves the injected auth contract service.
func requireAuthService(ctx context.Context) (contracts.AuthService, error) {
authSvc := GetAuthService(ctx)
if authSvc == nil {
return nil, errs.ErrAuthServiceUnavailable
}
return authSvc, nil
}
// requireTaskService resolves the injected task contract service.
func requireTaskService() (contracts.TaskService, error) {
taskSvc := GetTaskService()
if taskSvc == nil {
return nil, errs.ErrTaskServiceUnavailable
}
return taskSvc, nil
}
@@ -0,0 +1,163 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"fmt"
"math"
"runtime"
"time"
)
var startTime = time.Now()
const (
hoursInDay = 24
minutesInHour = 60
secondsInMinute = 60
nanosPerSecond = 1e9
logDBNamePostgres = "postgres"
logDBNameSQLite = "sqlite"
logDBNameClickHouse = "clickhouse"
defaultLogRetentionDays = 30
logMigrationIdle = "idle"
logMigrationInProgress = "migrating"
unknownGCLabel = "未知"
noGCLabel = "无"
)
// CollectSystemStatus samples the Go runtime counters for the console status page.
func CollectSystemStatus() model.SystemStatusResponse {
var m runtime.MemStats
runtime.ReadMemStats(&m)
uptime := formatDuration(time.Since(startTime))
numGoroutine := runtime.NumGoroutine()
var lastGCTime string
switch {
case m.LastGC > 0 && m.LastGC <= math.MaxInt64:
lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC))))
case m.LastGC > 0:
lastGCTime = unknownGCLabel
default:
lastGCTime = noGCLabel
}
var lastPause string
if m.NumGC > 0 {
lastPause = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC-1)%256])/nanosPerSecond)
} else {
lastPause = "0.000s"
}
return model.SystemStatusResponse{
Uptime: uptime,
NumGoroutine: numGoroutine,
Alloc: model.FormatBytes(m.Alloc),
TotalAlloc: model.FormatBytes(m.TotalAlloc),
Sys: model.FormatBytes(m.Sys),
Lookups: m.Lookups,
Mallocs: m.Mallocs,
Frees: m.Frees,
HeapAlloc: model.FormatBytes(m.HeapAlloc),
HeapSys: model.FormatBytes(m.HeapSys),
HeapIdle: model.FormatBytes(m.HeapIdle),
HeapInuse: model.FormatBytes(m.HeapInuse),
HeapReleased: model.FormatBytes(m.HeapReleased),
HeapObjects: m.HeapObjects,
StackInuse: model.FormatBytes(m.StackInuse),
StackSys: model.FormatBytes(m.StackSys),
MSpanInuse: model.FormatBytes(m.MSpanInuse),
MSpanSys: model.FormatBytes(m.MSpanSys),
MCacheInuse: model.FormatBytes(m.MCacheInuse),
MCacheSys: model.FormatBytes(m.MCacheSys),
BuckHashSys: model.FormatBytes(m.BuckHashSys),
GCSys: model.FormatBytes(m.GCSys),
OtherSys: model.FormatBytes(m.OtherSys),
NextGC: model.FormatBytes(m.NextGC),
LastGCTime: lastGCTime,
PauseTotalNs: fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/nanosPerSecond),
LastPause: lastPause,
NumGC: m.NumGC,
}
}
func formatDuration(d time.Duration) string {
days := int(d.Hours()) / hoursInDay
hours := int(d.Hours()) % hoursInDay
minutes := int(d.Minutes()) % minutesInHour
seconds := int(d.Seconds()) % secondsInMinute
var res string
if days > 0 {
res += fmt.Sprintf("%d天", days)
}
if hours > 0 {
res += fmt.Sprintf("%d小时", hours)
}
if minutes > 0 {
res += fmt.Sprintf("%d分钟", minutes)
}
if seconds > 0 || res == "" {
res += fmt.Sprintf("%d秒钟", seconds)
}
return res
}
// LogDatabaseStatus reports the active log engine, migration freeze state and retention.
func LogDatabaseStatus(ctx context.Context) model.LogDatabaseStatus {
activeDB := logDBNameSQLite
migration := logMigrationIdle
if rc := GetRiskControlService(); rc != nil {
activeDB = rc.ActiveLogEngine(ctx)
if rc.IsLogEngineMigrating(ctx) {
migration = logMigrationInProgress
}
}
return model.LogDatabaseStatus{
ActiveDatabase: activeDB,
Migration: migration,
RetentionDays: map[string]int{
logDBNamePostgres: retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres),
logDBNameSQLite: retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite),
logDBNameClickHouse: retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse),
},
AvailableTargets: availableLogTargets(activeDB),
}
}
func retentionOr(ctx context.Context, key string) int {
v, err := repository.GetIntByKey(ctx, key)
if err != nil {
if !isRecordMissing(err) {
logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err)
}
return defaultLogRetentionDays
}
if v < 1 {
return defaultLogRetentionDays
}
return v
}
func availableLogTargets(active string) []string {
if active == logDBNameClickHouse {
if GetDBConfig().Enabled {
return []string{logDBNamePostgres}
}
return []string{logDBNameSQLite}
}
if GetClickHouseConfig().Enabled {
return []string{logDBNameClickHouse}
}
return []string{}
}
@@ -0,0 +1,159 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service_test
import (
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"Wavelet/plugins/domain/admin/service"
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type testDBService struct {
db *gorm.DB
}
func (s *testDBService) DB(ctx context.Context) *gorm.DB {
return s.db
}
func (s *testDBService) MasterDB(ctx context.Context) *gorm.DB {
return s.db
}
func (s *testDBService) GORM() *gorm.DB {
return s.db
}
func (s *testDBService) Named(_ string) *gorm.DB {
return s.db
}
func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("gorm.Open(sqlite) error = %v", err)
}
if err := sqliteDB.AutoMigrate(&model.SystemConfig{}); err != nil {
t.Fatalf("AutoMigrate(SystemConfig) error = %v", err)
}
siteConfig := model.SystemConfig{
Key: model.ConfigKeySiteName,
Value: "Wavelet",
Type: "system",
Description: "系统平台的展示名称",
}
if err := sqliteDB.Create(&siteConfig).Error; err != nil {
t.Fatalf("Create(site_name) error = %v", err)
}
service.SetDBService(&testDBService{db: sqliteDB})
cleanup := func() {
repository.StopSystemConfigCacheListener()
repository.ResetSystemConfigRAMCacheForTest()
service.ResetServices()
}
return sqliteDB, cleanup
}
func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) {
result, err := repository.ListSystemConfigsByKeys(context.Background(), nil)
if err != nil {
t.Fatalf("ListSystemConfigsByKeys(nil) error = %v", err)
}
if len(result) != 0 {
t.Fatalf("ListSystemConfigsByKeys(nil) = %#v, want empty map", result)
}
}
func TestListSystemConfigsByKeys_LoadsFromRAMCache(t *testing.T) {
dbConn, cleanup := setupSystemConfigTest(t)
defer cleanup()
ctx := context.Background()
repository.ResetSystemConfigRAMCacheForTest()
// Initial load
warm, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err)
}
if warm.Value != "Wavelet" {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet")
}
// Update DB directly
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "db_only_value").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
// Fetch via ListSystemConfigsByKeys should serve from local store (meaning the old value "Wavelet")
configs, err := repository.ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName})
if err != nil {
t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err)
}
sc, ok := configs[model.ConfigKeySiteName]
if !ok {
t.Fatal("ListSystemConfigsByKeys(site_name) missing site_name entry")
}
if sc.Value != "Wavelet" {
t.Fatalf("ListSystemConfigsByKeys(site_name).Value = %q, want cached value %q", sc.Value, "Wavelet")
}
}
func TestGetSystemConfigByGroupAndInvalidation(t *testing.T) {
dbConn, cleanup := setupSystemConfigTest(t)
defer cleanup()
ctx := context.Background()
repository.ResetSystemConfigRAMCacheForTest()
// Get via specific group/type
cfg, err := repository.GetSystemConfigByGroup(ctx, repository.ConfigCacheType, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByGroup error = %v", err)
}
if cfg.Value != "Wavelet" {
t.Fatalf("value = %q, want %q", cfg.Value, "Wavelet")
}
// Direct DB update
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "new_site_name").Error; err != nil {
t.Fatalf("DB Update error = %v", err)
}
// Invalidate
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache error = %v", err)
}
// Wait for broadcast execution
time.Sleep(100 * time.Millisecond)
// Fetch again
updated, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey error = %v", err)
}
if updated.Value != "new_site_name" {
t.Fatalf("value = %q, want %q", updated.Value, "new_site_name")
}
}
@@ -0,0 +1,217 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"fmt"
"strings"
"github.com/robfig/cron/v3"
)
// ListTaskTypes returns every dispatchable task type declared in the task registry.
func ListTaskTypes() []contracts.TaskMetaDTO {
taskSvc := GetTaskService()
if taskSvc == nil {
return []contracts.TaskMetaDTO{}
}
return taskSvc.ListTasks()
}
// DispatchTask validates and enqueues a manual task run, returning the new task id.
func DispatchTask(ctx context.Context, req model.DispatchTaskRequest) (string, error) {
taskSvc, err := requireTaskService()
if err != nil {
return "", err
}
if _, ok := taskSvc.GetTaskMeta(req.TaskType); !ok {
return "", errs.ErrInvalidTaskType
}
validated, err := validateTaskPayload(taskSvc, req.TaskType, req.Payload)
if err != nil {
return "", err
}
taskID, err := taskSvc.Dispatch(ctx, req.TaskType, validated, "manual")
if err != nil {
return "", fmt.Errorf("%s: %w", errs.TaskDispatchFailed, err)
}
return taskID, nil
}
// validateTaskPayload normalises an optional raw payload through the task registry.
func validateTaskPayload(taskSvc contracts.TaskService, name, payload string) ([]byte, error) {
var payloadBytes []byte
if strings.TrimSpace(payload) != "" {
payloadBytes = []byte(payload)
}
validated, err := taskSvc.ValidatePayload(name, payloadBytes)
if err != nil {
return nil, errs.NewInvalidInputError(err.Error())
}
return validated, nil
}
// ListTaskExecutions pages task execution records for the console.
func ListTaskExecutions(
ctx context.Context,
req model.ListTaskExecutionsRequest,
) ([]model.TaskExecution, int64, error) {
if req.TaskType != "" {
if taskSvc := GetTaskService(); taskSvc != nil {
if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok {
req.TaskType = meta.Name
}
}
}
executions, total, err := repository.ListTaskExecutionRecords(ctx, req)
if err != nil {
return nil, 0, err
}
return executions, total, nil
}
// TaskExecution loads a single execution record including its buffered log.
func TaskExecution(ctx context.Context, id uint64) (*model.TaskExecution, error) {
return repository.GetTaskExecutionByID(ctx, id)
}
// RetryTask re-dispatches a failed execution as a new task run.
func RetryTask(ctx context.Context, id uint64) (string, error) {
taskSvc, err := requireTaskService()
if err != nil {
return "", err
}
newTaskID, err := taskSvc.Retry(ctx, id)
if err != nil {
return "", err
}
return newTaskID, nil
}
// IsRetryConflictError reports whether the task registry rejected the retry request
// because of the record state rather than an infrastructure failure.
func IsRetryConflictError(err error) bool {
msg := err.Error()
return strings.Contains(msg, errs.RemoteTaskNotFailedMsg) ||
strings.Contains(msg, errs.RemoteTaskNotRetryableMsg) ||
strings.Contains(msg, errs.RemoteTaskMaxRetryMsg)
}
// IsRetryMissingError reports whether the referenced execution record is absent.
func IsRetryMissingError(err error) bool {
return strings.Contains(err.Error(), errs.RemoteTaskNotFoundMsg)
}
// ListSchedules returns every dynamic schedule definition.
func ListSchedules(ctx context.Context) ([]model.Schedule, error) {
return repository.ListSchedulesRecord(ctx)
}
// CreateSchedule validates a schedule definition, persists it and reloads the scheduler.
func CreateSchedule(ctx context.Context, req model.CreateScheduleRequest) (*model.Schedule, error) {
if _, err := cron.ParseStandard(req.Cron); err != nil {
return nil, errs.ErrInvalidCronExpression
}
taskSvc, err := requireTaskService()
if err != nil {
return nil, err
}
meta, ok := taskSvc.GetTaskMeta(req.TaskType)
if !ok {
return nil, errs.ErrInvalidTaskType
}
validated, err := validateTaskPayload(taskSvc, meta.Name, req.Payload)
if err != nil {
return nil, err
}
schedule := &model.Schedule{
Name: req.Name,
TaskType: req.TaskType,
Cron: req.Cron,
Payload: string(validated),
IsActive: *req.IsActive,
}
if err := repository.CreateScheduleRecord(ctx, schedule); err != nil {
return nil, fmt.Errorf("%s: %w", errs.ScheduleSaveFailed, err)
}
reloadScheduler(ctx, taskSvc)
return schedule, nil
}
// UpdateSchedule rewrites an existing schedule definition and reloads the scheduler.
func UpdateSchedule(ctx context.Context, id uint64, req model.UpdateScheduleRequest) (*model.Schedule, error) {
schedule, err := repository.GetScheduleByID(ctx, id)
if err != nil {
return nil, errs.ErrScheduleNotFound
}
if _, err := cron.ParseStandard(req.Cron); err != nil {
return nil, errs.ErrInvalidCronExpression
}
taskSvc, err := requireTaskService()
if err != nil {
return nil, err
}
meta, ok := taskSvc.GetTaskMeta(req.TaskType)
if !ok {
return nil, errs.ErrInvalidTaskType
}
validated, err := validateTaskPayload(taskSvc, meta.Name, req.Payload)
if err != nil {
return nil, err
}
schedule.Name = req.Name
schedule.TaskType = req.TaskType
schedule.Cron = req.Cron
schedule.Payload = string(validated)
schedule.IsActive = *req.IsActive
if err := repository.UpdateScheduleRecord(ctx, schedule); err != nil {
return nil, fmt.Errorf("%s: %w", errs.ScheduleSaveFailed, err)
}
reloadScheduler(ctx, taskSvc)
return schedule, nil
}
// DeleteSchedule removes a schedule definition and reloads the scheduler.
func DeleteSchedule(ctx context.Context, id uint64) error {
if err := repository.DeleteScheduleRecord(ctx, id); err != nil {
return fmt.Errorf("%s: %w", errs.ScheduleDeleteFailed, err)
}
if taskSvc := GetTaskService(); taskSvc != nil {
reloadScheduler(ctx, taskSvc)
}
return nil
}
// reloadScheduler triggers the hot reload, degrading gracefully when the scheduler rejects it.
func reloadScheduler(ctx context.Context, taskSvc contracts.TaskService) {
if err := taskSvc.ReloadScheduler(); err != nil {
logger.ErrorF(ctx, "[TaskAdmin] 重载调度器失败: %v", err)
}
}
@@ -0,0 +1,86 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
)
// CreateTemplate persists a new notification template after key collision and field checks.
func CreateTemplate(ctx context.Context, req model.CreateTemplateRequest) (model.Template, error) {
exists, err := repository.TemplateExistsByKey(ctx, req.Key)
if err != nil {
return model.Template{}, err
}
if exists {
return model.Template{}, errs.ErrTemplateKeyExists
}
tmpl := model.Template{
Key: req.Key,
Name: req.Name,
Type: req.Type,
Subject: req.Subject,
Content: req.Content,
Description: req.Description,
IsSystem: false,
}
if err := tmpl.Validate(); err != nil {
return model.Template{}, err
}
if err := repository.CreateTemplateRecord(ctx, &tmpl); err != nil {
return model.Template{}, err
}
return tmpl, nil
}
// ListTemplates returns every notification template.
func ListTemplates(ctx context.Context) ([]model.Template, error) {
return repository.ListTemplatesRecord(ctx)
}
// GetTemplate loads a template by its identifier.
func GetTemplate(ctx context.Context, key string) (model.Template, error) {
tmpl, err := repository.GetTemplateByKey(ctx, key)
if err != nil {
return model.Template{}, translateNotFound(err, errs.ErrTemplateNotFound)
}
return tmpl, nil
}
// UpdateTemplate rewrites the mutable fields of an existing template.
func UpdateTemplate(ctx context.Context, key string, req model.UpdateTemplateRequest) (model.Template, error) {
tmpl, err := GetTemplate(ctx, key)
if err != nil {
return model.Template{}, err
}
tmpl.Name = req.Name
tmpl.Type = req.Type
tmpl.Subject = req.Subject
tmpl.Content = req.Content
tmpl.Description = req.Description
if err := tmpl.Validate(); err != nil {
return model.Template{}, err
}
if err := repository.SaveTemplateRecord(ctx, &tmpl); err != nil {
return model.Template{}, err
}
return tmpl, nil
}
// DeleteTemplate removes a custom template; system presets are protected.
func DeleteTemplate(ctx context.Context, key string) error {
tmpl, err := GetTemplate(ctx, key)
if err != nil {
return err
}
if tmpl.IsSystem {
return errs.ErrSystemTemplateCannotDelete
}
return repository.DeleteTemplateRecord(ctx, &tmpl)
}
@@ -0,0 +1,635 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/pkg/buildinfo"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"golang.org/x/mod/semver"
)
const (
githubAPIBaseURL = "https://api.github.com"
maxArchiveSize = int64(1024 * 1024 * 1024)
maxReleaseSize = int64(4 * 1024 * 1024)
repositoryParts = 2
windowsOS = "windows"
archiveFileMode = 0o600
stagedBinaryMode = 0o700
)
type releaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
State string `json:"state"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Published time.Time `json:"published_at"`
Assets []releaseAsset `json:"assets"`
}
type releaseClient interface {
Do(req *http.Request) (*http.Response, error)
}
// UpdaterManager manages application binary updates from GitHub releases.
type UpdaterManager struct {
client releaseClient
mu sync.Mutex
upgrading bool
}
// DefaultUpdaterManager is the default singleton update manager.
var DefaultUpdaterManager = &UpdaterManager{
client: &http.Client{Timeout: 10 * time.Minute},
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" || version == "dev" {
return ""
}
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if !semver.IsValid(version) {
return ""
}
return version
}
func parseRepository(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New(errs.ErrInvalidRepository)
}
if !strings.Contains(raw, "://") {
repo := strings.TrimSuffix(strings.Trim(raw, "/"), ".git")
if len(strings.Split(repo, "/")) == repositoryParts {
return repo, nil
}
return "", errors.New(errs.ErrInvalidRepository)
}
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") {
return "", errors.New(errs.ErrInvalidRepository)
}
repo := strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git")
if len(strings.Split(repo, "/")) != repositoryParts {
return "", errors.New(errs.ErrInvalidRepository)
}
return repo, nil
}
func expectedAssetName(tag string) string {
extension := "tar.gz"
if runtime.GOOS == windowsOS {
extension = "zip"
}
return fmt.Sprintf("wavelet_%s_%s_%s.%s", tag, runtime.GOOS, runtime.GOARCH, extension)
}
func expectedAssetNames(repo, tag string) []string {
names := []string{expectedAssetName(tag)}
if parts := strings.Split(repo, "/"); len(parts) == repositoryParts {
repoName := parts[1]
if repoName != "wavelet" {
extension := "tar.gz"
if runtime.GOOS == windowsOS {
extension = "zip"
}
names = append(names, fmt.Sprintf("%s_%s_%s_%s.%s", repoName, tag, runtime.GOOS, runtime.GOARCH, extension))
}
}
return names
}
func selectLatestRelease(repo string, releases []githubRelease) (githubRelease, releaseAsset, error) {
var selected githubRelease
var selectedAsset releaseAsset
selectedVersion := ""
for _, release := range releases {
version := normalizeVersion(release.TagName)
if release.Draft || version == "" {
continue
}
expectedNames := expectedAssetNames(repo, release.TagName)
for _, asset := range release.Assets {
matched := false
for _, name := range expectedNames {
if asset.Name == name {
matched = true
break
}
}
if !matched || asset.BrowserDownloadURL == "" || asset.State != "uploaded" {
continue
}
if selectedVersion == "" || semver.Compare(version, selectedVersion) > 0 {
selected = release
selectedAsset = asset
selectedVersion = version
}
}
}
if selectedVersion == "" {
return githubRelease{}, releaseAsset{}, errors.New(errs.ErrNoCompatibleRelease)
}
return selected, selectedAsset, nil
}
func (m *UpdaterManager) fetchRelease(ctx context.Context, repo string) (githubRelease, releaseAsset, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf("%s/repos/%s/releases?per_page=30", githubAPIBaseURL, repo),
nil,
)
if err != nil {
return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseRequestFailed, err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "Wavelet-Updater")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := m.client.Do(req)
if err != nil {
return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseRequestFailed, err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: HTTP %d", errs.ErrReleaseRequestFailed, resp.StatusCode)
}
var releases []githubRelease
decoder := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseSize))
if err := decoder.Decode(&releases); err != nil {
return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseResponseInvalid, err)
}
release, asset, err := selectLatestRelease(repo, releases)
if err != nil {
return githubRelease{}, releaseAsset{}, err
}
logger.InfoF(ctx, "[Updater] Selected latest compatible release: %s (Asset: %s)", release.TagName, asset.Name)
return release, asset, nil
}
func loadRepository(ctx context.Context) (string, error) {
cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUpdateUpstreamRepository)
if err != nil {
return "", fmt.Errorf("%s: %w", errs.ErrInvalidRepository, err)
}
return parseRepository(cfg.Value)
}
// status returns current version and update status.
func (m *UpdaterManager) status(ctx context.Context) (model.UpdaterStatus, releaseAsset, error) {
upstreamRepo, err := loadRepository(ctx)
if err != nil {
return model.UpdaterStatus{}, releaseAsset{}, err
}
release, asset, err := m.fetchRelease(ctx, upstreamRepo)
if err != nil {
return model.UpdaterStatus{}, releaseAsset{}, err
}
currentVersion := normalizeVersion(buildinfo.Version)
latestVersion := normalizeVersion(release.TagName)
updateAvailable := currentVersion != "" && semver.Compare(latestVersion, currentVersion) > 0
logger.InfoF(ctx, "[Updater] Check update complete. current: %s, latest: %s, update_available: %t", buildinfo.Version, release.TagName, updateAvailable)
return model.UpdaterStatus{
CurrentVersion: buildinfo.Version,
BuildTime: buildinfo.BuildTime,
LatestVersion: release.TagName,
UpdateAvailable: updateAvailable,
CanUpgrade: updateAvailable && runtime.GOOS != windowsOS,
Prerelease: release.Prerelease,
ReleaseName: release.Name,
ReleaseNotes: release.Body,
ReleaseURL: release.HTMLURL,
PublishedAt: release.Published.Format(time.RFC3339),
UpstreamRepository: upstreamRepo,
AssetName: asset.Name,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
}, asset, nil
}
// GetUpdateStatus returns current updater status.
func GetUpdateStatus(ctx context.Context) (model.UpdaterStatus, error) {
status, _, err := DefaultUpdaterManager.status(ctx)
return status, err
}
func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error {
if asset.Size <= 0 || asset.Size > maxArchiveSize {
return fmt.Errorf(errs.ErrReleaseAssetSizeInvalid, asset.Size)
}
logger.InfoF(ctx, "[Updater] Downloading release asset: %s", asset.Name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil)
if err != nil {
return fmt.Errorf(errs.ErrCreateUpgradeRequestFailed, err)
}
req.Header.Set("User-Agent", "Wavelet-Updater")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf(errs.ErrDownloadUpgradeAssetFailed, err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf(errs.ErrUpgradeAssetHTTPFailed, resp.StatusCode)
}
//nolint:gosec // updater download destination is validated
file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode)
if err != nil {
return fmt.Errorf(errs.ErrCreateUpgradeArchiveFailed, err)
}
written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1))
if err != nil {
_ = file.Close()
return fmt.Errorf(errs.ErrWriteUpgradeArchiveFailed, err)
}
if err := file.Close(); err != nil {
return fmt.Errorf(errs.ErrCloseUpgradeArchiveFailed, err)
}
if written > maxArchiveSize || written != asset.Size {
return fmt.Errorf(errs.ErrUpgradeArchiveSizeMismatch, written, asset.Size)
}
logger.InfoF(ctx, "[Updater] Successfully downloaded release asset to %s", destination)
return nil
}
func safeArchivePath(destination, name string) (string, error) {
cleanName := filepath.Clean(name)
if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
return "", fmt.Errorf(errs.ErrArchiveContainsIllegalPath, name)
}
target := filepath.Join(destination, cleanName)
relative, err := filepath.Rel(destination, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf(errs.ErrArchivePathOutOfDestination, name)
}
return target, nil
}
func matchBinaryName(name string, candidates []string) bool {
for _, candidate := range candidates {
if runtime.GOOS == windowsOS {
if strings.EqualFold(name, candidate) {
return true
}
} else {
if name == candidate {
return true
}
}
}
return false
}
func getCandidateBinaryNames(executable, repo string) []string {
execName := filepath.Base(executable)
names := []string{execName}
addName := func(base string) {
name := base
if runtime.GOOS == windowsOS && !strings.HasSuffix(strings.ToLower(name), ".exe") {
name += ".exe"
}
for _, existing := range names {
if existing == name {
return
}
}
names = append(names, name)
}
if parts := strings.Split(repo, "/"); len(parts) == repositoryParts {
addName(parts[1])
}
addName("wavelet")
return names
}
func isLikelyBinary(name string, isDir bool, mode os.FileMode) bool {
if isDir {
return false
}
base := strings.ToLower(filepath.Base(name))
exclusions := []string{
"license", "licence", "copying", "notice", "readme", "changelog",
}
for _, excl := range exclusions {
if strings.HasPrefix(base, excl) {
return false
}
}
if runtime.GOOS == windowsOS {
return filepath.Ext(base) == ".exe"
}
return (mode.Perm()&0o111 != 0) || (filepath.Ext(base) == "")
}
func findBinaryInTarGz(archivePath string, candidates []string) (string, error) {
//nolint:gosec // updater archivePath is verified
file, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer func() {
_ = gzipReader.Close()
}()
reader := tar.NewReader(gzipReader)
var binaries []string
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return "", err
}
if header.Typeflag == tar.TypeReg && isLikelyBinary(header.Name, false, header.FileInfo().Mode()) {
binaries = append(binaries, header.Name)
}
}
if len(binaries) == 1 {
return binaries[0], nil
}
for _, name := range binaries {
if matchBinaryName(filepath.Base(name), candidates) {
return name, nil
}
}
return "", errors.New(errs.ErrNoCompatibleAsset)
}
func findBinaryInZip(archivePath string, candidates []string) (string, error) {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return "", err
}
defer func() {
_ = reader.Close()
}()
var binaries []string
for _, file := range reader.File {
if !file.FileInfo().IsDir() && isLikelyBinary(file.Name, false, file.FileInfo().Mode()) {
binaries = append(binaries, file.Name)
}
}
if len(binaries) == 1 {
return binaries[0], nil
}
for _, name := range binaries {
if matchBinaryName(filepath.Base(name), candidates) {
return name, nil
}
}
return "", errors.New(errs.ErrNoCompatibleAsset)
}
func extractTarGz(ctx context.Context, archivePath, destination, targetName string, candidates []string) (string, error) {
binaryPathInArchive, err := findBinaryInTarGz(archivePath, candidates)
if err != nil {
return "", err
}
logger.InfoF(ctx, "[Updater] Extracting tar.gz archive: %s (extracting: %s)", archivePath, binaryPathInArchive)
//nolint:gosec // updater archivePath is verified
file, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer func() {
_ = gzipReader.Close()
}()
reader := tar.NewReader(gzipReader)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return "", err
}
if header.Name != binaryPathInArchive {
continue
}
target, err := safeArchivePath(destination, targetName)
if err != nil {
return "", err
}
//nolint:gosec // updater destination is sanitized
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode)
if err != nil {
return "", err
}
written, copyErr := io.Copy(output, io.LimitReader(reader, maxArchiveSize+1))
closeErr := output.Close()
if copyErr != nil {
return "", copyErr
}
if closeErr != nil {
return "", closeErr
}
if written > maxArchiveSize {
return "", errors.New(errs.ErrExtractedBinaryTooLarge)
}
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
return target, nil
}
return "", errors.New(errs.ErrNoCompatibleAsset)
}
func extractZip(ctx context.Context, archivePath, destination, targetName string, candidates []string) (string, error) {
binaryPathInArchive, err := findBinaryInZip(archivePath, candidates)
if err != nil {
return "", err
}
logger.InfoF(ctx, "[Updater] Extracting zip archive: %s (extracting: %s)", archivePath, binaryPathInArchive)
reader, err := zip.OpenReader(archivePath)
if err != nil {
return "", err
}
defer func() {
_ = reader.Close()
}()
for _, file := range reader.File {
if file.Name != binaryPathInArchive {
continue
}
target, err := safeArchivePath(destination, targetName)
if err != nil {
return "", err
}
input, err := file.Open()
if err != nil {
return "", err
}
//nolint:gosec // updater extraction target is safe
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode)
if err != nil {
return "", err
}
written, copyErr := io.Copy(output, io.LimitReader(input, maxArchiveSize+1))
inputCloseErr := input.Close()
outputCloseErr := output.Close()
if copyErr != nil {
return "", copyErr
}
if inputCloseErr != nil {
return "", inputCloseErr
}
if outputCloseErr != nil {
return "", outputCloseErr
}
if written > maxArchiveSize {
return "", errors.New(errs.ErrExtractedBinaryTooLarge)
}
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
return target, nil
}
return "", errors.New(errs.ErrNoCompatibleAsset)
}
// PrepareUpgrade validates preconditions and downloads the newest binary.
func (m *UpdaterManager) PrepareUpgrade(ctx context.Context) (string, string, error) {
if runtime.GOOS == windowsOS {
return "", "", errors.New(errs.ErrAutomaticUpgradeBlocked)
}
if normalizeVersion(buildinfo.Version) == "" {
return "", "", errors.New(errs.ErrDevelopmentBuild)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.upgrading {
return "", "", errors.New(errs.ErrUpgradeAlreadyRunning)
}
status, asset, err := m.status(ctx)
if err != nil {
return "", "", err
}
if !status.UpdateAvailable {
return "", "", errors.New(errs.ErrAlreadyUpToDate)
}
logger.InfoF(ctx, "[Updater] Preparing upgrade. current: %s, latest: %s", status.CurrentVersion, status.LatestVersion)
executable, err := os.Executable()
if err != nil {
return "", "", fmt.Errorf(errs.ErrLocateExecutableFailed, err)
}
executable, err = filepath.EvalSymlinks(executable)
if err != nil {
return "", "", fmt.Errorf(errs.ErrResolveExecutablePathFailed, err)
}
tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*")
if err != nil {
return "", "", fmt.Errorf(errs.ErrCreateUpgradeDirFailed, err)
}
archivePath := filepath.Join(tempDir, asset.Name)
if err := downloadArchive(ctx, m.client, asset, archivePath); err != nil {
_ = os.RemoveAll(tempDir)
return "", "", err
}
targetName := filepath.Base(executable)
candidates := getCandidateBinaryNames(executable, status.UpstreamRepository)
var stagedBinary string
if strings.HasSuffix(asset.Name, ".zip") {
stagedBinary, err = extractZip(ctx, archivePath, tempDir, targetName, candidates)
} else {
stagedBinary, err = extractTarGz(ctx, archivePath, tempDir, targetName, candidates)
}
if err != nil {
_ = os.RemoveAll(tempDir)
return "", "", fmt.Errorf(errs.ErrExtractUpgradeAssetFailed, err)
}
logger.InfoF(ctx, "[Updater] Staged binary successfully prepared: %s", stagedBinary)
m.upgrading = true
return executable, stagedBinary, nil
}
// FinishUpgrade resets the upgrading flag.
func (m *UpdaterManager) FinishUpgrade() {
m.mu.Lock()
defer m.mu.Unlock()
m.upgrading = false
}
@@ -0,0 +1,120 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"context"
"errors"
)
// ToUserResponse projects the user contract DTO onto the console response shape.
func ToUserResponse(u *contracts.UserDTO) model.UserResponse {
if u == nil {
return model.UserResponse{}
}
return model.UserResponse{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
AvatarURL: u.AvatarURL,
IsActive: u.IsActive,
IsAdmin: u.IsAdmin,
Bio: u.Bio,
Phone: u.Phone,
Gender: u.Gender,
Website: u.Website,
Location: u.Location,
LastLoginAt: u.LastLoginAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// AdminListUsers pages users through the user contract service.
func AdminListUsers(
ctx context.Context,
filter contracts.AdminListUsersFilter,
) (int64, []*contracts.UserDTO, error) {
userSvc, err := requireUserService(ctx)
if err != nil {
return 0, nil, err
}
total, dtos, err := userSvc.AdminListUsers(ctx, filter)
if err != nil {
logger.ErrorF(ctx, "List admin users failed: %v", err)
return 0, nil, errors.New(errs.ListAdminUsersFailed)
}
return total, dtos, nil
}
// AdminGetUser loads a single user profile.
func AdminGetUser(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
userSvc, err := requireUserService(ctx)
if err != nil {
return nil, err
}
targetUser, err := userSvc.AdminGetUser(ctx, id)
if err != nil {
return nil, translateNotFound(err, errs.ErrUserNotFound)
}
return targetUser, nil
}
// AdminUpdateUserStatus enables or disables a user account.
func AdminUpdateUserStatus(ctx context.Context, id uint64, isActive bool) error {
userSvc, err := requireUserService(ctx)
if err != nil {
return err
}
err = userSvc.AdminUpdateUserStatus(ctx, id, isActive)
return translateNotFound(err, errs.ErrUserNotFound)
}
// AdminDeleteUser removes a user on behalf of the acting administrator.
func AdminDeleteUser(ctx context.Context, operatorID, id uint64) error {
userSvc, err := requireUserService(ctx)
if err != nil {
return err
}
err = userSvc.AdminDeleteUser(ctx, operatorID, id)
return translateNotFound(err, errs.ErrUserNotFound)
}
// AdminCreateUser registers a local-password user.
func AdminCreateUser(ctx context.Context, req contracts.AdminCreateUserRequest) (*contracts.UserDTO, error) {
userSvc, err := requireUserService(ctx)
if err != nil {
return nil, err
}
newUser, err := userSvc.AdminCreateUser(ctx, req)
if err != nil {
return nil, translateNotFound(err, errs.ErrUserNotFound)
}
return newUser, nil
}
// AdminUpdateUser rewrites a user profile and optionally resets its password.
func AdminUpdateUser(
ctx context.Context,
operatorID uint64,
req contracts.AdminUpdateUserRequest,
) error {
userSvc, err := requireUserService(ctx)
if err != nil {
return err
}
err = userSvc.AdminUpdateUser(ctx, operatorID, req)
return translateNotFound(err, errs.ErrUserNotFound)
}