This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 53e8599bdd
commit 0a8ecbe09b
75 changed files with 2299 additions and 276 deletions
+13 -12
View File
@@ -79,20 +79,21 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
c.Status(http.StatusNotFound)
return
}
if !middleware.AuthenticateWebSocket(c, auth) {
if wsLogger != nil {
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed",
"mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(),
"has_token", hasToken, "origin", c.GetHeader("Origin"))
}
return
}
claims := middleware.Claims(c)
keys := map[string]any{
"request_id": contextString(c, "request_id"),
"request_id": contextString(c, "request_id"),
"authenticated": false,
}
if claims != nil {
keys["user_id"] = claims.ID
// Watching is public. A valid token enriches the connection; a stale
// token is cleared and continues as anonymous so it cannot block video.
if hasToken {
if authenticated := middleware.AuthenticateWebSocketOptional(c, auth); !authenticated && wsLogger != nil {
wsLogger.InfoContext(c.Request.Context(), "websocket token unavailable; continuing anonymously",
"mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"))
}
if claims := middleware.Claims(c); claims != nil {
keys["user_id"] = claims.ID
keys["authenticated"] = true
}
}
if wsLogger != nil {
wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection",
+12
View File
@@ -1,7 +1,9 @@
package handler
import (
"errors"
"github.com/gin-gonic/gin"
bizgift "kra/internal/biz/gift"
"kra/internal/service/dto"
service "kra/internal/service/gift"
)
@@ -136,6 +138,16 @@ func (h *Gift) Send(c *gin.Context) {
}
v, e := h.s.Send(c.Request.Context(), claims.ID, id, in)
if e != nil {
var insufficient *bizgift.InsufficientBalanceError
if errors.As(e, &insufficient) {
Write(c, CodeError, gin.H{
"reason": "insufficient_balance",
"required": insufficient.Required,
"balance": insufficient.Balance,
"missing": insufficient.Missing,
}, e.Error())
return
}
businessFail(c, e)
return
}
+96 -15
View File
@@ -2,6 +2,7 @@ package handler
import (
"errors"
"fmt"
"net/http"
bizlive "kra/internal/biz/live"
@@ -14,10 +15,89 @@ import (
type Live struct {
service *liveservice.StreamService
catalog *liveservice.CatalogService
follows *liveservice.FollowService
}
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live {
return &Live{service: service, catalog: catalog}
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService, follows *liveservice.FollowService) *Live {
return &Live{service: service, catalog: catalog, follows: follows}
}
func (h *Live) FollowStatus(c *gin.Context) {
id, ok := businessID(c)
if !ok {
return
}
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
followed, err := h.follows.Status(c.Request.Context(), claims.ID, id)
if err != nil {
businessFail(c, err)
return
}
OKWithData(c, gin.H{"followed": followed})
}
func (h *Live) Follow(c *gin.Context) {
id, ok := businessID(c)
if !ok {
return
}
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
followed, err := h.follows.Follow(c.Request.Context(), claims.ID, id)
if err != nil {
businessFail(c, err)
return
}
OKWithData(c, gin.H{"followed": followed})
}
func (h *Live) Unfollow(c *gin.Context) {
id, ok := businessID(c)
if !ok {
return
}
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
if err := h.follows.Unfollow(c.Request.Context(), claims.ID, id); err != nil {
businessFail(c, err)
return
}
OKWithData(c, gin.H{"followed": false})
}
func (h *Live) Like(c *gin.Context) {
id, ok := businessID(c)
if !ok {
return
}
// Do not create runtime stream state for arbitrary room IDs. The stream
// repository is process-local, so validating the catalog first also keeps
// bogus likes from accumulating forever.
room, err := h.catalog.GetRoom(c.Request.Context(), id)
if err != nil {
businessFail(c, err)
return
}
if !room.Enabled || room.Status != bizlive.RoomStatusLive {
businessFail(c, bizlive.ErrRoomNotLive)
return
}
count, err := h.service.Like(c.Request.Context(), fmt.Sprintf("room-%d", id))
if err != nil {
businessFail(c, err)
return
}
OKWithData(c, gin.H{"likeCount": count})
}
func (h *Live) List(c *gin.Context) {
@@ -114,17 +194,27 @@ func (h *Live) PublicRooms(c *gin.Context) {
in.PageSize = 20
}
if in.OrderBy == "" {
in.OrderBy = "sort_asc"
in.OrderBy = "viewer_count_desc"
}
enabled := true
in.Enabled = &enabled
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
items, total, e := h.catalog.ListPublicRooms(c.Request.Context(), in)
if e != nil {
businessFail(c, e)
return
}
OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize})
}
func (h *Live) GetPublicRoom(c *gin.Context) {
id, ok := businessID(c)
if !ok {
return
}
v, e := h.catalog.GetPublicRoom(c.Request.Context(), id)
if e != nil {
businessFail(c, e)
return
}
OKWithData(c, v)
}
func (h *Live) GetRoom(c *gin.Context) {
id, ok := businessID(c)
if !ok {
@@ -203,15 +293,6 @@ func (h *Live) SaveRoomSort(c *gin.Context) {
OK(c)
}
func (h *Live) LegacyList(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "直播列表获取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "streams": items})
}
func (h *Live) Find(c *gin.Context) {
item, err := h.service.Get(c.Request.Context(), c.Param("streamId"))
if err != nil {
+6 -6
View File
@@ -15,14 +15,14 @@ func (h *LiveChat) History(c *gin.Context) {
if !ok {
return
}
in, ok := bindQuery[dto.LiveChatHistoryRequest](c)
if !ok {
return
}
items, err := h.service.History(c.Request.Context(), roomID, in.Limit)
// Chat is intentionally scoped to the current live connection. Do not
// replay rows persisted by an earlier viewer/session through HTTP. Still
// validate the room so callers receive a useful not-found response.
items, err := h.service.History(c.Request.Context(), roomID, 1)
if err != nil {
businessFail(c, err)
return
}
OKWithData(c, gin.H{"list": items})
_ = items
OKWithData(c, gin.H{"list": []*dto.LiveChatMessageResponse{}})
}
+14
View File
@@ -58,6 +58,20 @@ func (h *LiveClient) Me(c *gin.Context) {
OKWithData(c, result)
}
func (h *LiveClient) Balance(c *gin.Context) {
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
result, err := h.service.Balance(c.Request.Context(), claims.ID)
if err != nil {
businessFail(c, err)
return
}
OKWithData(c, result)
}
func (h *LiveClient) UpdateProfile(c *gin.Context) {
claims := Claims(c)
if claims == nil {
+36
View File
@@ -9,6 +9,14 @@ import (
type Recharge struct{ s *service.Service }
func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} }
func (h *Recharge) Providers(c *gin.Context) {
v, e := h.s.Providers(c.Request.Context())
if e != nil {
businessFail(c, e)
return
}
OKWithData(c, gin.H{"list": v})
}
func (h *Recharge) Plans(c *gin.Context) {
v, e := h.s.Plans(c.Request.Context())
if e != nil {
@@ -48,6 +56,34 @@ func (h *Recharge) Get(c *gin.Context) {
}
OKWithData(c, v)
}
func (h *Recharge) GetMine(c *gin.Context) {
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
v, e := h.s.GetForUser(c.Request.Context(), claims.ID, c.Param("orderNo"))
if e != nil {
businessFail(c, e)
return
}
OKWithData(c, v)
}
func (h *Recharge) PaymentStatus(c *gin.Context) {
claims := Claims(c)
if claims == nil {
NoAuth(c, "请登录")
return
}
v, e := h.s.PaymentStatus(c.Request.Context(), claims.ID, c.Param("orderNo"))
if e != nil {
businessFail(c, e)
return
}
OKWithData(c, v)
}
func (h *Recharge) Create(c *gin.Context) {
in, ok := bindJSON[dto.RechargeOrderRequest](c)
if !ok {
+32 -1
View File
@@ -43,6 +43,33 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool {
return authenticate(c, auth, true)
}
// AuthenticateWebSocketOptional enriches a public WebSocket connection when
// a valid token is available. Missing, expired, malformed, or revoked tokens
// fall back to an anonymous session; invalid cookie state is cleared so a
// stale login cannot prevent public live viewing.
func AuthenticateWebSocketOptional(c *gin.Context, auth TokenAuthenticator) bool {
token := RequestToken(c, true)
if token == "" || auth == nil {
return false
}
value, err, _ := refreshTokens.Do(token, func() (any, error) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), tokenAuthTimeout)
defer cancel()
return auth.AuthenticateToken(ctx, token)
})
if err != nil {
httpx.SetCookie(c, "x-token", "", -1)
return false
}
authentication, ok := value.(*system.TokenAuthentication)
if !ok || authentication == nil || authentication.Claims == nil {
httpx.SetCookie(c, "x-token", "", -1)
return false
}
applyTokenAuthentication(c, authentication)
return true
}
func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool {
token := RequestToken(c, allowQueryToken)
if token == "" {
@@ -74,13 +101,17 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
httpx.NoAuth(c, "无法处理此token")
return false
}
applyTokenAuthentication(c, authentication)
return true
}
func applyTokenAuthentication(c *gin.Context, authentication *system.TokenAuthentication) {
if authentication.Refreshed != nil {
c.Header("new-token", authentication.Refreshed.Value)
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10))
httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
}
c.Set(claimsKey, authentication.Claims)
return true
}
// RequestToken extracts the authentication token accepted by HTTP handlers.
+34
View File
@@ -6,6 +6,7 @@ import (
"kra/internal/biz/system"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -79,3 +80,36 @@ func TestAuthenticateWebSocketSupportsBearerHeaderAndReportsInvalidToken(t *test
t.Fatalf("invalid token status=%d, want %d", invalidRecorder.Code, http.StatusUnauthorized)
}
}
func TestAuthenticateWebSocketOptionalFallsBackToAnonymous(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &tokenAuthenticatorStub{err: system.ErrTokenExpired}
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/ws", nil)
context.Request.AddCookie(&http.Cookie{Name: "x-token", Value: "expired-token"})
if AuthenticateWebSocketOptional(context, stub) {
t.Fatal("expired token was treated as authenticated")
}
if Claims(context) != nil {
t.Fatalf("anonymous fallback retained claims: %#v", Claims(context))
}
if recorder.Code == http.StatusUnauthorized {
t.Fatalf("optional authentication interrupted public handshake: body=%s", recorder.Body.String())
}
if cookie := recorder.Header().Get("Set-Cookie"); !strings.Contains(cookie, "x-token=") || !strings.Contains(cookie, "Max-Age=0") {
t.Fatalf("expired token cookie was not cleared: %q", cookie)
}
}
func TestAuthenticateWebSocketOptionalStoresValidClaims(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &tokenAuthenticatorStub{claims: &system.AuthClaims{ID: 9}}
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest(http.MethodGet, "/ws?token=valid-token", nil)
if !AuthenticateWebSocketOptional(context, stub) || Claims(context) == nil || Claims(context).ID != 9 {
t.Fatalf("valid optional token was not applied: claims=%#v", Claims(context))
}
}
+5 -3
View File
@@ -8,11 +8,13 @@ import (
func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) {
public.GET("/live/streams", h.List)
public.GET("/live/streams/:streamId", h.Find)
// Keep the old list endpoint as a compatibility alias for existing clients.
public.GET("/api/streams", h.LegacyList)
public.GET("/live/categories", h.PublicCategories)
public.GET("/live/rooms", h.PublicRooms)
public.GET("/live/rooms/:id", h.GetRoom)
public.GET("/live/rooms/:id", h.GetPublicRoom)
public.POST("/live/rooms/:id/like", h.Like)
private.GET("/live/rooms/:id/follow", h.FollowStatus)
private.POST("/live/rooms/:id/follow", h.Follow)
private.DELETE("/live/rooms/:id/follow", h.Unfollow)
private.GET("/live/admin/categories", h.ListCategories)
private.POST("/live/admin/categories", h.CreateCategory)
private.PUT("/live/admin/categories/:id", h.UpdateCategory)
+3 -1
View File
@@ -5,10 +5,12 @@ import (
"kra/internal/server/handler"
)
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) {
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient, session *handler.Session) {
public.POST("/live/client/auth/login", h.Login)
public.POST("/live/client/auth/register", h.Register)
private.POST("/live/client/auth/logout", session.Logout)
private.GET("/live/client/me", h.Me)
private.GET("/live/client/balance", h.Balance)
private.PUT("/live/client/me", h.UpdateProfile)
private.PUT("/live/client/me/password", h.ChangePassword)
}
+3
View File
@@ -6,7 +6,10 @@ import (
)
func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) {
public.GET("/live/recharge/providers", h.Providers)
public.GET("/live/recharge/plans", h.Plans)
private.GET("/live/recharge/orders/:orderNo", h.GetMine)
private.GET("/live/recharge/orders/:orderNo/status", h.PaymentStatus)
private.PUT("/live/admin/recharge/plans", h.SavePlans)
private.GET("/live/admin/orders", h.List)
private.GET("/live/admin/orders/:orderNo", h.Get)
+1 -1
View File
@@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
func() { RegisterPayment(private, public, r.handlers.Payment) },
func() { RegisterLive(public, private, r.handlers.Live) },
func() { RegisterLiveChat(public, r.handlers.LiveChat) },
func() { RegisterLiveClient(public, private, r.handlers.LiveClient) },
func() { RegisterLiveClient(public, private, r.handlers.LiveClient, r.handlers.Session) },
func() { RegisterGift(public, private, r.handlers.Gift) },
func() { RegisterRecharge(public, private, r.handlers.Recharge) },
func() { RegisterAsset(private, r.handlers.Asset) },
+22 -13
View File
@@ -30,7 +30,7 @@ func (s *websocketAuthStub) AuthenticateToken(_ context.Context, token string) (
var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil)
func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
func TestWebSocketRouteAcceptsAnonymousAndAuthenticatedViewers(t *testing.T) {
store := runtimeconfig.NewStore()
raw, err := json.Marshal(map[string]any{"path": "/ws"})
if err != nil {
@@ -46,26 +46,35 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws)
unauthorized := httptest.NewRecorder()
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil))
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized websocket status=%d body=%s", unauthorized.Code, unauthorized.Body.String())
}
httpServer := httptest.NewServer(engine)
defer httpServer.Close()
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws?token=login-token"
connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil)
baseURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws"
anonymous, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL, nil)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
t.Fatalf("authorized websocket handshake failed: %v", err)
t.Fatalf("anonymous websocket handshake failed: %v", err)
}
if connection == nil {
t.Fatal("authorized websocket handshake returned nil connection")
if anonymous == nil {
t.Fatal("anonymous websocket handshake returned nil connection")
}
_ = connection.Close()
_ = anonymous.Close()
if auth.got != "" {
t.Fatalf("anonymous websocket unexpectedly authenticated token=%q", auth.got)
}
authenticated, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL+"?token=login-token", nil)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
t.Fatalf("authenticated websocket handshake failed: %v", err)
}
if authenticated == nil {
t.Fatal("authenticated websocket handshake returned nil connection")
}
_ = authenticated.Close()
if auth.got != "login-token" {
t.Fatalf("websocket auth token=%q, want login-token", auth.got)
}