Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 31s
将 api 层校验规则提示语、service 层 gerror.Wrap 与 response.Error 错误信息、panic 未注册提示统一改为中文,并同步中文化 cmd 路由注释 与变更记录。仅涉及注释、文档与字符串改动,无业务逻辑变更。
353 lines
11 KiB
Go
353 lines
11 KiB
Go
// 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 实现未注册")
|
||
}
|
||
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, "查询通知渠道失败")
|
||
}
|
||
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, "更新通知渠道失败")
|
||
}
|
||
return in.Id, nil
|
||
}
|
||
id, err := dao.NoticeChannel.Ctx(ctx).Data(data).InsertAndGetId()
|
||
if err != nil {
|
||
return 0, gerror.Wrap(err, "新增通知渠道失败")
|
||
}
|
||
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, "查询通知规则失败")
|
||
}
|
||
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, "更新通知规则失败")
|
||
}
|
||
return in.Id, nil
|
||
}
|
||
id, err := dao.NoticeRule.Ctx(ctx).Data(data).InsertAndGetId()
|
||
if err != nil {
|
||
return 0, gerror.Wrap(err, "新增通知规则失败")
|
||
}
|
||
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, "删除通知规则失败")
|
||
}
|
||
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, "统计通知日志总数失败")
|
||
}
|
||
var list []entity.NoticeLog
|
||
if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil {
|
||
return nil, 0, gerror.Wrap(err, "查询通知日志列表失败")
|
||
}
|
||
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, "查询待发送通知规则失败")
|
||
}
|
||
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") }
|