// Package notice 提供统一通知服务(渠道/规则/发送/日志)。 // 发送实现:Bark 走路径式 key(POST {baseUrl}/{deviceKey} JSON);pushplus 走官方 send 接口。 package notice import ( "context" "encoding/json" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "service.xpcool.com/internal/consts" "service.xpcool.com/internal/dao" "service.xpcool.com/internal/library/response" "service.xpcool.com/internal/model/dto" "service.xpcool.com/internal/model/entity" ) // INotice 统一通知服务接口。 type INotice interface { ChannelList(context.Context) ([]dto.NoticeChannelVO, error) ChannelSave(context.Context, dto.NoticeChannelInput) (uint64, error) RuleList(context.Context, string) ([]dto.NoticeRuleVO, error) RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error) RuleDelete(context.Context, uint64) error LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error) Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员) Test(context.Context, uint64, string, string, []uint64, string, string) (bool, string, error) } type notice struct{} var localNotice INotice // New 创建通知服务实现。 func New() INotice { return ¬ice{} } // Notice 返回已注册的通知服务实现。 func Notice() INotice { if localNotice == nil { panic("Notice implementation not registered") } return localNotice } // RegisterNotice 注册通知服务实现。 func RegisterNotice(i INotice) { localNotice = i } // ---------------- 渠道 ---------------- func (s *notice) ChannelList(ctx context.Context) ([]dto.NoticeChannelVO, error) { var list []entity.NoticeChannel if err := dao.NoticeChannel.Ctx(ctx).OrderAsc("id").Scan(&list); err != nil { return nil, gerror.Wrap(err, "query notice channels") } out := make([]dto.NoticeChannelVO, 0, len(list)) for _, v := range list { out = append(out, dto.NoticeChannelVO{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark}) } return out, nil } func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) { data := map[string]interface{}{ "code": in.Code, "name": in.Name, "enabled": in.Enabled, "config": in.Config, "remark": in.Remark, } if in.Id > 0 { if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil { return 0, gerror.Wrap(err, "update notice channel") } return in.Id, nil } id, err := dao.NoticeChannel.Ctx(ctx).Data(data).InsertAndGetId() if err != nil { return 0, gerror.Wrap(err, "insert notice channel") } return uint64(id), nil } // ---------------- 规则 ---------------- func (s *notice) RuleList(ctx context.Context, eventType string) ([]dto.NoticeRuleVO, error) { m := dao.NoticeRule.Ctx(ctx) if eventType != "" { m = m.Where("event_type", eventType) } var list []entity.NoticeRule if err := m.OrderAsc("id").Scan(&list); err != nil { return nil, gerror.Wrap(err, "query notice rules") } out := make([]dto.NoticeRuleVO, 0, len(list)) for _, v := range list { out = append(out, dto.NoticeRuleVO{ Id: v.Id, Name: v.Name, EventType: v.EventType, ChannelCodes: parseStrArray(v.ChannelCodes), UserIds: parseUintArray(v.UserIds), TitleTemplate: v.TitleTemplate, BodyTemplate: v.BodyTemplate, Enabled: v.Enabled, }) } return out, nil } func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) { data := map[string]interface{}{ "name": in.Name, "event_type": in.EventType, "channel_codes": toJSON(in.ChannelCodes), "user_ids": toJSON(in.UserIds), "title_template": in.TitleTemplate, "body_template": in.BodyTemplate, "enabled": in.Enabled, } if in.Id > 0 { if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil { return 0, gerror.Wrap(err, "update notice rule") } return in.Id, nil } id, err := dao.NoticeRule.Ctx(ctx).Data(data).InsertAndGetId() if err != nil { return 0, gerror.Wrap(err, "insert notice rule") } return uint64(id), nil } func (s *notice) RuleDelete(ctx context.Context, id uint64) error { if _, err := dao.NoticeRule.Ctx(ctx).Where("id", id).Delete(); err != nil { return gerror.Wrap(err, "delete notice rule") } return nil } // ---------------- 日志 ---------------- func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error) { m := dao.NoticeLog.Ctx(ctx) if f.EventType != "" { m = m.Where("event_type", f.EventType) } if f.ChannelCode != "" { m = m.Where("channel_code", f.ChannelCode) } if f.Result > 0 { m = m.Where("result", f.Result) } if f.DateFrom != "" { m = m.WhereGTE("created_at", f.DateFrom) } if f.DateTo != "" { m = m.WhereLTE("created_at", f.DateTo) } total, err := m.Clone().Count() if err != nil { return nil, 0, gerror.Wrap(err, "count notice log") } var list []entity.NoticeLog if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil { return nil, 0, gerror.Wrap(err, "query notice log") } out := make([]dto.NoticeLogVO, 0, len(list)) for _, v := range list { out = append(out, dto.NoticeLogVO{ Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode, UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body, Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt, }) } return out, total, nil } // ---------------- 发送 ---------------- // Send 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。 func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error { var rules []entity.NoticeRule if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil { return gerror.Wrap(err, "query notice rules for send") } if len(rules) == 0 { return nil // 未配置规则 = 静默 } for _, r := range rules { channels := parseStrArray(r.ChannelCodes) userIds := parseUintArray(r.UserIds) title := dto.RenderTemplate(r.TitleTemplate, vars) body := dto.RenderTemplate(r.BodyTemplate, vars) for _, channel := range channels { for _, uid := range userIds { s.deliver(ctx, r.Id, eventType, channel, uid, title, body) } } } return nil } // deliver 单条投递:取用户渠道凭据 → 发送 → 记日志。 func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel string, userId uint64, title, body string) { var user entity.AdminUser _ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user) var target string switch channel { case "bark": target = user.BarkDeviceId case "pushplus": target = user.PushplusToken default: target = "" } ok, errMsg := s.sendByChannel(ctx, channel, target, title, body) result := 0 if ok { result = 1 } _, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{ "rule_id": ruleId, "event_type": eventType, "channel_code": channel, "user_id": userId, "target": target, "title": title, "body": body, "result": result, "error": errMsg, }).Insert() } // sendByChannel 按渠道发送单条;返回 (是否成功, 错误信息)。 func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body string) (bool, string) { if target == "" { return false, "目标凭据为空(用户未配置)" } client := g.Client().SetTimeout(10 * time.Second) switch channel { case "bark": // 渠道配置取 baseUrl(JSON config.baseUrl),默认容器网络内 bark:8080 baseURL := "http://bark:8080" var ch entity.NoticeChannel if err := dao.NoticeChannel.Ctx(ctx).Where("code", "bark").Scan(&ch); err == nil && ch.Config != "" { var cfg struct { BaseURL string `json:"baseUrl"` } _ = json.Unmarshal([]byte(ch.Config), &cfg) if cfg.BaseURL != "" { baseURL = cfg.BaseURL } } // 路径式 key:POST {baseUrl}/{deviceKey},body JSON {title, body} url := strings.TrimRight(baseURL, "/") + "/" + target resp, err := client.Post(ctx, url, map[string]string{"title": title, "body": body}) if err != nil { return false, err.Error() } if resp.StatusCode != 200 { return false, "bark http " + resp.Status } var out struct { Code int `json:"code"` } _ = json.Unmarshal(resp.ReadAll(), &out) if out.Code != 200 { return false, "bark code " + resp.ReadAllString() } return true, "" case "pushplus": // 官方接口:POST https://www.pushplus.plus/send {token,title,content} resp, err := client.Post(ctx, "https://www.pushplus.plus/send", map[string]string{ "token": target, "title": title, "content": body, }) if err != nil { return false, err.Error() } if resp.StatusCode != 200 { return false, "pushplus http " + resp.Status } var out struct { Code int `json:"code"` } _ = json.Unmarshal(resp.ReadAll(), &out) if out.Code != 200 { return false, "pushplus code " + resp.ReadAllString() } return true, "" } return false, "未知渠道: " + channel } // Test 测试发送:优先按规则;否则手动指定渠道+目标。 func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) { if ruleId > 0 { var r entity.NoticeRule if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 { return false, "", response.Error(consts.CodeInvalidParam, "规则不存在") } for _, ch := range parseStrArray(r.ChannelCodes) { for _, uid := range parseUintArray(r.UserIds) { s.deliver(ctx, r.Id, r.EventType, ch, uid, title, body) } } return true, "已按规则投递", nil } // 手动:渠道+目标(target 为空则按用户) if channelCode == "" { return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道") } if target == "" && len(userIds) > 0 { var user entity.AdminUser _ = dao.AdminUser.Ctx(ctx).Where("id", userIds[0]).Scan(&user) if channelCode == "bark" { target = user.BarkDeviceId } else if channelCode == "pushplus" { target = user.PushplusToken } } if target == "" { return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空") } ok, msg := s.sendByChannel(ctx, channelCode, target, title, body) _, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{ "rule_id": 0, "event_type": "test", "channel_code": channelCode, "user_id": 0, "target": target, "title": title, "body": body, "result": boolToInt(ok), "error": msg, }).Insert() return ok, msg, nil } // ---------------- 工具 ---------------- func parseStrArray(s string) []string { var out []string _ = json.Unmarshal([]byte(s), &out) if out == nil { out = []string{} } return out } func parseUintArray(s string) []uint64 { var out []uint64 _ = json.Unmarshal([]byte(s), &out) if out == nil { out = []uint64{} } return out } func toJSON(v interface{}) string { b, _ := json.Marshal(v) return string(b) } func boolToInt(b bool) int { if b { return 1 } return 0 } // NowStr 当前时间字符串(供日志)。 func NowStr() string { return gtime.Now().Format("Y-m-d H:i:s") }