feat(push-task): connect notification module with task module

- Add task_type to w_push_events table and GORM models.
- Implement OnTaskCompleted callback hook in task executor to avoid circular dependencies.
- Implement task listener in push package to trigger notifications on task completion.
- Automatically resolve User objects from payload and results.
- Enhance UI to select task completed events and preview default templates.
- Update Swagger documentation.
This commit is contained in:
ryan
2026-06-15 15:12:02 +08:00
parent de58b118b4
commit 2a3b9f8a5f
12 changed files with 540 additions and 60 deletions
+9 -4
View File
@@ -1458,7 +1458,7 @@ const docTemplate = `{
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限", "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -6012,6 +6012,10 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"task_type": {
"description": "关联的异步任务类型",
"type": "string"
},
"template": { "template": {
"description": "消息模板 JSON", "description": "消息模板 JSON",
"type": "string" "type": "string"
@@ -6513,9 +6517,6 @@ const docTemplate = `{
}, },
"push.CreateEventRequest": { "push.CreateEventRequest": {
"type": "object", "type": "object",
"required": [
"event_key"
],
"properties": { "properties": {
"channels": { "channels": {
"type": "array", "type": "array",
@@ -6535,6 +6536,10 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"task_type": {
"description": "关联的异步任务类型",
"type": "string"
},
"template": { "template": {
"type": "string" "type": "string"
} }
+9 -4
View File
@@ -1451,7 +1451,7 @@
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限", "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -6005,6 +6005,10 @@
"type": "string" "type": "string"
} }
}, },
"task_type": {
"description": "关联的异步任务类型",
"type": "string"
},
"template": { "template": {
"description": "消息模板 JSON", "description": "消息模板 JSON",
"type": "string" "type": "string"
@@ -6506,9 +6510,6 @@
}, },
"push.CreateEventRequest": { "push.CreateEventRequest": {
"type": "object", "type": "object",
"required": [
"event_key"
],
"properties": { "properties": {
"channels": { "channels": {
"type": "array", "type": "array",
@@ -6528,6 +6529,10 @@
"type": "string" "type": "string"
} }
}, },
"task_type": {
"description": "关联的异步任务类型",
"type": "string"
},
"template": { "template": {
"type": "string" "type": "string"
} }
+7 -3
View File
@@ -361,6 +361,9 @@ definitions:
items: items:
type: string type: string
type: array type: array
task_type:
description: 关联的异步任务类型
type: string
template: template:
description: 消息模板 JSON description: 消息模板 JSON
type: string type: string
@@ -715,10 +718,11 @@ definitions:
items: items:
type: string type: string
type: array type: array
task_type:
description: 关联的异步任务类型
type: string
template: template:
type: string type: string
required:
- event_key
type: object type: object
push.Definition: push.Definition:
properties: properties:
@@ -2268,7 +2272,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限 description: 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限
parameters: parameters:
- description: 创建参数 - description: 创建参数
in: body in: body
@@ -63,6 +63,12 @@ export function EventsTab() {
queryFn: () => services.push.listBuiltInEvents(), queryFn: () => services.push.listBuiltInEvents(),
}) })
// --- 获取所有系统可调度任务类型 ---
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.admin.getTaskTypes(),
})
// --- 修改保存事件 Mutation --- // --- 修改保存事件 Mutation ---
const updateEventMutation = useMutation({ const updateEventMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data), mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data),
@@ -93,6 +99,8 @@ export function EventsTab() {
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] }) queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setCreateEventOpen(false) setCreateEventOpen(false)
setNewEventKey("") setNewEventKey("")
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventChannels([]) setNewEventChannels([])
setNewEventEnabled(true) setNewEventEnabled(true)
}, },
@@ -121,7 +129,9 @@ export function EventsTab() {
// 事件创建对话框状态 // 事件创建对话框状态
const [createEventOpen, setCreateEventOpen] = React.useState(false) const [createEventOpen, setCreateEventOpen] = React.useState(false)
const [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
const [newEventKey, setNewEventKey] = React.useState("") const [newEventKey, setNewEventKey] = React.useState("")
const [newEventTaskType, setNewEventTaskType] = React.useState("")
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([]) const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
const [newEventTargets, setNewEventTargets] = React.useState("") const [newEventTargets, setNewEventTargets] = React.useState("")
const [newEventTemplate, setNewEventTemplate] = React.useState("") const [newEventTemplate, setNewEventTemplate] = React.useState("")
@@ -168,6 +178,8 @@ export function EventsTab() {
const handleCreateEventClick = () => { const handleCreateEventClick = () => {
setNewEventKey("") setNewEventKey("")
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventChannels([]) setNewEventChannels([])
setNewEventTargets("") setNewEventTargets("")
setNewEventTemplate("") setNewEventTemplate("")
@@ -185,11 +197,30 @@ export function EventsTab() {
} }
} }
const handleNewEventTaskTypeChange = (taskType: string) => {
setNewEventTaskType(taskType)
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
level: "INFO",
}
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
} else {
setNewEventTemplate("")
}
}
const handleCreateEvent = () => { const handleCreateEvent = () => {
if (!newEventKey) { if (newEventType === "builtin" && !newEventKey) {
toast.error("请选择系统事件") toast.error("请选择系统事件")
return return
} }
if (newEventType === "task" && !newEventTaskType) {
toast.error("请选择异步任务")
return
}
if (newEventTemplate) { if (newEventTemplate) {
try { try {
@@ -206,7 +237,8 @@ export function EventsTab() {
.filter(t => t !== "") .filter(t => t !== "")
createEventMutation.mutate({ createEventMutation.mutate({
event_key: newEventKey, event_key: newEventType === "builtin" ? newEventKey : undefined,
task_type: newEventType === "task" ? newEventTaskType : undefined,
channels: newEventChannels, channels: newEventChannels,
targets: targets.length > 0 ? targets : undefined, targets: targets.length > 0 ? targets : undefined,
template: newEventTemplate || undefined, template: newEventTemplate || undefined,
@@ -253,7 +285,14 @@ export function EventsTab() {
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell> <TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
<TableCell className="py-1"> <TableCell className="py-1">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span> <div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
{event.task_type && (
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
任务
</Badge>
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span> <span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
</div> </div>
</TableCell> </TableCell>
@@ -353,33 +392,96 @@ export function EventsTab() {
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold">系统事件</Label> <Label className="text-xs font-semibold">事件类型</Label>
{builtInEventsQuery.isLoading ? ( <div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<Loader2 className="size-3.5 animate-spin" /> <input
<span>加载系统事件中...</span> type="radio"
</div> name="eventType"
) : availableBuiltInEvents.length === 0 ? ( checked={newEventType === "builtin"}
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20"> onChange={() => {
所有内置事件都已配置,没有可新增的事件。 setNewEventType("builtin")
</div> setNewEventTaskType("")
) : ( setNewEventTemplate("")
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}> }}
<SelectTrigger className="text-xs h-9"> className="scale-90"
<SelectValue placeholder="请选择系统事件" /> />
</SelectTrigger> <span>系统内置事件</span>
<SelectContent> </label>
{availableBuiltInEvents.map(ev => ( <label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<SelectItem key={ev.key} value={ev.key} className="text-xs"> <input
{ev.name} ({ev.key}) type="radio"
</SelectItem> name="eventType"
))} checked={newEventType === "task"}
</SelectContent> onChange={() => {
</Select> setNewEventType("task")
)} setNewEventKey("")
setNewEventTemplate("")
}}
className="scale-90"
/>
<span>任务完成事件</span>
</label>
</div>
</div> </div>
{newEventKey && ( {newEventType === "builtin" ? (
<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>
) : (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">系统异步任务</Label>
{taskTypesQuery.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>加载异步任务中...</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
暂无可用的系统任务。
</div>
) : (
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="请选择异步任务" />
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map(taskMeta => (
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
{taskMeta.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{newEventType === "builtin" && newEventKey && (
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1"> <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 className="font-semibold text-foreground">事件说明:</span>
<span> <span>
@@ -388,6 +490,15 @@ export function EventsTab() {
</div> </div>
)} )}
{newEventType === "task" && newEventTaskType && (
<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>
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.description || "无描述信息"}
</span>
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label> <Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover> <Popover>
@@ -419,7 +530,7 @@ export function EventsTab() {
}} }}
/> />
<span> <span>
{ch === "email" ? "邮件推送 (内置)" : ch} {ch === "email" ? "邮件推送" : ch}
</span> </span>
</label> </label>
))} ))}
@@ -448,7 +559,9 @@ export function EventsTab() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label> <Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center"> <span className="text-[10px] text-muted-foreground font-mono flex items-center">
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"} {newEventType === "task"
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
</span> </span>
</div> </div>
<Textarea <Textarea
@@ -479,7 +592,7 @@ export function EventsTab() {
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
disabled={createEventMutation.isPending || availableBuiltInEvents.length === 0} disabled={createEventMutation.isPending || (newEventType === "builtin" && availableBuiltInEvents.length === 0)}
onClick={handleCreateEvent} onClick={handleCreateEvent}
className="h-9 px-5 text-xs" className="h-9 px-5 text-xs"
> >
@@ -513,6 +626,17 @@ export function EventsTab() {
</div> </div>
</div> </div>
{selectedEvent.task_type && (
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
disabled
className="text-xs h-9 bg-muted"
/>
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label> <Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
<Popover> <Popover>
@@ -544,7 +668,7 @@ export function EventsTab() {
}} }}
/> />
<span> <span>
{ch === "email" ? "邮件推送 (内置)" : ch} {ch === "email" ? "邮件推送" : ch}
</span> </span>
</label> </label>
))} ))}
@@ -573,7 +697,9 @@ export function EventsTab() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label> <Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
<span className="text-[10px] text-muted-foreground font-mono flex items-center"> <span className="text-[10px] text-muted-foreground font-mono flex items-center">
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"} {selectedEvent.task_type
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
</span> </span>
</div> </div>
<Textarea <Textarea
@@ -17,7 +17,17 @@ import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading" import {LoadingStateWithBorder} from "@/components/layout/loading"
import {cn} from "@/lib/utils" import {cn} from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import services from "@/lib/services" import services from "@/lib/services"
import type {PushHistory} from "@/lib/services/push"
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" { function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
switch (level) { switch (level) {
@@ -35,6 +45,14 @@ export function HistoriesTab() {
const [historySearch, setHistorySearch] = React.useState("") const [historySearch, setHistorySearch] = React.useState("")
const [historyStatus, setHistoryStatus] = React.useState("all") const [historyStatus, setHistoryStatus] = React.useState("all")
const [detailOpen, setDetailOpen] = React.useState(false)
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
const handleRowClick = (hist: PushHistory) => {
setSelectedHistory(hist)
setDetailOpen(true)
}
const historiesQuery = useQuery({ const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus], queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryFn: () => queryFn: () =>
@@ -124,7 +142,11 @@ export function HistoriesTab() {
</TableRow> </TableRow>
) : ( ) : (
(historiesQuery.data?.results ?? []).map(hist => ( (historiesQuery.data?.results ?? []).map(hist => (
<TableRow key={hist.id} className="hover:bg-muted/10"> <TableRow
key={hist.id}
className="hover:bg-muted/10 cursor-pointer transition-colors"
onClick={() => handleRowClick(hist)}
>
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell> <TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
<TableCell className="text-xs uppercase font-semibold text-muted-foreground"> <TableCell className="text-xs uppercase font-semibold text-muted-foreground">
{hist.channel === "email" ? "邮件" : hist.channel} {hist.channel === "email" ? "邮件" : hist.channel}
@@ -187,6 +209,104 @@ export function HistoriesTab() {
</div> </div>
</div> </div>
)} )}
{/* ==================== 对话框:推送详情 ==================== */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="size-5 text-primary" />
推送通知详情
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
</DialogDescription>
</DialogHeader>
{selectedHistory && (
<div className="space-y-4 py-4 text-xs">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">事件标识</span>
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送渠道</span>
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">推送目标</span>
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
{selectedHistory.target}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送时间</span>
<div className="bg-muted/40 p-2 rounded border">
{new Date(selectedHistory.created_at).toLocaleString()}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知等级</span>
<div>
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
{selectedHistory.level}
</Badge>
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送状态</span>
<div>
<Badge
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold py-0.5 px-2"
>
{selectedHistory.status === "success" ? "成功" : "失败"}
</Badge>
</div>
</div>
</div>
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
<div className="space-y-1.5">
<span className="font-semibold text-destructive block">失败原因</span>
<div className="font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all">
{selectedHistory.error_msg}
</div>
</div>
)}
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知标题</span>
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
{selectedHistory.title}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知内容</span>
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
{selectedHistory.content}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+3 -1
View File
@@ -5,6 +5,7 @@ export interface PushEvent {
id: number; id: number;
event_key: string; event_key: string;
name: string; name: string;
task_type?: string;
channels: string[]; channels: string[];
targets: string[]; targets: string[];
template: string; template: string;
@@ -71,7 +72,8 @@ export interface EventMetadata {
} }
export interface CreatePushEventRequest { export interface CreatePushEventRequest {
event_key: string; event_key?: string;
task_type?: string;
channels: string[]; channels: string[];
targets?: string[]; targets?: string[];
template?: string; template?: string;
+51 -14
View File
@@ -15,6 +15,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/util" "github.com/Rain-kl/Wavelet/internal/util"
"github.com/Rain-kl/Wavelet/pkg/push" "github.com/Rain-kl/Wavelet/pkg/push"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -84,7 +85,8 @@ func ListEvents(c *gin.Context) {
// CreateEventRequest 创建事件请求参数 // CreateEventRequest 创建事件请求参数
type CreateEventRequest struct { type CreateEventRequest struct {
EventKey string `json:"event_key" binding:"required"` EventKey string `json:"event_key"`
TaskType string `json:"task_type"` // 关联的异步任务类型
Channels []string `json:"channels"` Channels []string `json:"channels"`
Targets []string `json:"targets"` Targets []string `json:"targets"`
Template string `json:"template"` Template string `json:"template"`
@@ -112,9 +114,49 @@ func ListBuiltInEvents(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(BuiltInEvents)) c.JSON(http.StatusOK, util.OK(BuiltInEvents))
} }
func getEventInfo(req CreateEventRequest) (string, string, []byte, error) {
if req.TaskType != "" {
// 1. 检查关联任务是否存在
meta := task.GetTaskMetaByAsynqTask(req.TaskType)
if meta == nil {
return "", "", nil, errors.New("unsupported task type")
}
eventKey := "task_completed:" + req.TaskType
eventName := "任务完成: " + meta.Name
defaultTemplate := NotificationMessage{
Title: "任务完成: " + meta.Name,
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
Level: defaultLevelInfo,
}
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
if err != nil {
return "", "", nil, err
}
return eventKey, eventName, defaultTemplateBytes, nil
}
if req.EventKey == "" {
return "", "", nil, errors.New("either event_key or task_type must be provided")
}
// 1. 检查内置事件是否存在
meta, found := findBuiltInEvent(req.EventKey)
if !found {
return "", "", nil, errors.New("unsupported built-in event key")
}
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
if err != nil {
return "", "", nil, err
}
return req.EventKey, meta.Name, defaultTemplateBytes, nil
}
// CreateEvent 创建通知事件 // CreateEvent 创建通知事件
// @Summary 创建通知事件 // @Summary 创建通知事件
// @Description 绑定系统内置事件、推送渠道、接收目标并创建通知事件配置,需要管理员权限 // @Description 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限
// @Tags admin-push // @Tags admin-push
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -131,16 +173,15 @@ func CreateEvent(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
// 1. 检查内置事件是否存在 eventKey, eventName, defaultTemplateBytes, err := getEventInfo(req)
meta, found := findBuiltInEvent(req.EventKey) if err != nil {
if !found { c.JSON(http.StatusBadRequest, util.Err(err.Error()))
c.JSON(http.StatusBadRequest, util.Err("unsupported built-in event key"))
return return
} }
// 2. 检查是否已经创建过该事件的配置 // 2. 检查是否已经创建过该事件的配置
var count int64 var count int64
if err := db.DB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", req.EventKey).Count(&count).Error; err != nil { if err := db.DB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", eventKey).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error())) c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return return
} }
@@ -152,11 +193,6 @@ func CreateEvent(c *gin.Context) {
// 3. 模板处理 // 3. 模板处理
templateStr := strings.TrimSpace(req.Template) templateStr := strings.TrimSpace(req.Template)
if templateStr == "" { if templateStr == "" {
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
templateStr = string(defaultTemplateBytes) templateStr = string(defaultTemplateBytes)
} else { } else {
var tempMap map[string]any var tempMap map[string]any
@@ -177,8 +213,9 @@ func CreateEvent(c *gin.Context) {
} }
event := model.PushEvent{ event := model.PushEvent{
EventKey: meta.Key, EventKey: eventKey,
Name: meta.Name, Name: eventName,
TaskType: req.TaskType,
Channels: channels, Channels: channels,
Targets: targets, Targets: targets,
Template: templateStr, Template: templateStr,
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"context"
"encoding/json"
"strconv"
"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/task"
)
func init() {
task.OnTaskCompleted = handleTaskCompleted
}
// handleTaskCompleted handles task completions and triggers appropriate push events.
func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *task.TaskResult, execErr error) {
// Query all active push events configured for this task type
var events []model.PushEvent
err := db.DB(ctx).Where("task_type = ? AND enabled = ?", execution.TaskType, true).Find(&events).Error
if err != nil {
logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", execution.TaskType, err)
return
}
if len(events) == 0 {
return
}
// Build the notification body context
body := map[string]any{
"task_id": execution.TaskID,
"task_name": execution.TaskName,
"task_type": execution.TaskType,
"task_status": string(execution.Status),
"task_duration": execution.Duration,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
if execErr != nil {
body["task_error"] = execErr.Error()
} else {
body["task_error"] = ""
}
if result != nil {
body["task_result"] = result.Message
} else {
body["task_result"] = ""
}
// Parse payload parameters if it is valid JSON
var payloadMap map[string]any
if execution.Payload != "" {
if err := json.Unmarshal([]byte(execution.Payload), &payloadMap); err == nil {
body["payload"] = payloadMap
extractUserFromMap(ctx, payloadMap, body)
}
}
// Parse result detail parameters if it is valid JSON
if result != nil && result.Detail != "" {
var detailMap map[string]any
if err := json.Unmarshal([]byte(result.Detail), &detailMap); err == nil {
body["detail"] = detailMap
extractUserFromMap(ctx, detailMap, body)
}
}
// Trigger notifications for all configured events
for _, event := range events {
meta := EventMetadata{
Key: event.EventKey,
Name: event.Name,
Description: "异步任务执行完毕触发的自动通知",
}
DefaultTrigger.Trigger(ctx, meta, body)
}
}
// extractUserFromMap tries to find user information from a map and load the full User model.
func extractUserFromMap(ctx context.Context, data map[string]any, body map[string]any) {
if u, exists := body["user"]; exists && u != nil {
return
}
if uVal, ok := data["user"]; ok && uVal != nil {
body["user"] = uVal
return
}
if userID, ok := extractUserID(data); ok && userID > 0 {
var u model.User
if err := db.DB(ctx).Where("id = ?", userID).First(&u).Error; err == nil {
body["user"] = &u
return
}
}
if username := extractUsername(data); username != "" {
var u model.User
if err := db.DB(ctx).Where("username = ?", username).First(&u).Error; err == nil {
body["user"] = &u
return
}
}
}
// extractUserID extracts and validates a userID from map fields.
func extractUserID(data map[string]any) (uint64, bool) {
for _, k := range []string{"user_id", "userId", "uid"} {
val, ok := data[k]
if !ok || val == nil {
continue
}
switch v := val.(type) {
case float64:
if v >= 0 {
return uint64(v), true
}
case int:
if v >= 0 {
return uint64(v), true
}
case int64:
if v >= 0 {
return uint64(v), true
}
case uint64:
return v, true
case string:
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
return id, true
}
}
}
return 0, false
}
// extractUsername extracts a username string from map fields.
func extractUsername(data map[string]any) string {
for _, k := range []string{"username", "user_name"} {
if val, ok := data[k]; ok && val != nil {
if s, ok := val.(string); ok && s != "" {
return s
}
}
}
return ""
}
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
ALTER TABLE w_push_events DROP COLUMN task_type;
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
-- SQLite does not support DROP COLUMN in older versions easily, but standard ALTER TABLE DROP COLUMN works in SQLite 3.35.0+.
-- We can write standard DROP COLUMN.
ALTER TABLE w_push_events DROP COLUMN task_type;
+1
View File
@@ -17,6 +17,7 @@ type PushEvent struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login
Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录 Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录
TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"` // 关联的异步任务类型
Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"] Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"]
Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表 Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表
Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON
+8
View File
@@ -23,6 +23,9 @@ import (
// handlerRegistry 已注册的任务处理器 // handlerRegistry 已注册的任务处理器
var handlerRegistry = make(map[string]TaskHandler) var handlerRegistry = make(map[string]TaskHandler)
// OnTaskCompleted is a hook called when a task execution completes.
var OnTaskCompleted func(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error)
// RegisterHandler 注册任务处理器 // RegisterHandler 注册任务处理器
// 传入任务类型标识(对应 constants.go 中的 AsynqTask 常量)和 TaskHandler 实现 // 传入任务类型标识(对应 constants.go 中的 AsynqTask 常量)和 TaskHandler 实现
func RegisterHandler(asynqTaskType string, handler TaskHandler) { func RegisterHandler(asynqTaskType string, handler TaskHandler) {
@@ -353,6 +356,11 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution,
logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err) logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err)
} }
} }
if OnTaskCompleted != nil {
asyncCtx := context.WithoutCancel(ctx)
go OnTaskCompleted(asyncCtx, execution, result, execErr)
}
} }
func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool { func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool {