feat(controlplane): outbound gateway WS channel replaces inbound HTTP client
This commit is contained in:
@@ -14,6 +14,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/fasthttp/websocket v1.5.12 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofiber/schema v1.8.3 // indirect
|
||||
@@ -29,6 +30,7 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
|
||||
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -55,6 +57,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
||||
github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8=
|
||||
github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
|
||||
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||
|
||||
@@ -108,7 +108,7 @@ type douyinMessageHistory struct {
|
||||
|
||||
func (browser douyinGatewayBrowser) request() (douyinGatewayRequest, error) {
|
||||
environment := browser.environment
|
||||
if browser.gateway.Endpoint == "" || browser.gateway.Token == "" || !accountRunnable(environment) ||
|
||||
if browser.gateway.ID <= 0 || !accountRunnable(environment) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.AccountID) || !gatewayGenerationIDPattern.MatchString(environment.Alias) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.Exit.ID) || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeID) ||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -9,10 +8,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/controlplane/gatewayclient"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -21,12 +20,14 @@ import (
|
||||
// HubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。
|
||||
type HubStore interface {
|
||||
LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error)
|
||||
CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error)
|
||||
UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (hub.Gateway, error)
|
||||
CreateGateway(ctx context.Context, name string) (hub.Gateway, error)
|
||||
UpdateGateway(ctx context.Context, currentName, name string) (hub.Gateway, error)
|
||||
ResetGatewayKey(ctx context.Context, name string) (hub.Gateway, error)
|
||||
ListGateways(ctx context.Context) ([]hub.Gateway, error)
|
||||
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
|
||||
DeleteGateway(ctx context.Context, name string) error
|
||||
RecordGatewayCheck(ctx context.Context, name, status, reason string) (hub.Gateway, error)
|
||||
ListGatewayTasksPage(ctx context.Context, gatewayID int64, page, pageSize int) (hub.GatewayTaskPage, error)
|
||||
CountPendingGatewayTasks(ctx context.Context, gatewayID int64) (int, error)
|
||||
ListEnvs(ctx context.Context) ([]hub.Env, error)
|
||||
GetEnv(ctx context.Context, alias string) (hub.Env, error)
|
||||
CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error)
|
||||
@@ -88,13 +89,16 @@ const (
|
||||
largeGatewayResponseLimit = 16 << 20
|
||||
)
|
||||
|
||||
// gatewayCall 调用某个网关的 /v1 路由;ok 为 false 时 status/body 携带网关错误。
|
||||
// gatewayCall 经出站通道以任务形式调用某个网关的 /v1 路由;
|
||||
// 网关离线时任务立即终态化并返回明确错误,不伪装成功。
|
||||
func gatewayCall(ctx context.Context, target hub.Gateway, method, path string, body any, timeout time.Duration) (status int, responseBody []byte, err error) {
|
||||
return gatewayclient.Call(ctx, target, method, path, body, timeout)
|
||||
return gatewayCallWithLimit(ctx, target, method, path, body, timeout, defaultGatewayResponseLimit)
|
||||
}
|
||||
|
||||
// gatewayCallWithLimit 的 responseLimit 仅用于兼容原 HTTP 直连签名的响应上限语义;
|
||||
// 出站通道以帧承载响应,平台侧读上限由 WS 库统一约束。
|
||||
func gatewayCallWithLimit(ctx context.Context, target hub.Gateway, method, path string, body any, timeout time.Duration, responseLimit int) (status int, responseBody []byte, err error) {
|
||||
return gatewayclient.CallWithLimit(ctx, target, method, path, body, timeout, responseLimit)
|
||||
return dispatchGatewayTask(ctx, target, method, path, body, timeout, true)
|
||||
}
|
||||
|
||||
func gatewayCreatePayload(environment hub.EnvironmentContext, _ string, networkExit gatewayNetworkExit) map[string]any {
|
||||
@@ -325,6 +329,11 @@ type runtimeStatus struct {
|
||||
ProxyReady bool `json:"proxy_ready"`
|
||||
}
|
||||
|
||||
type gatewayView struct {
|
||||
hub.Gateway
|
||||
PendingTasks int `json:"pending_tasks"`
|
||||
}
|
||||
|
||||
type envView struct {
|
||||
hub.Env
|
||||
State string `json:"state"`
|
||||
@@ -473,40 +482,61 @@ func registerHubWithNetwork(app *fiber.App, store HubStore, probe NetworkExitPro
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(gateways)
|
||||
views := make([]gatewayView, 0, len(gateways))
|
||||
for _, gateway := range gateways {
|
||||
pending, pendingErr := store.CountPendingGatewayTasks(c.Context(), gateway.ID)
|
||||
if pendingErr != nil {
|
||||
return hubError(c, pendingErr)
|
||||
}
|
||||
views = append(views, gatewayView{Gateway: gateway, PendingTasks: pending})
|
||||
}
|
||||
return c.JSON(views)
|
||||
})
|
||||
app.Post("/api/gateways", func(c fiber.Ctx) error {
|
||||
input := struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
if err := decodeHubJSON(c, &input); err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
gateway, err := store.CreateGateway(c.Context(), input.Name, input.Endpoint, input.Token)
|
||||
gateway, err := store.CreateGateway(c.Context(), input.Name)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
probeGatewayAsync(c.Context(), store, gateway)
|
||||
return c.Status(fiber.StatusCreated).JSON(gateway)
|
||||
})
|
||||
app.Put("/api/gateways/:name", func(c fiber.Ctx) error {
|
||||
input := struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
if err := decodeHubJSON(c, &input); err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
gateway, err := store.UpdateGateway(c.Context(), c.Params("name"), input.Name, input.Endpoint, input.Token)
|
||||
gateway, err := store.UpdateGateway(c.Context(), c.Params("name"), input.Name)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
probeGatewayAsync(c.Context(), store, gateway)
|
||||
return c.JSON(gateway)
|
||||
})
|
||||
app.Post("/api/gateways/:name/reset-key", func(c fiber.Ctx) error {
|
||||
gateway, err := store.ResetGatewayKey(c.Context(), c.Params("name"))
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(gateway)
|
||||
})
|
||||
app.Get("/api/gateways/:name/tasks", func(c fiber.Ctx) error {
|
||||
gateway, err := store.GetGateway(c.Context(), c.Params("name"))
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
page, pageSize := pageParams(c)
|
||||
tasks, err := store.ListGatewayTasksPage(c.Context(), gateway.ID, page, pageSize)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(tasks)
|
||||
})
|
||||
app.Delete("/api/gateways/:name", func(c fiber.Ctx) error {
|
||||
if err := store.DeleteGateway(c.Context(), c.Params("name")); err != nil {
|
||||
return hubError(c, err)
|
||||
@@ -2019,7 +2049,7 @@ func actionForEnvironment(actionName string, environment hub.EnvironmentContext)
|
||||
}
|
||||
|
||||
func decodeHubJSON(c fiber.Ctx, destination any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
|
||||
decoder := json.NewDecoder(strings.NewReader(string(c.Body())))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return hub.ErrInvalid
|
||||
@@ -2030,6 +2060,27 @@ func decodeHubJSON(c fiber.Ctx, destination any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pageParams(c fiber.Ctx) (int, int) {
|
||||
page := max(fiberQueryInt(c, "page", 1), 1)
|
||||
pageSize := fiberQueryInt(c, "page_size", 25)
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 25
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
func fiberQueryInt(c fiber.Ctx, key string, fallback int) int {
|
||||
raw := c.Query(key, "")
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func hubError(c fiber.Ctx, err error) error {
|
||||
var failure gatewayFailure
|
||||
if errors.As(err, &failure) {
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type channelMessage struct {
|
||||
Type string `json:"type"`
|
||||
Subscription string `json:"subscription"`
|
||||
Error string `json:"error"`
|
||||
Deliveries []creator.ListenerDelivery `json:"deliveries"`
|
||||
}
|
||||
type eventSubscription struct {
|
||||
hub *gatewayEventChannel
|
||||
id, alias string
|
||||
messages chan channelMessage
|
||||
done chan struct{}
|
||||
failErr error
|
||||
}
|
||||
type gatewayEventChannel struct {
|
||||
target environment.Gateway
|
||||
once sync.Once
|
||||
connectErr error
|
||||
conn *websocket.Conn
|
||||
write sync.Mutex
|
||||
mu sync.Mutex
|
||||
subs map[string]*eventSubscription
|
||||
done chan struct{}
|
||||
finish sync.Once
|
||||
}
|
||||
|
||||
var eventChannels = struct {
|
||||
sync.Mutex
|
||||
items map[string]*gatewayEventChannel
|
||||
}{items: map[string]*gatewayEventChannel{}}
|
||||
var subscriptionSequence atomic.Uint64
|
||||
|
||||
func acquireEventSubscription(ctx context.Context, target environment.Gateway, params map[string]any) (*eventSubscription, error) {
|
||||
key := target.Endpoint + "\n" + target.Token
|
||||
eventChannels.Lock()
|
||||
hub := eventChannels.items[key]
|
||||
if hub != nil {
|
||||
select {
|
||||
case <-hub.done:
|
||||
hub = nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
if hub == nil {
|
||||
hub = &gatewayEventChannel{target: target, subs: map[string]*eventSubscription{}, done: make(chan struct{})}
|
||||
eventChannels.items[key] = hub
|
||||
}
|
||||
alias, _ := params["alias"].(string)
|
||||
sub := &eventSubscription{hub: hub, id: fmt.Sprint(subscriptionSequence.Add(1)), alias: alias, messages: make(chan channelMessage, 64), done: make(chan struct{})}
|
||||
// Reserve the subscription before releasing the pool lock, so closing the
|
||||
// previous last account cannot retire a channel another account is joining.
|
||||
hub.mu.Lock()
|
||||
hub.subs[sub.id] = sub
|
||||
hub.mu.Unlock()
|
||||
eventChannels.Unlock()
|
||||
hub.once.Do(func() {
|
||||
u, err := url.Parse(strings.TrimRight(target.Endpoint, "/") + "/v1/channel")
|
||||
if err != nil {
|
||||
hub.connectErr = err
|
||||
hub.fail(err)
|
||||
return
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
u.Scheme = "ws"
|
||||
case "https":
|
||||
u.Scheme = "wss"
|
||||
default:
|
||||
hub.connectErr = errors.New("invalid gateway scheme")
|
||||
hub.fail(hub.connectErr)
|
||||
return
|
||||
}
|
||||
headers := http.Header{"Authorization": []string{"Bearer " + target.Token}}
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||
hub.conn, _, hub.connectErr = dialer.DialContext(ctx, u.String(), headers)
|
||||
if hub.connectErr != nil {
|
||||
hub.fail(hub.connectErr)
|
||||
return
|
||||
}
|
||||
hub.conn.SetReadLimit(16 << 20)
|
||||
hub.conn.SetPingHandler(func(data string) error {
|
||||
_ = hub.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||
return hub.conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(5*time.Second))
|
||||
})
|
||||
go hub.receive()
|
||||
})
|
||||
if hub.connectErr != nil {
|
||||
sub.close()
|
||||
return nil, fmt.Errorf("gateway WS connection: %w", hub.connectErr)
|
||||
}
|
||||
value := make(map[string]any, len(params)+2)
|
||||
for k, v := range params {
|
||||
value[k] = v
|
||||
}
|
||||
value["type"] = "subscribe"
|
||||
value["subscription"] = sub.id
|
||||
if err := hub.send(value); err != nil {
|
||||
sub.close()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case message := <-sub.messages:
|
||||
if message.Type != "subscribed" {
|
||||
sub.close()
|
||||
return nil, fmt.Errorf("gateway WS subscription: %s", message.Error)
|
||||
}
|
||||
return sub, nil
|
||||
case <-hub.done:
|
||||
sub.close()
|
||||
return nil, errors.New("gateway WS disconnected")
|
||||
case <-ctx.Done():
|
||||
sub.close()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (h *gatewayEventChannel) send(value any) error {
|
||||
h.write.Lock()
|
||||
defer h.write.Unlock()
|
||||
select {
|
||||
case <-h.done:
|
||||
return errors.New("gateway WS disconnected")
|
||||
default:
|
||||
}
|
||||
if err := h.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.conn.WriteJSON(value); err != nil {
|
||||
h.fail(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (h *gatewayEventChannel) receive() {
|
||||
for {
|
||||
_ = h.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||
var message channelMessage
|
||||
if err := h.conn.ReadJSON(&message); err != nil {
|
||||
h.fail(err)
|
||||
return
|
||||
}
|
||||
if message.Type != "subscribed" && message.Type != "deliveries" && message.Type != "error" {
|
||||
h.fail(errors.New("invalid gateway WS message"))
|
||||
return
|
||||
}
|
||||
if message.Type == "error" && message.Subscription == "" {
|
||||
h.fail(errors.New(message.Error))
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
sub := h.subs[message.Subscription]
|
||||
h.mu.Unlock()
|
||||
if sub == nil {
|
||||
continue
|
||||
} // A closed generation cannot receive delayed replies.
|
||||
select {
|
||||
case sub.messages <- message:
|
||||
default:
|
||||
// One slow account must not tear down the shared channel: retire only
|
||||
// this subscription. The gateway keeps unacknowledged deliveries and
|
||||
// replays them when the account session resubscribes.
|
||||
spec := fmt.Errorf("event subscription queue overflow; the account session will resubscribe and replay unacknowledged deliveries")
|
||||
logrus.WithFields(logrus.Fields{"alias": sub.alias, "subscription": sub.id}).Error("event subscription queue overflow; isolating subscription from shared channel")
|
||||
sub.fail(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (h *gatewayEventChannel) fail(err error) {
|
||||
h.finish.Do(func() {
|
||||
close(h.done)
|
||||
if h.conn != nil {
|
||||
_ = h.conn.Close()
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, sub := range h.subs {
|
||||
select {
|
||||
case sub.messages <- channelMessage{Type: "error", Error: err.Error()}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fail retires one subscription without touching the shared channel: the
|
||||
// account session restarts and the gateway replays unacknowledged deliveries.
|
||||
// Only the receive goroutine calls this, so close(done) happens exactly once.
|
||||
func (s *eventSubscription) fail(err error) {
|
||||
if err == nil {
|
||||
err = errors.New("event subscription failed")
|
||||
}
|
||||
s.hub.mu.Lock()
|
||||
delete(s.hub.subs, s.id)
|
||||
s.hub.mu.Unlock()
|
||||
s.failErr = err
|
||||
close(s.done)
|
||||
}
|
||||
func (s *eventSubscription) poll(ctx context.Context, acks []string) ([]creator.ListenerDelivery, error) {
|
||||
if len(acks) > 0 {
|
||||
if err := s.hub.send(map[string]any{"type": "ack", "alias": s.alias, "subscription": s.id, "delivery_ids": acks}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
select {
|
||||
case message := <-s.messages:
|
||||
if message.Type != "deliveries" || len(message.Deliveries) == 0 {
|
||||
return nil, fmt.Errorf("gateway event delivery: %s", message.Error)
|
||||
}
|
||||
for _, d := range message.Deliveries {
|
||||
if err := d.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return message.Deliveries, nil
|
||||
case <-s.done:
|
||||
return nil, s.failErr
|
||||
case <-s.hub.done:
|
||||
return nil, errors.New("gateway WS disconnected")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (s *eventSubscription) close() {
|
||||
_ = s.hub.send(map[string]any{"type": "unsubscribe", "alias": s.alias, "subscription": s.id})
|
||||
key := s.hub.target.Endpoint + "\n" + s.hub.target.Token
|
||||
eventChannels.Lock()
|
||||
s.hub.mu.Lock()
|
||||
delete(s.hub.subs, s.id)
|
||||
empty := len(s.hub.subs) == 0
|
||||
if empty && eventChannels.items[key] == s.hub {
|
||||
delete(eventChannels.items, key)
|
||||
}
|
||||
s.hub.mu.Unlock()
|
||||
eventChannels.Unlock()
|
||||
if empty {
|
||||
s.hub.fail(errors.New("gateway event channel stopped"))
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventChannelSharesConnectionAndAcknowledges(t *testing.T) {
|
||||
var connections atomic.Int32
|
||||
acks := make(chan struct{}, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/channel" || r.Header.Get("Authorization") != "Bearer test" {
|
||||
t.Errorf("unexpected channel request")
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
connections.Add(1)
|
||||
for {
|
||||
var v map[string]any
|
||||
if c.ReadJSON(&v) != nil {
|
||||
return
|
||||
}
|
||||
switch v["type"] {
|
||||
case "subscribe":
|
||||
_ = c.WriteJSON(map[string]any{"type": "subscribed", "subscription": v["subscription"]})
|
||||
_ = c.WriteJSON(map[string]any{"type": "deliveries", "subscription": v["subscription"], "deliveries": []any{map[string]any{"kind": "open", "delivery_id": v["alias"]}}})
|
||||
case "ack":
|
||||
acks <- struct{}{}
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
target := environment.Gateway{Endpoint: server.URL, Token: "test"}
|
||||
first, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.close()
|
||||
second, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.close()
|
||||
if connections.Load() != 1 {
|
||||
t.Fatalf("connections=%d", connections.Load())
|
||||
}
|
||||
items, err := first.poll(ctx, nil)
|
||||
if err != nil || items[0].DeliveryID != "a" {
|
||||
t.Fatalf("%+v %v", items, err)
|
||||
}
|
||||
items, err = second.poll(ctx, nil)
|
||||
if err != nil || items[0].DeliveryID != "b" {
|
||||
t.Fatalf("%+v %v", items, err)
|
||||
}
|
||||
short, c := context.WithTimeout(ctx, 30*time.Millisecond)
|
||||
defer c()
|
||||
_, _ = first.poll(short, []string{"a"})
|
||||
select {
|
||||
case <-acks:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ACK not received")
|
||||
}
|
||||
}
|
||||
func TestEventChannelRejectsHandshakeAndSubscriptionTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) }))
|
||||
defer server.Close()
|
||||
for _, endpoint := range []string{server.URL, "invalid://gateway", "http://%"} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
_, err := acquireEventSubscription(ctx, environment.Gateway{Endpoint: endpoint}, map[string]any{"alias": "a"})
|
||||
cancel()
|
||||
if err == nil {
|
||||
t.Fatalf("invalid connection accepted: %s", endpoint)
|
||||
}
|
||||
}
|
||||
waiting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, e := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
for {
|
||||
var value map[string]any
|
||||
if c.ReadJSON(&value) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer waiting.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := acquireEventSubscription(ctx, environment.Gateway{Endpoint: waiting.URL}, map[string]any{"alias": "a"})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("subscription wait did not honor cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChannelReportsDisconnect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, e := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
var v map[string]any
|
||||
_ = c.ReadJSON(&v)
|
||||
_ = c.WriteJSON(map[string]any{"type": "subscribed", "subscription": v["subscription"]})
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
sub, err := acquireEventSubscription(ctx, environment.Gateway{Endpoint: server.URL}, map[string]any{"alias": "a"})
|
||||
if err == nil {
|
||||
defer sub.close()
|
||||
_, err = sub.poll(ctx, nil)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("disconnect hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChannelSubscriptionOverflowIsolatesAccounts(t *testing.T) {
|
||||
var connections atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
connections.Add(1)
|
||||
var write sync.Mutex
|
||||
send := func(v map[string]any) {
|
||||
write.Lock()
|
||||
defer write.Unlock()
|
||||
_ = c.WriteJSON(v)
|
||||
}
|
||||
for {
|
||||
var v map[string]any
|
||||
if c.ReadJSON(&v) != nil {
|
||||
return
|
||||
}
|
||||
if v["type"] != "subscribe" {
|
||||
continue
|
||||
}
|
||||
send(map[string]any{"type": "subscribed", "subscription": v["subscription"]})
|
||||
if v["alias"] == "flooded" {
|
||||
for i := 0; i < 80; i++ {
|
||||
send(map[string]any{"type": "deliveries", "subscription": v["subscription"],
|
||||
"deliveries": []any{map[string]any{"kind": "open", "delivery_id": fmt.Sprintf("flood-%d", i)}}})
|
||||
}
|
||||
}
|
||||
if v["alias"] == "late" {
|
||||
send(map[string]any{"type": "deliveries", "subscription": v["subscription"],
|
||||
"deliveries": []any{map[string]any{"kind": "open", "delivery_id": "late-1"}}})
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
target := environment.Gateway{Endpoint: server.URL, Token: "test"}
|
||||
flooded, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "flooded"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer flooded.close()
|
||||
late, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "late"})
|
||||
if err != nil {
|
||||
t.Fatalf("shared channel refused a joining account after another account overflowed: %v", err)
|
||||
}
|
||||
defer late.close()
|
||||
items, err := late.poll(ctx, nil)
|
||||
if err != nil || items[0].DeliveryID != "late-1" {
|
||||
t.Fatalf("other account deliveries broke after one account overflowed: %+v %v", items, err)
|
||||
}
|
||||
if connections.Load() != 1 {
|
||||
t.Fatalf("overflow tore down the shared channel, connections=%d", connections.Load())
|
||||
}
|
||||
// Buffered deliveries sent before the overflow fired remain valid work; the
|
||||
// subscription keeps reporting them until the buffer drains, then surfaces
|
||||
// the explicit overflow error.
|
||||
for {
|
||||
_, err = flooded.poll(ctx, nil)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "overflow") {
|
||||
t.Fatalf("overflowed subscription must fail with an explicit overflow error, got %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChannelClosingLastAccountDoesNotRetireJoiningAccount(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
for {
|
||||
var value map[string]any
|
||||
if c.ReadJSON(&value) != nil {
|
||||
return
|
||||
}
|
||||
if value["type"] == "subscribe" {
|
||||
_ = c.WriteJSON(map[string]any{"type": "subscribed", "subscription": value["subscription"]})
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
target := environment.Gateway{Endpoint: server.URL}
|
||||
previous, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { previous.close() }()
|
||||
for i := 0; i < 50; i++ {
|
||||
type result struct {
|
||||
sub *eventSubscription
|
||||
err error
|
||||
}
|
||||
joined := make(chan result, 1)
|
||||
go func() {
|
||||
sub, err := acquireEventSubscription(ctx, target, map[string]any{"alias": "b"})
|
||||
joined <- result{sub, err}
|
||||
}()
|
||||
previous.close()
|
||||
next := <-joined
|
||||
if next.err != nil {
|
||||
t.Fatalf("joining account interrupted at iteration %d: %v", i, next.err)
|
||||
}
|
||||
previous = next.sub
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,18 @@ type browserAliasResourceLocker interface {
|
||||
}
|
||||
|
||||
type gatewayListenerSession struct {
|
||||
target hub.Gateway
|
||||
environment hub.EnvironmentContext
|
||||
uid string
|
||||
generation string
|
||||
sessionID string
|
||||
enabled bool
|
||||
resources browserAliasResourceLocker
|
||||
boundaryAt *time.Time
|
||||
checkpoints map[string]string
|
||||
coverFiles eventCoverFiles
|
||||
subscription *eventSubscription
|
||||
target hub.Gateway
|
||||
environment hub.EnvironmentContext
|
||||
uid string
|
||||
generation string
|
||||
sessionID string
|
||||
enabled bool
|
||||
resources browserAliasResourceLocker
|
||||
boundaryAt *time.Time
|
||||
checkpoints map[string]string
|
||||
coverFiles eventCoverFiles
|
||||
session *gatewaySession
|
||||
events <-chan EventBatch
|
||||
}
|
||||
|
||||
func (s *gatewayListenerSession) request(ctx context.Context, action string) ([]creator.ListenerDelivery, error) {
|
||||
@@ -110,27 +111,44 @@ func (s *gatewayListenerSession) request(ctx context.Context, action string) ([]
|
||||
return nil, creator.ErrInvalid
|
||||
}
|
||||
func (s *gatewayListenerSession) Start(ctx context.Context) ([]creator.ListenerDelivery, error) {
|
||||
// 先订阅事件队列再启动监听任务,保证网关首批投递有接收者;
|
||||
// Start 失败时反注册,避免会话里残留无消费者的事件队列。
|
||||
session := sessionByGateway(s.target.ID)
|
||||
if session == nil {
|
||||
return nil, hub.ErrGatewayOffline
|
||||
}
|
||||
queue := session.subscribeEvents(s.environment.Alias)
|
||||
if _, err := s.request(ctx, "start"); err != nil {
|
||||
session.unsubscribeEvents(s.environment.Alias)
|
||||
return nil, err
|
||||
}
|
||||
params := gatewayGenerationPayload(s.environment)
|
||||
params["alias"], params["uid"] = s.environment.Alias, s.uid
|
||||
var err error
|
||||
params["listener_generation"] = s.generation
|
||||
params["session_id"] = s.sessionID
|
||||
subscribeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
s.subscription, err = acquireEventSubscription(subscribeCtx, s.target, params)
|
||||
if err != nil {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, cleanupErr := s.request(cleanupCtx, "stop")
|
||||
return nil, errors.Join(err, cleanupErr)
|
||||
}
|
||||
s.session = session
|
||||
s.events = queue
|
||||
return nil, nil
|
||||
}
|
||||
func (s *gatewayListenerSession) Poll(ctx context.Context, ack []string) ([]creator.ListenerDelivery, error) {
|
||||
return s.subscription.poll(ctx, ack)
|
||||
if len(ack) > 0 {
|
||||
if err := s.session.acknowledgeEvents(s.environment.Alias, ack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
select {
|
||||
case batch := <-s.events:
|
||||
if batch.Subscription == "" {
|
||||
// 队列溢出信号:本账号订阅被隔离,批次仍在网关待重推。
|
||||
return nil, errors.New("event subscription queue overflow; the account session will resubscribe and replay unacknowledged deliveries")
|
||||
}
|
||||
for _, d := range batch.Deliveries {
|
||||
if err := d.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return batch.Deliveries, nil
|
||||
case <-s.session.done:
|
||||
return nil, errors.New("gateway session disconnected")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (s *gatewayListenerSession) enrich(ctx context.Context, items []creator.ListenerDelivery) ([]creator.ListenerDelivery, error) {
|
||||
if s.coverFiles != nil {
|
||||
@@ -154,8 +172,8 @@ func (s *gatewayListenerSession) enrich(ctx context.Context, items []creator.Lis
|
||||
return items, nil
|
||||
}
|
||||
func (s *gatewayListenerSession) Stop(ctx context.Context) error {
|
||||
if s.subscription != nil {
|
||||
s.subscription.close()
|
||||
if s.session != nil {
|
||||
s.session.unsubscribeEvents(s.environment.Alias)
|
||||
}
|
||||
_, err := s.request(ctx, "stop")
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
// 平台侧网关出站通道:网关主动连入的 WS 会话、统一任务模型(持久化 + 下发 + 结果等待)
|
||||
// 与事件流路由。方向反转后平台不再拨号连网关;设计见 docs/gateway-outbound-channel-plan.md。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// gatewayChannelStore 是出站通道所需的存储能力;生产实现为 *hub.Store,测试用内存桩。
|
||||
type gatewayChannelStore interface {
|
||||
CreateGatewayTask(ctx context.Context, gatewayID int64, method, path string, payload []byte, timeout time.Duration) (hub.GatewayTask, error)
|
||||
DispatchGatewayTask(ctx context.Context, taskID int64) (bool, error)
|
||||
FinishGatewayTask(ctx context.Context, taskID int64, status string, responseStatus int, taskError string, responseBytes int64) (bool, error)
|
||||
InterruptDispatchedTasks(ctx context.Context, gatewayID int64, reason string) error
|
||||
NextPendingGatewayTasks(ctx context.Context, gatewayID int64, limit int) ([]hub.GatewayTask, error)
|
||||
ResetGatewayConnections(ctx context.Context) error
|
||||
TouchGatewaySession(ctx context.Context, gatewayID int64, version string) error
|
||||
SetGatewayOnline(ctx context.Context, gatewayID int64, online bool) error
|
||||
}
|
||||
|
||||
// gatewayChannelStoreHolder 由应用启动时注入生产 store;测试可直接替换。
|
||||
var gatewayChannelStoreHolder gatewayChannelStore
|
||||
|
||||
// SetGatewayChannelStore 注入通道存储实现。
|
||||
func SetGatewayChannelStore(store gatewayChannelStore) { gatewayChannelStoreHolder = store }
|
||||
|
||||
func channelStore() gatewayChannelStore {
|
||||
if gatewayChannelStoreHolder == nil {
|
||||
panic("gateway channel store is not configured")
|
||||
}
|
||||
return gatewayChannelStoreHolder
|
||||
}
|
||||
|
||||
// TaskResult 是任务 result 帧的载荷。
|
||||
type TaskResult struct {
|
||||
Status int `json:"status"`
|
||||
Body []byte `json:"body"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// EventBatch 是网关事件投递帧的载荷;语义沿用原 /v1/channel deliveries。
|
||||
type EventBatch struct {
|
||||
Subscription string `json:"subscription"`
|
||||
Alias string `json:"alias"`
|
||||
Deliveries []creator.ListenerDelivery `json:"deliveries"`
|
||||
}
|
||||
|
||||
// gatewaySession 是一个网关的活动出站连接。
|
||||
type gatewaySession struct {
|
||||
gateway hub.Gateway
|
||||
conn *websocket.Conn
|
||||
write sync.Mutex
|
||||
done chan struct{}
|
||||
finish sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
waiter chan TaskResult // 当前在等结果的同步任务(单连接串行下发场景按 id 匹配)
|
||||
events map[string]chan EventBatch // alias -> 事件队列
|
||||
}
|
||||
|
||||
var gatewaySessions = struct {
|
||||
sync.Mutex
|
||||
items map[int64]*gatewaySession
|
||||
}{items: map[int64]*gatewaySession{}}
|
||||
|
||||
func sessionByGateway(gatewayID int64) *gatewaySession {
|
||||
gatewaySessions.Lock()
|
||||
defer gatewaySessions.Unlock()
|
||||
return gatewaySessions.items[gatewayID]
|
||||
}
|
||||
|
||||
func (s *gatewaySession) send(value any) error {
|
||||
s.write.Lock()
|
||||
defer s.write.Unlock()
|
||||
select {
|
||||
case <-s.done:
|
||||
return errors.New("gateway session disconnected")
|
||||
default:
|
||||
}
|
||||
if err := s.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.conn.WriteJSON(value)
|
||||
}
|
||||
|
||||
type taskFrame struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type resultFrame struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
TaskResult
|
||||
}
|
||||
|
||||
// dispatchGatewayTask 创建持久化任务;网关在线时立即下发并(按需)等待结果。
|
||||
// wait=false 仅入队/补发,由通道下发器异步推进。
|
||||
func dispatchGatewayTask(ctx context.Context, gateway hub.Gateway, method, path string, body any, timeout time.Duration, wait bool) (int, []byte, error) {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("encode gateway task payload: %w", err)
|
||||
}
|
||||
task, err := channelStore().CreateGatewayTask(ctx, gateway.ID, method, path, payload, timeout)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("persist gateway task: %w", err)
|
||||
}
|
||||
session := sessionByGateway(gateway.ID)
|
||||
if session == nil {
|
||||
// 同步等待的任务立即终态化;异步任务保持 pending 由下发器补发。
|
||||
if wait {
|
||||
if _, markErr := channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskFailed, 0, "网关离线", 0); markErr != nil {
|
||||
logrus.WithError(markErr).Error("persist gateway offline task failure failed")
|
||||
}
|
||||
}
|
||||
return 0, nil, hub.ErrGatewayOffline
|
||||
}
|
||||
result, err := session.execute(ctx, task, wait)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return result.Status, result.Body, nil
|
||||
}
|
||||
|
||||
// execute 下发单个任务并等待结果;下发失败立即终态化,等待按任务超时。
|
||||
func (s *gatewaySession) execute(ctx context.Context, task hub.GatewayTask, wait bool) (*TaskResult, error) {
|
||||
var waiter chan TaskResult
|
||||
if _, err := channelStore().DispatchGatewayTask(ctx, task.ID); err != nil {
|
||||
return nil, fmt.Errorf("mark gateway task dispatched: %w", err)
|
||||
}
|
||||
frame := taskFrame{Type: "task", ID: task.ID, Method: task.Method, Path: task.Path, Payload: task.Payload}
|
||||
if wait {
|
||||
// 先建 waiter 再下发:网关可能秒回 result,晚建会丢结果导致同步调用假超时。
|
||||
s.mu.Lock()
|
||||
s.waiter = make(chan TaskResult, 1)
|
||||
waiter = s.waiter
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.waiter == waiter {
|
||||
s.waiter = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
if err := s.send(frame); err != nil {
|
||||
_, _ = channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskFailed, 0, "任务下发失败:"+err.Error(), 0)
|
||||
return nil, fmt.Errorf("send gateway task: %w", err)
|
||||
}
|
||||
if !wait {
|
||||
return &TaskResult{}, nil
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, time.Duration(task.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case r := <-waiter:
|
||||
if r.Error != "" {
|
||||
_, _ = channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskFailed, r.Status, r.Error, int64(len(r.Body)))
|
||||
return nil, fmt.Errorf("gateway task %d %s %s failed: %s", task.ID, task.Method, task.Path, r.Error)
|
||||
}
|
||||
_, _ = channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskSucceeded, r.Status, "", int64(len(r.Body)))
|
||||
return &r, nil
|
||||
case <-s.done:
|
||||
_, _ = channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskFailed, 0, "网关连接中断,结果未确认", 0)
|
||||
return nil, errors.New("gateway session disconnected")
|
||||
case <-waitCtx.Done():
|
||||
_, _ = channelStore().FinishGatewayTask(ctx, task.ID, hub.TaskFailed, 0, "网关任务等待超时", 0)
|
||||
return nil, fmt.Errorf("gateway task %d timed out after %ds", task.ID, task.TimeoutSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// receiveLoop 读取网关帧:result 唤醒等待者,event 路由到订阅队列,heartbeat 刷新健康。
|
||||
func (s *gatewaySession) receiveLoop(store gatewaySessionStore) {
|
||||
for {
|
||||
_ = s.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
*TaskResult
|
||||
Event *EventBatch `json:"event"`
|
||||
}
|
||||
if err := s.conn.ReadJSON(&frame); err != nil {
|
||||
s.fail(fmt.Sprintf("网关连接断开:%v", err), store)
|
||||
return
|
||||
}
|
||||
switch frame.Type {
|
||||
case "result":
|
||||
s.mu.Lock()
|
||||
waiter := s.waiter
|
||||
s.mu.Unlock()
|
||||
if waiter != nil && frame.TaskResult != nil {
|
||||
select {
|
||||
case waiter <- *frame.TaskResult:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "heartbeat":
|
||||
if err := store.TouchGatewaySession(context.Background(), s.gateway.ID, frame.Version); err != nil {
|
||||
logrus.WithError(err).Warn("gateway heartbeat persistence failed")
|
||||
}
|
||||
case "event":
|
||||
if frame.Event != nil {
|
||||
s.deliverEvent(*frame.Event)
|
||||
}
|
||||
default:
|
||||
s.fail("网关通道消息类型无效:"+frame.Type, store)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gatewaySession) deliverEvent(batch EventBatch) {
|
||||
s.mu.Lock()
|
||||
queue := s.events[batch.Alias]
|
||||
s.mu.Unlock()
|
||||
if queue == nil {
|
||||
// 无订阅者的投递直接确认丢弃:worker 注册订阅后才允许网关启动监听,
|
||||
// 该场景只出现在 worker 刚失败重启的间隙,批次仍留在网关待重推。
|
||||
logrus.WithFields(logrus.Fields{"gateway": s.gateway.Name, "alias": batch.Alias}).Warn("gateway event batch has no subscriber; asking gateway to retain it")
|
||||
_ = s.send(map[string]any{"type": "event_nack", "subscription": batch.Subscription, "alias": batch.Alias})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case queue <- batch:
|
||||
default:
|
||||
// 单账号消费过载只隔离该订阅;批次未确认,网关保留并重推。
|
||||
logrus.WithFields(logrus.Fields{"gateway": s.gateway.Name, "alias": batch.Alias}).Error("gateway event queue overflow; isolating subscription")
|
||||
s.removeEventQueue(batch.Alias)
|
||||
s.failSubscription(batch.Alias, queue)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gatewaySession) failSubscription(alias string, queue chan EventBatch) {
|
||||
queue <- EventBatch{Subscription: "", Alias: alias}
|
||||
}
|
||||
|
||||
func (s *gatewaySession) removeEventQueue(alias string) {
|
||||
s.mu.Lock()
|
||||
delete(s.events, alias)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *gatewaySession) fail(reason string, store gatewaySessionStore) {
|
||||
s.finish.Do(func() {
|
||||
close(s.done)
|
||||
_ = s.conn.Close()
|
||||
gatewaySessions.Lock()
|
||||
if gatewaySessions.items[s.gateway.ID] == s {
|
||||
delete(gatewaySessions.items, s.gateway.ID)
|
||||
}
|
||||
gatewaySessions.Unlock()
|
||||
logrus.WithFields(logrus.Fields{"gateway": s.gateway.Name, "reason": reason}).Error("gateway session closed")
|
||||
if store != nil {
|
||||
_ = store.InterruptDispatchedTasks(context.Background(), s.gateway.ID, reason)
|
||||
_ = store.SetGatewayOnline(context.Background(), s.gateway.ID, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// subscribeEvents 注册某账号事件队列;订阅先于监听启动任务,保证网关首批投递不丢失。
|
||||
func (s *gatewaySession) subscribeEvents(alias string) <-chan EventBatch {
|
||||
queue := make(chan EventBatch, 64)
|
||||
s.mu.Lock()
|
||||
s.events[alias] = queue
|
||||
s.mu.Unlock()
|
||||
return queue
|
||||
}
|
||||
|
||||
func (s *gatewaySession) unsubscribeEvents(alias string) {
|
||||
s.mu.Lock()
|
||||
queue := s.events[alias]
|
||||
delete(s.events, alias)
|
||||
s.mu.Unlock()
|
||||
if queue != nil {
|
||||
close(queue)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gatewaySession) acknowledgeEvents(alias string, deliveryIDs []string) error {
|
||||
return s.send(map[string]any{"type": "event_ack", "alias": alias, "delivery_ids": deliveryIDs})
|
||||
}
|
||||
|
||||
// HandleGatewayChannel 对外暴露网关出站通道 WS 路由注册。
|
||||
func HandleGatewayChannel(app *fiber.App, store *hub.Store) {
|
||||
handleGatewayChannel(app, store)
|
||||
}
|
||||
|
||||
// gatewaySessionStore 是会话读循环所需的存储能力;生产实现为 *hub.Store。
|
||||
type gatewaySessionStore interface {
|
||||
TouchGatewaySession(ctx context.Context, gatewayID int64, version string) error
|
||||
SetGatewayOnline(ctx context.Context, gatewayID int64, online bool) error
|
||||
InterruptDispatchedTasks(ctx context.Context, gatewayID int64, reason string) error
|
||||
GetGatewayByAccessKey(ctx context.Context, accessKey string) (hub.Gateway, error)
|
||||
}
|
||||
|
||||
// serveGatewaySession 完成认证、会话注册与读循环;fiber 路由与测试共用。
|
||||
func serveGatewaySession(conn *websocket.Conn, authorization string, store gatewaySessionStore) {
|
||||
gateway, err := store.GetGatewayByAccessKey(context.Background(), authorization)
|
||||
if err != nil {
|
||||
logrus.Warn("gateway agent rejected: invalid access key")
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
session := &gatewaySession{gateway: gateway, conn: conn, done: make(chan struct{}), events: map[string]chan EventBatch{}}
|
||||
gatewaySessions.Lock()
|
||||
gatewaySessions.items[gateway.ID] = session
|
||||
gatewaySessions.Unlock()
|
||||
if err := store.SetGatewayOnline(context.Background(), gateway.ID, true); err != nil {
|
||||
logrus.WithError(err).Warn("gateway online persistence failed")
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"gateway": gateway.Name, "gateway_id": gateway.ID}).Info("gateway agent connected")
|
||||
session.receiveLoop(store)
|
||||
}
|
||||
|
||||
// handleGatewayChannel 处理网关出站 WS 升级与会话生命周期。
|
||||
func handleGatewayChannel(app *fiber.App, store *hub.Store) {
|
||||
app.Get("/v1/agent", func(c fiber.Ctx) error {
|
||||
upgrader := websocket.FastHTTPUpgrader{}
|
||||
return upgrader.Upgrade(c.RequestCtx(), func(conn *websocket.Conn) {
|
||||
authorization := string(c.RequestCtx().Request.Header.Peek("Authorization"))
|
||||
serveGatewaySession(conn, authorization, store)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// RunGatewayTaskDispatcher 周期扫描 pending 任务,对在线网关按 id 序补发。
|
||||
func RunGatewayTaskDispatcher(ctx context.Context, store *hub.Store) {
|
||||
for ctx.Err() == nil {
|
||||
gateways, err := store.ListGateways(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("list gateways for task dispatch failed")
|
||||
} else {
|
||||
for _, gateway := range gateways {
|
||||
session := sessionByGateway(gateway.ID)
|
||||
if session == nil {
|
||||
continue
|
||||
}
|
||||
tasks, err := store.NextPendingGatewayTasks(ctx, gateway.ID, 64)
|
||||
if err != nil {
|
||||
logrus.WithError(err).WithField("gateway", gateway.Name).Error("read pending gateway tasks failed")
|
||||
continue
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if _, err := session.execute(ctx, task, false); err != nil {
|
||||
logrus.WithError(err).WithField("gateway", gateway.Name).Error("dispatch pending gateway task failed")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/fasthttp/websocket"
|
||||
)
|
||||
|
||||
func TestGatewayChannelDispatchesTaskAndWaitsForResult(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(1, "gw-main", "test-access-key-0001"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
|
||||
SetGatewayChannelStore(store)
|
||||
|
||||
fake := connectFakeGateway(t, server.URL, "test-access-key-0001", func(task taskFrame) *TaskResult {
|
||||
if task.Method != "POST" || task.Path != "/v1/browsers/alias/douyin/get" {
|
||||
return &TaskResult{Status: 404, Error: "unexpected path " + task.Path}
|
||||
}
|
||||
return &TaskResult{Status: 200, Body: []byte(`{"ok":true}`)}
|
||||
})
|
||||
_ = fake
|
||||
<-waitForSession(t, 1)
|
||||
|
||||
status, body, err := gatewayCall(context.Background(), newTestGateway(1, "gw-main", "unused"), "POST", "/v1/browsers/alias/douyin/get", map[string]any{"url": "https://x"}, 5*time.Second)
|
||||
if err != nil || status != 200 || string(body) != `{"ok":true}` {
|
||||
t.Fatalf("gateway call failed: status=%d body=%s err=%v", status, body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSession(t *testing.T, gatewayID int64) <-chan struct{} {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if sessionByGateway(gatewayID) != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Error("gateway session never registered")
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func TestGatewayChannelRejectsInvalidAccessKey(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(1, "gw-main", "test-access-key-0001"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
|
||||
header := map[string][]string{"Authorization": {"Bearer wrong-key"}}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(serverWSURL(server.URL)+"/v1/agent", header)
|
||||
if err != nil {
|
||||
t.Fatalf("dial before auth rejection is transport level: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// 服务端应立即关闭连接:读操作收尾即失败。
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, _, err = conn.ReadMessage(); err == nil {
|
||||
t.Fatal("invalid access key must not establish a session")
|
||||
}
|
||||
if sessionByGateway(1) != nil {
|
||||
t.Fatal("rejected gateway must not register a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayChannelOfflineFailsSyncTaskImmediately(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(2, "gw-offline", "test-access-key-0002"))
|
||||
SetGatewayChannelStore(store)
|
||||
|
||||
_, _, err := gatewayCall(context.Background(), newTestGateway(2, "gw-offline", "unused"), "POST", "/v1/browsers", nil, time.Second)
|
||||
if !errors.Is(err, hub.ErrGatewayOffline) {
|
||||
t.Fatalf("offline gateway must fail immediately: %v", err)
|
||||
}
|
||||
tasks, _ := store.NextPendingGatewayTasks(context.Background(), 2, 10)
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("offline sync task must be terminal, pending=%d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayChannelTaskTimeoutMarksFailure(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(3, "gw-slow", "test-access-key-0003"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
SetGatewayChannelStore(store)
|
||||
|
||||
// 假网关收到任务后不回结果。
|
||||
_ = connectFakeGateway(t, server.URL, "test-access-key-0003", func(task taskFrame) *TaskResult { return nil })
|
||||
<-waitForSession(t, 3)
|
||||
|
||||
_, _, err := gatewayCall(context.Background(), newTestGateway(3, "gw-slow", "unused"), "POST", "/v1/browsers/alias/stop", nil, 300*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("unanswered task must time out")
|
||||
}
|
||||
var offline hub.GatewayTask
|
||||
found := false
|
||||
for _, task := range func() map[int64]*hub.GatewayTask {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return store.tasks
|
||||
}() {
|
||||
if task.Error == "网关任务等待超时" {
|
||||
offline = *task
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || offline.Status != hub.TaskFailed {
|
||||
t.Fatalf("timeout must be recorded as failed task: %+v", offline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayChannelDisconnectInterruptsInflightTask(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(4, "gw-drop", "test-access-key-0004"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
SetGatewayChannelStore(store)
|
||||
|
||||
fake := connectFakeGateway(t, server.URL, "test-access-key-0004", func(task taskFrame) *TaskResult { return nil })
|
||||
<-waitForSession(t, 4)
|
||||
|
||||
callErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := gatewayCall(context.Background(), newTestGateway(4, "gw-drop", "unused"), "POST", "/v1/browsers/alias/start", nil, 10*time.Second)
|
||||
callErr <- err
|
||||
}()
|
||||
deadline := time.After(2 * time.Second)
|
||||
var taskID int64
|
||||
select {
|
||||
case taskID = <-fake.taskIDs:
|
||||
case <-deadline:
|
||||
t.Fatal("task was never dispatched")
|
||||
}
|
||||
// 网关断开:等待方必须以连接中断收尾。
|
||||
_ = fake.conn.Close()
|
||||
select {
|
||||
case err := <-callErr:
|
||||
if err == nil {
|
||||
t.Fatal("disconnect must fail the waiting task")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("waiting task did not finish after disconnect")
|
||||
}
|
||||
_ = taskID
|
||||
deadline = time.After(2 * time.Second)
|
||||
for {
|
||||
store.mu.Lock()
|
||||
interrupted := store.interrupted
|
||||
store.mu.Unlock()
|
||||
if interrupted >= 1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("disconnect must interrupt dispatched tasks")
|
||||
default:
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayChannelEventRoutingAndAck(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(5, "gw-events", "test-access-key-0005"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
|
||||
acks := make(chan []string, 4)
|
||||
var conn *websocket.Conn
|
||||
ackReady := make(chan struct{})
|
||||
go func() {
|
||||
header := map[string][]string{"Authorization": {"Bearer test-access-key-0005"}}
|
||||
c, _, err := websocket.DefaultDialer.Dial(serverWSURL(server.URL)+"/v1/agent", header)
|
||||
if err != nil {
|
||||
t.Errorf("fake gateway dial failed: %v", err)
|
||||
return
|
||||
}
|
||||
conn = c
|
||||
go func() {
|
||||
for {
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
Alias string `json:"alias"`
|
||||
DeliveryIDs []string `json:"delivery_ids"`
|
||||
}
|
||||
if err := c.ReadJSON(&frame); err != nil {
|
||||
return
|
||||
}
|
||||
if frame.Type == "event_ack" {
|
||||
acks <- frame.DeliveryIDs
|
||||
}
|
||||
}
|
||||
}()
|
||||
close(ackReady)
|
||||
}()
|
||||
<-ackReady
|
||||
defer func() { _ = conn.Close() }()
|
||||
<-waitForSession(t, 5)
|
||||
session := sessionByGateway(5)
|
||||
queue := session.subscribeEvents("alias-a")
|
||||
defer session.unsubscribeEvents("alias-a")
|
||||
|
||||
// 平台侧尚未消费前,网关先推两批:队列应缓冲而非丢弃。
|
||||
batch1 := eventBatchFor("alias-a", "d-1")
|
||||
batch2 := eventBatchFor("alias-a", "d-2")
|
||||
if err := conn.WriteJSON(map[string]any{"type": "event", "event": batch1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.WriteJSON(map[string]any{"type": "event", "event": batch2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := <-queue
|
||||
if got.Deliveries[0].DeliveryID != "d-1" {
|
||||
t.Fatalf("first batch lost: %+v", got)
|
||||
}
|
||||
if err := session.acknowledgeEvents("alias-a", []string{"d-1"}); err != nil {
|
||||
t.Fatalf("ack send failed: %v", err)
|
||||
}
|
||||
select {
|
||||
case ids := <-acks:
|
||||
if len(ids) != 1 || ids[0] != "d-1" {
|
||||
t.Fatalf("ack frame mismatch: %v", ids)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ack frame never reached the gateway")
|
||||
}
|
||||
|
||||
// 心跳帧刷新会话健康。
|
||||
if err := conn.WriteJSON(map[string]any{"type": "heartbeat", "version": "2026.10.0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
store.mu.Lock()
|
||||
heartbeats := store.heartbeats
|
||||
store.mu.Unlock()
|
||||
if heartbeats > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("heartbeat was not persisted")
|
||||
}
|
||||
|
||||
func TestGatewayChannelUnsubscribedEventBatchIsNacked(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(6, "gw-nack", "test-access-key-0006"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
|
||||
nacks := make(chan string, 4)
|
||||
ready := make(chan struct{})
|
||||
var conn *websocket.Conn
|
||||
go func() {
|
||||
header := map[string][]string{"Authorization": {"Bearer test-access-key-0006"}}
|
||||
c, _, err := websocket.DefaultDialer.Dial(serverWSURL(server.URL)+"/v1/agent", header)
|
||||
if err != nil {
|
||||
t.Errorf("dial failed: %v", err)
|
||||
return
|
||||
}
|
||||
conn = c
|
||||
go func() {
|
||||
for {
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
if err := c.ReadJSON(&frame); err != nil {
|
||||
return
|
||||
}
|
||||
if frame.Type == "event_nack" {
|
||||
nacks <- frame.Alias
|
||||
}
|
||||
}
|
||||
}()
|
||||
close(ready)
|
||||
}()
|
||||
<-ready
|
||||
defer func() { _ = conn.Close() }()
|
||||
<-waitForSession(t, 6)
|
||||
|
||||
if err := conn.WriteJSON(map[string]any{"type": "event", "event": eventBatchFor("ghost", "d-9")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case alias := <-nacks:
|
||||
if alias != "ghost" {
|
||||
t.Fatalf("nack alias mismatch: %s", alias)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("unsubscribed batch was silently consumed instead of nacked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayChannelTaskPayloadRoundTrip(t *testing.T) {
|
||||
store := newMemoryChannelStore(newTestGateway(7, "gw-payload", "test-access-key-0007"))
|
||||
server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)})
|
||||
SetGatewayChannelStore(store)
|
||||
|
||||
received := make(chan json.RawMessage, 1)
|
||||
fake := connectFakeGateway(t, server.URL, "test-access-key-0007", func(task taskFrame) *TaskResult {
|
||||
received <- task.Payload
|
||||
return &TaskResult{Status: 204}
|
||||
})
|
||||
_ = fake
|
||||
<-waitForSession(t, 7)
|
||||
status, _, err := gatewayCall(context.Background(), newTestGateway(7, "gw-payload", "unused"), "POST", "/v1/browsers", map[string]any{"alias": "a", "nested": map[string]int{"seed": 7}}, 5*time.Second)
|
||||
if err != nil || status != 204 {
|
||||
fake.mu.Lock()
|
||||
t.Logf("diagnose: taskIDs=%d results=%#v", len(fake.taskIDs), fake.results)
|
||||
fake.mu.Unlock()
|
||||
t.Fatalf("payload task failed: status=%d err=%v", status, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(<-received, &payload); err != nil {
|
||||
t.Fatalf("payload must stay valid JSON: %v", err)
|
||||
}
|
||||
if payload["alias"] != "a" {
|
||||
t.Fatalf("payload content mismatch: %v", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package api
|
||||
|
||||
// 网关出站通道测试基建:内存桩 store + 平台侧 WS 会话服务器 + 假网关客户端。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type memoryChannelStore struct {
|
||||
mu sync.Mutex
|
||||
gateways map[string]hub.Gateway // access_key -> gateway
|
||||
tasks map[int64]*hub.GatewayTask
|
||||
taskSeq int64
|
||||
heartbeats int
|
||||
onlineCalls int
|
||||
interrupted int
|
||||
}
|
||||
|
||||
func newMemoryChannelStore(gateways ...hub.Gateway) *memoryChannelStore {
|
||||
store := &memoryChannelStore{gateways: map[string]hub.Gateway{}, tasks: map[int64]*hub.GatewayTask{}}
|
||||
for _, gateway := range gateways {
|
||||
store.gateways[gateway.AccessKey] = gateway
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) TouchGatewaySession(_ context.Context, gatewayID int64, version string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.heartbeats++
|
||||
for key, gateway := range s.gateways {
|
||||
if gateway.ID == gatewayID {
|
||||
gateway.Version = version
|
||||
gateway.Online = true
|
||||
now := time.Now()
|
||||
gateway.LastSeenAt = &now
|
||||
s.gateways[key] = gateway
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) SetGatewayOnline(_ context.Context, gatewayID int64, online bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.onlineCalls++
|
||||
for key, gateway := range s.gateways {
|
||||
if gateway.ID == gatewayID {
|
||||
gateway.Online = online
|
||||
s.gateways[key] = gateway
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) InterruptDispatchedTasks(_ context.Context, _ int64, _ string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.interrupted++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) GetGatewayByAccessKey(_ context.Context, accessKey string) (hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
accessKey = strings.TrimPrefix(accessKey, "Bearer ")
|
||||
if gateway, ok := s.gateways[accessKey]; ok {
|
||||
return gateway, nil
|
||||
}
|
||||
return hub.Gateway{}, hub.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) CreateGatewayTask(_ context.Context, gatewayID int64, method, path string, payload []byte, timeout time.Duration) (hub.GatewayTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.taskSeq++
|
||||
timeoutSeconds := int(timeout / time.Second)
|
||||
if timeoutSeconds < 1 {
|
||||
timeoutSeconds = 1
|
||||
}
|
||||
task := hub.GatewayTask{ID: s.taskSeq, GatewayID: gatewayID, Method: method, Path: path,
|
||||
Payload: payload, Status: hub.TaskPending, TimeoutSeconds: timeoutSeconds}
|
||||
s.tasks[task.ID] = &task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) DispatchGatewayTask(_ context.Context, taskID int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if task, ok := s.tasks[taskID]; ok && task.Status == hub.TaskPending {
|
||||
task.Status = hub.TaskDispatched
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) FinishGatewayTask(_ context.Context, taskID int64, status string, responseStatus int, taskError string, responseBytes int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if task, ok := s.tasks[taskID]; ok && (task.Status == hub.TaskDispatched || task.Status == hub.TaskPending) {
|
||||
task.Status = status
|
||||
task.ResponseStatus = responseStatus
|
||||
task.Error = taskError
|
||||
task.ResponseBytes = responseBytes
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) NextPendingGatewayTasks(_ context.Context, gatewayID int64, limit int) ([]hub.GatewayTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tasks := []hub.GatewayTask{}
|
||||
for id := int64(1); id <= s.taskSeq && len(tasks) < limit; id++ {
|
||||
if task, ok := s.tasks[id]; ok && task.GatewayID == gatewayID && task.Status == hub.TaskPending {
|
||||
tasks = append(tasks, *task)
|
||||
}
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *memoryChannelStore) ResetGatewayConnections(_ context.Context) error { return nil }
|
||||
|
||||
func (s *memoryChannelStore) CreateTaskRow(gatewayID int64, method, path string, payload []byte) hub.GatewayTask {
|
||||
task, _ := s.CreateGatewayTask(context.Background(), gatewayID, method, path, payload, 30*time.Second)
|
||||
return task
|
||||
}
|
||||
|
||||
// startChannelServer 启动与生产一致的 fiber WS 服务(真实 listener + FastHTTPUpgrader)。
|
||||
func startChannelServer(t *testing.T, store gatewaySessionStore) string {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
app.Get("/v1/agent", func(c fiber.Ctx) error {
|
||||
authorization := string(c.RequestCtx().Request.Header.Peek("Authorization"))
|
||||
upgrader := websocket.FastHTTPUpgrader{}
|
||||
return upgrader.Upgrade(c.RequestCtx(), func(conn *websocket.Conn) {
|
||||
serveGatewaySession(conn, authorization, store)
|
||||
})
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("channel listener: %v", err)
|
||||
}
|
||||
go func() { _ = app.Listener(listener, fiber.ListenConfig{DisableStartupMessage: true}) }()
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
return "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
// gatewayChannelTestServer 旧签名兼容:URL 指向真实 fiber listener。
|
||||
type gatewayChannelTestServer struct {
|
||||
store *memoryChannelStore
|
||||
URL string
|
||||
}
|
||||
|
||||
type fakeChannelGateway struct {
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
taskIDs chan int64
|
||||
results map[int64]TaskResult // 已发送的 result,供测试诊断
|
||||
}
|
||||
|
||||
// WriteJSON 串行化对连接的写,避免与任务 result/心跳并发写帧。
|
||||
func (g *fakeChannelGateway) WriteJSON(value any) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.conn.WriteJSON(value)
|
||||
}
|
||||
|
||||
// connectFakeGateway 模拟网关出站连接:收到 task 帧后用 handler 生成 result 帧。
|
||||
func connectFakeGateway(t *testing.T, serverURL, accessKey string, handle func(task taskFrame) *TaskResult) *fakeChannelGateway {
|
||||
t.Helper()
|
||||
header := http.Header{"Authorization": []string{"Bearer " + accessKey}}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(serverWSURL(serverURL)+"/v1/agent", header)
|
||||
if err != nil {
|
||||
t.Fatalf("fake gateway dial failed: %v", err)
|
||||
}
|
||||
gateway := &fakeChannelGateway{conn: conn, taskIDs: make(chan int64, 8), results: map[int64]TaskResult{}}
|
||||
go func() {
|
||||
for {
|
||||
var raw struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := conn.ReadJSON(&raw); err != nil {
|
||||
return
|
||||
}
|
||||
if raw.Type != "task" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case gateway.taskIDs <- raw.ID:
|
||||
default:
|
||||
}
|
||||
if result := handle(taskFrame{ID: raw.ID, Method: raw.Method, Path: raw.Path, Payload: raw.Payload}); result != nil {
|
||||
gateway.mu.Lock()
|
||||
writeErr := conn.WriteJSON(resultFrame{Type: "result", ID: raw.ID, TaskResult: *result})
|
||||
gateway.results[raw.ID] = *result
|
||||
gateway.mu.Unlock()
|
||||
if writeErr != nil {
|
||||
t.Logf("fake gateway result write failed for task %d: %v", raw.ID, writeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 平台 60s 读超时依赖心跳;假网关必须周期回心跳保持会话。
|
||||
tickerStop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
gateway.mu.Lock()
|
||||
_ = conn.WriteJSON(map[string]any{"type": "heartbeat", "version": "test"})
|
||||
gateway.mu.Unlock()
|
||||
case <-tickerStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { close(tickerStop) })
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return gateway
|
||||
}
|
||||
|
||||
func newTestGateway(id int64, name, accessKey string) hub.Gateway {
|
||||
return hub.Gateway{ID: id, Name: name, AccessKey: accessKey}
|
||||
}
|
||||
|
||||
func eventBatchFor(alias, deliveryID string) EventBatch {
|
||||
return EventBatch{Subscription: "sub-1", Alias: alias, Deliveries: []creator.ListenerDelivery{{
|
||||
DeliveryID: deliveryID, Kind: "open",
|
||||
}}}
|
||||
}
|
||||
|
||||
// serverWSURL 把 http:// 地址转为 ws:// 地址。
|
||||
func serverWSURL(httpURL string) string {
|
||||
return "ws://" + strings.TrimPrefix(httpURL, "http://")
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// gatewayHealthProbeTimeout 是单次 /healthz 探活的超时;测试可临时替换。
|
||||
var gatewayHealthProbeTimeout = 5 * time.Second
|
||||
|
||||
// gatewayHealthStore 是网关健康检查所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。
|
||||
type gatewayHealthStore interface {
|
||||
ListGateways(ctx context.Context) ([]hub.Gateway, error)
|
||||
RecordGatewayCheck(ctx context.Context, name, status, reason string) (hub.Gateway, error)
|
||||
}
|
||||
|
||||
// classifyGatewayHealth 将一次 /healthz 探活结果归一为持久化状态与原因。
|
||||
// 连接失败或 5xx 判为 unhealthy;其余(含 401 令牌不匹配)说明进程可达,判为 healthy
|
||||
// 并把异常状态写进 reason 供排查。
|
||||
func classifyGatewayHealth(status int, callErr error) (string, string) {
|
||||
if callErr != nil {
|
||||
reason := callErr.Error()
|
||||
if len(reason) > 300 {
|
||||
reason = reason[:300]
|
||||
}
|
||||
return "unhealthy", reason
|
||||
}
|
||||
if status >= 500 {
|
||||
return "unhealthy", "gateway returned status " + http.StatusText(status)
|
||||
}
|
||||
if status >= 400 {
|
||||
return "healthy", "gateway responded with status " + http.StatusText(status)
|
||||
}
|
||||
return "healthy", ""
|
||||
}
|
||||
|
||||
// RecordGatewayHealthChecks 执行一轮网关健康探活并落库;供 workers 定时任务与测试调用。
|
||||
func RecordGatewayHealthChecks(ctx context.Context, store *hub.Store) error {
|
||||
return recordGatewayHealthChecks(ctx, store)
|
||||
}
|
||||
|
||||
// recordGatewayHealthChecks 对全部注册网关并发探活一次,每个网关只覆盖写入最近一次结果。
|
||||
func recordGatewayHealthChecks(ctx context.Context, store gatewayHealthStore) error {
|
||||
gateways, err := store.ListGateways(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, gateway := range gateways {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
probeAndRecordGateway(ctx, store, gateway)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeAndRecordGateway 对单个网关探活一次并覆盖写入最近一次结果;落库失败不阻断其余网关,仅记日志。
|
||||
func probeAndRecordGateway(ctx context.Context, store gatewayHealthStore, gateway hub.Gateway) {
|
||||
status, _, callErr := gatewayCall(ctx, gateway, http.MethodGet, "/healthz", nil, gatewayHealthProbeTimeout)
|
||||
healthStatus, reason := classifyGatewayHealth(status, callErr)
|
||||
if _, recordErr := store.RecordGatewayCheck(ctx, gateway.Name, healthStatus, reason); recordErr != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"service": "control-plane",
|
||||
"event_type": "gateway_health_check",
|
||||
"gateway": gateway.Name,
|
||||
"probed": healthStatus,
|
||||
}).WithError(recordErr).Warn("gateway health check result was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// probeGatewayAsync 在后台对单个网关立即探活一次并落库,不阻塞当前请求;
|
||||
// 用 WithoutCancel 脱离请求生命周期,请求结束后探活与落库仍会完成。
|
||||
func probeGatewayAsync(ctx context.Context, store gatewayHealthStore, gateway hub.Gateway) {
|
||||
go probeAndRecordGateway(context.WithoutCancel(ctx), store, gateway)
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// setGatewayHealthProbeTimeout 临时替换探活超时,返回旧值供 restore 恢复。
|
||||
func setGatewayHealthProbeTimeout(d time.Duration) time.Duration {
|
||||
old := gatewayHealthProbeTimeout
|
||||
gatewayHealthProbeTimeout = d
|
||||
return old
|
||||
}
|
||||
|
||||
// restoreGatewayHealthProbeTimeout 恢复探活超时旧值。
|
||||
func restoreGatewayHealthProbeTimeout(old time.Duration) func() {
|
||||
return func() { gatewayHealthProbeTimeout = old }
|
||||
}
|
||||
|
||||
type recordedGatewayCheck struct {
|
||||
name string
|
||||
status string
|
||||
reason string
|
||||
}
|
||||
|
||||
// healthMemoryStore 在 memoryStore 之上记录 RecordGatewayCheck 调用,供健康检查断言。
|
||||
type healthMemoryStore struct {
|
||||
*memoryStore
|
||||
mu sync.Mutex
|
||||
checks []recordedGatewayCheck
|
||||
}
|
||||
|
||||
func (s *healthMemoryStore) RecordGatewayCheck(_ context.Context, name, status, reason string) (hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.checks = append(s.checks, recordedGatewayCheck{name: name, status: status, reason: reason})
|
||||
return hub.Gateway{Name: name, HealthStatus: status, LastCheckReason: reason}, nil
|
||||
}
|
||||
|
||||
func TestClassifyGatewayHealth(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status int
|
||||
callErr error
|
||||
wantStatus string
|
||||
wantReasonHad bool // 是否应携带非空 reason
|
||||
}{
|
||||
{name: "204 healthy without reason", status: http.StatusNoContent, wantStatus: "healthy", wantReasonHad: false},
|
||||
{name: "200 healthy without reason", status: http.StatusOK, wantStatus: "healthy", wantReasonHad: false},
|
||||
{name: "401 reachable but unauthorized", status: http.StatusUnauthorized, wantStatus: "healthy", wantReasonHad: true},
|
||||
{name: "404 reachable but misrouted", status: http.StatusNotFound, wantStatus: "healthy", wantReasonHad: true},
|
||||
{name: "503 unhealthy", status: http.StatusServiceUnavailable, wantStatus: "unhealthy", wantReasonHad: true},
|
||||
{name: "connection error unhealthy", callErr: errors.New(`Get "http://gw:8081/healthz": dial tcp: connection refused`), wantStatus: "unhealthy", wantReasonHad: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
status, reason := classifyGatewayHealth(test.status, test.callErr)
|
||||
if status != test.wantStatus {
|
||||
t.Fatalf("classify status = %q, want %q (reason=%q)", status, test.wantStatus, reason)
|
||||
}
|
||||
if test.wantReasonHad != (reason != "") {
|
||||
t.Fatalf("classify reason = %q, wantReasonHad=%v", reason, test.wantReasonHad)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyGatewayHealthTruncatesReason(t *testing.T) {
|
||||
long := errors.New(string(make([]byte, 1024)))
|
||||
_, reason := classifyGatewayHealth(0, long)
|
||||
if len(reason) != 300 {
|
||||
t.Fatalf("reason length = %d, want 300", len(reason))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGatewayHealthChecksPersistsLatestResult(t *testing.T) {
|
||||
reachable := &fakeGateway{token: "token-a"}
|
||||
reachableServer := httptest.NewServer(reachable.handler(t))
|
||||
t.Cleanup(reachableServer.Close)
|
||||
|
||||
closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
closedServer.Close() // 端口已释放,保证连接失败
|
||||
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
store.gateways["gw-live"] = hub.Gateway{Name: "gw-live", Endpoint: reachableServer.URL, Token: reachable.token}
|
||||
store.gateways["gw-down"] = hub.Gateway{Name: "gw-down", Endpoint: closedServer.URL, Token: "unused-token"}
|
||||
|
||||
if err := recordGatewayHealthChecks(context.Background(), store); err != nil {
|
||||
t.Fatalf("record gateway health checks returned error: %v", err)
|
||||
}
|
||||
|
||||
byName := map[string]recordedGatewayCheck{}
|
||||
for _, check := range store.checks {
|
||||
byName[check.name] = check
|
||||
}
|
||||
if len(store.checks) != 2 {
|
||||
t.Fatalf("expected one check per gateway, got %#v", store.checks)
|
||||
}
|
||||
if byName["gw-live"].status != "healthy" {
|
||||
t.Fatalf("reachable gateway status = %q, want healthy", byName["gw-live"].status)
|
||||
}
|
||||
if byName["gw-live"].reason != "" {
|
||||
t.Fatalf("reachable gateway reason = %q, want empty", byName["gw-live"].reason)
|
||||
}
|
||||
if byName["gw-down"].status != "unhealthy" || byName["gw-down"].reason == "" {
|
||||
t.Fatalf("unreachable gateway = %#v, want unhealthy with reason", byName["gw-down"])
|
||||
}
|
||||
if requests := reachable.recorded(); len(requests) != 1 || requests[0].path != "/healthz" {
|
||||
t.Fatalf("reachable gateway probes = %#v, want exactly one /healthz", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGatewayHealthChecksEmptyListIsNoop(t *testing.T) {
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
if err := recordGatewayHealthChecks(context.Background(), store); err != nil {
|
||||
t.Fatalf("empty list returned error: %v", err)
|
||||
}
|
||||
if len(store.checks) != 0 {
|
||||
t.Fatalf("empty list recorded checks: %#v", store.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// 编译期约束:探活超时常量必须明显小于调度间隔,避免探活互相堆叠。
|
||||
func TestGatewayHealthProbeTimeoutBounded(t *testing.T) {
|
||||
if gatewayHealthProbeTimeout > 10*time.Second {
|
||||
t.Fatalf("probe timeout %v must stay below scheduling cadence", gatewayHealthProbeTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForGatewayCheck 轮询等待指定网关的立即探活结果落库;超时判失败。
|
||||
func waitForGatewayCheck(t *testing.T, store *healthMemoryStore, name string) recordedGatewayCheck {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
store.mu.Lock()
|
||||
for _, check := range store.checks {
|
||||
if check.name == name {
|
||||
store.mu.Unlock()
|
||||
return check
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("gateway %q health check was not recorded within deadline", name)
|
||||
return recordedGatewayCheck{}
|
||||
}
|
||||
|
||||
// healthzProbeServer 返回校验 Bearer token 后立即响应 204 的 /healthz 测试服务。
|
||||
func healthzProbeServer(t *testing.T, token string, probeStarted chan struct{}, respond func(http.ResponseWriter)) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/healthz" {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if request.Header.Get("Authorization") != "Bearer "+token {
|
||||
response.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if probeStarted != nil {
|
||||
close(probeStarted)
|
||||
}
|
||||
if respond != nil {
|
||||
respond(response)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// TestCreateGatewayProbesHealthImmediately:注册网关成功后立即按注册的 endpoint 与 token
|
||||
// 对 /healthz 探活并落库,结果为 healthy。
|
||||
func TestCreateGatewayProbesHealthImmediately(t *testing.T) {
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
probeStarted := make(chan struct{})
|
||||
server := healthzProbeServer(t, "unit-test-gateway-token", probeStarted, nil)
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
response := do(app, http.MethodPost, "/api/gateways",
|
||||
`{"name":"gw-new","endpoint":"`+server.URL+`","token":"unit-test-gateway-token"}`)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("create gateway returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case <-probeStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("gateway health probe was not triggered after create")
|
||||
}
|
||||
check := waitForGatewayCheck(t, store, "gw-new")
|
||||
if check.status != "healthy" || check.reason != "" {
|
||||
t.Fatalf("created gateway check = %#v, want healthy without reason", check)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateGatewayDoesNotBlockOnSlowGateway:网关探活被拖住时注册请求必须立即返回,
|
||||
// 后台探活超时后把 unhealthy 结果落库。
|
||||
func TestCreateGatewayDoesNotBlockOnSlowGateway(t *testing.T) {
|
||||
t.Cleanup(restoreGatewayHealthProbeTimeout(setGatewayHealthProbeTimeout(250 * time.Millisecond)))
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
server := healthzProbeServer(t, "unit-test-gateway-token", nil, func(response http.ResponseWriter) {
|
||||
time.Sleep(800 * time.Millisecond) // 超过探活超时,若探活同步执行则注册请求会被拖住
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
started := time.Now()
|
||||
response := do(app, http.MethodPost, "/api/gateways",
|
||||
`{"name":"gw-slow","endpoint":"`+server.URL+`","token":"unit-test-gateway-token"}`)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("create gateway returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("create gateway blocked %v on health probe", elapsed)
|
||||
}
|
||||
check := waitForGatewayCheck(t, store, "gw-slow")
|
||||
if check.status != "unhealthy" || check.reason == "" {
|
||||
t.Fatalf("slow gateway check = %#v, want unhealthy with reason", check)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateGatewayProbesHealthImmediately:编辑网关成功后立即按新 endpoint 探活并覆盖落库。
|
||||
func TestUpdateGatewayProbesHealthImmediately(t *testing.T) {
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-old:8081", Token: "unit-test-gateway-token"}
|
||||
server := healthzProbeServer(t, "unit-test-gateway-token", nil, nil)
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
response := do(app, http.MethodPut, "/api/gateways/gw-1",
|
||||
`{"name":"gw-1","endpoint":"`+server.URL+`","token":""}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
check := waitForGatewayCheck(t, store, "gw-1")
|
||||
if check.status != "healthy" || check.reason != "" {
|
||||
t.Fatalf("updated gateway check = %#v, want healthy without reason", check)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateGatewayUnreachableRecordsUnhealthy:编辑指向不可达地址时保存照常成功,
|
||||
// 后台探活把 unhealthy 结果落库供页面展示。
|
||||
func TestUpdateGatewayUnreachableRecordsUnhealthy(t *testing.T) {
|
||||
t.Cleanup(restoreGatewayHealthProbeTimeout(setGatewayHealthProbeTimeout(300 * time.Millisecond)))
|
||||
store := &healthMemoryStore{memoryStore: newMemoryStore()}
|
||||
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-old:8081", Token: "unit-test-gateway-token"}
|
||||
closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
closedServer.Close() // 端口已释放,保证连接失败
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
response := do(app, http.MethodPut, "/api/gateways/gw-1",
|
||||
`{"name":"gw-1","endpoint":"`+closedServer.URL+`","token":""}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
check := waitForGatewayCheck(t, store, "gw-1")
|
||||
if check.status != "unhealthy" || check.reason == "" {
|
||||
t.Fatalf("unreachable gateway check = %#v, want unhealthy with reason", check)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
)
|
||||
|
||||
func TestCreatorGatewayBrowserResolveAndSharePlatformValidation(t *testing.T) {
|
||||
environment := hub.EnvironmentContext{Env: hub.Env{Alias: "account-1"}, RuntimeID: "runtime-1", RuntimeNetworkID: "network-1", BindingVersion: 3}
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantURL string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "success", status: http.StatusOK, body: `{"url":"https://www.douyin.com/video/1"}`, wantURL: "https://www.douyin.com/video/1"},
|
||||
{name: "invalid json", status: http.StatusOK, body: `{`, wantErr: true},
|
||||
{name: "empty url", status: http.StatusOK, body: `{"url":""}`, wantErr: true},
|
||||
{name: "rejected", status: http.StatusBadGateway, body: `gateway down`, wantErr: true},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/browsers/account-1/douyin/resolve" {
|
||||
t.Fatalf("resolve path = %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(testCase.status)
|
||||
_, _ = w.Write([]byte(testCase.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
got, err := (creatorGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL}, environment: environment}).Resolve(context.Background(), "https://www.douyin.com/video/1")
|
||||
if (err != nil) != testCase.wantErr || got != testCase.wantURL {
|
||||
t.Fatalf("resolve = %q, err = %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
value string
|
||||
platform string
|
||||
wantErr bool
|
||||
}{
|
||||
{value: "https://www.douyin.com/video/1", platform: creator.PlatformDouyin},
|
||||
{value: "https://v.douyin.com/abc", platform: creator.PlatformDouyin},
|
||||
{value: "https://www.xiaohongshu.com/explore/1", wantErr: true},
|
||||
{value: "https://xhslink.com/abc", wantErr: true},
|
||||
{value: "http://www.douyin.com/video/1", wantErr: true},
|
||||
{value: "https://user@www.douyin.com/video/1", wantErr: true},
|
||||
{value: "https://www.douyin.com:443/video/1", wantErr: true},
|
||||
{value: "https://www.douyin.com/video/1#part", wantErr: true},
|
||||
{value: "https://example.com/video/1", wantErr: true},
|
||||
} {
|
||||
platform, err := competitorSharePlatform(testCase.value)
|
||||
if testCase.wantErr {
|
||||
if !errors.Is(err, creator.ErrInvalid) {
|
||||
t.Errorf("competitorSharePlatform(%q) error = %v", testCase.value, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || platform != testCase.platform {
|
||||
t.Errorf("competitorSharePlatform(%q) = %q, %v", testCase.value, platform, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindRecoveryErrorPreservesFailureStates(t *testing.T) {
|
||||
sentinel := errors.New("rebind failed")
|
||||
if got := rebindRecoveryError(true, sentinel); !errors.Is(got, sentinel) {
|
||||
t.Fatalf("existing error = %v", got)
|
||||
}
|
||||
if got := rebindRecoveryError(false, nil); got == nil {
|
||||
t.Fatal("incomplete recovery was reported as successful")
|
||||
}
|
||||
if got := rebindRecoveryError(true, nil); got != nil {
|
||||
t.Fatalf("complete recovery error = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,16 @@ func newCommand() *cobra.Command {
|
||||
defer close(listenerDone)
|
||||
api.RunAccountEventListeners(listenerContext, creatorStore, hubStore)
|
||||
}()
|
||||
gatewayHealthContext, stopGatewayHealth := context.WithCancel(command.Context())
|
||||
gatewayHealthDone := make(chan struct{})
|
||||
// 出站通道:启动时清空上次进程遗留的在线标记,任务下发器补发 pending 任务。
|
||||
if err := hubStore.ResetGatewayConnections(command.Context()); err != nil {
|
||||
logrus.WithError(err).Error("reset gateway connections failed")
|
||||
}
|
||||
api.SetGatewayChannelStore(hubStore)
|
||||
taskDispatchContext, stopTaskDispatch := context.WithCancel(command.Context())
|
||||
taskDispatchDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(gatewayHealthDone)
|
||||
workers.RunGatewayHealthCheck(gatewayHealthContext, hubStore)
|
||||
defer close(taskDispatchDone)
|
||||
api.RunGatewayTaskDispatcher(taskDispatchContext, hubStore)
|
||||
}()
|
||||
privateMessagesContext, stopPrivateMessages := context.WithCancel(command.Context())
|
||||
privateMessagesDone := make(chan struct{})
|
||||
@@ -121,12 +126,12 @@ func newCommand() *cobra.Command {
|
||||
stopCreatorScheduler()
|
||||
stopListeners()
|
||||
stopPrivateMessages()
|
||||
stopGatewayHealth()
|
||||
stopTaskDispatch()
|
||||
stopHeartbeat()
|
||||
<-creatorScheduleDone
|
||||
<-listenerDone
|
||||
<-privateMessagesDone
|
||||
<-gatewayHealthDone
|
||||
<-taskDispatchDone
|
||||
<-heartbeatDone
|
||||
return listenErr
|
||||
},
|
||||
@@ -251,6 +256,10 @@ func newHandler(webDirectory, username, password string, phaseAStore *account.St
|
||||
IdleTimeout: 60 * time.Second,
|
||||
})
|
||||
system.RegisterHealth(app, phaseAStore, hubStore, creatorStore)
|
||||
// 网关出站通道在认证中间件之外:连接层用网关 access_key 自认证。
|
||||
if hubStore != nil {
|
||||
api.HandleGatewayChannel(app, hubStore)
|
||||
}
|
||||
app.Use(system.Authenticate(username, password))
|
||||
if hubStore != nil {
|
||||
environmentsapi.Register(app, hubStore)
|
||||
|
||||
@@ -359,6 +359,8 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase {
|
||||
{http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest},
|
||||
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/gateways/:name/reset-key", "/api/gateways/missing/reset-key", "", http.StatusNotFound},
|
||||
{http.MethodGet, "/api/gateways/:name/tasks", "/api/gateways/missing/tasks", "", http.StatusNotFound},
|
||||
|
||||
{http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK},
|
||||
{http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound},
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package gatewayclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultResponseLimit = 1 << 20
|
||||
LargeResponseLimit = 16 << 20
|
||||
)
|
||||
|
||||
// Call performs one authenticated native gateway request with a bounded response body.
|
||||
func Call(ctx context.Context, target environment.Gateway, method, path string, body any, timeout time.Duration) (int, []byte, error) {
|
||||
return CallWithLimit(ctx, target, method, path, body, timeout, DefaultResponseLimit)
|
||||
}
|
||||
|
||||
// CallWithLimit performs one authenticated native gateway request with an explicit body limit.
|
||||
func CallWithLimit(ctx context.Context, target environment.Gateway, method, path string, body any, timeout time.Duration, responseLimit int) (int, []byte, error) {
|
||||
callCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
var payload io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
payload = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(callCtx, method, target.Endpoint+path, payload)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+target.Token)
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, int64(responseLimit)))
|
||||
return response.StatusCode, responseBody, err
|
||||
}
|
||||
@@ -29,19 +29,3 @@ func RunRuntimeLeaseHeartbeat(ctx context.Context, store *environment.Store) {
|
||||
func RunCreatorScheduler(ctx context.Context, store *creatorDomain.Store, accountStore *account.Store, environmentStore *environment.Store) {
|
||||
api.RunCreatorScheduler(ctx, store, accountStore, environmentStore)
|
||||
}
|
||||
|
||||
// RunGatewayHealthCheck 每 30 秒对全部注册网关探活一次,只保留最近一次结果。
|
||||
func RunGatewayHealthCheck(ctx context.Context, store *environment.Store) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := api.RecordGatewayHealthChecks(ctx, store); err != nil && ctx.Err() == nil {
|
||||
logrus.WithField("service", "control-plane").WithError(err).Warn("gateway health check failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user