2026-07-06 12:43:40 +08:00

338 lines
10 KiB
Go
Raw 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 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"
targetScopeAllRegistered = "all_registered_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 int64List `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, targetScopeAllRegistered:
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([]int64(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, targetScopeAllRegistered:
return true
default:
return false
}
}
type int64List []int64
func (values *int64List) UnmarshalJSON(data []byte) error {
var rawItems []json.RawMessage
if err := json.Unmarshal(data, &rawItems); err != nil {
return err
}
parsed := make([]int64, 0, len(rawItems))
for _, rawItem := range rawItems {
value, err := parseJSONInt64(rawItem)
if err != nil {
return err
}
parsed = append(parsed, value)
}
*values = parsed
return nil
}
func parseJSONInt64(raw json.RawMessage) (int64, error) {
text := strings.TrimSpace(string(raw))
if strings.HasPrefix(text, `"`) {
unquoted, err := strconv.Unquote(text)
if err != nil {
return 0, err
}
text = strings.TrimSpace(unquoted)
}
value, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return 0, err
}
return value, nil
}
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 ""
}