338 lines
10 KiB
Go
338 lines
10 KiB
Go
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 ""
|
||
}
|