service.xpcool.com/internal/service/notice/notice.go
夏犀麟 4b3c00270f
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 31s
feat(i18n): 中文化校验提示与服务层错误信息
将 api 层校验规则提示语、service 层 gerror.Wrap 与 response.Error
错误信息、panic 未注册提示统一改为中文,并同步中文化 cmd 路由注释
与变更记录。仅涉及注释、文档与字符串改动,无业务逻辑变更。
2026-09-13 23:22:46 +08:00

353 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package notice 提供统一通知服务(渠道/规则/发送/日志)。
// 发送实现Bark 走路径式 keyPOST {baseUrl}/{deviceKey} JSONpushplus 走官方 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 &notice{} }
// 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":
// 渠道配置取 baseUrlJSON 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
}
}
// 路径式 keyPOST {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") }