feat(gateway): SSE token 流可回放 + 断点续传(Redis Stream)

根治 core NATS 不回放导致的"连晚/刷新/抖动重连丢实时 token":
- store/redis:token 流落 Redis Stream(XADD,带 TTL)——StreamAppend /
  StreamRead(XREAD BLOCK,支持从任意 ID 续读)。
- gateway:SubmitTask 起即启动 token 录制器(订阅早于 dispatcher 产 token,
  全量捕获,与 SSE 客户端是否在线无关);StreamTask 改从 Redis Stream 读,
  发 SSE 时带 id(= Redis entry ID),浏览器重连自动带 Last-Event-ID 续传。
  Redis 降级时回退原 live-NATS 路径(streamTaskLive)。

实测:任务跑完后才连 SSE → 完整回放;带 Last-Event-ID 重连 → 断点续传不重不漏;
make test-go 全绿。最终答复本就持久在 Redis 历史,此改让"实时流"也无损。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-23 15:05:53 +08:00
parent 79437e1c1c
commit 529cd0fdd6
3 changed files with 114 additions and 5 deletions
+58
View File
@@ -48,6 +48,64 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.
return n <= limit, nil
}
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token----
const streamTTL = 10 * time.Minute
func streamKey(taskID string) string { return "sundynix:stream:" + taskID }
// StreamEntry 是 token 流里的一条记录(ID 用于 SSE 的 Last-Event-ID 断点续传)。
type StreamEntry struct {
ID string
Kind string // token / done
Data string
}
// StreamAppend 把一条 token / 结束标记追加到任务的 Redis Stream(带 TTL 自动清理)。
func (r *Redis) StreamAppend(ctx context.Context, taskID, kind, data string) error {
if r.rdb == nil {
return nil
}
k := streamKey(taskID)
if err := r.rdb.XAdd(ctx, &redis.XAddArgs{Stream: k, Values: map[string]any{"kind": kind, "data": data}}).Err(); err != nil {
return err
}
return r.rdb.Expire(ctx, k, streamTTL).Err()
}
// StreamRead 从 lastID 之后阻塞读取新条目(XREAD BLOCK)。lastID="0" 表示从头回放。
// 阻塞超时无新数据时返回空切片 + 原 lastID(调用方据此继续轮询)。
func (r *Redis) StreamRead(ctx context.Context, taskID, lastID string, block time.Duration) ([]StreamEntry, string, error) {
if r.rdb == nil {
return nil, lastID, nil
}
res, err := r.rdb.XRead(ctx, &redis.XReadArgs{
Streams: []string{streamKey(taskID), lastID}, Block: block, Count: 256,
}).Result()
if err == redis.Nil {
return nil, lastID, nil // 阻塞超时、无新条目
}
if err != nil {
return nil, lastID, err
}
var out []StreamEntry
nl := lastID
for _, st := range res {
for _, m := range st.Messages {
out = append(out, StreamEntry{ID: m.ID, Kind: asString(m.Values["kind"]), Data: asString(m.Values["data"])})
nl = m.ID
}
}
return out, nl, nil
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// Close 释放底层连接。
func (r *Redis) Close() {
if r.rdb != nil {