mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
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:
+9
-4
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user