package fullservernotice import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" activityv1 "hyapp.local/api/proto/activity/v1" "github.com/gin-gonic/gin" ) const ( messageTypeSystem = "system" messageTypeActivity = "activity" targetScopeSingleUser = "single_user" targetScopeUserIDs = "user_ids" targetScopeRegion = "region" targetScopeCountry = "country" targetScopeAllActiveUsers = "all_active_users" defaultProducerEventType = "admin_full_server_notice" defaultBatchSize = 500 ) type Handler struct { activity activityclient.Client requestTimeout time.Duration audit shared.OperationLogger } func New(activity activityclient.Client, audit shared.OperationLogger) *Handler { return &Handler{activity: activity, requestTimeout: 3 * time.Second, audit: audit} } type fanoutRequest struct { CommandID string `json:"command_id"` MessageType string `json:"message_type"` TargetScope string `json:"target_scope"` TargetUserID int64 `json:"target_user_id"` UserIDs []int64 `json:"user_ids"` RegionID int64 `json:"region_id"` Country string `json:"country"` ProducerEventType string `json:"producer_event_type"` AggregateType string `json:"aggregate_type"` AggregateID string `json:"aggregate_id"` TemplateID string `json:"template_id"` TemplateVersion string `json:"template_version"` Title string `json:"title"` Summary string `json:"summary"` Body string `json:"body"` IconURL string `json:"icon_url"` ImageURL string `json:"image_url"` ActionType string `json:"action_type"` ActionParam string `json:"action_param"` Priority int32 `json:"priority"` SentAtMS int64 `json:"sent_at_ms"` ExpireAtMS int64 `json:"expire_at_ms"` MetadataJSON string `json:"metadata_json"` BatchSize int32 `json:"batch_size"` } type fanoutDTO struct { JobID string `json:"job_id"` Status string `json:"status"` Created bool `json:"created"` CommandID string `json:"command_id"` MessageType string `json:"message_type"` TargetScope string `json:"target_scope"` } func (h *Handler) CreateFanout(c *gin.Context) { var req fanoutRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "全服通知参数不正确") return } normalized, err := normalizeFanoutRequest(req, int64(middleware.CurrentUserID(c))) if err != nil { response.BadRequest(c, err.Error()) return } targetFilterJSON, err := buildTargetFilterJSON(normalized) if err != nil { response.BadRequest(c, err.Error()) return } templateSnapshotJSON, err := buildTemplateSnapshotJSON(normalized) if err != nil { response.BadRequest(c, err.Error()) return } ctx, cancel := h.activityContext(c) defer cancel() // 全服通知只向 activity-service 创建 fanout 命令;真正逐用户写入由 message fanout worker 执行。 // command_id 作为后台操作幂等键,重复提交同一 payload 会返回同一个 job,不会重复刷用户收件箱。 resp, err := h.activity.CreateFanoutJob(ctx, &activityv1.CreateFanoutJobRequest{ Meta: h.meta(c), CommandId: normalized.CommandID, MessageType: normalized.MessageType, TargetScope: normalized.TargetScope, TargetFilterJson: targetFilterJSON, TemplateSnapshotJson: templateSnapshotJSON, BatchSize: normalized.BatchSize, CreatedBy: "admin:" + strconv.FormatInt(int64(middleware.CurrentUserID(c)), 10), }) if err != nil { response.BadRequest(c, err.Error()) return } item := fanoutDTO{ JobID: resp.GetJobId(), Status: resp.GetStatus(), Created: resp.GetCreated(), CommandID: normalized.CommandID, MessageType: normalized.MessageType, TargetScope: normalized.TargetScope, } shared.OperationLogWithResourceID(c, h.audit, "create-full-server-notice-fanout", "message_fanout_jobs", item.JobID, "success", normalized.Title) response.Created(c, item) } func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", AppCode: appctx.FromContext(c.Request.Context()), SentAtMs: time.Now().UTC().UnixMilli(), } } func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) { timeout := h.requestTimeout if timeout <= 0 { timeout = 3 * time.Second } return context.WithTimeout(c.Request.Context(), timeout) } func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, error) { req.MessageType = strings.ToLower(strings.TrimSpace(req.MessageType)) if req.MessageType == "" { req.MessageType = messageTypeSystem } if req.MessageType != messageTypeSystem && req.MessageType != messageTypeActivity { return fanoutRequest{}, fmt.Errorf("message_type 只支持 system 或 activity") } req.TargetScope = strings.TrimSpace(req.TargetScope) if req.TargetScope == "" { req.TargetScope = targetScopeAllActiveUsers } if !allowedTargetScope(req.TargetScope) { return fanoutRequest{}, fmt.Errorf("target_scope 不正确") } req.Title = strings.TrimSpace(req.Title) req.Summary = strings.TrimSpace(req.Summary) req.Body = strings.TrimSpace(req.Body) req.IconURL = strings.TrimSpace(req.IconURL) req.ImageURL = strings.TrimSpace(req.ImageURL) req.ActionType = strings.TrimSpace(req.ActionType) req.ActionParam = strings.TrimSpace(req.ActionParam) req.ProducerEventType = firstText(req.ProducerEventType, defaultProducerEventType) req.AggregateType = firstText(req.AggregateType, "admin_notice") req.AggregateID = strings.TrimSpace(req.AggregateID) req.TemplateID = strings.TrimSpace(req.TemplateID) req.TemplateVersion = strings.TrimSpace(req.TemplateVersion) req.Country = strings.TrimSpace(req.Country) req.MetadataJSON = strings.TrimSpace(req.MetadataJSON) if req.Title == "" || req.Summary == "" { return fanoutRequest{}, fmt.Errorf("title 和 summary 不能为空") } if req.ExpireAtMS > 0 && req.SentAtMS > 0 && req.ExpireAtMS <= req.SentAtMS { return fanoutRequest{}, fmt.Errorf("expire_at_ms 必须晚于 sent_at_ms") } if req.MetadataJSON != "" && !json.Valid([]byte(req.MetadataJSON)) { return fanoutRequest{}, fmt.Errorf("metadata_json 不是合法 JSON") } if req.BatchSize <= 0 { req.BatchSize = defaultBatchSize } if req.CommandID == "" { req.CommandID = fmt.Sprintf("admin_notice_%d_%d", adminID, time.Now().UTC().UnixMilli()) } req.CommandID = strings.TrimSpace(req.CommandID) if req.AggregateID == "" { req.AggregateID = req.CommandID } return req, nil } func buildTargetFilterJSON(req fanoutRequest) (string, error) { var payload map[string]any switch req.TargetScope { case targetScopeAllActiveUsers: payload = map[string]any{} case targetScopeSingleUser: if req.TargetUserID <= 0 { return "", fmt.Errorf("target_user_id 不能为空") } payload = map[string]any{"target_user_id": req.TargetUserID} case targetScopeUserIDs: userIDs := positiveUniqueInt64s(req.UserIDs) if len(userIDs) == 0 { return "", fmt.Errorf("user_ids 不能为空") } payload = map[string]any{"user_ids": userIDs} case targetScopeRegion: if req.RegionID <= 0 { return "", fmt.Errorf("region_id 不能为空") } payload = map[string]any{"region_id": req.RegionID} case targetScopeCountry: if req.Country == "" { return "", fmt.Errorf("country 不能为空") } payload = map[string]any{"country": req.Country} default: return "", fmt.Errorf("target_scope 不正确") } encoded, err := json.Marshal(payload) if err != nil { return "", err } return string(encoded), nil } func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) { payload := map[string]any{ "producer_event_type": req.ProducerEventType, "aggregate_type": req.AggregateType, "aggregate_id": req.AggregateID, "template_id": req.TemplateID, "template_version": req.TemplateVersion, "title": req.Title, "summary": req.Summary, "body": req.Body, "icon_url": req.IconURL, "image_url": req.ImageURL, "action_type": req.ActionType, "action_param": req.ActionParam, "priority": req.Priority, "sent_at_ms": req.SentAtMS, "expire_at_ms": req.ExpireAtMS, } if req.MetadataJSON != "" { var parsed any if err := json.Unmarshal([]byte(req.MetadataJSON), &parsed); err != nil { return "", fmt.Errorf("metadata_json 不是合法 JSON") } payload["metadata_json"] = parsed } encoded, err := json.Marshal(payload) if err != nil { return "", err } return string(encoded), nil } func allowedTargetScope(scope string) bool { switch scope { case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers: return true default: return false } } func positiveUniqueInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return out } func firstText(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" }