Go sse推送任务进度

测试一个账户在多个设备登录、同时接收消息、模拟视频任务成功、发送推送

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"time"

	sse "github.com/r3labs/sse/v2"
)

var sseServer = newSSEServer()

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", indexHandler)
	mux.HandleFunc("/events", eventsHandler)
	mux.HandleFunc("/mock/task-done", taskDoneHandler)
	mux.HandleFunc("/mock/task-failed", taskFailedHandler)

	addr := ":3801"
	log.Printf("SSE demo listening on http://localhost%s", addr)
	log.Printf("Open the same URL in multiple browsers/tabs with the same userId, then click send.")

	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatal(err)
	}
}

func newSSEServer() *sse.Server {
	server := sse.New()
	server.AutoReplay = false
	return server
}

func streamID(userID uint64) string {
	return fmt.Sprintf("user:%d", userID)
}

func ensureUserStream(userID uint64) string {
	id := streamID(userID)
	sseServer.CreateStream(id)
	return id
}

func publishToUser(userID uint64, eventName string, payload any) error {
	data, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	sseServer.Publish(ensureUserStream(userID), &sse.Event{
		Event: []byte(eventName),
		Data:  data,
	})

	return nil
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write([]byte(indexHTML))
}

func eventsHandler(w http.ResponseWriter, r *http.Request) {
	userID, ok := parseUserID(w, r)
	if !ok {
		return
	}

	query := r.URL.Query()
	query.Set("stream", ensureUserStream(userID))
	r.URL.RawQuery = query.Encode()

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no")

	log.Printf("SSE connected: user=%d remote=%s", userID, r.RemoteAddr)
	sseServer.ServeHTTP(w, r)
	log.Printf("SSE disconnected: user=%d remote=%s", userID, r.RemoteAddr)
}

func taskDoneHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req taskRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if req.TaskID == "" {
		req.TaskID = fmt.Sprintf("task_%d", time.Now().UnixMilli())
	}
	if req.VideoURL == "" {
		req.VideoURL = "https://example.com/demo-video.mp4"
	}

	payload := map[string]any{
		"taskId":   req.TaskID,
		"status":   "succeeded",
		"type":     "video",
		"videoUrl": req.VideoURL,
		"time":     time.Now().Format(time.RFC3339),
	}
	if err := publishToUser(req.UserID, "video_task_done", payload); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, map[string]any{"message": "published", "event": payload})
}

func taskFailedHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req taskRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if req.TaskID == "" {
		req.TaskID = fmt.Sprintf("task_%d", time.Now().UnixMilli())
	}
	if req.Message == "" {
		req.Message = "mock generation failed"
	}

	payload := map[string]any{
		"taskId":  req.TaskID,
		"status":  "failed",
		"type":    "video",
		"message": req.Message,
		"time":    time.Now().Format(time.RFC3339),
	}
	if err := publishToUser(req.UserID, "video_task_failed", payload); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, map[string]any{"message": "published", "event": payload})
}

type taskRequest struct {
	UserID   uint64 `json:"userId"`
	TaskID   string `json:"taskId"`
	VideoURL string `json:"videoUrl"`
	Message  string `json:"message"`
}

func parseUserID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
	value := r.URL.Query().Get("userId")
	if value == "" {
		http.Error(w, "missing userId", http.StatusBadRequest)
		return 0, false
	}

	userID, err := strconv.ParseUint(value, 10, 64)
	if err != nil || userID == 0 {
		http.Error(w, "invalid userId", http.StatusBadRequest)
		return 0, false
	}

	return userID, true
}

func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
	defer r.Body.Close()
	if err := json.NewDecoder(r.Body).Decode(target); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return false
	}
	return true
}

func writeJSON(w http.ResponseWriter, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(value)
}

const indexHTML = `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>SSE 多客户端测试</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; color: #111827; }
    .card { max-width: 900px; border: 1px solid #e5e7eb; border-radius: 14px; padding: 20px; box-shadow: 0 10px 30px rgba(15, 23, 42, .06); }
    label { display: block; margin: 12px 0 6px; font-weight: 600; }
    input { width: 100%; max-width: 420px; box-sizing: border-box; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 10px; }
    button { margin: 12px 8px 0 0; padding: 10px 14px; border: 0; border-radius: 10px; color: white; background: #2563eb; cursor: pointer; }
    button.secondary { background: #4b5563; }
    button.danger { background: #dc2626; }
    button:disabled { background: #9ca3af; cursor: not-allowed; }
    pre { min-height: 280px; max-height: 520px; overflow: auto; padding: 14px; background: #0f172a; color: #d1fae5; border-radius: 12px; white-space: pre-wrap; }
    .hint { color: #6b7280; line-height: 1.7; }
    .status { display: inline-block; margin-top: 12px; padding: 4px 10px; border-radius: 999px; background: #f3f4f6; }
  </style>
</head>
<body>
  <div class="card">
    <h1>SSE 多客户端测试</h1>
    <p class="hint">
      打开多个浏览器或标签页,填同一个 userId 后点击“连接 SSE”。任意一个页面点击“发送成功事件”,所有连接到同一 userId 的页面都会收到消息。
    </p>

    <label for="userId">userId</label>
    <input id="userId" value="123" />

    <label for="taskId">taskId</label>
    <input id="taskId" value="task_demo_001" />

    <label for="videoUrl">videoUrl</label>
    <input id="videoUrl" value="https://example.com/demo-video.mp4" />

    <div>
      <button id="connectBtn">连接 SSE</button>
      <button id="closeBtn" class="secondary" disabled>断开</button>
      <button id="doneBtn">发送成功事件</button>
      <button id="failedBtn" class="danger">发送失败事件</button>
      <button id="clearBtn" class="secondary">清空日志</button>
    </div>

    <div class="status" id="status">未连接</div>
    <h3>事件日志</h3>
    <pre id="log"></pre>
  </div>

  <script>
    let es = null
    const userIdInput = document.getElementById('userId')
    const taskIdInput = document.getElementById('taskId')
    const videoUrlInput = document.getElementById('videoUrl')
    const connectBtn = document.getElementById('connectBtn')
    const closeBtn = document.getElementById('closeBtn')
    const doneBtn = document.getElementById('doneBtn')
    const failedBtn = document.getElementById('failedBtn')
    const clearBtn = document.getElementById('clearBtn')
    const statusEl = document.getElementById('status')
    const logEl = document.getElementById('log')

    function log(message, data) {
      const line = '[' + new Date().toLocaleTimeString() + '] ' + message + (data ? '\n' + JSON.stringify(data, null, 2) : '')
      logEl.textContent = line + '\n\n' + logEl.textContent
    }

    function setConnected(connected) {
      connectBtn.disabled = connected
      closeBtn.disabled = !connected
      statusEl.textContent = connected ? '已连接 userId=' + userIdInput.value : '未连接'
    }

    connectBtn.onclick = () => {
      if (es) es.close()
      const userId = encodeURIComponent(userIdInput.value.trim())
      es = new EventSource('/events?userId=' + userId)

      es.onopen = () => {
        setConnected(true)
        log('SSE connected')
      }

      es.onerror = () => {
        log('SSE error or reconnecting')
      }

      es.addEventListener('video_task_done', (event) => {
        log('video_task_done', JSON.parse(event.data))
      })

      es.addEventListener('video_task_failed', (event) => {
        log('video_task_failed', JSON.parse(event.data))
      })
    }

    closeBtn.onclick = () => {
      if (es) {
        es.close()
        es = null
      }
      setConnected(false)
      log('SSE closed')
    }

    doneBtn.onclick = async () => {
      const payload = {
        userId: Number(userIdInput.value),
        taskId: taskIdInput.value,
        videoUrl: videoUrlInput.value,
      }
      const res = await fetch('/mock/task-done', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
      log('POST /mock/task-done ' + res.status, await res.json())
    }

    failedBtn.onclick = async () => {
      const payload = {
        userId: Number(userIdInput.value),
        taskId: taskIdInput.value,
        message: '模拟生成失败',
      }
      const res = await fetch('/mock/task-failed', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
      log('POST /mock/task-failed ' + res.status, await res.json())
    }

    clearBtn.onclick = () => {
      logEl.textContent = ''
    }
  </script>
</body>
</html>`

posted on 2026-06-26 12:32  朝阳1  阅读(4)  评论(0)    收藏  举报