From b94aa6137c7ae28a2e489103db4296019abe4b2c Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Sun, 9 Nov 2025 22:34:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=BF=BD=E7=95=A5=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E5=92=8C=E5=8F=96=E6=B6=88=E5=BF=BD=E7=95=A5=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E6=8E=A5=E5=8F=A3=E5=8F=8A=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/exceeding-threshold-alarm/index.md | 3 +- internal/app/api/api.go | 5 + internal/app/api/router.go | 11 ++ .../alarm/threshold_alarm_controller.go | 109 ++++++++++++++++++ internal/app/dto/alarm_dto.go | 6 + .../app/service/threshold_alarm_service.go | 44 +++++++ internal/core/application.go | 1 + internal/core/component_initializers.go | 46 +++++--- internal/domain/alarm/alarm_service.go | 51 ++++++++ internal/infra/repository/alarm_repository.go | 29 ++++- project_structure.txt | 3 + 11 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 internal/app/controller/alarm/threshold_alarm_controller.go create mode 100644 internal/app/dto/alarm_dto.go create mode 100644 internal/app/service/threshold_alarm_service.go diff --git a/design/exceeding-threshold-alarm/index.md b/design/exceeding-threshold-alarm/index.md index 58de104..0d5e400 100644 --- a/design/exceeding-threshold-alarm/index.md +++ b/design/exceeding-threshold-alarm/index.md @@ -135,4 +135,5 @@ 3. 创建仓库层对象(不包含方法) 4. 实现告警发送任务 5. 实现告警通知发送计划/全量采集计划改名 -6. 实现设备阈值检查任务 \ No newline at end of file +6. 实现设备阈值检查任务 +7. 实现忽略告警和取消忽略告警接口及功能 \ No newline at end of file diff --git a/internal/app/api/api.go b/internal/app/api/api.go index a858f7c..43804b4 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -19,6 +19,7 @@ import ( "time" _ "git.huangwc.com/pig/pig-farm-controller/docs" // 引入 swag 生成的 docs + "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/alarm" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/health" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/management" @@ -53,6 +54,7 @@ type API struct { pigBatchController *management.PigBatchController // 猪群控制器实例 monitorController *monitor.Controller // 数据监控控制器实例 healthController *health.Controller // 健康检查控制器实例 + alarmController *alarm.ThresholdAlarmController // 阈值告警控制器 listenHandler webhook.ListenHandler // 设备上行事件监听器 analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例 } @@ -69,6 +71,7 @@ func NewAPI(cfg config.ServerConfig, planService service.PlanService, userService service.UserService, auditService service.AuditService, + alarmService service.ThresholdAlarmService, tokenGenerator token.Generator, listenHandler webhook.ListenHandler, ) *API { @@ -106,6 +109,8 @@ func NewAPI(cfg config.ServerConfig, monitorController: monitor.NewController(logs.AddCompName(baseCtx, "MonitorController"), monitorService), // 在 NewAPI 中初始化健康检查控制器 healthController: health.NewController(logs.AddCompName(baseCtx, "HealthController")), + // 在 NewAPI 中初始化阈 + alarmController: alarm.NewThresholdAlarmController(logs.AddCompName(baseCtx, "ThresholdAlarmController"), alarmService), } api.setupRoutes() // 设置所有路由 diff --git a/internal/app/api/router.go b/internal/app/api/router.go index cc09cb4..b816f7d 100644 --- a/internal/app/api/router.go +++ b/internal/app/api/router.go @@ -187,6 +187,17 @@ func (a *API) setupRoutes() { monitorGroup.GET("/notifications", a.monitorController.ListNotifications) } logger.Debug("数据监控相关接口注册成功 (需要认证和审计)") + + // 告警相关路由组 + alarmGroup := authGroup.Group("/alarm") + { + thresholdGroup := alarmGroup.Group("/thresholds") + { + thresholdGroup.POST("/:id/snooze", a.alarmController.SnoozeThresholdAlarm) // 忽略阈值告警 + thresholdGroup.POST("/:id/cancel-snooze", a.alarmController.CancelSnoozeThresholdAlarm) // 取消忽略阈值告警 + } + } + logger.Debug("告警相关接口注册成功 (需要认证和审计)") } logger.Debug("所有接口注册成功") diff --git a/internal/app/controller/alarm/threshold_alarm_controller.go b/internal/app/controller/alarm/threshold_alarm_controller.go new file mode 100644 index 0000000..4310221 --- /dev/null +++ b/internal/app/controller/alarm/threshold_alarm_controller.go @@ -0,0 +1,109 @@ +package alarm + +import ( + "context" + "errors" + "strconv" + + "git.huangwc.com/pig/pig-farm-controller/internal/app/controller" + "git.huangwc.com/pig/pig-farm-controller/internal/app/dto" + "git.huangwc.com/pig/pig-farm-controller/internal/app/service" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" + + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +// ThresholdAlarmController 阈值告警控制器,封装了所有与阈值告警配置相关的业务逻辑 +type ThresholdAlarmController struct { + ctx context.Context + thresholdAlarmService service.ThresholdAlarmService +} + +// NewThresholdAlarmController 创建一个新的阈值告警控制器实例 +func NewThresholdAlarmController( + ctx context.Context, + thresholdAlarmService service.ThresholdAlarmService, +) *ThresholdAlarmController { + return &ThresholdAlarmController{ + ctx: ctx, + thresholdAlarmService: thresholdAlarmService, + } +} + +// SnoozeThresholdAlarm godoc +// @Summary 忽略阈值告警 +// @Description 根据告警ID忽略一个活跃的阈值告警,或更新其忽略时间 +// @Tags 告警管理 +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path string true "告警ID" +// @Param request body dto.SnoozeAlarmRequest true "忽略告警请求体" +// @Success 200 {object} controller.Response "成功忽略告警" +// @Router /api/v1/alarm/threshold/{id}/snooze [post] +func (t *ThresholdAlarmController) SnoozeThresholdAlarm(ctx echo.Context) error { + reqCtx, logger := logs.Trace(ctx.Request().Context(), t.ctx, "SnoozeThresholdAlarm") + + const actionType = "忽略阈值告警" + alarmIDStr := ctx.Param("id") + + alarmID, err := strconv.ParseUint(alarmIDStr, 10, 64) + if err != nil { + logger.Errorf("%s: 无效的告警ID: %s", actionType, alarmIDStr) + return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的告警ID: "+alarmIDStr, actionType, "无效的告警ID", alarmIDStr) + } + + var req dto.SnoozeAlarmRequest + if err := ctx.Bind(&req); err != nil { + logger.Errorf("%s: 参数绑定失败: %v", actionType, err) + return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) + } + + if err := t.thresholdAlarmService.SnoozeThresholdAlarm(reqCtx, uint(alarmID), req.DurationMinutes); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.Warnf("%s: 告警不存在, ID: %d", actionType, alarmID) + return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "告警未找到", actionType, "告警不存在", alarmID) + } + logger.Errorf("%s: 服务层忽略告警失败: %v, ID: %d", actionType, err, alarmID) + return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "忽略告警失败: "+err.Error(), actionType, "服务层忽略告警失败", alarmID) + } + + logger.Infof("%s: 告警已成功忽略, ID: %d", actionType, alarmID) + return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "告警已成功忽略", nil, actionType, "告警已成功忽略", alarmID) +} + +// CancelSnoozeThresholdAlarm godoc +// @Summary 取消忽略阈值告警 +// @Description 根据告警ID取消对一个阈值告警的忽略状态 +// @Tags 告警管理 +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path string true "告警ID" +// @Success 200 {object} controller.Response "成功取消忽略告警" +// @Router /api/v1/alarm/threshold/{id}/cancel-snooze [post] +func (t *ThresholdAlarmController) CancelSnoozeThresholdAlarm(ctx echo.Context) error { + reqCtx, logger := logs.Trace(ctx.Request().Context(), t.ctx, "CancelSnoozeThresholdAlarm") + + const actionType = "取消忽略阈值告警" + alarmIDStr := ctx.Param("id") + + alarmID, err := strconv.ParseUint(alarmIDStr, 10, 64) + if err != nil { + logger.Errorf("%s: 无效的告警ID: %s", actionType, alarmIDStr) + return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的告警ID: "+alarmIDStr, actionType, "无效的告警ID", alarmIDStr) + } + + if err := t.thresholdAlarmService.CancelSnoozeThresholdAlarm(reqCtx, uint(alarmID)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.Warnf("%s: 告警不存在, ID: %d", actionType, alarmID) + return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "告警未找到", actionType, "告警不存在", alarmID) + } + logger.Errorf("%s: 服务层取消忽略告警失败: %v, ID: %d", actionType, err, alarmID) + return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "取消忽略告警失败: "+err.Error(), actionType, "服务层取消忽略告警失败", alarmID) + } + + logger.Infof("%s: 告警忽略状态已成功取消, ID: %d", actionType, alarmID) + return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "告警忽略状态已成功取消", nil, actionType, "告警忽略状态已成功取消", alarmID) +} diff --git a/internal/app/dto/alarm_dto.go b/internal/app/dto/alarm_dto.go new file mode 100644 index 0000000..1239cdf --- /dev/null +++ b/internal/app/dto/alarm_dto.go @@ -0,0 +1,6 @@ +package dto + +// SnoozeAlarmRequest 定义了忽略告警的请求体 +type SnoozeAlarmRequest struct { + DurationMinutes uint `json:"duration_minutes" validate:"required,min=1"` // 忽略时长,单位分钟 +} diff --git a/internal/app/service/threshold_alarm_service.go b/internal/app/service/threshold_alarm_service.go new file mode 100644 index 0000000..5825ffb --- /dev/null +++ b/internal/app/service/threshold_alarm_service.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "time" + + domainAlarm "git.huangwc.com/pig/pig-farm-controller/internal/domain/alarm" // 引入领域层的 AlarmService + "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" +) + +// ThresholdAlarmService 定义了阈值告警配置服务的接口。 +// 该服务负责管理阈值告警任务的配置,并将其与计划进行联动。 +type ThresholdAlarmService interface { + // SnoozeThresholdAlarm 忽略一个阈值告警,或更新其忽略时间。 + SnoozeThresholdAlarm(ctx context.Context, alarmID uint, durationMinutes uint) error + // CancelSnoozeThresholdAlarm 取消对一个阈值告警的忽略状态。 + CancelSnoozeThresholdAlarm(ctx context.Context, alarmID uint) error +} + +// thresholdAlarmService 是 ThresholdAlarmService 接口的具体实现。 +type thresholdAlarmService struct { + ctx context.Context + alarmService domainAlarm.AlarmService // 注入领域层的 AlarmService +} + +// NewThresholdAlarmService 创建一个新的 ThresholdAlarmService 实例。 +func NewThresholdAlarmService(ctx context.Context, alarmService domainAlarm.AlarmService) ThresholdAlarmService { + return &thresholdAlarmService{ + ctx: ctx, + alarmService: alarmService, + } +} + +// SnoozeThresholdAlarm 实现了忽略阈值告警的逻辑。 +func (s *thresholdAlarmService) SnoozeThresholdAlarm(ctx context.Context, alarmID uint, durationMinutes uint) error { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "SnoozeThresholdAlarm") + return s.alarmService.SnoozeAlarm(serviceCtx, alarmID, time.Duration(durationMinutes)*time.Minute) +} + +// CancelSnoozeThresholdAlarm 实现了取消忽略阈值告警的逻辑。 +func (s *thresholdAlarmService) CancelSnoozeThresholdAlarm(ctx context.Context, alarmID uint) error { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "CancelSnoozeThresholdAlarm") + return s.alarmService.CancelAlarmSnooze(serviceCtx, alarmID) +} diff --git a/internal/core/application.go b/internal/core/application.go index 1cc65c2..2dff55d 100644 --- a/internal/core/application.go +++ b/internal/core/application.go @@ -61,6 +61,7 @@ func NewApplication(configPath string) (*Application, error) { appServices.planService, appServices.userService, appServices.auditService, + appServices.thresholdAlarmService, infra.tokenGenerator, infra.lora.listenHandler, ) diff --git a/internal/core/component_initializers.go b/internal/core/component_initializers.go index 8f83bf1..17f3d64 100644 --- a/internal/core/component_initializers.go +++ b/internal/core/component_initializers.go @@ -7,6 +7,7 @@ import ( "git.huangwc.com/pig/pig-farm-controller/internal/app/service" "git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" + "git.huangwc.com/pig/pig-farm-controller/internal/domain/alarm" "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig" @@ -126,6 +127,7 @@ type DomainServices struct { analysisPlanTaskManager plan.AnalysisPlanTaskManager planService plan.Service notifyService domain_notify.Service + alarmService alarm.AlarmService } // initDomainServices 初始化所有的领域服务。 @@ -196,6 +198,13 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr taskFactory, ) + // 告警服务 + alarmService := alarm.NewAlarmService( + logs.AddCompName(baseCtx, "AlarmService"), + infra.repos.alarmRepo, + infra.repos.unitOfWork, + ) + return &DomainServices{ pigPenTransferManager: pigPenTransferManager, pigTradeManager: pigTradeManager, @@ -207,18 +216,20 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr planExecutionManager: planExecutionManager, planService: planService, notifyService: notifyService, + alarmService: alarmService, }, nil } // AppServices 聚合了所有的应用服务实例。 type AppServices struct { - pigFarmService service.PigFarmService - pigBatchService service.PigBatchService - monitorService service.MonitorService - deviceService service.DeviceService - planService service.PlanService - userService service.UserService - auditService service.AuditService + pigFarmService service.PigFarmService + pigBatchService service.PigBatchService + monitorService service.MonitorService + deviceService service.DeviceService + planService service.PlanService + userService service.UserService + auditService service.AuditService + thresholdAlarmService service.ThresholdAlarmService } // initAppServices 初始化所有的应用服务。 @@ -254,14 +265,21 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices planService := service.NewPlanService(logs.AddCompName(baseCtx, "AppPlanService"), domainServices.planService) userService := service.NewUserService(logs.AddCompName(baseCtx, "UserService"), infra.repos.userRepo, infra.tokenGenerator, domainServices.notifyService) + // 初始化阈值告警服务 + thresholdAlarmService := service.NewThresholdAlarmService( + logs.AddCompName(baseCtx, "ThresholdAlarmService"), + domainServices.alarmService, + ) + return &AppServices{ - pigFarmService: pigFarmService, - pigBatchService: pigBatchService, - monitorService: monitorService, - deviceService: deviceService, - auditService: auditService, - planService: planService, - userService: userService, + pigFarmService: pigFarmService, + pigBatchService: pigBatchService, + monitorService: monitorService, + deviceService: deviceService, + auditService: auditService, + planService: planService, + userService: userService, + thresholdAlarmService: thresholdAlarmService, } } diff --git a/internal/domain/alarm/alarm_service.go b/internal/domain/alarm/alarm_service.go index 8ddf3df..661ba25 100644 --- a/internal/domain/alarm/alarm_service.go +++ b/internal/domain/alarm/alarm_service.go @@ -3,6 +3,7 @@ package alarm import ( "context" "errors" + "fmt" "time" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" @@ -21,6 +22,14 @@ type AlarmService interface { // CloseAlarm 关闭一个活跃告警,将其归档到历史记录。 // 如果指定的告警当前不活跃,则不执行任何操作并返回 nil。 CloseAlarm(ctx context.Context, sourceType models.AlarmSourceType, sourceID uint, alarmCode models.AlarmCode, resolveMethod string, resolvedBy *uint) error + + // SnoozeAlarm 忽略一个活跃告警,或更新其忽略时间。 + // 如果告警不存在,将返回错误。 + SnoozeAlarm(ctx context.Context, alarmID uint, duration time.Duration) error + + // CancelAlarmSnooze 取消对一个告警的忽略状态。 + // 如果告警不存在,或本就未被忽略,不执行任何操作并返回 nil。 + CancelAlarmSnooze(ctx context.Context, alarmID uint) error } // alarmService 是 AlarmService 接口的具体实现。 @@ -122,3 +131,45 @@ func (s *alarmService) CloseAlarm(ctx context.Context, sourceType models.AlarmSo return nil }) } + +// SnoozeAlarm 忽略一个活跃告警,或更新其忽略时间。 +func (s *alarmService) SnoozeAlarm(ctx context.Context, alarmID uint, duration time.Duration) error { + serviceCtx, logger := logs.Trace(ctx, s.ctx, "SnoozeAlarm") + + if duration <= 0 { + return errors.New("忽略时长必须为正数") + } + + ignoredUntil := time.Now().Add(duration) + err := s.alarmRepo.UpdateIgnoreStatus(serviceCtx, alarmID, true, &ignoredUntil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.Warnf("尝试忽略一个不存在的告警: %d", alarmID) + return fmt.Errorf("告警 %d 不存在", alarmID) + } + logger.Errorf("更新告警 %d 的忽略状态失败: %v", alarmID, err) + return err + } + + logger.Infof("告警 %d 已被成功忽略,持续时间: %v", alarmID, duration) + return nil +} + +// CancelAlarmSnooze 取消对一个告警的忽略状态。 +func (s *alarmService) CancelAlarmSnooze(ctx context.Context, alarmID uint) error { + serviceCtx, logger := logs.Trace(ctx, s.ctx, "CancelAlarmSnooze") + + err := s.alarmRepo.UpdateIgnoreStatus(serviceCtx, alarmID, false, nil) + if err != nil { + // 如果告警本就不存在,这不是一个需要上报的错误 + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.Infof("尝试取消忽略一个不存在的告警: %d,无需操作", alarmID) + return nil + } + logger.Errorf("取消告警 %d 的忽略状态失败: %v", alarmID, err) + return err + } + + logger.Infof("告警 %d 的忽略状态已被成功取消。", alarmID) + return nil +} diff --git a/internal/infra/repository/alarm_repository.go b/internal/infra/repository/alarm_repository.go index e5dfba8..2b91609 100644 --- a/internal/infra/repository/alarm_repository.go +++ b/internal/infra/repository/alarm_repository.go @@ -40,6 +40,9 @@ type AlarmRepository interface { // DeleteActiveAlarmTx 在指定事务中根据主键 ID 删除一个活跃告警 DeleteActiveAlarmTx(ctx context.Context, tx *gorm.DB, id uint) error + // UpdateIgnoreStatus 更新指定告警的忽略状态 + UpdateIgnoreStatus(ctx context.Context, id uint, isIgnored bool, ignoredUntil *time.Time) error + // ListActiveAlarms 支持分页和过滤的活跃告警列表查询。 // 返回活跃告警列表、总记录数和错误。 ListActiveAlarms(ctx context.Context, opts ActiveAlarmListOptions, page, pageSize int) ([]models.ActiveAlarm, int64, error) @@ -80,7 +83,7 @@ func (r *gormAlarmRepository) CreateActiveAlarm(ctx context.Context, alarm *mode return r.db.WithContext(repoCtx).Create(alarm).Error } -// IsAlarmActive 检查具有相同来源和告警代码的告警当前是否处于活跃状态 +// IsAlarmActiveInUse 检查具有相同来源和告警代码的告警当前是否处于活跃表中 func (r *gormAlarmRepository) IsAlarmActiveInUse(ctx context.Context, sourceType models.AlarmSourceType, sourceID uint, alarmCode models.AlarmCode) (bool, error) { repoCtx := logs.AddFuncName(ctx, r.ctx, "IsAlarmActiveInUse") var count int64 @@ -116,6 +119,30 @@ func (r *gormAlarmRepository) DeleteActiveAlarmTx(ctx context.Context, tx *gorm. return tx.WithContext(repoCtx).Unscoped().Delete(&models.ActiveAlarm{}, id).Error } +// UpdateIgnoreStatus 更新指定告警的忽略状态 +func (r *gormAlarmRepository) UpdateIgnoreStatus(ctx context.Context, id uint, isIgnored bool, ignoredUntil *time.Time) error { + repoCtx := logs.AddFuncName(ctx, r.ctx, "UpdateIgnoreStatus") + updates := map[string]interface{}{ + "is_ignored": isIgnored, + "ignored_until": ignoredUntil, + } + + result := r.db.WithContext(repoCtx). + Model(&models.ActiveAlarm{}). + Where("id = ?", id). + Updates(updates) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} + // ListActiveAlarms 实现了分页和过滤查询活跃告警记录的功能 func (r *gormAlarmRepository) ListActiveAlarms(ctx context.Context, opts ActiveAlarmListOptions, page, pageSize int) ([]models.ActiveAlarm, int64, error) { repoCtx := logs.AddFuncName(ctx, r.ctx, "ListActiveAlarms") diff --git a/project_structure.txt b/project_structure.txt index 44bcb3e..f56100c 100644 --- a/project_structure.txt +++ b/project_structure.txt @@ -40,6 +40,7 @@ go.mod go.sum internal/app/api/api.go internal/app/api/router.go +internal/app/controller/alarm/threshold_alarm_controller.go internal/app/controller/auth_utils.go internal/app/controller/device/device_controller.go internal/app/controller/health/health_controller.go @@ -53,6 +54,7 @@ internal/app/controller/monitor/monitor_controller.go internal/app/controller/plan/plan_controller.go internal/app/controller/response.go internal/app/controller/user/user_controller.go +internal/app/dto/alarm_dto.go internal/app/dto/device_converter.go internal/app/dto/device_dto.go internal/app/dto/monitor_converter.go @@ -73,6 +75,7 @@ internal/app/service/pig_batch_service.go internal/app/service/pig_farm_service.go internal/app/service/pig_service.go internal/app/service/plan_service.go +internal/app/service/threshold_alarm_service.go internal/app/service/user_service.go internal/app/webhook/chirp_stack.go internal/app/webhook/chirp_stack_types.go