feat(push): implement system notification and push framework

This commit is contained in:
ryan
2026-06-14 16:52:06 +08:00
parent aee457093d
commit cb018b3b60
60 changed files with 8756 additions and 160 deletions
+176
View File
@@ -0,0 +1,176 @@
---
name: "push-notification"
description: "Wavelet 项目专用:当需要开发或接入新的系统通知推送事件、修改消息推送底层设计、调用统一触发器投递消息、或开发带消息推送功能的业务功能时必须使用。本技能指导元数据声明、触发流程、解耦防线和动态同步机制。"
---
# 新增消息推送与通知事件开发规范
本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
---
## 消息推送架构设计 (Architecture)
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
| :--- | :--- | :--- |
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 自定义的事件定义和触发函数单独放到此包下,**一个 Go 文件代表一个事件**。例如:<br>[admin_login.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/admin_login.go) 代表管理员登录事件。 |
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
---
## 核心开发步骤 (Step-by-Step Flow)
如果某个新业务(如“新用户注册”或“订单创建”)需要带有消息推送功能,请严格按照以下步骤开发:
### 步骤 1:在 `custom_events/` 中以一个文件声明事件元数据
在 `internal/apps/admin/push/custom_events/` 下新建一个 Go 文件(如 `user_registered.go`),声明其事件元数据并利用 `init()` 动态注册。
```go
package custom_events
import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/model"
)
// NewUserRegistered is the metadata definition for the user registered event.
var NewUserRegistered = push.EventMetadata{
Key: "user_registered",
Name: "新用户注册提醒",
DefaultTemplate: push.NotificationMessage{
Title: "新用户注册通知",
Content: "新用户 {{user.username}} (邮箱: {{user.email}}) 于 {{time}} 成功注册。",
Level: "INFO",
},
Description: "当系统有新用户注册成功时,向管理员或指定目标发送通知",
}
func init() {
push.RegisterBuiltInEvent(NewUserRegistered)
}
```
### 步骤 2:在该事件文件中编写触发封装函数 (Wrapper)
为了使业务层调用方便且类型安全,在同一个事件 Go 文件中为该事件定义一个封装函数。
> [!NOTE]
> - 底层框架 `EventTrigger.Trigger` 本身已经内置了**异步 Goroutine 执行**以及 **`context.WithoutCancel(ctx)` 衍生上下文转换**逻辑。
> - 开发者只需在封装函数中组装数据,并直接调用 `DefaultTrigger.Trigger` 即可,无需在外部手动写 `go func()` 也不需要处理上下文防取消问题,从而通过框架底层强制约束了异步投递行为。
```go
// TriggerNewUserRegisteredEvent triggers the user registration notification event.
func TriggerNewUserRegisteredEvent(ctx context.Context, user *model.User) {
if user == nil {
return
}
body := map[string]any{
"user": user,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
push.DefaultTrigger.Trigger(ctx, NewUserRegistered, body)
}
```
### 步骤 3:在业务代码中调用触发函数
在业务逻辑完成处(例如 `internal/apps/user/routers.go` 的注册 Handler 中)导入 `custom_events` 并调用该函数。
```go
import (
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
)
func Register(c *gin.Context) {
// ... 注册成功逻辑 ...
// 异步触发通知推送事件
custom_events.TriggerNewUserRegisteredEvent(ctx, user)
}
```
### 步骤 4:在主路由器或初始化模块进行匿名导入以确保注册
由于事件是在 `custom_events` 的 `init()` 中注册到 `push` 包的,所以应用程序的执行路径(例如 [router.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/router.go))必须匿名导入 `custom_events` 包,以确保其在程序启动时被加载和初始化。
```go
import (
_ "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
)
```
当程序启动后,系统在初始化阶段的 `SyncEvents` 流程中会自动将新声明的 `user_registered` 事件元数据插入数据库表 `w_push_events` 中。此后,管理员即可直接在管理端前端界面上为该事件配置推送渠道。
---
## 模板模板渲染与支持的系统变量 (Template Rendering & Variables)
消息的 `title`、`content` 以及 `ext` 字段中的字符串值都支持变量占位符替换,采用双花括号形式 `{{variable}}`。
### 1. 通用事件参数 (Common Variables)
任何通知事件触发时,均支持以下通用参数的渲染:
- `{{time}}`:事件发生/触发的具体时间(格式:`2006-01-02 15:04:05`)
### 2. 特定事件携带的业务变量 (Event Specific Variables)
特定事件在触发时会携带复杂的业务对象(例如 `user`),可以通过点(`.`)路径语法获取其属性:
- **管理员登录提醒 (`admin_login`)**
- `{{user.id}}`:管理员 ID
- `{{user.username}}`:管理员用户名
- `{{user.email}}`:管理员邮箱地址
- `{{ip}}`:管理员登录来源的客户端 IP
- `{{time}}`:管理员登录成功时间
- **新用户注册提醒 (`user_registered`)**
- `{{user.id}}`:新注册用户 ID
- `{{user.username}}`:新注册用户名
- `{{user.email}}`:新注册用户邮箱地址
- `{{time}}`:注册成功时间
---
## 严格遵循事项与防线 (Guardrails)
### 1. 严格禁止包循环引用 (No Circular Dependencies)
- `custom_events` 包在定义事件时会导入并依赖 `push` 包的 `EventMetadata` 与 `DefaultTrigger` 等底层逻辑。
- **因此,`push` 包本身绝对不能导入 `custom_events` 包**(否则编译会抛出 package dependency cycle 错误)。
- 对新事件的自动注册只能在 `custom_events` 内部使用 `init()` 调用 `push.RegisterBuiltInEvent` 来实现。
### 2. 单元测试防循环依赖隔离 (Unit Testing Isolation)
- 在对 `push` 包自身进行单元测试(如 `push_test.go`)时,由于 `custom_events` 依赖 `push`,测试文件 `push_test.go` 也无法直接导入 `custom_events`。
- **防线策略**:在 `push_test.go` 的 `init()` 中本地声明并使用 `RegisterBuiltInEvent` 注册测试专用的 `EventMetadata`,以此来完成 `push` 包的隔离自测。
### 3. 禁止绕过统一触发器 (Always Use EventTrigger)
- 所有推送请求必须经过 `EventTrigger.Trigger`,以确保进行“事件是否启用”、“目标渠道过滤”、“全局推送配置读取”及“发送日志审计”等流程。
### 4. 代码质量与零 Lint 警报
- **魔法值防范**:对于推送日志级别(如 `"INFO"`),不要在多个文件里写硬编码字符串,应统一在 `constants.go` 中引用 `defaultLevelInfo` 常量。
- **命名规范**:不要定义容易造成 Stuttering 的导出类型,例如在 `push` 包内不要使用 `PushSendPayload`,应重命名为 `SendPayload`。
---
## 验证计划 (Verification Guide)
1. **授权许可头部补全**:
新增/修改 Go 文件后,运行自动许可证生成:
```bash
make license
```
2. **Swagger 文档重新构建**:
如果修改了路由或 Swagger 注释:
```bash
make swagger
```
3. **静态代码质量门禁 (0 Issues)**:
运行静态检查,必须保证后端与前端均无任何警告:
```bash
make code-check
```
4. **单元测试通过**:
```bash
go test ./internal/apps/admin/push/...
```
+16
View File
@@ -56,8 +56,12 @@
- `docs/`:自动生成的 Swagger 文档。请勿手动编辑生成的文件。
- `frontend/`:Next.js 应用。
- `internal/`:私有 Go 后端代码。
- `pkg/`:公共 Go 库/工具包(留作扩展或存放不依赖特定业务的通用代码)。
- `scripts/`:本地和 CI 辅助脚本。
- `support-files/`:部署和数据库辅助文件。
- `bin/`:本地编译生成的二进制可执行文件。
- `data/`:本地运行时数据文件目录(如 PostgreSQL、Redis 数据等)。
- `uploads/`:本地文件上传存储目录。
后端目录:
@@ -78,6 +82,8 @@
- `internal/listener/`:事件监听器和消息/Webhook 消费者。
- `internal/otel_trace/`:链路追踪(tracing)助手。
- `internal/testhelper/`:后端测试共享辅助能力。
- `internal/buildinfo/`:暴露在发布/构建工作流中注入的元数据(如版本号、编译时间等)。
- `internal/httppool/`:管理全局共享且经过优化的 HTTP 传输客户端及连接池,并集成 OTel 链路追踪。
前端目录:
@@ -99,9 +105,19 @@
- `frontend/components/common/admin/system.tsx`:系统参数管理。
- `frontend/components/common/admin/files.tsx`:上传文件管理。
- `frontend/components/common/admin/users.tsx`:用户管理。
- `frontend/components/common/admin/access-analytics.tsx`:访问分析与图表展示。
- `frontend/components/common/admin/access-logs.tsx`:访问日志审计 UI。
- `frontend/components/common/admin/app-logs.tsx`:应用日志查看 UI。
- `frontend/components/common/admin/database-manage.tsx`:数据库备份、恢复与管理 UI。
- `frontend/components/common/admin/file-list.tsx`:管理员文件列表管理组件。
- `frontend/components/common/admin/file-stats.tsx`:文件存储状态与统计 UI。
- `frontend/components/common/admin/status.tsx`:系统运行状态与监控 UI。
- `frontend/components/common/admin/storage-config-tab.tsx`:存储策略与配置 tab 页。
- `frontend/components/common/admin/system-logs.tsx`:系统日志查看组件。
- `frontend/components/common/general/manage-pannel.tsx`:通用列表/详情管理器。
- `frontend/components/common/general/password-dialog.tsx`:敏感操作密码确认对话框。
- `frontend/components/common/settings/system-settings.tsx`:管理员图形化系统设置。
- `frontend/components/common/user/file-manager.tsx`:用户端文件管理组件。
## 开发要求
+14
View File
@@ -31,6 +31,20 @@ code-check:
golangci-lint run
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
go build \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/wavelet \
main.go
build-frontend:
@echo "==> Building frontend version=$(VERSION) build_date=$(BUILD_DATE)..."
cd frontend && \
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
pnpm build:embed
build-test:
@echo "==> Running frontend and backend build tests in parallel..."
@PIDS=""; \
+1049
View File
File diff suppressed because it is too large Load Diff
+1049
View File
File diff suppressed because it is too large Load Diff
+646
View File
@@ -306,6 +306,91 @@ definitions:
id:
type: integer
type: object
model.PushChannel:
properties:
created_at:
type: string
description:
description: 备注
type: string
enabled:
description: 通道是否启用
type: boolean
id:
type: integer
name:
description: 通道名称,仅英文字母和下划线,唯一
type: string
other:
description: 请求体/SMTP 密码等
type: string
token:
description: 鉴权令牌或发信用户名等
type: string
type:
description: 通道类型:custom, lark, email
type: string
updated_at:
type: string
url:
description: 请求地址,HTTPS 协议或 SMTP 地址
type: string
type: object
model.PushEvent:
properties:
channels:
description: 推送渠道列表,如 ["lark"]
items:
type: string
type: array
created_at:
type: string
enabled:
description: 是否启用
type: boolean
event_key:
description: 如 admin_login
type: string
id:
type: integer
name:
description: 如 管理员登录
type: string
targets:
description: 推送目标用户/邮箱列表
items:
type: string
type: array
template:
description: 消息模板 JSON
type: string
updated_at:
type: string
type: object
model.PushHistory:
properties:
channel:
type: string
content:
type: string
created_at:
type: string
error_msg:
type: string
event_key:
type: string
id:
type: integer
level:
type: string
status:
description: success / failed
type: string
target:
type: string
title:
type: string
type: object
model.Schedule:
properties:
created_at:
@@ -577,6 +662,192 @@ definitions:
user:
$ref: '#/definitions/oauth.BasicUserInfo'
type: object
push.Config:
properties:
channel:
description: 渠道名称,例如 "lark", "custom", "email" 等,唯一标识
type: string
ext:
additionalProperties: {}
description: 预留拓展 JSON 配置
type: object
key:
description: AppID 或 SMTP 用户名
type: string
secret:
description: 签名密钥或 SMTP 密码/Token
type: string
url:
description: Webhook 地址或 SMTP 地址
type: string
type: object
push.CreateChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
name:
type: string
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- name
- type
type: object
push.CreateEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
event_key:
type: string
targets:
items:
type: string
type: array
template:
type: string
required:
- event_key
type: object
push.Definition:
properties:
description:
description: short description
type: string
fields:
description: form fields
items:
$ref: '#/definitions/push.Field'
type: array
name:
description: display name
type: string
type:
description: channel type (e.g., custom, lark, email)
type: string
type: object
push.EventMetadata:
properties:
default_template:
$ref: '#/definitions/push.NotificationMessage'
description:
type: string
key:
type: string
name:
type: string
type: object
push.Field:
properties:
description:
description: field explanation/help text
type: string
key:
description: unique key for the field (e.g. url, token, other)
type: string
label:
description: human readable label (e.g. "Webhook 地址")
type: string
placeholder:
description: input placeholder
type: string
required:
description: whether this field is required
type: boolean
type:
description: 'input type: "text" | "password" | "textarea"'
type: string
type: object
push.NotificationMessage:
properties:
content:
type: string
ext:
additionalProperties: {}
type: object
level:
type: string
title:
type: string
type: object
push.TestChannelRequest:
properties:
name:
type: string
other:
type: string
target:
type: string
token:
type: string
type:
type: string
url:
type: string
type: object
push.TestPushRequest:
properties:
config:
$ref: '#/definitions/push.Config'
target:
type: string
required:
- config
type: object
push.UpdateChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- type
type: object
push.UpdateEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
targets:
items:
type: string
type: array
template:
type: string
required:
- template
type: object
push.pushHistoriesResponse:
properties:
results:
items:
$ref: '#/definitions/model.PushHistory'
type: array
total:
type: integer
type: object
status.DatabaseInfoResponse:
properties:
name:
@@ -1820,6 +2091,381 @@ paths:
summary: 系统日志实时推送
tags:
- admin
/api/v1/admin/push/channels:
get:
description: 返回系统配置的所有消息通道列表,需要管理员权限
produces:
- application/json
responses:
"200":
description: 消息通道列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
items:
$ref: '#/definitions/model.PushChannel'
type: array
type: object
security:
- SessionCookie: []
summary: 获取所有消息通道
tags:
- admin-push
post:
consumes:
- application/json
description: 新建一个消息通道配置,需要管理员权限
parameters:
- description: 创建参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.CreateChannelRequest'
produces:
- application/json
responses:
"200":
description: 创建成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/model.PushChannel'
type: object
security:
- SessionCookie: []
summary: 创建消息通道
tags:
- admin-push
/api/v1/admin/push/channels/{id}:
delete:
description: 根据ID删除消息通道,需要管理员权限
parameters:
- description: 通道ID
format: int64
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除成功
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 删除消息通道
tags:
- admin-push
put:
consumes:
- application/json
description: 修改消息通道配置,需要管理员权限
parameters:
- description: 通道ID
format: int64
in: path
name: id
required: true
type: integer
- description: 更新参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.UpdateChannelRequest'
produces:
- application/json
responses:
"200":
description: 更新成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/model.PushChannel'
type: object
security:
- SessionCookie: []
summary: 更新消息通道
tags:
- admin-push
/api/v1/admin/push/channels/definitions:
get:
description: 返回系统支持的所有消息通道类型(如飞书、邮件、自定义)的动态表单定义,需要管理员权限
produces:
- application/json
responses:
"200":
description: 通道配置定义列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
items:
$ref: '#/definitions/push.Definition'
type: array
type: object
security:
- SessionCookie: []
summary: 获取所有消息通道配置字段定义
tags:
- admin-push
/api/v1/admin/push/channels/test:
post:
consumes:
- application/json
description: 触发一次临时的或现有的通道连通性推送测试,需要管理员权限
parameters:
- description: 测试参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.TestChannelRequest'
produces:
- application/json
responses:
"200":
description: 测试触发成功
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 测试通道连通性
tags:
- admin-push
/api/v1/admin/push/events:
get:
description: 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限
produces:
- application/json
responses:
"200":
description: 通知事件列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
items:
$ref: '#/definitions/model.PushEvent'
type: array
type: object
security:
- SessionCookie: []
summary: 获取所有通知事件
tags:
- admin-push
post:
consumes:
- application/json
description: 绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限
parameters:
- description: 创建参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.CreateEventRequest'
produces:
- application/json
responses:
"200":
description: 创建成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/model.PushEvent'
type: object
security:
- SessionCookie: []
summary: 创建通知事件
tags:
- admin-push
/api/v1/admin/push/events/{id}:
delete:
description: 删除数据库中的特定通知事件配置,需要管理员权限
parameters:
- description: 事件 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
security:
- SessionCookie: []
summary: 删除通知事件配置
tags:
- admin-push
put:
consumes:
- application/json
description: 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限
parameters:
- description: 事件 ID
in: path
name: id
required: true
type: integer
- description: 更新参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.UpdateEventRequest'
produces:
- application/json
responses:
"200":
description: 修改成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
security:
- SessionCookie: []
summary: 更新通知事件
tags:
- admin-push
/api/v1/admin/push/events/{id}/toggle:
post:
description: 启用或禁用指定的通知事件
parameters:
- description: 事件 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 切换成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
security:
- SessionCookie: []
summary: 快捷切换通知事件启用状态
tags:
- admin-push
/api/v1/admin/push/events/builtin:
get:
description: 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限
produces:
- application/json
responses:
"200":
description: 内置通知事件列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
items:
$ref: '#/definitions/push.EventMetadata'
type: array
type: object
security:
- SessionCookie: []
summary: 获取所有内置通知事件
tags:
- admin-push
/api/v1/admin/push/histories:
get:
description: 返回分页的通知历史日志数据,需要管理员权限
parameters:
- description: 当前页码
in: query
name: page
type: integer
- description: 分页大小
in: query
name: page_size
type: integer
- description: 过滤事件名称
in: query
name: event_key
type: string
- description: 过滤发送状态
in: query
name: status
type: string
produces:
- application/json
responses:
"200":
description: 推送历史列表
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/push.pushHistoriesResponse'
type: object
security:
- SessionCookie: []
summary: 分页获取通知推送历史
tags:
- admin-push
/api/v1/admin/push/test:
post:
consumes:
- application/json
description: 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息
parameters:
- description: 测试请求体
in: body
name: request
required: true
schema:
$ref: '#/definitions/push.TestPushRequest'
produces:
- application/json
responses:
"200":
description: 测试成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
security:
- SessionCookie: []
summary: 测试推送通道发送
tags:
- admin-push
/api/v1/admin/status:
get:
description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import type {Metadata} from "next";
import {PushNotificationManager} from "@/components/common/admin/push-notification";
export const metadata: Metadata = {
title: "通知推送 - Wavelet Admin",
description: "系统通知多渠道推送与事件管理控制台",
};
export default function PushAdminPage() {
return <PushNotificationManager />;
}
@@ -0,0 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {motion} from "motion/react"
import {Bell, History, Layers, Settings} from "lucide-react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {EventsTab} from "./push/events-tab"
import {HistoriesTab} from "./push/histories-tab"
import {SettingsTab} from "./push/settings-tab"
export function PushNotificationManager() {
const [activeTab, setActiveTab] = React.useState("events")
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="w-full py-6 space-y-6"
>
<div className="flex items-center gap-2">
<Bell className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList variant="line" className="w-fit inline-flex gap-8">
<TabsTrigger value="events" className="px-0 pb-2 text-xs font-semibold">
<Layers className="size-3.5 mr-1" />
事件管理
</TabsTrigger>
<TabsTrigger value="histories" className="px-0 pb-2 text-xs font-semibold">
<History className="size-3.5 mr-1" />
通知历史
</TabsTrigger>
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
<Settings className="size-3.5 mr-1" />
通道管理与设置
</TabsTrigger>
</TabsList>
{/* ==================== 1. 事件管理 TAB ==================== */}
<TabsContent value="events" className="focus-visible:outline-none">
<EventsTab />
</TabsContent>
{/* ==================== 2. 通知历史 TAB ==================== */}
<TabsContent value="histories" className="focus-visible:outline-none">
<HistoriesTab />
</TabsContent>
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
<TabsContent value="settings" className="focus-visible:outline-none">
<SettingsTab />
</TabsContent>
</Tabs>
</motion.div>
)
}
@@ -0,0 +1,609 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {ChevronDown, Edit2, Layers, Loader2, Plus, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Checkbox} from "@/components/ui/checkbox"
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import services from "@/lib/services"
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
export function EventsTab() {
const queryClient = useQueryClient()
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => services.push.listChannels(),
})
const availableChannels = React.useMemo(() => {
const customChannels = (channelsQuery.data ?? [])
.filter(c => c.enabled)
.map(c => c.name)
return ["email", ...customChannels]
}, [channelsQuery.data])
// --- 获取通知事件 ---
const eventsQuery = useQuery({
queryKey: ["admin", "push-events"],
queryFn: () => services.push.listEvents(),
})
const builtInEventsQuery = useQuery({
queryKey: ["admin", "push-builtin-events"],
queryFn: () => services.push.listBuiltInEvents(),
})
// --- 修改保存事件 Mutation ---
const updateEventMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data),
onSuccess: () => {
toast.success("事件更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setEditEventOpen(false)
},
onError: (err: unknown) => {
toast.error("事件更新失败: " + (err as Error).message)
},
})
const toggleEventMutation = useMutation({
mutationFn: (id: number) => services.push.toggleEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
onError: (err: unknown) => {
toast.error("操作失败: " + (err as Error).message)
},
})
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => services.push.createEvent(data),
onSuccess: () => {
toast.success("事件创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setCreateEventOpen(false)
setNewEventKey("")
setNewEventChannels([])
setNewEventEnabled(true)
},
onError: (err: unknown) => {
toast.error("事件创建失败: " + (err as Error).message)
},
})
const deleteEventMutation = useMutation({
mutationFn: (id: number) => services.push.deleteEvent(id),
onSuccess: () => {
toast.success("配置删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
onError: (err: unknown) => {
toast.error("配置删除失败: " + (err as Error).message)
},
})
// --- 事件编辑对话框状态 ---
const [editEventOpen, setEditEventOpen] = React.useState(false)
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
const [eventChannels, setEventChannels] = React.useState<string[]>([])
const [eventTargets, setEventTargets] = React.useState("")
const [eventTemplate, setEventTemplate] = React.useState("")
// 事件创建对话框状态
const [createEventOpen, setCreateEventOpen] = React.useState(false)
const [newEventKey, setNewEventKey] = React.useState("")
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
const [newEventTargets, setNewEventTargets] = React.useState("")
const [newEventTemplate, setNewEventTemplate] = React.useState("")
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
const availableBuiltInEvents = React.useMemo(() => {
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
}, [builtInEventsQuery.data, eventsQuery.data])
const handleEditEventClick = (event: PushEvent) => {
setSelectedEvent(event)
setEventChannels(event.channels)
setEventTargets((event.targets ?? []).join(", "))
setEventTemplate(event.template)
setEditEventOpen(true)
}
const handleSaveEvent = () => {
if (!selectedEvent) return
try {
JSON.parse(eventTemplate)
} catch {
toast.error("消息模板不是合法的 JSON 格式")
return
}
const targets = eventTargets
.split(",")
.map(t => t.trim())
.filter(t => t !== "")
updateEventMutation.mutate({
id: selectedEvent.id,
data: {
channels: eventChannels,
targets,
template: eventTemplate,
enabled: selectedEvent.enabled,
},
})
}
const handleCreateEventClick = () => {
setNewEventKey("")
setNewEventChannels([])
setNewEventTargets("")
setNewEventTemplate("")
setNewEventEnabled(true)
setCreateEventOpen(true)
}
const handleNewEventKeyChange = (key: string) => {
setNewEventKey(key)
const ev = availableBuiltInEvents.find(e => e.key === key)
if (ev) {
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
} else {
setNewEventTemplate("")
}
}
const handleCreateEvent = () => {
if (!newEventKey) {
toast.error("请选择系统事件")
return
}
if (newEventTemplate) {
try {
JSON.parse(newEventTemplate)
} catch {
toast.error("内容渲染模板不是合法的 JSON 格式")
return
}
}
const targets = newEventTargets
.split(",")
.map(t => t.trim())
.filter(t => t !== "")
createEventMutation.mutate({
event_key: newEventKey,
channels: newEventChannels,
targets: targets.length > 0 ? targets : undefined,
template: newEventTemplate || undefined,
enabled: newEventEnabled,
})
}
return (
<div className="pt-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
<Plus className="size-3.5 mr-1" />
新增通知事件
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
) : eventsQuery.isError ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
</div>
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无通知配置事件,请点击右上角新增" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(eventsQuery.data ?? []).map((event) => (
<TableRow
key={event.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleEditEventClick(event)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
<TableCell className="py-1">
<div className="flex flex-col gap-0.5">
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
</div>
</TableCell>
<TableCell className="py-1">
<div className="flex flex-wrap gap-1">
{(event.channels ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
) : (
event.channels.map(ch => (
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
{ch === "email" ? "邮件" : ch}
</Badge>
))
)}
</div>
</TableCell>
<TableCell className="py-1">
<div className="flex flex-wrap gap-1">
{(!event.targets || event.targets.length === 0) ? (
<span className="text-muted-foreground text-[10px] font-mono">-</span>
) : (
event.targets.map(t => (
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
{t}
</Badge>
))
)}
</div>
</TableCell>
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
<Switch
checked={event.enabled}
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleEditEventClick(event)}
>
<Edit2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
配置
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm("确定要删除该通知事件配置吗?")) {
deleteEventMutation.mutate(event.id)
}
}}
>
<Trash2 className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
删除
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* ==================== 对话框:新增事件 ==================== */}
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">系统事件</Label>
{builtInEventsQuery.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>加载系统事件中...</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
所有内置事件都已配置,没有可新增的事件。
</div>
) : (
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="请选择系统事件" />
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map(ev => (
<SelectItem key={ev.key} value={ev.key} className="text-xs">
{ev.name} ({ev.key})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{newEventKey && (
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
<span className="font-semibold text-foreground">事件说明:</span>
<span>
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
</span>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
{newEventChannels.length > 0
? newEventChannels
.map(ch => {
if (ch === "email") return "邮件"
return ch
})
.join(", ")
: "选择已配置推送渠道"}
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{availableChannels.map(ch => (
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
<Checkbox
checked={newEventChannels.includes(ch)}
onCheckedChange={checked => {
if (checked) {
setNewEventChannels([...newEventChannels, ch])
} else {
setNewEventChannels(newEventChannels.filter(c => c !== ch))
}
}}
/>
<span>
{ch === "email" ? "邮件推送 (内置)" : ch}
</span>
</label>
))}
{availableChannels.length === 0 && (
<div className="text-[11px] text-muted-foreground italic p-1">
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
<Input
type="text"
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
value={newEventTargets}
onChange={e => setNewEventTargets(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"}
</span>
</div>
<Textarea
value={newEventTemplate}
onChange={e => setNewEventTemplate(e.target.value)}
rows={6}
className="text-xs font-mono"
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
<div className="space-y-0.5">
<Label className="text-xs font-semibold">启用状态</Label>
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
</div>
<Switch
checked={newEventEnabled}
onCheckedChange={setNewEventEnabled}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={createEventMutation.isPending || availableBuiltInEvents.length === 0}
onClick={handleCreateEvent}
className="h-9 px-5 text-xs"
>
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
启用保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ==================== 对话框:编辑事件 ==================== */}
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-5 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
{eventChannels.length > 0
? eventChannels
.map(ch => {
if (ch === "email") return "邮件"
return ch
})
.join(", ")
: "选择已配置推送渠道"}
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{availableChannels.map(ch => (
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
<Checkbox
checked={eventChannels.includes(ch)}
onCheckedChange={checked => {
if (checked) {
setEventChannels([...eventChannels, ch])
} else {
setEventChannels(eventChannels.filter(c => c !== ch))
}
}}
/>
<span>
{ch === "email" ? "邮件推送 (内置)" : ch}
</span>
</label>
))}
{availableChannels.length === 0 && (
<div className="text-[11px] text-muted-foreground italic p-1">
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
<Input
type="text"
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
value={eventTargets}
onChange={e => setEventTargets(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"}
</span>
</div>
<Textarea
value={eventTemplate}
onChange={e => setEventTemplate(e.target.value)}
rows={6}
className="text-xs font-mono"
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={updateEventMutation.isPending}
onClick={handleSaveEvent}
className="h-9 px-5 text-xs"
>
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
保存修改
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,192 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useQuery} from "@tanstack/react-query"
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {cn} from "@/lib/utils"
import services from "@/lib/services"
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
switch (level) {
case "CRITICAL":
return "destructive"
case "IMPORTANT":
return "default"
default:
return "outline"
}
}
export function HistoriesTab() {
const [historyPage, setHistoryPage] = React.useState(1)
const [historySearch, setHistorySearch] = React.useState("")
const [historyStatus, setHistoryStatus] = React.useState("all")
const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryFn: () =>
services.push.listHistories({
page: historyPage,
page_size: 10,
event_key: historySearch || undefined,
status: historyStatus === "all" ? undefined : historyStatus,
}),
})
return (
<div className="pt-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="输入事件键过滤..."
className="pl-8 text-xs h-9"
value={historySearch}
onChange={e => {
setHistorySearch(e.target.value)
setHistoryPage(1)
}}
/>
</div>
<div className="w-[150px]">
<Select
value={historyStatus}
onValueChange={val => {
setHistoryStatus(val)
setHistoryPage(1)
}}
>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="发送状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => historiesQuery.refetch()}
>
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="text-xs font-semibold">事件</TableHead>
<TableHead className="text-xs font-semibold">渠道</TableHead>
<TableHead className="text-xs font-semibold">目标</TableHead>
<TableHead className="text-xs font-semibold">标题</TableHead>
<TableHead className="text-xs font-semibold">等级</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
<TableHead className="text-xs font-semibold">时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historiesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
</TableCell>
</TableRow>
) : historiesQuery.isError ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
</TableCell>
</TableRow>
) : (historiesQuery.data?.results ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
无推送历史数据
</TableCell>
</TableRow>
) : (
(historiesQuery.data?.results ?? []).map(hist => (
<TableRow key={hist.id} className="hover:bg-muted/10">
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
{hist.channel === "email" ? "邮件" : hist.channel}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
{hist.target}
</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
<TableCell className="text-xs">
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
{hist.level}
</Badge>
</TableCell>
<TableCell className="text-xs">
<Badge
variant={hist.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold"
>
{hist.status === "success" ? "成功" : "失败"}
</Badge>
{hist.status !== "success" && hist.error_msg && (
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
{hist.error_msg}
</div>
)}
</TableCell>
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(hist.created_at).toLocaleString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>共 {historiesQuery.data.total} 条历史记录</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
disabled={historyPage === 1}
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
className="h-8 w-8"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs font-medium px-2">{historyPage}</span>
<Button
variant="outline"
size="icon"
disabled={historyPage * 10 >= historiesQuery.data.total}
onClick={() => setHistoryPage(p => p + 1)}
className="h-8 w-8"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,631 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {Edit2, Loader2, Play, Plus, Settings, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Badge} from "@/components/ui/badge"
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import services from "@/lib/services"
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
export function SettingsTab() {
const queryClient = useQueryClient()
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => services.push.listChannels(),
})
// --- 获取动态通道表单字段定义 ---
const definitionsQuery = useQuery({
queryKey: ["admin", "push-channels-definitions"],
queryFn: () => services.push.listChannelDefinitions(),
})
// --- 消息通道 CRUD Mutations ---
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => services.push.createChannel(data),
onSuccess: () => {
toast.success("通道创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
setChannelDialogOpen(false)
},
onError: (err: unknown) => {
toast.error("通道创建失败: " + (err as Error).message)
},
})
const updateChannelMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
services.push.updateChannel(id, data),
onSuccess: () => {
toast.success("通道更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
setChannelDialogOpen(false)
},
onError: (err: unknown) => {
toast.error("通道更新失败: " + (err as Error).message)
},
})
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => services.push.deleteChannel(id),
onSuccess: () => {
toast.success("通道删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
},
onError: (err: unknown) => {
toast.error("通道删除失败: " + (err as Error).message)
},
})
// --- 消息通道与设置相关 State ---
const [channelDialogOpen, setChannelDialogOpen] = React.useState(false)
const [editingChannel, setEditingChannel] = React.useState<PushChannel | null>(null)
const [channelName, setChannelName] = React.useState("")
const [channelDescription, setChannelDescription] = React.useState("")
const [channelType, setChannelType] = React.useState("custom")
const [channelToken, setChannelToken] = React.useState("")
const [channelUrl, setChannelUrl] = React.useState("")
const [channelOther, setChannelOther] = React.useState("")
const activeDef = React.useMemo<ChannelDefinition | undefined>(() => {
return (definitionsQuery.data ?? []).find(d => d.type === channelType)
}, [definitionsQuery.data, channelType])
const [testChannelOpen, setTestChannelOpen] = React.useState(false)
const [testChannelName, setTestChannelName] = React.useState("")
const [testChannelTarget, setTestChannelTarget] = React.useState("")
const [isTestingChannel, setIsTestingChannel] = React.useState(false)
const handleChannelTypeChange = (newType: string) => {
setChannelType(newType)
setChannelUrl("")
setChannelToken("")
if (newType === "custom") {
setChannelOther(JSON.stringify({
title: "$title",
description: "$description",
content: "$content",
url: "$url",
to: "$to"
}, null, 2))
} else {
setChannelOther("")
}
}
const handleCreateChannelClick = () => {
setEditingChannel(null)
setChannelName("")
setChannelDescription("")
setChannelType("custom")
setChannelToken("")
setChannelUrl("")
setChannelOther(JSON.stringify({
title: "$title",
description: "$description",
content: "$content",
url: "$url",
to: "$to"
}, null, 2))
setChannelDialogOpen(true)
}
const handleEditChannelClick = (channel: PushChannel) => {
setEditingChannel(channel)
setChannelName(channel.name)
setChannelDescription(channel.description ?? "")
setChannelType(channel.type)
setChannelToken(channel.token ?? "")
setChannelUrl(channel.url)
setChannelOther(channel.other)
setChannelDialogOpen(true)
}
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error("通道名称不能为空")
return
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error("通道名称只能使用英文字母、数字和下划线")
return
}
if (!activeDef) {
toast.error("无效的通道类型")
return
}
// 动态字段必填性校验
for (const field of activeDef.fields) {
const value = field.key === "url"
? channelUrl
: field.key === "token"
? channelToken
: channelOther;
if (field.required && !value.trim()) {
toast.error(`${field.label}不能为空`)
return
}
}
// 协议安全校验(非邮件服务强制 HTTPS 协议)
if (channelType !== "email") {
if (!channelUrl.startsWith("https://")) {
toast.error("地址必须以 https:// 开头以确保安全性")
return
}
}
// JSON 结构格式校验
if (channelType === "custom") {
try {
JSON.parse(channelOther)
} catch {
toast.error("请求体必须是合法的 JSON 格式")
return
}
} else if (channelType === "lark" && channelOther) {
try {
JSON.parse(channelOther)
} catch {
toast.error("自定义卡片模版必须是合法的 JSON 格式")
return
}
}
if (editingChannel) {
updateChannelMutation.mutate({
id: editingChannel.id,
data: {
description: channelDescription,
type: channelType,
token: channelToken || undefined,
url: channelUrl,
other: channelOther,
enabled: editingChannel.enabled,
}
})
} else {
createChannelMutation.mutate({
name: channelName,
description: channelDescription,
type: channelType,
token: channelToken || undefined,
url: channelUrl,
other: channelOther,
enabled: true,
})
}
}
const handleTestChannelClick = (name: string) => {
setTestChannelName(name)
setTestChannelTarget("")
setTestChannelOpen(true)
}
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true)
toast.info("正在发送测试推送...")
await services.push.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
})
toast.success("测试推送发送成功,请前往对应平台确认。")
setTestChannelOpen(false)
} catch (err: unknown) {
toast.error("连通性测试失败: " + (err as Error).message)
} finally {
setIsTestingChannel(false)
}
}
return (
<div className="pt-4 space-y-6">
<Card className="h-full flex flex-col">
<CardHeader className="pb-3 border-b bg-muted/10">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">自定义推送通道</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
</p>
</div>
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
<Plus className="size-3.5 mr-1" />
新建消息通道
</Button>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-x-auto">
{channelsQuery.isLoading ? (
<div className="p-8">
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." className="border-0 shadow-none animate-pulse" />
</div>
) : channelsQuery.isError ? (
<div className="p-8">
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
</div>
) : (channelsQuery.data ?? []).length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="text-xs font-semibold">名称</TableHead>
<TableHead className="text-xs font-semibold">类型</TableHead>
<TableHead className="text-xs font-semibold">备注</TableHead>
<TableHead className="text-xs font-semibold w-[80px] text-center">状态</TableHead>
<TableHead className="text-xs font-semibold w-[180px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(channelsQuery.data ?? []).map(ch => (
<TableRow key={ch.id} className="hover:bg-muted/5">
<TableCell className="text-xs font-mono font-bold">
{ch.name}
</TableCell>
<TableCell className="text-xs">
<Badge variant="outline" className="text-[10px] whitespace-nowrap">
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
{ch.description || <span className="italic">无备注</span>}
</TableCell>
<TableCell className="text-center">
<Switch
checked={ch.enabled}
onCheckedChange={checked => {
updateChannelMutation.mutate({
id: ch.id,
data: {
description: ch.description,
type: ch.type,
token: ch.token,
url: ch.url,
other: ch.other,
enabled: checked,
}
})
}}
/>
</TableCell>
<TableCell className="text-right space-x-1 whitespace-nowrap">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestChannelClick(ch.name)}
className="h-7 text-[11px] text-primary hover:text-primary hover:bg-primary/10"
>
<Play className="size-2.5 mr-1" />
测试
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditChannelClick(ch)}
className="h-7 text-[11px]"
>
<Edit2 className="size-2.5 mr-1" />
编辑
</Button>
<Button
variant="ghost"
size="sm"
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
deleteChannelMutation.mutate(ch.id)
}
}}
className="h-7 text-[11px] text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-2.5 mr-1" />
删除
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">名称</Label>
<Input
type="text"
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
value={channelName}
onChange={e => setChannelName(e.target.value)}
disabled={!!editingChannel}
className="text-xs h-9 font-mono"
/>
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">备注</Label>
<Input
type="text"
placeholder="请输入备注信息"
value={channelDescription}
onChange={e => setChannelDescription(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">通道类型</Label>
<Select value={channelType} onValueChange={handleChannelTypeChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="选择通道类型" />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map(d => (
<SelectItem key={d.type} value={d.type} className="text-xs">
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeDef && (
<>
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{activeDef.description}
</p>
</div>
{activeDef.fields.map(field => {
const value = field.key === "url"
? channelUrl
: field.key === "token"
? channelToken
: channelOther;
const onChange = (val: string) => {
if (field.key === "url") setChannelUrl(val);
else if (field.key === "token") setChannelToken(val);
else setChannelOther(val);
};
return (
<div key={field.key} className="space-y-1.5">
<Label className="text-xs font-semibold">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{field.type === "textarea" ? (
<Textarea
placeholder={field.placeholder}
value={value}
onChange={e => onChange(e.target.value)}
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
className="text-xs font-mono"
/>
) : (
<Input
type={field.type}
placeholder={field.placeholder}
value={value}
onChange={e => onChange(e.target.value)}
className="text-xs h-9 font-mono"
/>
)}
{field.description && (
<p className="text-[10px] text-muted-foreground">
{field.description}
</p>
)}
</div>
);
})}
{/* Custom post helper templates card */}
{channelType === "custom" && (
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
<div className="flex flex-wrap gap-1.5">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
setChannelOther(JSON.stringify({
msg_type: "text",
content: {
text: "$title\n$description\n$content\n$url"
}
}, null, 2));
toast.success("已加载飞书 Webhook 模版");
}}
>
飞书 Webhook
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
setChannelOther(JSON.stringify({
msgtype: "markdown",
markdown: {
title: "$title",
text: "### $title\n$content\n\n[查看详情]($url)"
}
}, null, 2));
toast.success("已加载钉钉机器人模版");
}}
>
钉钉群机器人
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
setChannelOther(JSON.stringify({
msgtype: "markdown",
markdown: {
content: "### $title\n$content\n\n[查看详情]($url)"
}
}, null, 2));
toast.success("已加载企业微信机器人模版");
}}
>
企业微信群机器人
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 py-0"
type="button"
onClick={() => {
setChannelUrl("https://api.day.app/push");
setChannelOther(JSON.stringify({
device_key: "$to",
title: "$title",
body: "$content",
url: "$url"
}, null, 2));
toast.success("已加载 Bark App 模版");
}}
>
Bark App
</Button>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
onClick={handleSaveChannel}
className="h-9 px-5 text-xs"
>
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
确定
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ==================== 对话框:测试渠道连通性 ==================== */}
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-3">
<div className="space-y-1.5">
<Label className="text-xs">推送通道名称</Label>
<Input
type="text"
value={testChannelName}
disabled
className="text-xs h-9 bg-muted font-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
<Input
type="text"
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
value={testChannelTarget}
onChange={e => setTestChannelTarget(e.target.value)}
className="text-xs h-9"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
取消
</Button>
<Button
variant="default"
size="sm"
disabled={isTestingChannel}
onClick={handleSendChannelTest}
className="h-9 px-5 text-xs"
>
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
发送测试
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+2
View File
@@ -59,6 +59,7 @@ import {
ShieldCheck,
Terminal,
UserRound,
Bell,
} from "lucide-react"
import {useUser} from "@/contexts/user-context"
@@ -75,6 +76,7 @@ const data = {
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
{ title: "存储管理", url: "/admin/files", icon: FolderOpen },
{ title: "数据管理", url: "/admin/database", icon: Database },
{ title: "通知推送", url: "/admin/push", icon: Bell },
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "系统设置", url: "/admin/settings", icon: Settings },
+7
View File
@@ -26,6 +26,7 @@ import {UserService} from './user';
import {ConfigService} from './config';
import {UploadService} from './upload';
import {DbManageService} from './db-manage';
import {PushService} from './push';
/**
* 服务对象
@@ -47,6 +48,8 @@ const services = {
upload: UploadService,
/** 数据库管理服务 */
dbManage: DbManageService,
/** 通知推送服务 */
push: PushService,
} as const;
export default services;
@@ -131,3 +134,7 @@ export type { UploadImageResponse } from './upload';
// 数据库管理服务
export { DbManageService } from './db-manage';
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './db-manage';
// 通知推送服务
export { PushService } from './push';
export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesRequest, ListPushHistoriesResponse, UpdatePushEventRequest, TestPushRequest } from './push';
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
export * from './types';
export * from './push.service';
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import {BaseService} from '@/lib/services';
import type {
ChannelDefinition,
CreateChannelRequest,
CreatePushEventRequest,
EventMetadata,
ListPushHistoriesRequest,
ListPushHistoriesResponse,
PushChannel,
PushEvent,
TestChannelRequest,
TestPushRequest,
UpdateChannelRequest,
UpdatePushEventRequest
} from './types';
/**
* 通知推送服务类
*/
export class PushService extends BaseService {
protected static readonly basePath = '/api/v1/admin/push';
/**
* 获取通知事件列表
*/
static async listEvents(): Promise<PushEvent[]> {
return this.get<PushEvent[]>('/events');
}
/**
* 获取系统内置通知事件元数据列表
*/
static async listBuiltInEvents(): Promise<EventMetadata[]> {
return this.get<EventMetadata[]>('/events/builtin');
}
/**
* 创建通知事件配置
*/
static async createEvent(data: CreatePushEventRequest): Promise<PushEvent> {
return this.post<PushEvent>('/events', data as unknown as Record<string, unknown>);
}
/**
* 更新指定通知事件配置
*/
static async updateEvent(id: number, data: UpdatePushEventRequest): Promise<void> {
return this.put<void>(`/events/${id}`, data as unknown as Record<string, unknown>);
}
/**
* 删除指定通知事件配置
*/
static async deleteEvent(id: number): Promise<void> {
return this.delete<void>(`/events/${id}`);
}
/**
* 快捷切换事件启用状态
*/
static async toggleEvent(id: number): Promise<boolean> {
return this.post<boolean>(`/events/${id}/toggle`);
}
/**
* 分页查询通知推送历史
*/
static async listHistories(params: ListPushHistoriesRequest): Promise<ListPushHistoriesResponse> {
return this.get<ListPushHistoriesResponse>('/histories', params as unknown as Record<string, unknown>);
}
/**
* 发送测试推送进行联通性校验
*/
static async testPush(data: TestPushRequest): Promise<void> {
return this.post<void>('/test', data as unknown as Record<string, unknown>);
}
/**
* 获取所有消息通道
*/
static async listChannels(): Promise<PushChannel[]> {
return this.get<PushChannel[]>('/channels');
}
/**
* 创建新的消息通道
*/
static async createChannel(data: CreateChannelRequest): Promise<PushChannel> {
return this.post<PushChannel>('/channels', data as unknown as Record<string, unknown>);
}
/**
* 更新指定消息通道
*/
static async updateChannel(id: number, data: UpdateChannelRequest): Promise<PushChannel> {
return this.put<PushChannel>(`/channels/${id}`, data as unknown as Record<string, unknown>);
}
/**
* 删除指定消息通道
*/
static async deleteChannel(id: number): Promise<void> {
return this.delete<void>(`/channels/${id}`);
}
/**
* 测试消息通道连通性
*/
static async testChannel(data: TestChannelRequest): Promise<void> {
return this.post<void>('/channels/test', data as unknown as Record<string, unknown>);
}
/**
* 获取各消息通道的动态表单定义
*/
static async listChannelDefinitions(): Promise<ChannelDefinition[]> {
return this.get<ChannelDefinition[]>('/channels/definitions');
}
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
export interface PushEvent {
id: number;
event_key: string;
name: string;
channels: string[];
targets: string[];
template: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface PushHistory {
id: number;
event_key: string;
channel: string;
target: string;
title: string;
content: string;
level: 'INFO' | 'IMPORTANT' | 'CRITICAL';
status: 'success' | 'failed';
error_msg?: string;
created_at: string;
}
export interface PushChannelConfig {
channel: string;
url?: string;
secret?: string;
key?: string;
ext?: Record<string, unknown>;
}
export interface ListPushHistoriesRequest {
page?: number;
page_size?: number;
event_key?: string;
status?: string;
}
export interface ListPushHistoriesResponse {
total: number;
results: PushHistory[];
}
export interface UpdatePushEventRequest {
channels: string[];
targets: string[];
template: string;
enabled: boolean;
}
export interface TestPushRequest {
config: PushChannelConfig;
target?: string;
}
export interface EventMetadata {
key: string;
name: string;
default_template: {
title: string;
content: string;
level: string;
ext?: Record<string, unknown>;
};
description: string;
}
export interface CreatePushEventRequest {
event_key: string;
channels: string[];
targets?: string[];
template?: string;
enabled: boolean;
}
export interface PushChannel {
id: number;
name: string;
description?: string;
type: string;
token?: string;
url: string;
other: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
}
export interface CreateChannelRequest {
name: string;
description?: string;
type: string;
token?: string;
url: string;
other: string;
enabled?: boolean;
}
export interface UpdateChannelRequest {
description?: string;
type: string;
token?: string;
url: string;
other: string;
enabled?: boolean;
}
export interface TestChannelRequest {
name?: string;
type?: string;
url?: string;
other?: string;
target?: string;
}
export interface ChannelFieldDef {
key: string;
label: string;
type: 'text' | 'password' | 'textarea';
required: boolean;
placeholder?: string;
description?: string;
}
export interface ChannelDefinition {
type: string;
name: string;
description: string;
fields: ChannelFieldDef[];
}
+363
View File
@@ -0,0 +1,363 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/util"
pkgpush "github.com/Rain-kl/Wavelet/pkg/push"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ListChannelDefinitions 获取各种消息通道的表单配置定义列表
// @Summary 获取所有消息通道配置字段定义
// @Description 返回系统支持的所有消息通道类型(如飞书、邮件、自定义)的动态表单定义,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]Definition} "通道配置定义列表"
// @Router /api/v1/admin/push/channels/definitions [get]
func ListChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(ListDefinitions()))
}
// ListChannels 获取消息通道列表
// @Summary 获取所有消息通道
// @Description 返回系统配置的所有消息通道列表,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]model.PushChannel} "消息通道列表"
// @Router /api/v1/admin/push/channels [get]
func ListChannels(c *gin.Context) {
ctx := c.Request.Context()
var channels []model.PushChannel
if err := db.DB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(channels))
}
// CreateChannelRequest 创建通道参数
type CreateChannelRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
// CreateChannel 创建消息通道
// @Summary 创建消息通道
// @Description 新建一个消息通道配置,需要管理员权限
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body CreateChannelRequest true "创建参数"
// @Success 200 {object} util.ResponseAny{data=model.PushChannel} "创建成功"
// @Router /api/v1/admin/push/channels [post]
func CreateChannel(c *gin.Context) {
var req CreateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Model(&model.PushChannel{}).Where("name = ?", req.Name).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, util.Err("channel name already exists"))
return
}
channel := model.PushChannel{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Token: req.Token,
URL: req.URL,
Other: req.Other,
Enabled: req.Enabled,
}
if err := channel.Validate(); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(channel))
}
// UpdateChannelRequest 修改通道参数
type UpdateChannelRequest struct {
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
// UpdateChannel 更新消息通道
// @Summary 更新消息通道
// @Description 修改消息通道配置,需要管理员权限
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "通道ID"
// @Param request body UpdateChannelRequest true "更新参数"
// @Success 200 {object} util.ResponseAny{data=model.PushChannel} "更新成功"
// @Router /api/v1/admin/push/channels/{id} [put]
func UpdateChannel(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid channel id"))
return
}
var req UpdateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
var channel model.PushChannel
if err := db.DB(ctx).Where("id = ?", id).First(&channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err("channel not found"))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
channel.Description = req.Description
channel.Type = req.Type
channel.Token = req.Token
channel.URL = req.URL
channel.Other = req.Other
channel.Enabled = req.Enabled
if err := channel.Validate(); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Save(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(channel))
}
// DeleteChannel 删除消息通道
// @Summary 删除消息通道
// @Description 根据ID删除消息通道,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "通道ID"
// @Success 200 {object} util.ResponseAny "删除成功"
// @Router /api/v1/admin/push/channels/{id} [delete]
func DeleteChannel(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid channel id"))
return
}
ctx := c.Request.Context()
var channel model.PushChannel
if err := db.DB(ctx).Where("id = ?", id).First(&channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err("channel not found"))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Delete(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// TestChannelRequest 测试通道连通性参数
type TestChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Target string `json:"target"`
}
// TestChannel 测试通道连通性
// @Summary 测试通道连通性
// @Description 触发一次临时的或现有的通道连通性推送测试,需要管理员权限
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body TestChannelRequest true "测试参数"
// @Success 200 {object} util.ResponseAny "测试触发成功"
// @Router /api/v1/admin/push/channels/test [post]
func TestChannel(c *gin.Context) {
var req TestChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
var url, token, other, channelType string
if req.Name != "" {
var channel model.PushChannel
if err := db.DB(ctx).Where("name = ?", req.Name).First(&channel).Error; err != nil {
c.JSON(http.StatusBadRequest, util.Err("channel not found"))
return
}
url = channel.URL
token = channel.Token
other = channel.Other
channelType = channel.Type
} else {
url = req.URL
token = req.Token
other = req.Other
channelType = req.Type
}
// 对邮件类型应用全局配置作为回退
if channelType == channelEmail {
url, token, other = resolveSMTPConfig(ctx, url, token, other)
}
tempChannel := model.PushChannel{
Name: "test_temp",
URL: url,
Token: token,
Other: other,
Type: channelType,
Enabled: true,
}
if err := tempChannel.Validate(); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var config pkgpush.Config
var renderedJSON string
switch channelType {
case channelLark:
config = pkgpush.Config{
Channel: channelLark,
URL: url,
Secret: token,
}
renderedJSON = other
case channelEmail:
config = pkgpush.Config{
Channel: channelEmail,
URL: url,
Key: token,
Secret: other,
}
default:
config = pkgpush.Config{
Channel: channelCustom,
URL: url,
}
customPushReq := CustomPushRequest{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Description: "系统通道测试",
URL: "https://example.com",
To: req.Target,
}
renderedJSON = renderCustomPayload(other, customPushReq)
}
payload := SendPayload{
EventKey: "test_channel",
Config: config,
Target: req.Target,
Body: NotificationMessage{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Level: defaultLevelInfo,
},
Template: renderedJSON,
}
if err := enqueuePushTask(ctx, payload); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// CustomPushRequest 外部公开推送请求参数
type CustomPushRequest struct {
Title string `json:"title" form:"title"`
Description string `json:"description" form:"description"`
Content string `json:"content" form:"content"`
URL string `json:"url" form:"url"`
To string `json:"to" form:"to"`
Token string `json:"token" form:"token"`
}
func escapeJSONString(s string) string {
b, _ := json.Marshal(s)
const minJSONLen = 2
if len(b) >= minJSONLen {
return string(b[1 : len(b)-1])
}
return s
}
func renderCustomPayload(template string, req CustomPushRequest) string {
result := template
result = strings.ReplaceAll(result, "$title", escapeJSONString(req.Title))
result = strings.ReplaceAll(result, "$description", escapeJSONString(req.Description))
result = strings.ReplaceAll(result, "$content", escapeJSONString(req.Content))
result = strings.ReplaceAll(result, "$url", escapeJSONString(req.URL))
result = strings.ReplaceAll(result, "$to", escapeJSONString(req.To))
return result
}
@@ -0,0 +1,150 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import "sync"
const (
// KeyURL represents the URL field key
KeyURL = "url"
// KeyToken represents the Token field key
KeyToken = "token"
// KeyOther represents the Other field key
KeyOther = "other"
// TypeText represents standard text input type
TypeText = "text"
// TypePassword represents password input type
TypePassword = "password"
// TypeTextarea represents textarea input type
TypeTextarea = "textarea"
)
// Field represents a form field configuration for a channel.
type Field struct {
Key string `json:"key"` // unique key for the field (e.g. url, token, other)
Label string `json:"label"` // human readable label (e.g. "Webhook 地址")
Type string `json:"type"` // input type: "text" | "password" | "textarea"
Required bool `json:"required"` // whether this field is required
Placeholder string `json:"placeholder"` // input placeholder
Description string `json:"description"` // field explanation/help text
}
// Definition represents the metadata and form schema for a notification channel.
type Definition struct {
Type string `json:"type"` // channel type (e.g., custom, lark, email)
Name string `json:"name"` // display name
Description string `json:"description"` // short description
Fields []Field `json:"fields"` // form fields
}
var (
defMu sync.RWMutex
definitions = make(map[string]Definition)
)
// RegisterChannelDefinition registers a channel definition.
func RegisterChannelDefinition(def Definition) {
defMu.Lock()
defer defMu.Unlock()
definitions[def.Type] = def
}
// ListDefinitions returns all registered channel definitions.
func ListDefinitions() []Definition {
defMu.RLock()
defer defMu.RUnlock()
// We want a stable order: custom, lark, email
order := []string{channelCustom, channelLark, channelEmail}
res := make([]Definition, 0, len(definitions))
for _, t := range order {
if d, ok := definitions[t]; ok {
res = append(res, d)
}
}
// Add any others
for t, d := range definitions {
found := false
for _, o := range order {
if o == t {
found = true
break
}
}
if !found {
res = append(res, d)
}
}
return res
}
func init() {
// Register custom webhook channel
RegisterChannelDefinition(Definition{
Type: channelCustom,
Name: "自定义消息通道",
Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。",
Fields: []Field{
{
Key: KeyURL,
Label: "请求地址",
Type: TypeText,
Required: true,
Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议",
Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook",
},
{
Key: KeyOther,
Label: "请求体 (JSON)",
Type: TypeTextarea,
Required: true,
Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式",
Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}",
},
},
})
// Register Lark robot channel
RegisterChannelDefinition(Definition{
Type: channelLark,
Name: "飞书群机器人",
Description: "配置飞书群自定义机器人的 Webhook 接口投递。",
Fields: []Field{
{
Key: KeyURL,
Label: "Webhook 地址",
Type: TypeText,
Required: true,
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN",
Description: "从飞书群机器人设置中复制 of Webhook URL",
// Note: using 'of' was in feishu.go, let's keep original wording or fix it
},
{
Key: KeyToken,
Label: "签名校验密钥 (Secret) (可选)",
Type: TypeText,
Required: false,
Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入",
Description: "飞书群机器人安全设置中的签名校验 Key",
},
{
Key: KeyOther,
Label: "自定义卡片 JSON 模版 (可选)",
Type: TypeTextarea,
Required: false,
Placeholder: "可选,留空则默认使用系统内置的精美互动卡片",
Description: "若填写,必须是合法的飞书卡片 JSON 格式",
},
},
})
// Register Email channel
RegisterChannelDefinition(Definition{
Type: channelEmail,
Name: "邮件推送通道",
Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。",
Fields: []Field{},
})
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
const (
channelCustom = "custom"
channelEmail = "email"
channelLark = "lark"
defaultLevelInfo = "INFO"
keyTitle = "title"
keyContent = "content"
keyLevel = "level"
)
@@ -0,0 +1,43 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package custom_events defines custom push notification events.
package custom_events
import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/model"
)
// AdminLogin is the metadata definition for the admin login event.
var AdminLogin = push.EventMetadata{
Key: "admin_login",
Name: "管理员登录",
DefaultTemplate: push.NotificationMessage{
Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Level: "INFO",
},
Description: "当管理员成功登录系统时触发此通知",
}
func init() {
push.RegisterBuiltInEvent(AdminLogin)
}
// TriggerAdminLoginEvent triggers the admin login event asynchronously.
func TriggerAdminLoginEvent(ctx context.Context, user *model.User, ip string) {
if user == nil || !user.IsAdmin {
return
}
body := map[string]any{
"user": user,
"ip": ip,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
push.DefaultTrigger.Trigger(ctx, AdminLogin, body)
}
+599
View File
@@ -0,0 +1,599 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package push defines push notification HTTP routes, background tasks, and events.
package push
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
pkgpush "github.com/Rain-kl/Wavelet/pkg/push"
"gorm.io/gorm"
)
// NotificationMessage represents the structured notification message payload.
type NotificationMessage struct {
Title string `json:"title"`
Content string `json:"content"`
Level string `json:"level"`
Ext map[string]any `json:"ext,omitempty"`
}
// Flatten converts the structured NotificationMessage back to a flat map (original json structure).
func (m NotificationMessage) Flatten() map[string]any {
res := map[string]any{
keyTitle: m.Title,
keyContent: m.Content,
keyLevel: m.Level,
}
for k, v := range m.Ext {
res[k] = v
}
return res
}
// EventMetadata represents the metadata of a push notification event.
type EventMetadata struct {
Key string `json:"key"`
Name string `json:"name"`
DefaultTemplate NotificationMessage `json:"default_template"`
Description string `json:"description"`
}
// SendPayload 异步投递推送载荷 (供 task/Worker 使用)
type SendPayload struct {
EventKey string `json:"event_key"`
Config pkgpush.Config `json:"config"`
Target string `json:"target"`
Body NotificationMessage `json:"body"`
Template string `json:"template"`
}
// BuiltInEvents lists all built-in events defined in custom_events.
var BuiltInEvents []EventMetadata
// RegisterBuiltInEvent registers a built-in event definition.
func RegisterBuiltInEvent(meta EventMetadata) {
BuiltInEvents = append(BuiltInEvents, meta)
}
// EventTrigger represents the unified event trigger class.
type EventTrigger struct{}
// DefaultTrigger is the singleton instance of EventTrigger.
var DefaultTrigger = &EventTrigger{}
var (
systemUser *model.User
systemOnce sync.Once
)
func getSystemUser(ctx context.Context) *model.User {
systemOnce.Do(func() {
var u model.User
if err := db.DB(ctx).Where("username = ?", "system").First(&u).Error; err == nil {
systemUser = &u
} else {
systemUser = &model.User{
ID: 999,
Username: "system",
Nickname: "系统",
Email: "",
}
}
})
return systemUser
}
// Trigger receives event metadata and processes the event notification dispatch asynchronously.
// It automatically enqueues tasks using a background goroutine and avoids blocking the calling thread.
//
//nolint:contextcheck
func (t *EventTrigger) Trigger(ctx context.Context, meta EventMetadata, body map[string]any) {
asyncCtx := context.WithoutCancel(ctx)
go func() {
if body == nil {
body = make(map[string]any)
}
if _, hasUser := body["user"]; !hasUser || body["user"] == nil {
body["user"] = getSystemUser(asyncCtx)
}
// 1. Check if the event is enabled in the database
var event model.PushEvent
err := db.DB(asyncCtx).Where("event_key = ? AND enabled = ?", meta.Key, true).First(&event).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err)
return
}
if len(event.Channels) == 0 {
return
}
// 2. Read push configs
configs, err := t.getPushConfigs(asyncCtx)
if err != nil {
logger.ErrorF(asyncCtx, "push_event_trigger: getPushConfigs failed: %v", err)
return
}
// 3. Build and render notification message
flatBody := getFlatBody(body)
msg, renderedTemplate := t.buildMessage(&event, meta, flatBody, body)
// 4. Enqueue tasks for each matching channel
t.enqueuePushTasks(asyncCtx, meta, &event, configs, msg, renderedTemplate, flatBody)
}()
}
func (t *EventTrigger) getPushConfigs(ctx context.Context) ([]pkgpush.Config, error) {
var configVal string
var sc model.SystemConfig
if err := sc.GetByKey(ctx, model.ConfigKeyPushConfig); err == nil {
configVal = sc.Value
}
if configVal == "" || configVal == "[]" {
return nil, errors.New("push_config is empty or not configured")
}
var configs []pkgpush.Config
if err := json.Unmarshal([]byte(configVal), &configs); err != nil {
return nil, fmt.Errorf("unmarshal push_config failed: %w", err)
}
return configs, nil
}
func (t *EventTrigger) buildMessage(event *model.PushEvent, meta EventMetadata, flatBody map[string]any, body map[string]any) (NotificationMessage, string) {
var msg NotificationMessage
renderedTemplate := ""
templateSource := event.Template
if templateSource != "" {
var err error
msg, renderedTemplate, err = t.parseCustomTemplate(event, templateSource, flatBody)
if err != nil {
msg.Title = event.Name
msg.Content = renderedTemplate
msg.Level = defaultLevelInfo
}
} else {
msg = t.parseDefaultTemplate(meta, flatBody)
}
if msg.Ext == nil {
msg.Ext = make(map[string]any)
}
for k, v := range body {
if k == keyTitle || k == keyContent || k == keyLevel {
continue
}
if _, exists := msg.Ext[k]; !exists {
msg.Ext[k] = v
}
}
return msg, renderedTemplate
}
func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (NotificationMessage, string, error) {
var msg NotificationMessage
renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody)
var tMap map[string]any
if err := json.Unmarshal([]byte(renderedTemplate), &tMap); err != nil {
return msg, renderedTemplate, err
}
if title, ok := tMap[keyTitle].(string); ok && title != "" {
msg.Title = title
} else {
msg.Title = event.Name
}
delete(tMap, keyTitle)
if content, ok := tMap[keyContent].(string); ok && content != "" {
msg.Content = content
} else {
msg.Content = renderedTemplate
}
delete(tMap, keyContent)
if level, ok := tMap[keyLevel].(string); ok && level != "" {
msg.Level = level
} else {
msg.Level = defaultLevelInfo
}
delete(tMap, keyLevel)
msg.Ext = tMap
return msg, renderedTemplate, nil
}
func (t *EventTrigger) parseDefaultTemplate(meta EventMetadata, flatBody map[string]any) NotificationMessage {
var msg NotificationMessage
msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody)
msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody)
msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody)
if meta.DefaultTemplate.Ext != nil {
msg.Ext = make(map[string]any)
for k, v := range meta.DefaultTemplate.Ext {
if strVal, ok := v.(string); ok {
msg.Ext[k] = pkgpush.ParseTemplate(strVal, flatBody)
} else {
msg.Ext[k] = v
}
}
}
return msg
}
func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, configs []pkgpush.Config, msg NotificationMessage, renderedTemplate string, flatBody map[string]any) {
for _, channelName := range event.Channels {
if channelName == channelEmail {
t.enqueueEmailPushTasks(ctx, meta, event, configs, msg, renderedTemplate, flatBody)
continue
}
// 检查是不是自定义数据库渠道
var customChannel model.PushChannel
err := db.DB(ctx).Where("name = ? AND enabled = ?", channelName, true).First(&customChannel).Error
if err == nil {
t.enqueueCustomPushChannelTasks(ctx, meta, event, &customChannel, msg, flatBody)
continue
}
// 数据库中不存在。我们核对它是不是通过代码内置注册的 Pusher 渠道
if _, errPusher := pkgpush.GetPusher(channelName); errPusher == nil {
t.enqueueBuiltinPushTasks(ctx, meta, event, channelName, configs, msg, renderedTemplate, flatBody)
continue
}
logger.WarnF(ctx, "push_event_trigger: channel %q not found in DB and not registered as built-in: %v", channelName, err)
}
}
func (t *EventTrigger) enqueueEmailPushTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, configs []pkgpush.Config, msg NotificationMessage, renderedTemplate string, flatBody map[string]any) {
var matchedConfigs []pkgpush.Config
for _, cfg := range configs {
if cfg.Channel == channelEmail {
matchedConfigs = append(matchedConfigs, cfg)
}
}
if len(matchedConfigs) == 0 {
logger.WarnF(ctx, "push_event_trigger: no active settings for channel %q", channelEmail)
return
}
for _, cfg := range matchedConfigs {
if cfg.URL == "" || cfg.Key == "" {
var smtpHost, smtpPort, smtpUser, smtpPass model.SystemConfig
_ = smtpHost.GetByKey(ctx, model.ConfigKeySMTPHost)
_ = smtpPort.GetByKey(ctx, model.ConfigKeySMTPPort)
_ = smtpUser.GetByKey(ctx, model.ConfigKeySMTPUsername)
_ = smtpPass.GetByKey(ctx, model.ConfigKeySMTPPassword)
if smtpHost.Value != "" && smtpUser.Value != "" {
port := smtpPort.Value
if port == "" {
port = "587"
}
cfg.URL = smtpHost.Value + ":" + port
cfg.Key = smtpUser.Value
cfg.Secret = smtpPass.Value
}
}
if len(event.Targets) > 0 {
for _, target := range event.Targets {
resolvedTarget := resolveTarget(ctx, target, flatBody, channelEmail)
payload := SendPayload{
EventKey: meta.Key,
Config: cfg,
Target: resolvedTarget,
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s -> %s: %v", channelEmail, resolvedTarget, err)
}
}
} else {
payload := SendPayload{
EventKey: meta.Key,
Config: cfg,
Target: "",
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s: %v", channelEmail, err)
}
}
}
}
func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg NotificationMessage, flatBody map[string]any) {
if len(event.Targets) == 0 {
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg)
return
}
for _, target := range event.Targets {
resolvedTarget := resolveTarget(ctx, target, flatBody, channel.Name)
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, resolvedTarget, msg)
}
}
func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta EventMetadata, channel *model.PushChannel, target string, msg NotificationMessage) {
var config pkgpush.Config
var renderedTemplate string
switch channel.Type {
case channelLark:
config = pkgpush.Config{
Channel: channelLark,
URL: channel.URL,
Secret: channel.Token, // Feishu Bot Sign Secret
}
renderedTemplate = channel.Other // Optional custom template/card for lark
case channelEmail:
url, token, other := resolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other)
config = pkgpush.Config{
Channel: channelEmail,
URL: url, // SMTP host:port
Key: token, // SMTP Username
Secret: other, // SMTP Password
}
default: // custom
config = pkgpush.Config{
Channel: channelCustom,
URL: channel.URL,
}
customPushReq := CustomPushRequest{
Title: msg.Title,
Content: msg.Content,
Description: meta.Description,
To: target,
}
if urlVal, ok := msg.Ext["url"].(string); ok {
customPushReq.URL = urlVal
}
renderedTemplate = renderCustomPayload(channel.Other, customPushReq)
}
payload := SendPayload{
EventKey: meta.Key,
Config: config,
Target: target,
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s channel %s -> %s: %v", channel.Type, channel.Name, target, err)
}
}
func (t *EventTrigger) enqueueBuiltinPushTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, channelName string, configs []pkgpush.Config, msg NotificationMessage, renderedTemplate string, flatBody map[string]any) {
var matchedConfigs []pkgpush.Config
for _, cfg := range configs {
if cfg.Channel == channelName {
matchedConfigs = append(matchedConfigs, cfg)
}
}
if len(matchedConfigs) == 0 {
logger.WarnF(ctx, "push_event_trigger: no active settings for built-in channel %q", channelName)
return
}
for _, cfg := range matchedConfigs {
if len(event.Targets) > 0 {
for _, target := range event.Targets {
resolvedTarget := resolveTarget(ctx, target, flatBody, channelName)
payload := SendPayload{
EventKey: meta.Key,
Config: cfg,
Target: resolvedTarget,
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for builtin %s -> %s: %v", channelName, resolvedTarget, err)
}
}
} else {
payload := SendPayload{
EventKey: meta.Key,
Config: cfg,
Target: "",
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for builtin %s: %v", channelName, err)
}
}
}
}
func enqueuePushTask(ctx context.Context, payload SendPayload) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = task.DispatchTask(ctx, "send_notification", payloadBytes, "system")
return err
}
func getFlatBody(body map[string]any) map[string]any {
jsonBytes, err := json.Marshal(body)
if err != nil {
return body
}
var jsonMap map[string]any
if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil {
return body
}
flatResult := make(map[string]any)
flattenMap("", jsonMap, flatResult)
return flatResult
}
func flattenMap(prefix string, m map[string]any, result map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nestedMap, ok := v.(map[string]any); ok {
flattenMap(key, nestedMap, result)
} else {
result[key] = v
}
}
}
func resolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string {
target = strings.TrimSpace(target)
if target == "" {
return ""
}
resolved := resolveDynamicKeyword(target, flatBody)
// 2. 如果包含 @,说明已经是个邮箱,直接返回
if strings.Contains(resolved, "@") {
return resolved
}
// 2.5 如果为特殊的系统虚拟用户,自动映射为首位管理员
if val, matched := resolveSystemTarget(ctx, resolved, channel); matched {
return val
}
// 3. 不包含 @,说明可能是用户 ID 或用户名。我们需要从数据库中查询对应用户
var user model.User
found := false
// 尝试作为用户 ID 查询(纯数字)
if id, err := strconv.ParseUint(resolved, 10, 64); err == nil {
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err == nil {
found = true
}
}
// 如果没有按 ID 查到,尝试作为用户名查询
if !found {
if err := db.DB(ctx).Where("username = ?", resolved).First(&user).Error; err == nil {
found = true
}
}
// 4. 根据查询结果 and 推送渠道进行转换
if !found {
return resolved
}
if channel == channelEmail && user.Email != "" {
return user.Email
}
if channel != channelEmail && user.Username != "" {
return user.Username
}
return resolved
}
func resolveDynamicKeyword(target string, flatBody map[string]any) string {
switch target {
case "user.id", "id":
if val, ok := flatBody["user.id"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["id"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.username", "username":
if val, ok := flatBody["user.username"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["username"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.email", channelEmail:
if val, ok := flatBody["user.email"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["email"]; ok {
return fmt.Sprintf("%v", val)
}
}
return target
}
func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) {
if resolved != "系统" && resolved != "system" && resolved != "0" {
return "", false
}
var adminUser model.User
if err := db.DB(ctx).Where("is_admin = ?", true).Order("id asc").First(&adminUser).Error; err != nil {
return resolved, true
}
if channel == channelEmail && adminUser.Email != "" {
return adminUser.Email, true
}
if channel != channelEmail && adminUser.Username != "" {
return adminUser.Username, true
}
return resolved, true
}
// resolveSMTPConfig resolves SMTP configuration by falling back to system-wide global configuration if any inputs are blank.
func resolveSMTPConfig(ctx context.Context, url, token, other string) (string, string, string) {
if url != "" && token != "" {
return url, token, other
}
var smtpHost, smtpPort, smtpUser, smtpPass model.SystemConfig
_ = smtpHost.GetByKey(ctx, model.ConfigKeySMTPHost)
_ = smtpPort.GetByKey(ctx, model.ConfigKeySMTPPort)
_ = smtpUser.GetByKey(ctx, model.ConfigKeySMTPUsername)
_ = smtpPass.GetByKey(ctx, model.ConfigKeySMTPPassword)
if smtpHost.Value == "" || smtpUser.Value == "" {
return url, token, other
}
port := smtpPort.Value
if port == "" {
port = "587"
}
if url == "" {
url = smtpHost.Value + ":" + port
}
if token == "" {
token = smtpUser.Value
}
if other == "" {
other = smtpPass.Value
}
return url, token, other
}
+737
View File
@@ -0,0 +1,737 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/internal/util"
pkgpush "github.com/Rain-kl/Wavelet/pkg/push"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
var adminLoginEvent = EventMetadata{
Key: "admin_login",
Name: "管理员登录",
DefaultTemplate: NotificationMessage{
Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Level: "INFO",
},
Description: "当管理员成功登录系统时触发此通知",
}
func init() {
RegisterBuiltInEvent(adminLoginEvent)
}
// mockPusher mock implementation of pkgpush.Pusher
type mockPusher struct {
mu sync.Mutex
sentBody map[string]any
sentTgt string
}
func (m *mockPusher) Send(ctx context.Context, cfg pkgpush.Config, target string, body map[string]any, template string, ext map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sentBody = body
m.sentTgt = target
return nil
}
func (m *mockPusher) ValidateConfig(cfg pkgpush.Config) error {
return nil
}
func setupPushTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
dbConn, mr, cleanup := testhelper.SetupTestEnvironment(t)
// AutoMigrate push tables in SQLite test environment
err := dbConn.AutoMigrate(&model.PushEvent{}, &model.PushHistory{}, &model.User{}, &model.PushChannel{}, &model.SystemConfig{})
require.NoError(t, err)
// 写入数据库系统默认用户 Seed 记录
sysUser := &model.User{
ID: 999,
Username: "system",
Nickname: "系统",
Password: "*",
IsActive: true,
}
err = dbConn.Create(sysUser).Error
require.NoError(t, err)
// Initialize AsynqClient pointing to miniredis
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
Addr: mr.Addr(),
})
// Register the task handler and metadata
task.RegisterHandler(SendNotificationTask, &PushHandler{})
task.RegisterTaskMeta(SendNotificationMeta)
return dbConn, mr, func() {
cleanup()
if task.AsynqClient != nil {
task.AsynqClient.Close()
task.AsynqClient = nil
}
}
}
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
adminGroup := r.Group("/api/v1/admin/push")
adminGroup.Use(func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, "user_obj", authUser)
}
c.Next()
})
adminGroup.GET("/events", ListEvents)
adminGroup.GET("/events/builtin", ListBuiltInEvents)
adminGroup.POST("/events", CreateEvent)
adminGroup.PUT("/events/:id", UpdateEvent)
adminGroup.DELETE("/events/:id", DeleteEvent)
adminGroup.POST("/events/:id/toggle", ToggleEvent)
adminGroup.GET("/histories", ListHistories)
adminGroup.POST("/test", TestPush)
return r
}
func TestSyncEvents(t *testing.T) {
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
// 1. SyncEvents first time
err := SyncEvents(context.Background())
require.NoError(t, err)
// Verify event exists in DB
var event model.PushEvent
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
require.NoError(t, err)
assert.Equal(t, "管理员登录", event.Name)
assert.False(t, event.Enabled)
// Verify DefaultTemplate matches GORM template field
var defaultMsg NotificationMessage
err = json.Unmarshal([]byte(event.Template), &defaultMsg)
require.NoError(t, err)
assert.Equal(t, adminLoginEvent.DefaultTemplate.Title, defaultMsg.Title)
assert.Equal(t, adminLoginEvent.DefaultTemplate.Content, defaultMsg.Content)
}
func TestEventTrigger(t *testing.T) {
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
// Register mock pusher
mPusher := &mockPusher{}
pkgpush.Register("mock_channel", mPusher)
// SyncEvents
err := SyncEvents(context.Background())
require.NoError(t, err)
t.Run("trigger disabled event silently ignored", func(t *testing.T) {
body := map[string]any{
"user": map[string]any{"username": "test_admin"},
"ip": "127.0.0.1",
}
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body)
// Sleep briefly since Trigger runs in goroutine
time.Sleep(50 * time.Millisecond)
// Verify no tasks enqueued in TaskExecution GORM table
var count int64
dbConn.Model(&model.TaskExecution{}).Count(&count)
assert.Equal(t, int64(0), count)
})
t.Run("trigger enabled event enqueues task", func(t *testing.T) {
// Set push_config system configuration
cfgJson := `[{"channel": "mock_channel", "url": "http://mock"}]`
sysConfig := &model.SystemConfig{
Key: model.ConfigKeyPushConfig,
Value: cfgJson,
}
err = dbConn.Create(sysConfig).Error
require.NoError(t, err)
// Enable the push event in DB using struct to trigger JSON serializer
var event model.PushEvent
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
require.NoError(t, err)
event.Enabled = true
event.Channels = []string{"mock_channel"}
event.Targets = []string{"admin_user"}
err = dbConn.Save(&event).Error
require.NoError(t, err)
// Trigger
body := map[string]any{
"user": map[string]any{
"username": "super_admin",
},
"ip": "1.1.1.1",
"time": "2026-06-14 18:00:00",
}
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body)
// Wait for goroutine execution
time.Sleep(50 * time.Millisecond)
// Verify TaskExecution enqueued record
var execution model.TaskExecution
err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error
require.NoError(t, err)
// Verify enqueued payload structure
var payload SendPayload
err = json.Unmarshal([]byte(execution.Payload), &payload)
require.NoError(t, err)
assert.Equal(t, "admin_login", payload.EventKey)
assert.Equal(t, "mock_channel", payload.Config.Channel)
assert.Equal(t, "admin_user", payload.Target)
assert.Equal(t, "管理员登录提醒", payload.Body.Title)
assert.Contains(t, payload.Body.Content, "super_admin")
assert.Contains(t, payload.Body.Content, "1.1.1.1")
})
t.Run("trigger without user injects virtual system user", func(t *testing.T) {
// Set push_config system configuration
dbConn.Where("key = ?", model.ConfigKeyPushConfig).Delete(&model.SystemConfig{})
cfgJson := `[{"channel": "mock_channel", "url": "http://mock"}]`
sysConfig := &model.SystemConfig{
Key: model.ConfigKeyPushConfig,
Value: cfgJson,
}
err = dbConn.Create(sysConfig).Error
require.NoError(t, err)
// Enable the push event in DB
var event model.PushEvent
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
require.NoError(t, err)
// 清理旧任务执行记录
dbConn.Where("task_type = ?", SendNotificationTask).Delete(&model.TaskExecution{})
event.Enabled = true
event.Channels = []string{"mock_channel"}
event.Targets = []string{"user.username"} // 动态目标
err = dbConn.Save(&event).Error
require.NoError(t, err)
// Trigger with empty body (simulates cron scheduler triggering)
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, nil)
// Wait for goroutine execution
time.Sleep(50 * time.Millisecond)
// Verify TaskExecution enqueued record
var execution model.TaskExecution
err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error
require.NoError(t, err)
var payload SendPayload
err = json.Unmarshal([]byte(execution.Payload), &payload)
require.NoError(t, err)
// 检查 payload 是否将 target (user.username) 成功替换为 "system"
assert.Equal(t, "system", payload.Target)
// 检查 payload 中的 Content,应当被替换为 "system" 变量
assert.Contains(t, payload.Body.Content, "system")
})
}
func TestPushHandler(t *testing.T) {
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
mPusher := &mockPusher{}
pkgpush.Register("mock_channel", mPusher)
handler := &PushHandler{}
payload := SendPayload{
EventKey: "admin_login",
Config: pkgpush.Config{
Channel: "mock_channel",
URL: "http://mock-url",
},
Target: "admin_user",
Body: NotificationMessage{
Title: "Structured Alert",
Content: "Hello World",
Level: "WARNING",
Ext: map[string]any{"extra_val": 42},
},
}
payloadBytes, err := json.Marshal(payload)
require.NoError(t, err)
t.Run("validate payload", func(t *testing.T) {
validated, valErr := handler.ValidatePayload(payloadBytes)
require.NoError(t, valErr)
assert.NotEmpty(t, validated)
})
t.Run("execute task successfully", func(t *testing.T) {
res, execErr := handler.Execute(context.Background(), payloadBytes)
require.NoError(t, execErr)
assert.Contains(t, res.Message, "推送成功")
// Verify mock pusher received flattened variables
mPusher.mu.Lock()
assert.Equal(t, "admin_user", mPusher.sentTgt)
assert.Equal(t, "Structured Alert", mPusher.sentBody["title"])
assert.Equal(t, "Hello World", mPusher.sentBody["content"])
assert.Equal(t, "WARNING", mPusher.sentBody["level"])
assert.Equal(t, float64(42), mPusher.sentBody["extra_val"]) // unmarshaled json numbers are float64 by default
mPusher.mu.Unlock()
// Verify PushHistory recorded
var history model.PushHistory
err = dbConn.First(&history).Error
require.NoError(t, err)
assert.Equal(t, "admin_login", history.EventKey)
assert.Equal(t, "mock_channel", history.Channel)
assert.Equal(t, "success", history.Status)
assert.Equal(t, "Structured Alert", history.Title)
})
}
func TestPushRouters(t *testing.T) {
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
r := setupTestRouter(adminUser)
// Sync events to populate db
err := SyncEvents(context.Background())
require.NoError(t, err)
t.Run("list events", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/push/events", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp util.ResponseAny
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
dataBytes, _ := json.Marshal(resp.Data)
var events []model.PushEvent
err = json.Unmarshal(dataBytes, &events)
require.NoError(t, err)
assert.Len(t, events, 1)
assert.Equal(t, "admin_login", events[0].EventKey)
})
t.Run("toggle event status", func(t *testing.T) {
var event model.PushEvent
dbConn.First(&event)
req, _ := http.NewRequest("POST", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10)+"/toggle", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated model.PushEvent
dbConn.First(&updated)
assert.True(t, updated.Enabled)
})
t.Run("update event", func(t *testing.T) {
var event model.PushEvent
dbConn.First(&event)
updateReq := UpdateEventRequest{
Channels: []string{"email"},
Targets: []string{"user@test.com"},
Template: `{"title": "Custom Login Alert", "content": "Alert", "level": "WARNING"}`,
Enabled: true,
}
bodyBytes, _ := json.Marshal(updateReq)
req, _ := http.NewRequest("PUT", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated model.PushEvent
dbConn.First(&updated)
assert.Equal(t, []string{"email"}, updated.Channels)
assert.Equal(t, []string{"user@test.com"}, updated.Targets)
assert.Contains(t, updated.Template, "Custom Login Alert")
})
t.Run("list push histories", func(t *testing.T) {
// Populate history record
hist := model.PushHistory{
EventKey: "admin_login",
Channel: "email",
Target: "user@test.com",
Title: "Custom Login Alert",
Content: "Alert",
Level: "WARNING",
Status: "success",
}
dbConn.Create(&hist)
req, _ := http.NewRequest("GET", "/api/v1/admin/push/histories?page=1&page_size=10", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataMap, ok := resp.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, float64(1), dataMap["total"])
})
t.Run("test push endpoint", func(t *testing.T) {
mPusher := &mockPusher{}
pkgpush.Register("test_channel", mPusher)
testReq := TestPushRequest{
Config: pkgpush.Config{
Channel: "test_channel",
URL: "http://test-url",
},
Target: "test_target",
}
bodyBytes, _ := json.Marshal(testReq)
req, _ := http.NewRequest("POST", "/api/v1/admin/push/test", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("list built-in events", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/push/events/builtin", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp util.ResponseAny
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
builtins, ok := resp.Data.([]any)
assert.True(t, ok)
assert.NotEmpty(t, builtins)
})
t.Run("create and delete push event", func(t *testing.T) {
// Clean up any existing admin_login event first
dbConn.Where("event_key = ?", "admin_login").Delete(&model.PushEvent{})
// 1. Create event
createReq := CreateEventRequest{
EventKey: "admin_login",
Channels: []string{"email"},
Targets: []string{"admin@test.com"},
Enabled: true,
}
bodyBytes, _ := json.Marshal(createReq)
req, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify created in DB
var event model.PushEvent
err := dbConn.Where("event_key = ?", "admin_login").First(&event).Error
require.NoError(t, err)
assert.Equal(t, "admin_login", event.EventKey)
assert.Equal(t, "管理员登录", event.Name)
assert.True(t, event.Enabled)
// 2. Try creating again (should fail)
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes))
req2.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusBadRequest, w2.Code)
// 3. Delete event
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest("DELETE", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), nil)
r.ServeHTTP(w3, req3)
assert.Equal(t, http.StatusOK, w3.Code)
// Verify deleted from DB
var count int64
dbConn.Model(&model.PushEvent{}).Where("event_key = ?", "admin_login").Count(&count)
assert.Equal(t, int64(0), count)
})
}
func TestResolveTarget(t *testing.T) {
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
// 1. 创建测试用户与管理员用户
testUser := &model.User{
ID: 9999,
Username: "target_user",
Email: "target@test.com",
IsAdmin: false,
}
err := dbConn.Create(testUser).Error
require.NoError(t, err)
adminUser := &model.User{
ID: 8888,
Username: "admin_user",
Email: "admin@test.com",
IsAdmin: true,
}
err = dbConn.Create(adminUser).Error
require.NoError(t, err)
flatBody := map[string]any{
"user.id": float64(9999), // JSON 反序列化后一般是 float64
"user.username": "target_user",
"user.email": "target@test.com",
}
ctx := context.Background()
t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "user.id", flatBody, "email")
assert.Equal(t, "target@test.com", res)
})
t.Run("dynamic user.username resolved and converted for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "user.username", flatBody, "email")
assert.Equal(t, "target@test.com", res)
})
t.Run("dynamic user.email resolved directly for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "user.email", flatBody, "email")
assert.Equal(t, "target@test.com", res)
})
t.Run("fixed user id resolved and converted for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "9999", flatBody, "email")
assert.Equal(t, "target@test.com", res)
})
t.Run("fixed username resolved and converted for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "target_user", flatBody, "email")
assert.Equal(t, "target@test.com", res)
})
t.Run("fixed email address resolved directly for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "fixed@example.com", flatBody, "email")
assert.Equal(t, "fixed@example.com", res)
})
t.Run("fixed username resolved for non-email channel", func(t *testing.T) {
res := resolveTarget(ctx, "target_user", flatBody, "lark")
assert.Equal(t, "target_user", res)
})
t.Run("non-exist user resolved as fallback", func(t *testing.T) {
res := resolveTarget(ctx, "non_exist_user", flatBody, "email")
assert.Equal(t, "non_exist_user", res)
})
t.Run("system target resolves to admin email for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "系统", flatBody, "email")
assert.Equal(t, "admin@test.com", res)
res2 := resolveTarget(ctx, "system", flatBody, "email")
assert.Equal(t, "admin@test.com", res2)
res3 := resolveTarget(ctx, "0", flatBody, "email")
assert.Equal(t, "admin@test.com", res3)
})
t.Run("system target resolves to admin username for lark channel", func(t *testing.T) {
res := resolveTarget(ctx, "系统", flatBody, "lark")
assert.Equal(t, "admin_user", res)
})
}
func TestPushChannelAPI(t *testing.T) {
// 1. 模型校验测试
t.Run("validate push channel model constraints", func(t *testing.T) {
// 校验名称合法性
c1 := &model.PushChannel{Name: "invalid-name!", URL: "https://hook.com", Other: "{}"}
assert.Error(t, c1.Validate())
// 校验 URL 安全前缀 HTTPS
c2 := &model.PushChannel{Name: "custom_channel", URL: "http://insecure-hook.com", Other: "{}"}
assert.Error(t, c2.Validate())
// 校验 JSON 格式
c3 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{invalid-json}"}
assert.Error(t, c3.Validate())
// 正确配置
c4 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{\"content\":\"$content\"}"}
assert.NoError(t, c4.Validate())
// 飞书渠道校验:非 HTTPS 地址报错
c5 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "http://open.feishu.cn", Other: ""}
assert.Error(t, c5.Validate())
// 飞书正确配置
c6 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "https://open.feishu.cn", Other: ""}
assert.NoError(t, c6.Validate())
// 邮件配置校验:允许空配置以复用系统全局设置
c7 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "", Token: "", Other: ""}
assert.NoError(t, c7.Validate())
// 邮件正确配置 (非 HTTPS 协议 URL 允许)
c8 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "smtp.exmail.qq.com:465", Token: "user@example.com", Other: "authcode"}
assert.NoError(t, c8.Validate())
})
// 2. HTTP CRUD & 触发鉴权测试
dbConn, _, cleanup := setupPushTest(t)
defer cleanup()
// 构建路由以进行 HTTP 模拟请求
r := gin.New()
adminGroup := r.Group("/api/v1/admin")
{
adminGroup.GET("/push/channels", ListChannels)
adminGroup.POST("/push/channels", CreateChannel)
adminGroup.PUT("/push/channels/:id", UpdateChannel)
adminGroup.DELETE("/push/channels/:id", DeleteChannel)
adminGroup.POST("/push/channels/test", TestChannel)
}
var createdID uint64
t.Run("admin create channel", func(t *testing.T) {
reqBody := CreateChannelRequest{
Name: "my_custom_channel",
Description: "My custom channel webhook",
Type: "custom",
Token: "my_chan_token",
URL: "https://webhook.site/test",
Other: `{"title": "$title", "body": "$content"}`,
Enabled: true,
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataMap, ok := resp.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, "my_custom_channel", dataMap["name"])
createdID = uint64(dataMap["id"].(float64))
})
t.Run("admin list channels", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/push/channels", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
list, ok := resp.Data.([]any)
assert.True(t, ok)
assert.Len(t, list, 1)
})
t.Run("admin update channel", func(t *testing.T) {
updateReq := UpdateChannelRequest{
Description: "Updated remark",
Type: "custom",
Token: "new_chan_token",
URL: "https://webhook.site/updated",
Other: `{"text": "$content"}`,
Enabled: true,
}
bodyBytes, _ := json.Marshal(updateReq)
req, _ := http.NewRequest("PUT", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated model.PushChannel
dbConn.First(&updated, createdID)
assert.Equal(t, "Updated remark", updated.Description)
assert.Equal(t, "new_chan_token", updated.Token)
assert.Equal(t, `{"text": "$content"}`, updated.Other)
})
t.Run("admin test channel endpoint", func(t *testing.T) {
testReq := TestChannelRequest{
Name: "my_custom_channel",
Target: "test_target",
}
bodyBytes, _ := json.Marshal(testReq)
req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels/test", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("admin delete channel", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var count int64
dbConn.Model(&model.PushChannel{}).Where("id = ?", createdID).Count(&count)
assert.Equal(t, int64(0), count)
})
}
+450
View File
@@ -0,0 +1,450 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package push defines push notification HTTP routes.
package push
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/util"
"github.com/Rain-kl/Wavelet/pkg/push"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// UpdateEventRequest 更新事件请求参数
type UpdateEventRequest struct {
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template" binding:"required"`
Enabled bool `json:"enabled"`
}
// TestPushRequest 测试推送通道请求参数
type TestPushRequest struct {
Config push.Config `json:"config" binding:"required"`
Target string `json:"target"`
}
// SyncEvents automatically registers/updates built-in events in the database.
func SyncEvents(ctx context.Context) error {
for _, meta := range BuiltInEvents {
var event model.PushEvent
err := db.DB(ctx).Where("event_key = ?", meta.Key).First(&event).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
var defaultTemplateStr string
if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil {
defaultTemplateStr = string(defaultTemplateBytes)
}
event = model.PushEvent{
EventKey: meta.Key,
Name: meta.Name,
Channels: []string{},
Targets: []string{},
Template: defaultTemplateStr,
Enabled: false,
}
if err := db.DB(ctx).Create(&event).Error; err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
// ListEvents 获取通知事件列表
// @Summary 获取所有通知事件
// @Description 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]model.PushEvent} "通知事件列表"
// @Router /api/v1/admin/push/events [get]
func ListEvents(c *gin.Context) {
ctx := c.Request.Context()
var events []model.PushEvent
if err := db.DB(ctx).Order("created_at DESC").Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(events))
}
// CreateEventRequest 创建事件请求参数
type CreateEventRequest struct {
EventKey string `json:"event_key" binding:"required"`
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template"`
Enabled bool `json:"enabled"`
}
func findBuiltInEvent(key string) (EventMetadata, bool) {
for _, meta := range BuiltInEvents {
if meta.Key == key {
return meta, true
}
}
return EventMetadata{}, false
}
// ListBuiltInEvents 获取内置通知事件列表
// @Summary 获取所有内置通知事件
// @Description 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]EventMetadata} "内置通知事件列表"
// @Router /api/v1/admin/push/events/builtin [get]
func ListBuiltInEvents(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(BuiltInEvents))
}
// CreateEvent 创建通知事件
// @Summary 创建通知事件
// @Description 绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body CreateEventRequest true "创建参数"
// @Success 200 {object} util.ResponseAny{data=model.PushEvent} "创建成功"
// @Router /api/v1/admin/push/events [post]
func CreateEvent(c *gin.Context) {
var req CreateEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
// 1. 检查内置事件是否存在
meta, found := findBuiltInEvent(req.EventKey)
if !found {
c.JSON(http.StatusBadRequest, util.Err("unsupported built-in event key"))
return
}
// 2. 检查是否已经创建过该事件的配置
var count int64
if err := db.DB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", req.EventKey).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, util.Err("this notification event is already configured"))
return
}
// 3. 模板处理
templateStr := strings.TrimSpace(req.Template)
if templateStr == "" {
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
templateStr = string(defaultTemplateBytes)
} else {
var tempMap map[string]any
if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil {
c.JSON(http.StatusBadRequest, util.Err("custom template is not a valid JSON format"))
return
}
}
// 4. 创建事件记录
channels := req.Channels
if channels == nil {
channels = []string{}
}
targets := req.Targets
if targets == nil {
targets = []string{}
}
event := model.PushEvent{
EventKey: meta.Key,
Name: meta.Name,
Channels: channels,
Targets: targets,
Template: templateStr,
Enabled: req.Enabled,
}
if err := event.Validate(); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(event))
}
// DeleteEvent 删除通知事件配置
// @Summary 删除通知事件配置
// @Description 删除数据库中的特定通知事件配置,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Param id path int true "事件 ID"
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
// @Router /api/v1/admin/push/events/{id} [delete]
func DeleteEvent(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid event id"))
return
}
ctx := c.Request.Context()
var event model.PushEvent
if err := db.DB(ctx).First(&event, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err("notification event not found"))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
if err := db.DB(ctx).Delete(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// UpdateEvent 更新通知事件
// @Summary 更新通知事件
// @Description 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "事件 ID"
// @Param request body push.UpdateEventRequest true "更新参数"
// @Success 200 {object} util.ResponseAny{data=string} "修改成功"
// @Router /api/v1/admin/push/events/{id} [put]
func UpdateEvent(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid event id"))
return
}
var req UpdateEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var event model.PushEvent
if err := db.DB(c.Request.Context()).First(&event, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err("notification event not found"))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
event.Channels = req.Channels
event.Targets = req.Targets
event.Template = req.Template
event.Enabled = req.Enabled
if err := event.Validate(); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := db.DB(c.Request.Context()).Save(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// ToggleEvent 快捷切换通知事件启用状态
// @Summary 快捷切换通知事件启用状态
// @Description 启用或禁用指定的通知事件
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Param id path int true "事件 ID"
// @Success 200 {object} util.ResponseAny{data=string} "切换成功"
// @Router /api/v1/admin/push/events/{id}/toggle [post]
func ToggleEvent(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid event id"))
return
}
var event model.PushEvent
if err := db.DB(c.Request.Context()).First(&event, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err("notification event not found"))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
event.Enabled = !event.Enabled
if err := db.DB(c.Request.Context()).Model(&event).Update("enabled", event.Enabled).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(event.Enabled))
}
// pushHistoriesResponse 推送历史分页响应
//
//nolint:unused
type pushHistoriesResponse struct {
Total int64 `json:"total"`
Results []model.PushHistory `json:"results"`
}
// ListHistories 分页获取通知推送历史
// @Summary 分页获取通知推送历史
// @Description 返回分页的通知历史日志数据,需要管理员权限
// @Tags admin-push
// @Produce json
// @Security SessionCookie
// @Param page query int false "当前页码"
// @Param page_size query int false "分页大小"
// @Param event_key query string false "过滤事件名称"
// @Param status query string false "过滤发送状态"
// @Success 200 {object} util.ResponseAny{data=pushHistoriesResponse} "推送历史列表"
// @Router /api/v1/admin/push/histories [get]
func ListHistories(c *gin.Context) {
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
eventKey := c.Query("event_key")
status := c.Query("status")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 {
pageSize = 20
}
query := db.DB(c.Request.Context()).Model(&model.PushHistory{}).Order("created_at DESC")
if eventKey != "" {
query = query.Where("event_key = ?", eventKey)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
var results []model.PushHistory
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&results).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(map[string]any{
"total": total,
"results": results,
}))
}
// TestPush 测试推送通道发送
// @Summary 测试推送通道发送
// @Description 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息
// @Tags admin-push
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body push.TestPushRequest true "测试请求体"
// @Success 200 {object} util.ResponseAny{data=string} "测试成功"
// @Router /api/v1/admin/push/test [post]
func TestPush(c *gin.Context) {
var req TestPushRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
pusher, err := push.GetPusher(req.Config.Channel)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 校验配置
if err := pusher.ValidateConfig(req.Config); err != nil {
c.JSON(http.StatusBadRequest, util.Err(fmt.Sprintf("validation failed: %v", err)))
return
}
// 邮件渠道需要从系统设置中拉取发件人 SMTP 信息做测试 (除非配了独立的)
if req.Config.Channel == channelEmail && (req.Config.URL == "" || req.Config.Key == "") {
var smtpHost, smtpPort, smtpUser, smtpPass model.SystemConfig
ctx := c.Request.Context()
_ = smtpHost.GetByKey(ctx, model.ConfigKeySMTPHost)
_ = smtpPort.GetByKey(ctx, model.ConfigKeySMTPPort)
_ = smtpUser.GetByKey(ctx, model.ConfigKeySMTPUsername)
_ = smtpPass.GetByKey(ctx, model.ConfigKeySMTPPassword)
if smtpHost.Value != "" && smtpUser.Value != "" {
port := smtpPort.Value
if port == "" {
port = "587"
}
req.Config.URL = smtpHost.Value + ":" + port
req.Config.Key = smtpUser.Value
req.Config.Secret = smtpPass.Value
}
}
testBody := map[string]any{
keyTitle: "测试通道推送",
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
keyLevel: defaultLevelInfo,
}
err = pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil)
if err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package push defines push notification HTTP routes and background tasks.
package push
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/pkg/push"
)
const (
// SendNotificationTask 发送推送通知任务标识
SendNotificationTask = "push:send"
// TaskTypeSendNotification 推送通知管理类型
TaskTypeSendNotification = "send_notification"
)
// SendNotificationMeta represents the task metadata.
var SendNotificationMeta = task.TaskMeta{
Type: TaskTypeSendNotification,
AsynqTask: SendNotificationTask,
Name: "推送通知",
Description: "异步执行系统通知的多渠道派发与推送",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
Retryable: true,
Params: []task.TaskParam{
{
Name: "event_key",
Label: "事件标识",
Type: "string",
Required: true,
Placeholder: "admin_login",
},
{
Name: "target",
Label: "目标接收者",
Type: "string",
Required: false,
},
},
}
// PushHandler 通知推送异步任务处理器
//
//nolint:revive
type PushHandler struct{}
// ValidatePayload 校验并标准化推送参数
func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("payload is required")
}
var req SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("invalid json format: %w", err)
}
if req.Config.Channel == "" {
return nil, errors.New("channel type is required")
}
return json.Marshal(req)
}
// Execute 异步执行推送操作并记录推送历史审计
func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
var req SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
task.AppendLog(ctx, "解析推送参数失败: %v", err)
return nil, fmt.Errorf("parse payload failed: %w", err)
}
task.AppendLog(ctx, "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
pusher, err := push.GetPusher(req.Config.Channel)
if err != nil {
errWrap := fmt.Errorf("get pusher failed: %w", err)
task.AppendLog(ctx, "推送失败: %v", errWrap)
if task.IsFinalAttempt(ctx) {
h.recordHistory(ctx, req, "failed", errWrap.Error())
}
return nil, errWrap
}
// 执行真正的消息推送,扁平化为原始 json 格式
flatBody := req.Body.Flatten()
err = pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil)
title := req.Body.Title
content := req.Body.Content
if err != nil {
task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err)
if task.IsFinalAttempt(ctx) {
h.recordHistory(ctx, req, "failed", err.Error())
}
return nil, fmt.Errorf("pusher.Send failed: %w", err)
}
task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content)
h.recordHistory(ctx, req, "success", "")
return &task.TaskResult{
Message: fmt.Sprintf("推送成功: [%s] -> %s", req.Config.Channel, req.Target),
}, nil
}
func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status string, errMsg string) {
title := req.Body.Title
content := req.Body.Content
level := req.Body.Level
if title == "" {
title = "系统通知"
}
if level == "" {
level = defaultLevelInfo
}
target := req.Target
if target == "" {
// 如果目标人为空 (例如 webhook bot),用其地址填充前缀或默认词作为归档
if req.Config.URL != "" {
target = req.Config.URL
// 隐藏敏感 URL 细节
//nolint:mnd
if len(target) > 50 {
target = target[:47] + "..."
}
} else {
target = "default"
}
}
history := model.PushHistory{
EventKey: req.EventKey,
Channel: req.Config.Channel,
Target: target,
Title: title,
Content: content,
Level: level,
Status: status,
ErrorMsg: errMsg,
CreatedAt: time.Now(),
}
// 记录到数据库
if dbErr := db.DB(ctx).Create(&history).Error; dbErr != nil {
task.AppendLog(ctx, "写入推送历史审计记录失败: %v", dbErr)
}
}
+19 -18
View File
@@ -18,6 +18,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/internal/util"
@@ -91,7 +92,7 @@ func TestListTaskTypes(t *testing.T) {
foundCleanup := false
foundWarmImageCache := false
for _, m := range taskMetas {
if m.Type == upload.TaskTypeCleanupUploads {
if m.Type == service.TaskTypeSystemCleanup {
foundCleanup = true
}
if m.Type == upload.TaskTypeWarmImageCache {
@@ -99,7 +100,7 @@ func TestListTaskTypes(t *testing.T) {
}
}
if !foundCleanup {
t.Errorf("expected task type %s to be listed", upload.TaskTypeCleanupUploads)
t.Errorf("expected task type %s to be listed", service.TaskTypeSystemCleanup)
}
if !foundWarmImageCache {
t.Errorf("expected task type %s to be listed", upload.TaskTypeWarmImageCache)
@@ -115,7 +116,7 @@ func TestDispatchTask(t *testing.T) {
t.Run("dispatch valid task successfully", func(t *testing.T) {
payload := DispatchTaskRequest{
TaskType: upload.TaskTypeCleanupUploads,
TaskType: service.TaskTypeSystemCleanup,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body))
@@ -227,9 +228,9 @@ func TestListTaskExecutions(t *testing.T) {
// 准备测试数据
now := time.Now()
records := []*model.TaskExecution{
{TaskID: "exec_001", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual", Duration: 1500, Result: "清理完成", StartedAt: &now, FinishedAt: &now},
{TaskID: "exec_002", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system", ErrorMessage: "连接超时", StartedAt: &now, FinishedAt: &now},
{TaskID: "exec_003", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual", Retryable: true, MaxRetry: 3},
{TaskID: "exec_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual", Duration: 1500, Result: "清理完成", StartedAt: &now, FinishedAt: &now},
{TaskID: "exec_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system", ErrorMessage: "连接超时", StartedAt: &now, FinishedAt: &now},
{TaskID: "exec_003", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual", Retryable: true, MaxRetry: 3},
}
for _, r := range records {
err := model.CreateTaskExecution(ctx, r)
@@ -271,7 +272,7 @@ func TestListTaskExecutions(t *testing.T) {
})
t.Run("filter by task_type (asynq task name)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=upload:cleanup_unused", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system:cleanup", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -288,7 +289,7 @@ func TestListTaskExecutions(t *testing.T) {
})
t.Run("filter by task_type (management task type)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=cleanup_unused_uploads", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=system_cleanup", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -333,8 +334,8 @@ func TestGetTaskExecution(t *testing.T) {
// 创建测试记录
execution := &model.TaskExecution{
TaskID: "detail_001",
TaskType: "upload:cleanup_unused",
TaskName: "清理上传",
TaskType: "system:cleanup",
TaskName: "系统垃圾清理",
Status: model.TaskExecutionStatusSucceeded,
Log: "[10:00:01] 开始扫描\n[10:00:02] 找到 50 个文件\n[10:00:03] 清理完成",
Result: "共清理 50 个文件",
@@ -397,8 +398,8 @@ func TestRetryTask(t *testing.T) {
now := time.Now()
execution := &model.TaskExecution{
TaskID: "retry_api_001",
TaskType: "upload:cleanup_unused",
TaskName: "清理上传",
TaskType: "system:cleanup",
TaskName: "系统垃圾清理",
Status: model.TaskExecutionStatusFailed,
ErrorMessage: "S3 连接超时",
Retryable: true,
@@ -437,8 +438,8 @@ func TestRetryTask(t *testing.T) {
t.Run("retry succeeded task fails", func(t *testing.T) {
execution := &model.TaskExecution{
TaskID: "retry_succeeded_001",
TaskType: "upload:cleanup_unused",
TaskName: "清理上传",
TaskType: "system:cleanup",
TaskName: "系统垃圾清理",
Status: model.TaskExecutionStatusSucceeded,
Retryable: true,
MaxRetry: 3,
@@ -458,8 +459,8 @@ func TestRetryTask(t *testing.T) {
t.Run("retry non-retryable task fails", func(t *testing.T) {
execution := &model.TaskExecution{
TaskID: "retry_not_allowed_001",
TaskType: "upload:cleanup_unused",
TaskName: "清理上传",
TaskType: "system:cleanup",
TaskName: "系统垃圾清理",
Status: model.TaskExecutionStatusFailed,
Retryable: false,
TriggeredBy: "manual",
@@ -502,8 +503,8 @@ func TestRetryTaskMaxRetryExceeded(t *testing.T) {
execution := &model.TaskExecution{
TaskID: "retry_max_api_001",
TaskType: "upload:cleanup_unused",
TaskName: "清理上传",
TaskType: "system:cleanup",
TaskName: "系统垃圾清理",
Status: model.TaskExecutionStatusFailed,
Retryable: true,
MaxRetry: 1,
+27 -9
View File
@@ -5,6 +5,7 @@
package oauth
import (
"context"
"errors"
"net/http"
@@ -28,6 +29,19 @@ type loginRequiredAuditLog struct {
Referer string `json:"referer"`
}
func getUserByToken(ctx context.Context, tokenStr string) (*model.User, *model.AccessToken, error) {
tokenHash := model.HashToken(tokenStr)
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err != nil {
return nil, nil, err
}
var user model.User
if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err != nil {
return nil, nil, err
}
return &user, &tokenRecord, nil
}
// GetUserFromRequest 校验 Access Token 或 Session 并返回用户对象,如果未登录或用户失效则返回 error
func GetUserFromRequest(c *gin.Context) (*model.User, error) {
ctx := c.Request.Context()
@@ -41,18 +55,16 @@ func GetUserFromRequest(c *gin.Context) (*model.User, error) {
}
}
var user model.User
// 优先使用 Access Token 鉴权
if tokenStr != "" {
tokenHash := model.HashToken(tokenStr)
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil {
util.SetToContext(c, TokenAuthKey, true)
util.SetToContext(c, TokenAdminKey, tokenRecord.IsAdmin)
return &user, nil
if user, tokenRecord, err := getUserByToken(ctx, tokenStr); err == nil {
// 强行阻止 system 用户任何会话/Token 鉴权通过
if user.Username == "system" {
return nil, errors.New("system user is not allowed to login")
}
util.SetToContext(c, TokenAuthKey, true)
util.SetToContext(c, TokenAdminKey, tokenRecord.IsAdmin)
return user, nil
}
}
@@ -62,6 +74,7 @@ func GetUserFromRequest(c *gin.Context) (*model.User, error) {
return nil, errors.New("unauthorized")
}
var user model.User
// load user from db to make sure is active
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user)
if tx.Error != nil {
@@ -81,6 +94,11 @@ func GetUserFromRequest(c *gin.Context) (*model.User, error) {
util.SetToContext(c, TokenAuthKey, false)
util.SetToContext(c, TokenAdminKey, false)
// 强行阻止 system 用户任何会话/Token 鉴权通过
if user.Username == "system" {
return nil, errors.New("system user is not allowed to login")
}
return &user, nil
}
+74
View File
@@ -26,6 +26,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-jose/go-jose/v4"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -1181,3 +1183,75 @@ func TestOIDCPolicyEnforcement(t *testing.T) {
}
}
func TestSystemUserBlockedByMiddleware(t *testing.T) {
initializeTestConfig()
dbConn := setupTestDB(t)
// 1. 创建正常管理员
adminUser := &model.User{ID: 1001, Username: "normal_admin", IsAdmin: true, IsActive: true}
err := dbConn.Create(adminUser).Error
require.NoError(t, err)
// 2. 创建系统用户 (根据架构设计,系统用户 id = 999)
systemUser := &model.User{ID: 999, Username: "system", Nickname: "系统", Password: "*", IsActive: true}
err = dbConn.Create(systemUser).Error
require.NoError(t, err)
// 3. 设置全局测试数据库连接并构建测试路由组
db.SetDB(dbConn)
rProtected := gin.New()
store := cookie.NewStore([]byte("secret"))
rProtected.Use(sessions.Sessions("mysession", store))
rProtected.Use(LoginRequired())
rProtected.GET("/test-auth", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 4. 测试未登录用户 (401)
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest("GET", "/test-auth", nil)
rProtected.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusUnauthorized, w1.Code)
// 5. 测试正常用户登录并访问 (200)
rLogin := gin.New()
rLogin.Use(sessions.Sessions("mysession", store))
rLogin.GET("/login-mock", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("user_id", uint64(1001))
_ = session.Save()
c.Status(200)
})
wLogin := httptest.NewRecorder()
reqLogin, _ := http.NewRequest("GET", "/login-mock", nil)
rLogin.ServeHTTP(wLogin, reqLogin)
cookieStr := wLogin.Header().Get("Set-Cookie")
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/test-auth", nil)
req2.Header.Set("Cookie", cookieStr)
rProtected.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
// 6. 测试 system 用户(ID: 999)登录并访问 (被中间件阻断返回 401)
rLoginSystem := gin.New()
rLoginSystem.Use(sessions.Sessions("mysession", store))
rLoginSystem.GET("/login-system-mock", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("user_id", uint64(999))
_ = session.Save()
c.Status(200)
})
wLoginSystem := httptest.NewRecorder()
reqLoginSystem, _ := http.NewRequest("GET", "/login-system-mock", nil)
rLoginSystem.ServeHTTP(wLoginSystem, reqLoginSystem)
cookieSystemStr := wLoginSystem.Header().Get("Set-Cookie")
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest("GET", "/test-auth", nil)
req3.Header.Set("Cookie", cookieSystemStr)
rProtected.ServeHTTP(w3, req3)
assert.Equal(t, http.StatusUnauthorized, w3.Code)
}
+3
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
@@ -675,6 +676,8 @@ func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.Auth
logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP())
custom_events.TriggerAdminLoginEvent(ctx, &user, c.ClientIP())
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in")))
}
+3 -3
View File
@@ -115,7 +115,7 @@ func ListFiles(c *gin.Context) {
// @Router /api/v1/admin/uploads/{id} [delete]
func DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
@@ -259,7 +259,7 @@ func ListMyFiles(c *gin.Context) {
func DeleteMyFile(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
@@ -313,7 +313,7 @@ type updateMyFileRequest struct {
func UpdateMyFile(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
+2 -2
View File
@@ -362,7 +362,7 @@ func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User,
if err != nil {
return false, err
}
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return true, nil
}
@@ -396,7 +396,7 @@ func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User,
// storeUploadFile 将文件写入当前活动存储驱动。
func storeUploadFile(ctx context.Context, subPath string, size int64, mimeType string, buf *bytes.Buffer, meta *model.UploadMetadata) (string, string, string) {
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
return "", "", ErrStorageReadOnly
}
driver, backend, err := storage.Active(ctx)
+1 -3
View File
@@ -12,7 +12,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/storage"
)
func storageReadOnly(ctx context.Context) bool {
func StorageReadOnly(ctx context.Context) bool {
execution, ok, err := latestStorageMigrationExecution(ctx)
if err != nil {
logger.ErrorF(ctx, "读取存储维护状态失败: %v", err)
@@ -66,5 +66,3 @@ func currentMigrationTargetConfig(ctx context.Context) (storage.Config, bool, er
}
return target, true, nil
}
+2 -95
View File
@@ -11,21 +11,14 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/task"
"gorm.io/gorm"
)
// 异步任务名称与管理类型定义
const (
// CleanupUnusedUploadsTask 清理未使用上传任务标识
CleanupUnusedUploadsTask = "upload:cleanup_unused"
// TaskTypeCleanupUploads 清理未使用上传管理类型
TaskTypeCleanupUploads = "cleanup_unused_uploads"
// WarmImageCacheTask 图片压缩缓存预热任务标识
WarmImageCacheTask = "upload:warm_image_cache"
// TaskTypeWarmImageCache 图片压缩缓存预热管理类型
@@ -34,18 +27,6 @@ const (
var warmImageCacheMu sync.Mutex
// CleanupUnusedUploadsMeta represents the task metadata.
var CleanupUnusedUploadsMeta = task.TaskMeta{
Type: TaskTypeCleanupUploads,
AsynqTask: CleanupUnusedUploadsTask,
Name: "清理未使用上传",
Description: "清理超过1小时未使用的上传文件",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
Retryable: true,
}
// WarmImageCacheMeta represents the image cache warmup task metadata.
var WarmImageCacheMeta = task.TaskMeta{
Type: TaskTypeWarmImageCache,
@@ -73,86 +54,12 @@ type WarmImageCachePayload struct {
Quality string `json:"quality"`
}
// CleanupUnusedUploadsHandler 清理未使用上传文件的异步任务处理器
type CleanupUnusedUploadsHandler struct{}
// SystemCleanupHandler 系统定期垃圾清理异步任务处理器
// WarmImageCacheHandler serially warms compressed image cache entries.
type WarmImageCacheHandler struct{}
// Execute 执行清理未使用上传文件的业务逻辑
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
if storageReadOnly(ctx) {
return nil, errors.New(ErrStorageReadOnly)
}
const batchSize = 100 // 每批处理100个文件
var lastID uint64
var totalProcessed int
var totalDeleted int
// 计算1小时前的时间
oneHourAgo := time.Now().Add(-1 * time.Hour)
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339))
for {
// 使用游标分页查询未使用且超过1小时的上传记录
var unusedUploads []model.Upload
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&unusedUploads).Error; err != nil {
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
return nil, fmt.Errorf(ErrQueryUnusedUploadsFailed, err)
}
// 没有更多数据,退出循环
if len(unusedUploads) == 0 {
break
}
task.AppendLog(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
// 处理每个未使用的上传文件
for _, upload := range unusedUploads {
totalProcessed++
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新上传记录状态
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", upload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusDeleted).Error; err != nil {
return err
}
driver := storage.Driver(upload.StorageDriver)
if driver == "" {
driver = storage.DriverLocal
}
backend, err := storage.ForDriver(ctx, driver)
if err != nil {
return err
}
if err := backend.Delete(ctx, upload.FilePath); err != nil {
return err
}
return nil
}); err != nil {
task.AppendLog(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
lastID = upload.ID
continue
}
totalDeleted++
lastID = upload.ID
}
}
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
// Execute 执行系统清理(包含文件清理和历史消息推送日志清理)
// ValidatePayload validates and normalizes image cache warmup parameters.
func (h *WarmImageCacheHandler) ValidatePayload(payload []byte) ([]byte, error) {
+50 -9
View File
@@ -20,6 +20,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/diskcache"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/testhelper"
@@ -27,7 +28,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
func TestSystemCleanupHandler_Execute(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -42,6 +43,8 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
defer storageMock()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
require.NoError(t, err)
// 准备测试数据:创建一些上传记录
now := time.Now()
@@ -81,14 +84,40 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
require.NoError(t, err)
}
// 准备推送历史测试数据:1个旧的(应删除),1个新的(应保留)
oldPush := &model.PushHistory{
EventKey: "admin_login",
Channel: "email",
Target: "admin@test.com",
Title: "Old Login",
Content: "Old Content",
Level: "INFO",
Status: "success",
CreatedAt: now.AddDate(0, 0, -10),
}
newPush := &model.PushHistory{
EventKey: "admin_login",
Channel: "lark",
Target: "http://webhook.com",
Title: "New Login",
Content: "New Content",
Level: "INFO",
Status: "success",
CreatedAt: now,
}
err = db.DB(ctx).Create(oldPush).Error
require.NoError(t, err)
err = db.DB(ctx).Create(newPush).Error
require.NoError(t, err)
// 执行 handler
handler := &CleanupUnusedUploadsHandler{}
handler := &service.SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
// 验证结果
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "共处理 2 个文件,成功删除 2 个")
assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 2/2 个;清理历史推送审计日志 1 条。")
// 验证数据库状态:pending 且超过1小时的应被标记为 deleted
var pendingCount int64
@@ -102,9 +131,19 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
var usedCount int64
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusUsed).Count(&usedCount)
assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响")
// 验证推送历史数据状态:10天前的应被删除,今天的应保留
var pushCount int64
db.DB(ctx).Model(&model.PushHistory{}).Count(&pushCount)
assert.Equal(t, int64(1), pushCount, "应只剩1条推送历史记录")
var remainingPush model.PushHistory
err = db.DB(ctx).First(&remainingPush).Error
require.NoError(t, err)
assert.Equal(t, "New Login", remainingPush.Title)
}
func TestCleanupUnusedUploadsHandler_ExecuteNoFiles(t *testing.T) {
func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -119,19 +158,21 @@ func TestCleanupUnusedUploadsHandler_ExecuteNoFiles(t *testing.T) {
defer storageMock()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
require.NoError(t, err)
// 没有任何上传记录
handler := &CleanupUnusedUploadsHandler{}
handler := &service.SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "共处理 0 个文件,成功删除 0 个")
assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 0/0 个;清理历史推送审计日志 0 条。")
}
func TestCleanupUnusedUploadsHandler_ImplementsTaskHandler(t *testing.T) {
// 编译期验证 CleanupUnusedUploadsHandler 实现了 TaskHandler 接口
var _ task.TaskHandler = (*CleanupUnusedUploadsHandler)(nil)
func TestSystemCleanupHandler_ImplementsTaskHandler(t *testing.T) {
// 编译期验证 SystemCleanupHandler 实现了 TaskHandler 接口
var _ task.TaskHandler = (*service.SystemCleanupHandler)(nil)
}
func TestWarmImageCacheHandlerValidatePayload(t *testing.T) {
+3
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/config"
@@ -171,6 +172,8 @@ func Login(c *gin.Context) {
logger.InfoF(ctx, "[LoginAudit] successful login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
custom_events.TriggerAdminLoginEvent(ctx, &user, c.ClientIP())
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
}
@@ -0,0 +1,34 @@
-- +goose Up
CREATE TABLE w_push_events (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
channels TEXT NOT NULL,
targets TEXT NOT NULL,
template TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled);
CREATE TABLE w_push_histories (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL,
channel VARCHAR(50) NOT NULL,
target VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
level VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
error_msg TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at);
-- +goose Down
DROP TABLE IF EXISTS w_push_histories;
DROP TABLE IF EXISTS w_push_events;
@@ -0,0 +1,27 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'push_config',
'[]',
'system',
0,
'通知推送渠道配置',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) ON CONFLICT (key) DO NOTHING;
INSERT INTO w_push_events (event_key, name, channels, targets, template, enabled, created_at, updated_at)
VALUES (
'admin_login',
'管理员登录',
'[]',
'[]',
'{"title": "管理员登录提醒", "content": "管理员 {{user.username}} 于 {{time}} 从 IP: {{ip}} 登录成功。", "level": "INFO"}',
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) ON CONFLICT (event_key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'push_config';
DELETE FROM w_push_events WHERE event_key = 'admin_login';
@@ -0,0 +1,8 @@
-- +goose Up
INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
SELECT setval(pg_get_serial_sequence('w_users', 'id'), COALESCE((SELECT MAX(id) FROM w_users), 1));
-- +goose Down
DELETE FROM w_users WHERE username = 'system';
@@ -0,0 +1,31 @@
-- +goose Up
CREATE TABLE w_push_channels (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255),
type VARCHAR(50) NOT NULL DEFAULT 'custom',
token VARCHAR(100),
url TEXT NOT NULL,
other TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled);
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'push_global_token',
'',
'system',
0,
'系统全局推送鉴权令牌',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'push_global_token';
DROP TABLE IF EXISTS w_push_channels;
@@ -0,0 +1,11 @@
-- +goose Up
UPDATE w_schedules
SET name = '系统定期垃圾清理',
task_type = 'system_cleanup'
WHERE id = 1;
-- +goose Down
UPDATE w_schedules
SET name = '清理未使用上传',
task_type = 'cleanup_unused_uploads'
WHERE id = 1;
@@ -0,0 +1,34 @@
-- +goose Up
CREATE TABLE w_push_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
channels TEXT NOT NULL,
targets TEXT NOT NULL,
template TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled);
CREATE TABLE w_push_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL,
channel TEXT NOT NULL,
target TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL,
status TEXT NOT NULL,
error_msg TEXT,
created_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at);
-- +goose Down
DROP TABLE IF EXISTS w_push_histories;
DROP TABLE IF EXISTS w_push_events;
@@ -0,0 +1,27 @@
-- +goose Up
INSERT OR IGNORE INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'push_config',
'[]',
'system',
0,
'通知推送渠道配置',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO w_push_events (event_key, name, channels, targets, template, enabled, created_at, updated_at)
VALUES (
'admin_login',
'管理员登录',
'[]',
'[]',
'{"title": "管理员登录提醒", "content": "管理员 {{user.username}} 于 {{time}} 从 IP: {{ip}} 登录成功。", "level": "INFO"}',
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'push_config';
DELETE FROM w_push_events WHERE event_key = 'admin_login';
@@ -0,0 +1,7 @@
-- +goose Up
INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
-- +goose Down
DELETE FROM w_users WHERE username = 'system';
@@ -0,0 +1,31 @@
-- +goose Up
CREATE TABLE w_push_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
type TEXT NOT NULL DEFAULT 'custom',
token TEXT,
url TEXT NOT NULL,
other TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled);
INSERT OR IGNORE INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'push_global_token',
'',
'system',
0,
'系统全局推送鉴权令牌',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'push_global_token';
DROP TABLE IF EXISTS w_push_channels;
@@ -0,0 +1,11 @@
-- +goose Up
UPDATE w_schedules
SET name = '系统定期垃圾清理',
task_type = 'system_cleanup'
WHERE id = 1;
-- +goose Down
UPDATE w_schedules
SET name = '清理未使用上传',
task_type = 'cleanup_unused_uploads'
WHERE id = 1;
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"gorm.io/gorm"
)
const expectedMigratedSystemConfigCount = 30
const expectedMigratedSystemConfigCount = 32
func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
)
const (
// TypeCustom 自定义消息通道类型
TypeCustom = "custom"
// TypeEmail 邮件推送消息通道类型
TypeEmail = "email"
)
// PushChannel 消息通道模型
type PushChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` // 通道名称,仅英文字母和下划线,唯一
Description string `json:"description" gorm:"size:255"` // 备注
Type string `json:"type" gorm:"size:50;not null;default:'custom'"` // 通道类型:custom, lark, email
Token string `json:"token" gorm:"size:100"` // 鉴权令牌或发信用户名等
URL string `json:"url" gorm:"type:text;not null"` // 请求地址,HTTPS 协议或 SMTP 地址
Other string `json:"other" gorm:"type:text;not null"` // 请求体/SMTP 密码等
Enabled bool `json:"enabled" gorm:"index;not null;default:true"` // 通道是否启用
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushChannel) TableName() string {
return "w_push_channels"
}
var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
// Validate 参数合法性与 JSON 格式校验
func (pc *PushChannel) Validate() error {
pc.Name = strings.TrimSpace(pc.Name)
pc.URL = strings.TrimSpace(pc.URL)
pc.Other = strings.TrimSpace(pc.Other)
pc.Type = strings.TrimSpace(pc.Type)
if pc.Type == "" {
pc.Type = TypeCustom
}
if pc.Name == "" {
return errors.New("channel name is required")
}
if !nameRegex.MatchString(pc.Name) {
return errors.New("channel name can only contain letters, numbers, and underscores")
}
if pc.Type != TypeEmail && pc.URL == "" {
return errors.New("request URL/address is required")
}
// For custom and lark, we must enforce https:// URL prefix for security.
// For email, it is an SMTP host:port, so no need for https:// prefix.
if pc.Type != TypeEmail && !strings.HasPrefix(pc.URL, "https://") {
return errors.New("request URL must use HTTPS protocol for security reasons")
}
switch pc.Type {
case TypeCustom:
if pc.Other == "" {
return errors.New("payload schema (request body) is required")
}
return validateJSON(pc.Other)
case TypeEmail:
// Email channel SMTP configs fall back to global settings, so they are not required to be filled.
}
return nil
}
func validateJSON(s string) error {
var jsonTest map[string]any
if err := json.Unmarshal([]byte(s), &jsonTest); err == nil {
return nil
}
var jsonArr []any
if err := json.Unmarshal([]byte(s), &jsonArr); err == nil {
return nil
}
return errors.New("payload schema must be a valid JSON format")
}
// GetPushChannelByName 根据名称获取消息通道
func GetPushChannelByName(ctx context.Context, name string) (*PushChannel, error) {
var channel PushChannel
err := db.DB(ctx).Where("name = ?", name).First(&channel).Error
if err != nil {
return nil, err
}
return &channel, nil
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
)
// PushEvent 系统通知事件模型
type PushEvent struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login
Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录
Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"]
Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表
Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON
Enabled bool `json:"enabled" 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 指定 GORM 表名
func (PushEvent) TableName() string {
return "w_push_events"
}
// Validate 基础校验
func (pe *PushEvent) Validate() error {
pe.EventKey = strings.TrimSpace(pe.EventKey)
pe.Name = strings.TrimSpace(pe.Name)
pe.Template = strings.TrimSpace(pe.Template)
if pe.EventKey == "" {
return errors.New("event key is required")
}
if pe.Name == "" {
return errors.New("event name is required")
}
if pe.Template == "" {
return errors.New("event template is required")
}
return nil
}
// GetActivePushEventByKey 获取启用的通知事件
func GetActivePushEventByKey(ctx context.Context, key string) (*PushEvent, error) {
var event PushEvent
err := db.DB(ctx).Where("event_key = ? AND enabled = ?", key, true).First(&event).Error
if err != nil {
return nil, err
}
return &event, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"time"
)
// PushHistory 推送日志/历史实体
type PushHistory struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"size:80;not null;index"`
Channel string `json:"channel" gorm:"size:50;not null"`
Target string `json:"target" gorm:"size:255;not null"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Level string `json:"level" gorm:"size:20;not null"`
Status string `json:"status" gorm:"size:20;not null"` // success / failed
ErrorMsg string `json:"error_msg" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
}
// TableName 指定表名
func (PushHistory) TableName() string {
return "w_push_histories"
}
+1
View File
@@ -49,6 +49,7 @@ const (
ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期)
ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库
ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON)
ConfigKeyPushConfig = "push_config" // 通知推送渠道配置 (JSON)
)
const (
+12 -12
View File
@@ -56,7 +56,7 @@ func TestCreateTaskExecution(t *testing.T) {
execution := &TaskExecution{
TaskID: "manual_cleanup_123",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -81,7 +81,7 @@ func TestGetTaskExecutionByTaskID(t *testing.T) {
// 创建记录
execution := &TaskExecution{
TaskID: "test_task_id_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -112,7 +112,7 @@ func TestGetTaskExecutionByID(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_by_id_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "system",
@@ -134,7 +134,7 @@ func TestUpdateTaskExecution(t *testing.T) {
// 创建记录
execution := &TaskExecution{
TaskID: "test_update_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -178,7 +178,7 @@ func TestUpdateTaskExecutionFailed(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_fail_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
Retryable: true,
@@ -212,7 +212,7 @@ func TestUpdateTaskExecutionDoesNotPersistBufferedLog(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_omit_log_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -249,7 +249,7 @@ func TestAppendTaskExecutionLog(t *testing.T) {
execution := &TaskExecution{
TaskID: "test_log_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusPending,
TriggeredBy: "manual",
@@ -334,7 +334,7 @@ func TestGetTaskExecutionLogPrefersRedis(t *testing.T) {
execution := &TaskExecution{
TaskID: "redis_priority_001",
TaskType: "upload:cleanup_unused",
TaskType: "system:cleanup",
TaskName: "清理未使用上传",
Status: TaskExecutionStatusRunning,
Log: "数据库旧日志",
@@ -358,10 +358,10 @@ func TestListTaskExecutions(t *testing.T) {
// 创建多条记录,包含不同状态和类型
records := []*TaskExecution{
{TaskID: "list_001", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusSucceeded, TriggeredBy: "manual"},
{TaskID: "list_002", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusFailed, TriggeredBy: "system"},
{TaskID: "list_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusSucceeded, TriggeredBy: "manual"},
{TaskID: "list_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusFailed, TriggeredBy: "system"},
{TaskID: "list_003", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusPending, TriggeredBy: "manual"},
{TaskID: "list_004", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusRunning, TriggeredBy: "manual"},
{TaskID: "list_004", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: TaskExecutionStatusRunning, TriggeredBy: "manual"},
{TaskID: "list_005", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusSucceeded, TriggeredBy: "system"},
}
for _, r := range records {
@@ -409,7 +409,7 @@ func TestListTaskExecutions(t *testing.T) {
assert.NotEqual(t, items[0].ID, items2[0].ID)
// 状态 + 类型组合筛选
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "succeeded", TaskType: "upload:cleanup_unused", Page: 1, PageSize: 10})
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "succeeded", TaskType: "system:cleanup", Page: 1, PageSize: 10})
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "list_001", items[0].TaskID)
+26
View File
@@ -20,6 +20,7 @@ import (
admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache"
admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage"
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template"
@@ -37,6 +38,7 @@ import (
// Swagger 文档生成
_ "github.com/Rain-kl/Wavelet/docs"
_ "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/config"
@@ -59,6 +61,11 @@ func Serve() {
// 初始化 ClickHouse 异步日志写入器
risk_control.InitLogWriter()
// 运行内置事件同步
if err := admin_push.SyncEvents(context.Background()); err != nil {
log.Printf("[API] sync push events failed: %v\n", err)
}
// 初始化路由
r := gin.New()
r.Use(gin.Recovery())
@@ -171,6 +178,7 @@ func registerRoutes(r *gin.Engine) {
// Register custom business routes
registerCustomRoutes(apiV1Router)
}
}
@@ -332,6 +340,24 @@ func registerAdminRoutes(apiV1Router *gin.RouterGroup) {
adminRouter.PUT("/auth-sources/:id", admin_auth_source.UpdateAuthSource)
adminRouter.PUT("/auth-sources/:id/toggle", admin_auth_source.ToggleAuthSource)
adminRouter.DELETE("/auth-sources/:id", admin_auth_source.DeleteAuthSource)
// Push Notifications
adminRouter.GET("/push/events", admin_push.ListEvents)
adminRouter.GET("/push/events/builtin", admin_push.ListBuiltInEvents)
adminRouter.POST("/push/events", admin_push.CreateEvent)
adminRouter.PUT("/push/events/:id", admin_push.UpdateEvent)
adminRouter.DELETE("/push/events/:id", admin_push.DeleteEvent)
adminRouter.POST("/push/events/:id/toggle", admin_push.ToggleEvent)
adminRouter.GET("/push/histories", admin_push.ListHistories)
adminRouter.POST("/push/test", admin_push.TestPush)
// Message Channels CRUD
adminRouter.GET("/push/channels/definitions", admin_push.ListChannelDefinitions)
adminRouter.GET("/push/channels", admin_push.ListChannels)
adminRouter.POST("/push/channels", admin_push.CreateChannel)
adminRouter.PUT("/push/channels/:id", admin_push.UpdateChannel)
adminRouter.DELETE("/push/channels/:id", admin_push.DeleteChannel)
adminRouter.POST("/push/channels/test", admin_push.TestChannel)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/logger"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/task"
"gorm.io/gorm"
)
// 异步任务名称与管理类型定义
const (
// SystemCleanupTask 系统定期垃圾清理任务标识
SystemCleanupTask = "system:cleanup"
// TaskTypeSystemCleanup 系统定期垃圾清理管理类型
TaskTypeSystemCleanup = "system_cleanup"
// 错误描述常量
errStorageReadOnly = "存储迁移维护中,当前仅允许读取文件"
errQueryUnusedUploadsFailed = "查询未使用的上传文件失败: %w"
)
// SystemCleanupMeta represents the task metadata.
var SystemCleanupMeta = task.TaskMeta{
Type: TaskTypeSystemCleanup,
AsynqTask: SystemCleanupTask,
Name: "系统垃圾清理",
Description: "定期清理超过1小时的未使用上传文件和超过7天的历史推送记录",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
Retryable: true,
}
// SystemCleanupHandler 系统定期垃圾清理异步任务处理器
type SystemCleanupHandler struct{}
// Execute 执行系统清理(包含文件清理和历史消息推送日志清理)
func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
if storageReadOnly(ctx) {
return nil, errors.New(errStorageReadOnly)
}
const batchSize = 100 // 每批处理100个文件
var lastID uint64
var totalProcessed int
var totalDeleted int
// 计算1小时前的时间
oneHourAgo := time.Now().Add(-1 * time.Hour)
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339))
for {
// 使用游标分页查询未使用且超过1小时的上传记录
var unusedUploads []model.Upload
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&unusedUploads).Error; err != nil {
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
return nil, fmt.Errorf(errQueryUnusedUploadsFailed, err)
}
// 没有更多数据,退出循环
if len(unusedUploads) == 0 {
break
}
task.AppendLog(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
// 处理每个未使用的上传文件
for _, u := range unusedUploads {
totalProcessed++
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新上传记录状态
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", u.ID, model.UploadStatusPending).
Update("status", model.UploadStatusDeleted).Error; err != nil {
return err
}
driver := storage.Driver(u.StorageDriver)
if driver == "" {
driver = storage.DriverLocal
}
backend, err := storage.ForDriver(ctx, driver)
if err != nil {
return err
}
if err := backend.Delete(ctx, u.FilePath); err != nil {
return err
}
return nil
}); err != nil {
task.AppendLog(ctx, "清理上传文件失败 [ID:%d]: %v", u.ID, err)
lastID = u.ID
continue
}
totalDeleted++
lastID = u.ID
}
}
// 2. 清理超过7天的历史推送日志
task.AppendLog(ctx, "开始清理历史推送审计日志,只保留最近7天数据...")
cutoff := time.Now().AddDate(0, 0, -7)
var pushHistoryCount int64
if err := db.DB(ctx).Model(&model.PushHistory{}).Where("created_at < ?", cutoff).Count(&pushHistoryCount).Error; err != nil {
task.AppendLog(ctx, "统计待清理的历史推送记录失败: %v", err)
} else if pushHistoryCount > 0 {
if err := db.DB(ctx).Where("created_at < ?", cutoff).Delete(&model.PushHistory{}).Error; err != nil {
task.AppendLog(ctx, "删除历史推送记录失败: %v", err)
} else {
task.AppendLog(ctx, "成功删除 %d 条历史推送记录 (截止时间: %s)", pushHistoryCount, cutoff.Format("2006-01-02 15:04:05"))
}
} else {
task.AppendLog(ctx, "没有需要清理的历史推送记录 (截止时间: %s)", cutoff.Format("2006-01-02 15:04:05"))
}
msg := fmt.Sprintf("系统清理完成。成功清理未使用的上传文件 %d/%d 个;清理历史推送审计日志 %d 条。", totalDeleted, totalProcessed, pushHistoryCount)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
func storageReadOnly(ctx context.Context) bool {
var execution model.TaskExecution
err := db.DB(ctx).Where("task_type = ?", "storage:migrate").Order("id DESC").First(&execution).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false
}
logger.ErrorF(ctx, "读取存储维护状态失败: %v", err)
return true
}
return execution.Status != model.TaskExecutionStatusSucceeded
}
+33 -6
View File
@@ -67,6 +67,16 @@ func GetTaskID(ctx context.Context) string {
return ""
}
// IsFinalAttempt 判断当前任务执行是否为最后一次重试尝试(若再次失败即为最终失败)
func IsFinalAttempt(ctx context.Context) bool {
retryCount, hasRetryCount := asynq.GetRetryCount(ctx)
maxRetry, hasMaxRetry := asynq.GetMaxRetry(ctx)
if !hasRetryCount || !hasMaxRetry {
return true
}
return retryCount >= maxRetry
}
// AppendLog 追加日志到任务执行记录
// 在 TaskHandler.Execute 中调用,日志会自动追加到 TaskExecution.Log 字段
func AppendLog(ctx context.Context, format string, args ...interface{}) {
@@ -235,12 +245,8 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
// 加载或动态创建执行记录
now := time.Now()
execution, err := getOrCreateTaskExecution(ctx, taskID, t, now)
if err == nil && execution != nil && execution.TriggeredBy != "schedule" {
execution.Status = model.TaskExecutionStatusRunning
execution.StartedAt = &now
if updateErr := model.UpdateTaskExecution(ctx, execution); updateErr != nil {
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, updateErr)
}
if err == nil {
updateExecutionOnStart(ctx, execution, now)
}
if execution != nil {
@@ -270,6 +276,27 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
return execErr
}
func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution, now time.Time) {
if execution == nil {
return
}
dirty := false
if retryCount, hasRetry := asynq.GetRetryCount(ctx); hasRetry && execution.RetryCount != retryCount {
execution.RetryCount = retryCount
dirty = true
}
if execution.Status != model.TaskExecutionStatusRunning {
execution.Status = model.TaskExecutionStatusRunning
execution.StartedAt = &now
dirty = true
}
if dirty {
if updateErr := model.UpdateTaskExecution(ctx, execution); updateErr != nil {
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr)
}
}
}
// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录
func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, now time.Time) (*model.TaskExecution, error) {
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
+10 -2
View File
@@ -6,8 +6,10 @@
package handlers
import (
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/task"
)
@@ -16,13 +18,19 @@ func Register() {
task.RegisterHandler(upload.StorageMigrationTask, &upload.MigrationHandler{})
task.RegisterTaskMeta(upload.StorageMigrationMeta)
// system cleanup
task.RegisterHandler(service.SystemCleanupTask, &service.SystemCleanupHandler{})
task.RegisterTaskMeta(service.SystemCleanupMeta)
// upload
task.RegisterHandler(upload.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
task.RegisterTaskMeta(upload.CleanupUnusedUploadsMeta)
task.RegisterHandler(upload.WarmImageCacheTask, &upload.WarmImageCacheHandler{})
task.RegisterTaskMeta(upload.WarmImageCacheMeta)
// user
task.RegisterHandler(user.SendEmailTask, &user.SendEmailHandler{})
task.RegisterTaskMeta(user.SendEmailMeta)
// push
task.RegisterHandler(push.SendNotificationTask, &push.PushHandler{})
task.RegisterTaskMeta(push.SendNotificationMeta)
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
func init() {
Register("custom", &CustomPusher{})
}
// CustomPusher 自定义 Webhook 发送实现
type CustomPusher struct{}
// Send 发送自定义 webhook
func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) error {
if cfg.URL == "" {
return errors.New("custom: URL is required")
}
var reqBody []byte
if template != "" {
// 替换模板中的 {{key}} 占位符
rendered := ParseTemplate(template, body)
reqBody = []byte(rendered)
} else {
// 兜底:直接把 body 转为 JSON 字符串发送
var err error
reqBody, err = json.Marshal(body)
if err != nil {
return fmt.Errorf("custom: marshal body failed: %w", err)
}
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("custom: create http request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// 如果配置了 Key 且格式为 "HeaderName:HeaderValue",我们可以附加测试用 Header
if cfg.Key != "" && strings.Contains(cfg.Key, ":") {
parts := strings.SplitN(cfg.Key, ":", 2) //nolint:mnd
httpReq.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("custom: http request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("custom: http status %s", resp.Status)
}
return nil
}
// ValidateConfig 校验自定义配置
func (p *CustomPusher) ValidateConfig(cfg Config) error {
if cfg.URL == "" {
return errors.New("webhook URL is required")
}
if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
return errors.New("webhook URL must start with http:// or https://")
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"context"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
)
func init() {
Register("email", &EmailPusher{})
}
// EmailPusher 极简 SMTP 邮件推送实现 (静态、解耦)
type EmailPusher struct{}
// Send 发送邮件
func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) error {
if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" {
return errors.New("email: SMTP configuration (url, key, secret) is incomplete")
}
if target == "" {
return errors.New("email: target email address is required")
}
title := defaultTitle
if t, ok := body["title"].(string); ok && t != "" {
title = t
}
content := ""
if c, ok := body["content"].(string); ok && c != "" {
content = c
} else {
// 自动格式化 map
var parts []string
for k, v := range body {
parts = append(parts, fmt.Sprintf("<p><b>%s</b>: %v</p>", k, v))
}
content = strings.Join(parts, "")
}
// 邮件头和体
from := cfg.Key
to := target
// 如果 ext 中指定了 from_name,我们在 From 头部包含它
fromName := "System Notification"
if ext != nil {
if fn, ok := ext["from_name"].(string); ok && fn != "" {
fromName = fn
}
}
subjectHeader := fmt.Sprintf("Subject: %s\r\n", title)
fromHeader := fmt.Sprintf("From: %s <%s>\r\n", fromName, from)
toHeader := fmt.Sprintf("To: %s\r\n", to)
mimeHeader := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n"
// 拼装完整的邮件报文
// 简单的 HTML 正文渲染
htmlBody := fmt.Sprintf(`<html><body><h2>%s</h2><div>%s</div></body></html>`, title, content)
msg := []byte(fromHeader + toHeader + subjectHeader + mimeHeader + htmlBody + "\r\n")
// 解析 Host 和 Port
host, port, err := net.SplitHostPort(cfg.URL)
if err != nil {
host = cfg.URL
port = "25" // 默认 SMTP 端口
}
auth := smtp.PlainAuth("", cfg.Key, cfg.Secret, host)
// 异步超时处理
errChan := make(chan error, 1)
go func() {
errChan <- smtp.SendMail(host+":"+port, auth, from, []string{to}, msg)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errChan:
if err != nil {
return fmt.Errorf("email: send smtp mail failed: %w", err)
}
}
return nil
}
// ValidateConfig 校验邮件 SMTP 配置
func (p *EmailPusher) ValidateConfig(cfg Config) error {
if cfg.URL == "" {
return errors.New("SMTP host:port is required")
}
if cfg.Key == "" {
return errors.New("SMTP username is required")
}
if cfg.Secret == "" {
return errors.New("SMTP password is required")
}
return nil
}
+273
View File
@@ -0,0 +1,273 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func init() {
Register("lark", &LarkPusher{})
}
const (
msgTypeInteractive = "interactive"
)
// LarkPusher 飞书 Webhook 机器人推送实现
type LarkPusher struct{}
type larkTextContent struct {
Text string `json:"text"`
}
type larkCardHeaderTitle struct {
Content string `json:"content"`
Tag string `json:"tag"`
}
type larkCardHeader struct {
Template string `json:"template"` // "blue", "orange", "red" etc.
Title larkCardHeaderTitle `json:"title"`
}
type larkCardElementText struct {
Content string `json:"content"`
Tag string `json:"tag"` // "lark_md"
}
type larkCardElement struct {
Tag string `json:"tag"` // "div"
Text larkCardElementText `json:"text"`
}
type larkCardContent struct {
Header larkCardHeader `json:"header"`
Elements []larkCardElement `json:"elements"`
}
type larkMessageRequest struct {
MessageType string `json:"msg_type"`
Timestamp string `json:"timestamp,omitempty"`
Sign string `json:"sign,omitempty"`
Content larkTextContent `json:"content,omitempty"`
Card *larkCardContent `json:"card,omitempty"`
}
type larkMessageResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// Send 执行飞书消息发送
//
//nolint:nestif,cyclop
func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) error {
if cfg.URL == "" {
return errors.New("lark: URL is required")
}
var req larkMessageRequest
// 1. 如果有自定义模板,我们尝试进行解析
if template != "" {
rendered := ParseTemplate(template, body)
// 尝试解析原生的 Lark Card
var customCard larkCardContent
var rawMap map[string]any
_ = json.Unmarshal([]byte(rendered), &rawMap)
if rawMap != nil && rawMap["elements"] != nil {
// 如果包含 elements 字段,说明是用户定制的原生飞书卡片 JSON
if err := json.Unmarshal([]byte(rendered), &customCard); err == nil {
req.MessageType = msgTypeInteractive
req.Card = &customCard
} else {
req.MessageType = "text"
req.Content.Text = rendered
}
} else {
// 说明配置的是系统统一通知消息 of JSON 模板:{"title": "...", "content": "...", "level": "..."}
type larkNotificationMessage struct {
Title string `json:"title"`
Content string `json:"content"`
Level string `json:"level"`
}
var msg larkNotificationMessage
if err := json.Unmarshal([]byte(rendered), &msg); err == nil && (msg.Title != "" || msg.Content != "") {
title := msg.Title
if title == "" {
title = defaultTitle
}
content := msg.Content
level := strings.ToUpper(msg.Level)
if level == "" {
level = "INFO"
}
headerColor := "blue"
switch level {
case "IMPORTANT":
headerColor = "orange"
case "CRITICAL":
headerColor = "red"
}
req.MessageType = msgTypeInteractive
req.Card = &larkCardContent{
Header: larkCardHeader{
Template: headerColor,
Title: larkCardHeaderTitle{
Content: title,
Tag: "plain_text",
},
},
Elements: []larkCardElement{
{
Tag: "div",
Text: larkCardElementText{
Content: content,
Tag: "lark_md",
},
},
},
}
} else {
// 兜底:如果无法按 JSON 解析出结构化字段,当做普通文本发送
req.MessageType = "text"
req.Content.Text = rendered
}
}
} else {
// 2. 如果无模板,默认生成一个精美的飞书互动卡片
title := defaultTitle
if t, ok := body["title"].(string); ok && t != "" {
title = t
}
content := ""
if c, ok := body["content"].(string); ok && c != "" {
content = c
} else {
// 兜底:如果连 content 都没有,把 body 里的所有值拼成 markdown
var parts []string
for k, v := range body {
parts = append(parts, fmt.Sprintf("**%s**: %v", k, v))
}
content = strings.Join(parts, "\n")
}
level := "INFO"
if l, ok := body["level"].(string); ok && l != "" {
level = strings.ToUpper(l)
}
// 根据级别确定飞书卡片头部的背景色模板
headerColor := "blue"
switch level {
case "IMPORTANT":
headerColor = "orange"
case "CRITICAL":
headerColor = "red"
}
req.MessageType = msgTypeInteractive
req.Card = &larkCardContent{
Header: larkCardHeader{
Template: headerColor,
Title: larkCardHeaderTitle{
Content: title,
Tag: "plain_text",
},
},
Elements: []larkCardElement{
{
Tag: "div",
Text: larkCardElementText{
Content: content,
Tag: "lark_md",
},
},
},
}
}
// 3. 计算签名 (如果配置了 secret)
if cfg.Secret != "" {
timestamp := time.Now().Unix()
sign, err := larkSign(cfg.Secret, timestamp)
if err != nil {
return fmt.Errorf("lark: sign failed: %w", err)
}
req.Timestamp = strconv.FormatInt(timestamp, 10)
req.Sign = sign
}
jsonData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("lark: marshal request failed: %w", err)
}
// 4. 发送 POST 请求
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("lark: create http request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("lark: http request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("lark: http status %s", resp.Status)
}
var res larkMessageResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return fmt.Errorf("lark: decode response failed: %w", err)
}
if res.Code != 0 {
return fmt.Errorf("lark: send message failed, code %d: %s", res.Code, res.Msg)
}
return nil
}
// ValidateConfig 校验飞书配置
func (p *LarkPusher) ValidateConfig(cfg Config) error {
if cfg.URL == "" {
return errors.New("webhook URL is required")
}
if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
return errors.New("webhook URL must start with http:// or https://")
}
return nil
}
func larkSign(secret string, timestamp int64) (string, error) {
stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret
h := hmac.New(sha256.New, []byte(stringToSign))
_, err := h.Write(nil)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package push 提供解耦的、无外部业务依赖 of 通知推送底层实现
package push
import (
"context"
"fmt"
"sync"
)
const defaultTitle = "系统通知"
// Config 基础通知渠道配置
type Config struct {
Channel string `json:"channel"` // 渠道名称,例如 "lark", "custom", "email" 等,唯一标识
URL string `json:"url,omitempty"` // Webhook 地址或 SMTP 地址
Secret string `json:"secret,omitempty"` // 签名密钥或 SMTP 密码/Token
Key string `json:"key,omitempty"` // AppID 或 SMTP 用户名
Ext map[string]any `json:"ext,omitempty"` // 预留拓展 JSON 配置
}
// Pusher 通知推送渠道接口
type Pusher interface {
// Send 发送通知消息
// target: 发送目标 (如邮箱地址或特定用户标识;若为 bot 机器人此项为空)
// body: 消息体数据 (含默认字段如 title, content, level)
// template: 消息卡片/模板 JSON (可选)
// ext: 预留的单次发送拓展数据
Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, ext map[string]any) error
// ValidateConfig 校验渠道配置合法性
ValidateConfig(cfg Config) error
}
var (
pushersMu sync.RWMutex
pushers = make(map[string]Pusher)
)
// Register 注册一个推送渠道实现
func Register(channelType string, pusher Pusher) {
pushersMu.Lock()
defer pushersMu.Unlock()
if pusher == nil {
panic("push: Register pusher is nil")
}
pushers[channelType] = pusher
}
// GetPusher 获取指定类型的推送渠道实现
func GetPusher(channelType string) (Pusher, error) {
pushersMu.RLock()
defer pushersMu.RUnlock()
pusher, ok := pushers[channelType]
if !ok {
return nil, fmt.Errorf("push: unknown channel type %q", channelType)
}
return pusher, nil
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body.
// It is a single-pass parser designed for high performance and low allocations.
func ParseTemplate(template string, body map[string]any) string {
var buf strings.Builder
buf.Grow(len(template))
i := 0
for {
pos := strings.Index(template[i:], "{{")
if pos == -1 {
buf.WriteString(template[i:])
break
}
// Write prefix
buf.WriteString(template[i : i+pos])
i += pos + 2 // skip "{{"
endPos := strings.Index(template[i:], "}}")
if endPos == -1 {
// Unbalanced "{{"
buf.WriteString("{{")
buf.WriteString(template[i:])
break
}
key := template[i : i+endPos]
if val, ok := body[key]; ok {
buf.WriteString(formatValue(val))
} else {
// Keep the placeholder if key not found
buf.WriteString("{{")
buf.WriteString(key)
buf.WriteString("}}")
}
i += endPos + 2 // skip "}}"
}
return buf.String()
}
func formatValue(v any) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
case int:
return strconv.Itoa(val)
case int32:
return strconv.FormatInt(int64(val), 10)
case int64:
return strconv.FormatInt(val, 10)
case float64:
return strconv.FormatFloat(val, 'f', -1, 64)
case bool:
return strconv.FormatBool(val)
default:
// If it's a map, slice, or struct, marshal it to JSON.
b, err := json.Marshal(v)
if err == nil {
return string(b)
}
return fmt.Sprintf("%v", v)
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseTemplate(t *testing.T) {
tests := []struct {
name string
template string
body map[string]any
expected string
}{
{
name: "simple replacement",
template: "hello {{name}}",
body: map[string]any{"name": "world"},
expected: "hello world",
},
{
name: "multiple replacements",
template: "{{greeting}} {{name}}!",
body: map[string]any{"greeting": "Hello", "name": "Alice"},
expected: "Hello Alice!",
},
{
name: "missing key preserves placeholder",
template: "hello {{name}} and {{other}}",
body: map[string]any{"name": "world"},
expected: "hello world and {{other}}",
},
{
name: "unbalanced placeholders",
template: "hello {{name",
body: map[string]any{"name": "world"},
expected: "hello {{name",
},
{
name: "nil value",
template: "val: {{val}}",
body: map[string]any{"val": nil},
expected: "val: ",
},
{
name: "basic types",
template: "int: {{i}}, float: {{f}}, bool: {{b}}",
body: map[string]any{"i": 123, "f": 45.67, "b": true},
expected: "int: 123, float: 45.67, bool: true",
},
{
name: "complex type slice",
template: "items: {{items}}",
body: map[string]any{"items": []string{"a", "b"}},
expected: `items: ["a","b"]`,
},
{
name: "complex type map",
template: "obj: {{obj}}",
body: map[string]any{"obj": map[string]any{"key": "value"}},
expected: `obj: {"key":"value"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseTemplate(tt.template, tt.body)
assert.Equal(t, tt.expected, result)
})
}
}