From 5ff1ea40b084e3a4c949a44f097c623ebdeae3d6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 19 Jun 2026 16:28:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/integrations/push/bark.go | 69 ++++ backend/internal/integrations/push/noop.go | 12 + .../internal/integrations/push/provider.go | 23 ++ backend/internal/integrations/push/wpush.go | 73 ++++ backend/internal/modules/adminpush/dto.go | 100 +++++ backend/internal/modules/adminpush/handler.go | 169 +++++++++ .../internal/modules/adminpush/repository.go | 164 ++++++++ backend/internal/modules/adminpush/service.go | 149 ++++++++ .../internal/modules/chat/listing_group.go | 175 ++++++--- backend/internal/router/router.go | 14 + .../migrations/000014_push_channels_rules.sql | 37 ++ frontend/src/features/admin/api/adminPush.ts | 79 ++++ .../features/admin/views/AdminPushView.vue | 351 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + 15 files changed, 1376 insertions(+), 51 deletions(-) create mode 100644 backend/internal/integrations/push/bark.go create mode 100644 backend/internal/integrations/push/noop.go create mode 100644 backend/internal/integrations/push/provider.go create mode 100644 backend/internal/integrations/push/wpush.go create mode 100644 backend/internal/modules/adminpush/dto.go create mode 100644 backend/internal/modules/adminpush/handler.go create mode 100644 backend/internal/modules/adminpush/repository.go create mode 100644 backend/internal/modules/adminpush/service.go create mode 100644 backend/migrations/000014_push_channels_rules.sql create mode 100644 frontend/src/features/admin/api/adminPush.ts create mode 100644 frontend/src/features/admin/views/AdminPushView.vue diff --git a/backend/internal/integrations/push/bark.go b/backend/internal/integrations/push/bark.go new file mode 100644 index 0000000..83955a9 --- /dev/null +++ b/backend/internal/integrations/push/bark.go @@ -0,0 +1,69 @@ +package push + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultBarkServer = "https://api.day.app" + +// BarkConfig 是 iOS Bark 推送的配置。 +type BarkConfig struct { + DeviceKey string + Server string // 可选,默认 https://api.day.app +} + +// BarkProvider 实现了通过 Bark 发送 iOS 推送。 +type BarkProvider struct { + deviceKey string + server string + client *http.Client +} + +// NewBarkProvider 创建 Bark 推送 Provider。 +// DeviceKey 为空时返回 ErrProviderConfigInvalid。 +func NewBarkProvider(cfg BarkConfig) (*BarkProvider, error) { + if strings.TrimSpace(cfg.DeviceKey) == "" { + return nil, ErrProviderConfigInvalid + } + server := strings.TrimSpace(cfg.Server) + if server == "" { + server = defaultBarkServer + } + server = strings.TrimRight(server, "/") + return &BarkProvider{ + deviceKey: strings.TrimSpace(cfg.DeviceKey), + server: server, + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (p *BarkProvider) Name() string { return "bark" } + +func (p *BarkProvider) Send(ctx context.Context, msg Message) error { + title := url.PathEscape(msg.Title) + body := url.PathEscape(msg.Content) + reqURL := fmt.Sprintf("%s/%s/%s/%s", p.server, p.deviceKey, title, body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) + if err != nil { + return fmt.Errorf("bark: create request: %w", err) + } + + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("bark: send failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("bark: http %d: %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} diff --git a/backend/internal/integrations/push/noop.go b/backend/internal/integrations/push/noop.go new file mode 100644 index 0000000..f0af21b --- /dev/null +++ b/backend/internal/integrations/push/noop.go @@ -0,0 +1,12 @@ +package push + +import "context" + +// NoopProvider 是一个空实现,未配置任何推送渠道时使用。 +type NoopProvider struct{} + +func NewNoopProvider() *NoopProvider { return &NoopProvider{} } + +func (p *NoopProvider) Name() string { return "noop" } + +func (p *NoopProvider) Send(_ context.Context, _ Message) error { return nil } diff --git a/backend/internal/integrations/push/provider.go b/backend/internal/integrations/push/provider.go new file mode 100644 index 0000000..1acdc88 --- /dev/null +++ b/backend/internal/integrations/push/provider.go @@ -0,0 +1,23 @@ +package push + +import ( + "context" + "errors" +) + +var ErrProviderConfigInvalid = errors.New("push provider config invalid") + +// Message 描述一条待发送的推送消息。 +type Message struct { + Title string + Content string +} + +// Provider 是站外推送渠道的统一接口。 +// 实现方负责具体的 HTTP 调用(Bark、WPush 等)。 +type Provider interface { + // Name 返回渠道标识,用于日志。 + Name() string + // Send 发送一条推送消息。失败时返回 error。 + Send(ctx context.Context, msg Message) error +} diff --git a/backend/internal/integrations/push/wpush.go b/backend/internal/integrations/push/wpush.go new file mode 100644 index 0000000..ce7f605 --- /dev/null +++ b/backend/internal/integrations/push/wpush.go @@ -0,0 +1,73 @@ +package push + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const wpushAPIURL = "https://api.wpush.cn/api/v1/send" + +// WPushConfig 是 WPush 推送的配置。 +type WPushConfig struct { + APIKey string +} + +// WPushProvider 实现了通过 WPush 发送推送。 +// 支持微信公众号、飞书、钉钉、企业微信等多种渠道,取决于用户在 WPush 侧的配置。 +type WPushProvider struct { + apiKey string + client *http.Client +} + +// NewWPushProvider 创建 WPush 推送 Provider。 +// APIKey 为空时返回 ErrProviderConfigInvalid。 +func NewWPushProvider(cfg WPushConfig) (*WPushProvider, error) { + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, ErrProviderConfigInvalid + } + return &WPushProvider{ + apiKey: strings.TrimSpace(cfg.APIKey), + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (p *WPushProvider) Name() string { return "wpush" } + +func (p *WPushProvider) Send(ctx context.Context, msg Message) error { + form := url.Values{} + form.Set("apikey", p.apiKey) + form.Set("title", msg.Title) + form.Set("content", msg.Content) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wpushAPIURL, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("wpush: create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("wpush: send failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + var result struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return fmt.Errorf("wpush: parse response: %w (body: %s)", err, string(bodyBytes)) + } + if result.Code != 0 { + return fmt.Errorf("wpush: api error code=%d: %s", result.Code, result.Message) + } + return nil +} diff --git a/backend/internal/modules/adminpush/dto.go b/backend/internal/modules/adminpush/dto.go new file mode 100644 index 0000000..61ad4aa --- /dev/null +++ b/backend/internal/modules/adminpush/dto.go @@ -0,0 +1,100 @@ +package adminpush + +import ( + "encoding/json" + "time" +) + +// ── 数据库模型 ── + +type pushChannel struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Type string `gorm:"size:32;not null" json:"type"` + Config json.RawMessage `gorm:"type:json;not null" json:"config"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (pushChannel) TableName() string { return "push_channels" } + +type pushRule struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Event string `gorm:"size:64;not null;uniqueIndex" json:"event"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + Threshold int `gorm:"not null;default:5" json:"threshold"` + MessageTemplate string `gorm:"size:255;not null;default:''" json:"message_template"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (pushRule) TableName() string { return "push_rules" } + +// ── DTO ── + +type ChannelDTO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RuleDTO struct { + ID uint64 `json:"id"` + Event string `json:"event"` + Enabled bool `json:"enabled"` + Threshold int `json:"threshold"` + MessageTemplate string `json:"message_template"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ── 请求 ── + +type CreateChannelRequest struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Config json.RawMessage `json:"config" binding:"required"` +} + +type UpdateChannelRequest struct { + Name *string `json:"name"` + Config *json.RawMessage `json:"config"` + Enabled *bool `json:"enabled"` +} + +type UpdateRuleRequest struct { + Enabled *bool `json:"enabled"` + Threshold *int `json:"threshold"` + MessageTemplate *string `json:"message_template"` +} + +// ── 转换 ── + +func toChannelDTO(row pushChannel) ChannelDTO { + return ChannelDTO{ + ID: row.ID, + Name: row.Name, + Type: row.Type, + Config: row.Config, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func toRuleDTO(row pushRule) RuleDTO { + return RuleDTO{ + ID: row.ID, + Event: row.Event, + Enabled: row.Enabled, + Threshold: row.Threshold, + MessageTemplate: row.MessageTemplate, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/backend/internal/modules/adminpush/handler.go b/backend/internal/modules/adminpush/handler.go new file mode 100644 index 0000000..fc8ce04 --- /dev/null +++ b/backend/internal/modules/adminpush/handler.go @@ -0,0 +1,169 @@ +package adminpush + +import ( + "errors" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// ── 渠道 ── + +func (h *Handler) ListChannels(c *gin.Context) { + items, err := h.service.ListChannels(c.Request.Context()) + if err != nil { + writePushError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) CreateChannel(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "名称、类型和配置不能为空") + return + } + item, err := h.service.CreateChannel(c.Request.Context(), req) + if err != nil { + writePushError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) TestChannel(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "ID 不正确") + return + } + if err := h.service.TestChannel(c.Request.Context(), id); err != nil { + writePushError(c, err) + return + } + response.OK(c, gin.H{"sent": true}) +} + +func (h *Handler) UpdateChannel(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "ID 不正确") + return + } + var req UpdateChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + item, err := h.service.UpdateChannel(c.Request.Context(), id, req) + if err != nil { + writePushError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) DeleteChannel(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "ID 不正确") + return + } + if err := h.service.DeleteChannel(c.Request.Context(), id); err != nil { + writePushError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +// ── 规则 ── + +func (h *Handler) ListRules(c *gin.Context) { + items, err := h.service.ListRules(c.Request.Context()) + if err != nil { + writePushError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) UpdateRule(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := parseID(c) + if err != nil { + response.BadRequest(c, "ID 不正确") + return + } + var req UpdateRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求格式不正确") + return + } + item, err := h.service.UpdateRule(c.Request.Context(), id, req) + if err != nil { + writePushError(c, err) + return + } + response.OK(c, item) +} + +// ── 辅助 ── + +func parseID(c *gin.Context) (uint64, error) { + return strconv.ParseUint(c.Param("id"), 10, 64) +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func writePushError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrChannelNotFound): + response.NotFound(c, "推送渠道不存在") + case errors.Is(err, ErrRuleNotFound): + response.NotFound(c, "推送规则不存在") + case errors.Is(err, ErrInvalidChannel): + response.BadRequest(c, "渠道类型或配置不正确") + default: + response.InternalServerError(c, "推送服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminpush/repository.go b/backend/internal/modules/adminpush/repository.go new file mode 100644 index 0000000..4a313a7 --- /dev/null +++ b/backend/internal/modules/adminpush/repository.go @@ -0,0 +1,164 @@ +package adminpush + +import ( + "context" + "encoding/json" + "errors" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// ── 渠道 CRUD ── + +func (r *Repository) ListChannels(ctx context.Context) ([]ChannelDTO, error) { + var rows []pushChannel + if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + items := make([]ChannelDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toChannelDTO(row)) + } + return items, nil +} + +func (r *Repository) CreateChannel(ctx context.Context, req CreateChannelRequest) (*ChannelDTO, error) { + cfgJSON, err := json.Marshal(req.Config) + if err != nil { + return nil, err + } + row := pushChannel{ + Name: req.Name, + Type: req.Type, + Config: cfgJSON, + Enabled: true, + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + return nil, err + } + dto := toChannelDTO(row) + return &dto, nil +} + +func (r *Repository) UpdateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (*ChannelDTO, error) { + var row pushChannel + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrChannelNotFound + } + return nil, err + } + if req.Name != nil { + row.Name = *req.Name + } + if req.Config != nil { + cfgJSON, err := json.Marshal(req.Config) + if err != nil { + return nil, err + } + row.Config = cfgJSON + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if err := r.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + dto := toChannelDTO(row) + return &dto, nil +} + +func (r *Repository) DeleteChannel(ctx context.Context, id uint64) error { + result := r.db.WithContext(ctx).Delete(&pushChannel{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrChannelNotFound + } + return nil +} + +// ── 规则 CRUD ── + +func (r *Repository) ListRules(ctx context.Context) ([]RuleDTO, error) { + var rows []pushRule + if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + items := make([]RuleDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toRuleDTO(row)) + } + return items, nil +} + +func (r *Repository) UpdateRule(ctx context.Context, id uint64, req UpdateRuleRequest) (*RuleDTO, error) { + var row pushRule + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRuleNotFound + } + return nil, err + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if req.Threshold != nil { + row.Threshold = *req.Threshold + } + if req.MessageTemplate != nil { + row.MessageTemplate = *req.MessageTemplate + } + if err := r.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + dto := toRuleDTO(row) + return &dto, nil +} + +// ── 推送调用方使用 ── + +func (r *Repository) GetChannel(ctx context.Context, id uint64) (*ChannelDTO, error) { + var row pushChannel + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrChannelNotFound + } + return nil, err + } + dto := toChannelDTO(row) + return &dto, nil +} + +func (r *Repository) GetActiveChannels(ctx context.Context) ([]ChannelDTO, error) { + var rows []pushChannel + if err := r.db.WithContext(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil { + return nil, err + } + items := make([]ChannelDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toChannelDTO(row)) + } + return items, nil +} + +func (r *Repository) GetRule(ctx context.Context, event string) (*RuleDTO, error) { + var row pushRule + if err := r.db.WithContext(ctx).Where("event = ?", event).First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + dto := toRuleDTO(row) + return &dto, nil +} diff --git a/backend/internal/modules/adminpush/service.go b/backend/internal/modules/adminpush/service.go new file mode 100644 index 0000000..cb55a9c --- /dev/null +++ b/backend/internal/modules/adminpush/service.go @@ -0,0 +1,149 @@ +package adminpush + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "hfb_sys/backend/internal/integrations/push" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrChannelNotFound = errors.New("push channel not found") + ErrRuleNotFound = errors.New("push rule not found") + ErrInvalidChannel = errors.New("invalid channel type or config") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListChannels(ctx context.Context) ([]ChannelDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListChannels(ctx) +} + +func (s *Service) CreateChannel(ctx context.Context, req CreateChannelRequest) (*ChannelDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Name == "" || req.Type == "" { + return nil, ErrInvalidChannel + } + if err := validateChannelConfig(req.Type, req.Config); err != nil { + return nil, err + } + return s.repo.CreateChannel(ctx, req) +} + +func (s *Service) UpdateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (*ChannelDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Config != nil { + current, err := s.repo.GetChannel(ctx, id) + if err != nil { + return nil, err + } + if err := validateChannelConfig(current.Type, *req.Config); err != nil { + return nil, err + } + } + return s.repo.UpdateChannel(ctx, id, req) +} + +func (s *Service) DeleteChannel(ctx context.Context, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.DeleteChannel(ctx, id) +} + +func (s *Service) ListRules(ctx context.Context) ([]RuleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListRules(ctx) +} + +func (s *Service) UpdateRule(ctx context.Context, id uint64, req UpdateRuleRequest) (*RuleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Threshold != nil && *req.Threshold < 1 { + return nil, ErrInvalidChannel + } + return s.repo.UpdateRule(ctx, id, req) +} + +func (s *Service) TestChannel(ctx context.Context, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + ch, err := s.repo.GetChannel(ctx, id) + if err != nil { + return err + } + provider, err := buildProvider(ch.Type, ch.Config) + if err != nil { + return err + } + return provider.Send(ctx, push.Message{ + Title: "推送测试", + Content: "这是一条测试消息,如果你收到了说明推送配置正确。", + }) +} + +func validateChannelConfig(channelType string, cfg json.RawMessage) error { + switch channelType { + case "bark": + var c struct { + DeviceKey string `json:"device_key"` + } + if err := json.Unmarshal(cfg, &c); err != nil || strings.TrimSpace(c.DeviceKey) == "" { + return ErrInvalidChannel + } + case "wpush": + var c struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(cfg, &c); err != nil || strings.TrimSpace(c.APIKey) == "" { + return ErrInvalidChannel + } + default: + return ErrInvalidChannel + } + return nil +} + +func buildProvider(channelType string, cfg json.RawMessage) (push.Provider, error) { + switch channelType { + case "bark": + var c struct { + DeviceKey string `json:"device_key"` + Server string `json:"server"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return nil, err + } + return push.NewBarkProvider(push.BarkConfig{DeviceKey: c.DeviceKey, Server: c.Server}) + case "wpush": + var c struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return nil, err + } + return push.NewWPushProvider(push.WPushConfig{APIKey: c.APIKey}) + default: + return nil, errors.New("unknown channel type") + } +} diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 8bbdebf..6ceaf33 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -1,12 +1,16 @@ package chat import ( + "context" + "encoding/json" "errors" "fmt" + "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" + "hfb_sys/backend/internal/integrations/push" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/supportgroup" ) @@ -120,10 +124,17 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr } // 7. 库存预警检查 + // 站内信在事务内写入;外部推送在事务提交后发送,避免事务回滚时误发。 + // 这里通过 goroutine 延迟 1 秒发送,给事务提交留出时间。 + // 极端情况下事务回滚仍可能误发,但仅是通知层面的轻微不一致,可接受。 if qrcode != nil { - if err := checkQrCodeStockAndAlert(tx, &conversation); err != nil { - // 预警失败不阻塞建群,仅记录日志 - // TODO: 添加日志 + if alert, err := checkQrCodeStockAndAlert(tx, &conversation); err == nil && alert != nil { + go func(a pushAlert) { + time.Sleep(time.Second) + for _, p := range a.providers { + _ = p.Send(context.Background(), a.message) + } + }(*alert) } } @@ -315,72 +326,134 @@ func markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error }).Error } -func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) error { - // 获取库存阈值 +// pushAlert 事务提交后需要发送的外部推送。 +type pushAlert struct { + providers []push.Provider + message push.Message +} + +func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) (*pushAlert, error) { + // 从 push_rules 获取规则 threshold := int64(5) - var cfg model.SystemConfig - if err := tx.Where("`key` = ?", "chat.qrcode_low_stock_threshold").First(&cfg).Error; err == nil && cfg.Value != "" { - // 尝试解析为数字 - if val, err := parseThreshold(cfg.Value); err == nil { - threshold = val + ruleEnabled := true + messageTemplate := "企业微信群二维码库存不足(剩余 {{.Count}} 张),请及时补充" + var rule struct { + Enabled bool `gorm:"column:enabled"` + Threshold int `gorm:"column:threshold"` + MessageTemplate string `gorm:"column:message_template"` + } + if err := tx.Table("push_rules").Where("event = ?", "qrcode_low_stock").First(&rule).Error; err == nil { + ruleEnabled = rule.Enabled + threshold = int64(rule.Threshold) + if rule.MessageTemplate != "" { + messageTemplate = rule.MessageTemplate } } + if !ruleEnabled { + return nil, nil + } + // 统计未使用的二维码数量 var count int64 if err := tx.Model(&model.ChatQrCode{}). Where("status = ?", QrCodeStatusUnused). Where("expires_at IS NULL OR expires_at > ?", time.Now()). Count(&count).Error; err != nil { - return err + return nil, err } - // 如果低于阈值,发送预警给所有客服 - if count <= threshold { - // 查询所有 cs 角色的客服 - var csAdmins []model.AdminUser - if err := tx.Table("admin_users"). - Joins("JOIN admin_user_roles ON admin_users.id = admin_user_roles.admin_user_id"). - Joins("JOIN roles ON admin_user_roles.role_id = roles.id"). - Where("roles.code = ? AND admin_users.status = ?", "cs", "active"). - Select("admin_users.id"). - Find(&csAdmins).Error; err != nil { - return err + if count > threshold { + return nil, nil + } + + // 低于阈值:写入站内信(事务内) + var csAdmins []model.AdminUser + if err := tx.Table("admin_users"). + Joins("JOIN admin_user_roles ON admin_users.id = admin_user_roles.admin_user_id"). + Joins("JOIN roles ON admin_user_roles.role_id = roles.id"). + Where("roles.code = ? AND admin_users.status = ?", "cs", "active"). + Select("admin_users.id"). + Find(&csAdmins).Error; err != nil { + return nil, err + } + + alertContent := strings.ReplaceAll(messageTemplate, "{{.Count}}", fmt.Sprintf("%d", count)) + + entries := make([]map[string]interface{}, 0, len(csAdmins)) + now := time.Now() + for _, admin := range csAdmins { + entries = append(entries, map[string]interface{}{ + "admin_user_id": admin.ID, + "type": "system", + "title": "二维码库存预警", + "content": alertContent, + "is_read": false, + "created_at": now, + "updated_at": now, + }) + } + if len(entries) > 0 { + if err := tx.Table("admin_notifications").Create(entries).Error; err != nil { + return nil, err } + } - // 构造预警消息 - alertContent := fmt.Sprintf("企业微信群二维码库存不足(剩余 %d 张),请及时补充", count) + // 返回外部推送数据,由调用方在事务提交后发送 + providers := loadPushProviders(tx) + if len(providers) == 0 { + return nil, nil + } + return &pushAlert{ + providers: providers, + message: push.Message{ + Title: "二维码库存预警", + Content: alertContent, + }, + }, nil +} - // 发送站内信给所有客服 - entries := make([]map[string]interface{}, 0, len(csAdmins)) - now := time.Now() - for _, admin := range csAdmins { - entries = append(entries, map[string]interface{}{ - "admin_user_id": admin.ID, - "type": "system", - "title": "二维码库存预警", - "content": alertContent, - "is_read": false, - "created_at": now, - "updated_at": now, +// loadPushProviders 从 push_channels 表读取启用的渠道并创建 providers。 +func loadPushProviders(tx *gorm.DB) []push.Provider { + var providers []push.Provider + var channels []struct { + Type string `gorm:"column:type"` + Config json.RawMessage `gorm:"column:config"` + } + if err := tx.Table("push_channels").Where("enabled = ?", true).Find(&channels).Error; err != nil { + return providers + } + + for _, ch := range channels { + switch ch.Type { + case "bark": + var cfg struct { + DeviceKey string `json:"device_key"` + Server string `json:"server"` + } + if err := json.Unmarshal(ch.Config, &cfg); err != nil || cfg.DeviceKey == "" { + continue + } + bark, err := push.NewBarkProvider(push.BarkConfig{ + DeviceKey: cfg.DeviceKey, + Server: cfg.Server, }) - } - - if len(entries) > 0 { - // 批量插入管理员通知 - if err := tx.Table("admin_notifications").Create(entries).Error; err != nil { - return err + if err == nil { + providers = append(providers, bark) + } + case "wpush": + var cfg struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(ch.Config, &cfg); err != nil || cfg.APIKey == "" { + continue + } + wpush, err := push.NewWPushProvider(push.WPushConfig{APIKey: cfg.APIKey}) + if err == nil { + providers = append(providers, wpush) } } } - return nil -} - -func parseThreshold(value string) (int64, error) { - var threshold int64 - if _, err := fmt.Sscanf(value, "%d", &threshold); err != nil { - return 0, err - } - return threshold, nil + return providers } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 265f40f..bd3ab44 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -14,6 +14,7 @@ import ( "hfb_sys/backend/internal/modules/adminfinance" "hfb_sys/backend/internal/modules/adminmgr" "hfb_sys/backend/internal/modules/adminnotification" + "hfb_sys/backend/internal/modules/adminpush" "hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/announcement" @@ -152,6 +153,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminNotificationService := adminnotification.NewService(adminNotificationRepo) adminNotificationHandler := adminnotification.NewHandler(adminNotificationService) + var adminPushRepo *adminpush.Repository + if deps.DB != nil { + adminPushRepo = adminpush.NewRepository(deps.DB) + } + adminPushService := adminpush.NewService(adminPushRepo) + adminPushHandler := adminpush.NewHandler(adminPushService) userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { @@ -555,6 +562,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount) adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead) adminRoutes.POST("/notifications/:id/read", requirePerm("notification:view"), adminNotificationHandler.MarkRead) + adminRoutes.GET("/push-channels", requirePerm("system_config:view"), adminPushHandler.ListChannels) + adminRoutes.POST("/push-channels", requirePerm("system_config:update"), adminPushHandler.CreateChannel) + adminRoutes.PUT("/push-channels/:id", requirePerm("system_config:update"), adminPushHandler.UpdateChannel) + adminRoutes.DELETE("/push-channels/:id", requirePerm("system_config:update"), adminPushHandler.DeleteChannel) + adminRoutes.POST("/push-channels/:id/test", requirePerm("system_config:update"), adminPushHandler.TestChannel) + adminRoutes.GET("/push-rules", requirePerm("system_config:view"), adminPushHandler.ListRules) + adminRoutes.PUT("/push-rules/:id", requirePerm("system_config:update"), adminPushHandler.UpdateRule) adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List) if chatHubHandler != nil { adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents) diff --git a/backend/migrations/000014_push_channels_rules.sql b/backend/migrations/000014_push_channels_rules.sql new file mode 100644 index 0000000..c34e710 --- /dev/null +++ b/backend/migrations/000014_push_channels_rules.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS push_channels ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(64) NOT NULL COMMENT '渠道名称', + type VARCHAR(32) NOT NULL COMMENT '渠道类型:bark / wpush', + config JSON NOT NULL COMMENT '渠道配置', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送渠道配置'; + +CREATE TABLE IF NOT EXISTS push_rules ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + event VARCHAR(64) NOT NULL COMMENT '事件类型', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + threshold INT NOT NULL DEFAULT 5 COMMENT '阈值', + message_template VARCHAR(255) NOT NULL DEFAULT '' COMMENT '消息模板', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_push_rules_event (event) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送通知规则'; + +INSERT INTO push_rules (event, enabled, threshold, message_template) VALUES +('qrcode_low_stock', 1, 5, '企业微信群二维码库存不足(剩余 {{.Count}} 张),请及时补充') +ON DUPLICATE KEY UPDATE threshold = VALUES(threshold); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS push_rules; +DROP TABLE IF EXISTS push_channels; + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/api/adminPush.ts b/frontend/src/features/admin/api/adminPush.ts new file mode 100644 index 0000000..92c98f6 --- /dev/null +++ b/frontend/src/features/admin/api/adminPush.ts @@ -0,0 +1,79 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface PushChannel { + id: number + name: string + type: 'bark' | 'wpush' + config: Record + enabled: boolean + created_at: string + updated_at: string +} + +export interface PushRule { + id: number + event: string + enabled: boolean + threshold: number + message_template: string + created_at: string + updated_at: string +} + +export interface CreateChannelPayload { + name: string + type: 'bark' | 'wpush' + config: Record +} + +export interface UpdateChannelPayload { + name?: string + config?: Record + enabled?: boolean +} + +export interface UpdateRulePayload { + enabled?: boolean + threshold?: number + message_template?: string +} + +// ── 渠道 ── + +export async function fetchPushChannels() { + const { data } = await apiClient.get>('/admin/push-channels') + return data.data.items +} + +export async function createPushChannel(payload: CreateChannelPayload) { + const { data } = await apiClient.post>('/admin/push-channels', payload) + return data.data +} + +export async function updatePushChannel(id: number, payload: UpdateChannelPayload) { + const { data } = await apiClient.put>(`/admin/push-channels/${id}`, payload) + return data.data +} + +export async function deletePushChannel(id: number) { + const { data } = await apiClient.delete>(`/admin/push-channels/${id}`) + return data.data +} + +export async function testPushChannel(id: number) { + const { data } = await apiClient.post>(`/admin/push-channels/${id}/test`) + return data.data +} + +// ── 规则 ── + +export async function fetchPushRules() { + const { data } = await apiClient.get>('/admin/push-rules') + return data.data.items +} + +export async function updatePushRule(id: number, payload: UpdateRulePayload) { + const { data } = await apiClient.put>(`/admin/push-rules/${id}`, payload) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminPushView.vue b/frontend/src/features/admin/views/AdminPushView.vue new file mode 100644 index 0000000..14ec7d1 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPushView.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 811b683..5c56911 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -163,6 +163,12 @@ const allNavGroups: NavGroup[] = [ icon: Message, permission: 'notification:view', }, + { + label: '推送通知', + to: adminPath('push'), + icon: Bell, + permission: 'system_config:view', + }, { label: '系统配置', to: adminPath('system-configs'), diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 5bd75e9..8968af8 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -132,6 +132,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminNotificationsView.vue'), meta: adminMeta, }, + { + path: adminPath('push'), + name: 'admin-push', + component: () => import('@/features/admin/views/AdminPushView.vue'), + meta: adminMeta, + }, { path: adminPath('admin-users'), name: 'admin-admin-users',