diff --git a/MongoDB/create_collections.js b/MongoDB/create_collections.js index 322af10..3d60b54 100644 --- a/MongoDB/create_collections.js +++ b/MongoDB/create_collections.js @@ -81,4 +81,11 @@ if (!db.getCollectionNames().includes("role_permissions")) { } db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true }); +// 11. site_users(前台直播弹幕账号,与后台 users 分离) +if (!db.getCollectionNames().includes("site_users")) { + db.createCollection("site_users"); + print("已创建集合: site_users"); +} +db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true }); + print("集合与索引处理完成。"); diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 473cf4e..f9efefa 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -37,6 +37,10 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 75s; + proxy_send_timeout 75s; + proxy_read_timeout 75s; + proxy_buffering off; } location /admin/ { @@ -55,5 +59,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 75s; + proxy_send_timeout 75s; + proxy_read_timeout 75s; } } diff --git a/nginx/yuheng.yuxindazhineng.com.conf b/nginx/yuheng.yuxindazhineng.com.conf index 67d0e23..1670d00 100644 --- a/nginx/yuheng.yuxindazhineng.com.conf +++ b/nginx/yuheng.yuxindazhineng.com.conf @@ -56,5 +56,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 75s; + proxy_send_timeout 75s; + proxy_read_timeout 75s; + proxy_buffering off; } } diff --git a/server/.env.example b/server/.env.example index 42acb80..d66e3cd 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,6 +9,9 @@ GIN_MODE=release # 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/) ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com +# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值) +# SITE_JWT_SECRET=your-long-random-secret + # 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭) # SKIP_PROMOTION_TRANSCODE=1 diff --git a/server/handlers/site_auth.go b/server/handlers/site_auth.go new file mode 100644 index 0000000..bb331d6 --- /dev/null +++ b/server/handlers/site_auth.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "time" + "unicode/utf8" + + "yh_web/server/config" + "yh_web/server/models" + "yh_web/server/utils" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +const siteJWTExpire = 30 * 24 * time.Hour + +// SiteClaims 前台 JWT(仅弹幕等轻量场景,勿与后台 Claims 混用) +type SiteClaims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +func siteJWTSigningKey() []byte { + s := strings.TrimSpace(os.Getenv("SITE_JWT_SECRET")) + if s == "" { + s = "yh_web_site_dm_jwt_change_in_production" + } + return []byte(s) +} + +// ParseSiteClaims 解析前台 JWT,失败返回 nil,false +func ParseSiteClaims(tokenStr string) (*SiteClaims, bool) { + tokenStr = strings.TrimSpace(tokenStr) + if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") { + tokenStr = strings.TrimSpace(tokenStr[7:]) + } + if tokenStr == "" { + return nil, false + } + var claims SiteClaims + t, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) { + return siteJWTSigningKey(), nil + }) + if err != nil || !t.Valid { + return nil, false + } + return &claims, true +} + +// SiteDanmakuTokenValid 弹幕发送权限:有效的前台 JWT +func SiteDanmakuTokenValid(tokenStr string) bool { + _, ok := ParseSiteClaims(tokenStr) + return ok +} + +type siteRegisterInput struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type siteLoginInput struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func siteUsersColl() *mongo.Collection { + return config.GetDB(config.DBName).Collection("site_users") +} + +// WebSiteRegister POST /api/web/site/register — 仅用于前台直播弹幕账号 +func WebSiteRegister(c *gin.Context) { + if config.MongoClient == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"}) + return + } + var input siteRegisterInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"}) + return + } + u := strings.TrimSpace(input.Username) + if utf8.RuneCountInString(u) < 2 || utf8.RuneCountInString(u) > 32 { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户名为 2~32 个字符"}) + return + } + if len(input.Password) < 6 { + c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少 6 位"}) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + coll := siteUsersColl() + var existing models.SiteUser + err := coll.FindOne(ctx, bson.M{"username": u}).Decode(&existing) + if err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"}) + return + } + if !errors.Is(err, mongo.ErrNoDocuments) { + c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"}) + return + } + doc := models.SiteUser{ + Username: u, + PasswordHash: utils.HashPassword(input.Password), + CreatedAt: time.Now().UTC(), + } + res, err := coll.InsertOne(ctx, doc) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"}) + return + } + id, _ := res.InsertedID.(bson.ObjectID) + token, err := issueSiteToken(id.Hex(), u) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"token": token, "username": u}) +} + +// WebSiteLogin POST /api/web/site/login +func WebSiteLogin(c *gin.Context) { + if config.MongoClient == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"}) + return + } + var input siteLoginInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"}) + return + } + u := strings.TrimSpace(input.Username) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + var user models.SiteUser + err := siteUsersColl().FindOne(ctx, bson.M{"username": u}).Decode(&user) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + if utils.HashPassword(input.Password) != user.PasswordHash { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + token, err := issueSiteToken(user.ID.Hex(), user.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"token": token, "username": user.Username}) +} + +func issueSiteToken(userID, username string) (string, error) { + claims := SiteClaims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(siteJWTExpire)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: userID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString(siteJWTSigningKey()) +} diff --git a/server/main.go b/server/main.go index 41fc6c9..50af5fc 100644 --- a/server/main.go +++ b/server/main.go @@ -210,6 +210,10 @@ func main() { r.GET("/api/web/routes", handlers.GetWebRoutes) r.GET("/api/web/page", handlers.GetWebPageByPath) + // 前台直播弹幕账号(与后台 users 无关;需 MongoDB) + r.POST("/api/web/site/register", handlers.WebSiteRegister) + r.POST("/api/web/site/login", handlers.WebSiteLogin) + // 前台 API 路由组 web := r.Group("/api/web") { diff --git a/server/models/site_user.go b/server/models/site_user.go new file mode 100644 index 0000000..e907231 --- /dev/null +++ b/server/models/site_user.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// SiteUser 前台用户(目前仅用于直播弹幕身份,与后台 users 集合分离) +type SiteUser struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` + Username string `bson:"username" json:"username"` + PasswordHash string `bson:"password_hash" json:"-"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/server/pkg/schema/sync.go b/server/pkg/schema/sync.go index 071a714..5053c26 100644 --- a/server/pkg/schema/sync.go +++ b/server/pkg/schema/sync.go @@ -28,6 +28,7 @@ var requiredCollections = map[string][]indexSpec{ "files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}}, "system_config": {}, "role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}}, + "site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}}, } type indexSpec struct { @@ -48,6 +49,7 @@ var tableDDL = map[string]string{ "messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';", "files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';", "system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';", + "site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';", } // CollectionInfo 线上集合信息(名称、文档数、索引) diff --git a/server/pkg/weblive/danmaku.go b/server/pkg/weblive/danmaku.go index e2ccef2..4d9c149 100644 --- a/server/pkg/weblive/danmaku.go +++ b/server/pkg/weblive/danmaku.go @@ -7,6 +7,8 @@ import ( "time" "unicode/utf8" + "yh_web/server/handlers" + "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) @@ -18,8 +20,18 @@ var ( danmakuClients = make(map[*websocket.Conn]struct{}) ) -// handleDanmakuWS 弹幕:客户端发 JSON {"text":"..."},服务端立刻向所有连接广播 {"type":"dm","text","ts"},不落库 +func writeDanmakuJSON(ws *websocket.Conn, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + return ws.WriteMessage(websocket.TextMessage, b) +} + +// handleDanmakuWS 弹幕:收 JSON {"text":"..."};未带有效 token 仅可收广播不可发。广播 {"type":"dm","text","ts"},不落库 func handleDanmakuWS(c *gin.Context) { + canSend := handlers.SiteDanmakuTokenValid(c.Query("token")) + ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return @@ -45,6 +57,14 @@ func handleDanmakuWS(c *gin.Context) { if mt != websocket.TextMessage { continue } + if !canSend { + _ = writeDanmakuJSON(ws, map[string]interface{}{ + "type": "error", + "code": "login_required", + "message": "请先登录或注册后再发弹幕", + }) + continue + } text := extractDanmakuText(payload) if text == "" { continue diff --git a/web/src/router/index.js b/web/src/router/index.js index 3024d0e..1b8a891 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -23,6 +23,18 @@ const routes = [ component: () => import('../views/LiveRoom.vue'), meta: { title: '直播' } }, + { + path: '/live/login', + name: 'LiveLogin', + component: () => import('../views/LiveLogin.vue'), + meta: { title: '登录' } + }, + { + path: '/live/register', + name: 'LiveRegister', + component: () => import('../views/LiveRegister.vue'), + meta: { title: '注册' } + }, { path: '/index', redirect: '/' diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index bf65924..e794f5e 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -16,8 +16,10 @@ export function liveInfoURL() { return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info' } -export function liveDanmakuWsURL() { - const path = '/api/web/live/danmaku/ws' +/** @param {string} [token] 前台弹幕 JWT,缺省则仅收广播不可发 */ +export function liveDanmakuWsURL(token) { + const q = token ? `?token=${encodeURIComponent(token)}` : '' + const path = `/api/web/live/danmaku/ws${q}` if (apiBase) { const base = apiBase.replace(/\/$/, '') const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') diff --git a/web/src/utils/siteUserAuth.js b/web/src/utils/siteUserAuth.js new file mode 100644 index 0000000..a8f4ad8 --- /dev/null +++ b/web/src/utils/siteUserAuth.js @@ -0,0 +1,35 @@ +/** 前台直播弹幕账号(localStorage,与后台管理 JWT 无关) */ +const TOKEN_KEY = 'yh_site_dm_token' +const USER_KEY = 'yh_site_dm_username' + +export function getSiteDmToken() { + try { + return localStorage.getItem(TOKEN_KEY) || '' + } catch { + return '' + } +} + +export function getSiteDmUsername() { + try { + return localStorage.getItem(USER_KEY) || '' + } catch { + return '' + } +} + +export function setSiteDmSession(token, username) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) + if (username) localStorage.setItem(USER_KEY, username) + else localStorage.removeItem(USER_KEY) + } catch (_) {} +} + +export function clearSiteDmSession() { + try { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) + } catch (_) {} +} diff --git a/web/src/views/LiveLogin.vue b/web/src/views/LiveLogin.vue new file mode 100644 index 0000000..c0acc44 --- /dev/null +++ b/web/src/views/LiveLogin.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/src/views/LiveRegister.vue b/web/src/views/LiveRegister.vue new file mode 100644 index 0000000..3ff15a1 --- /dev/null +++ b/web/src/views/LiveRegister.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index 06e5755..a4c12c7 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -40,17 +40,29 @@ +
+ + +
- +

{{ dmHint }}

@@ -77,6 +89,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { apiBase } from '../config' import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality' import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC' +import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth' const watchVideoRef = ref(null) const rawLiveUrl = ref('') @@ -95,6 +108,16 @@ let dmReconnectAttempt = 0 /** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */ let dmGen = 0 const dmSendQueue = [] +/** 递增以触发登录态 computed(同页退出后刷新 UI) */ +const dmAuthRev = ref(0) +const dmLoggedIn = computed(() => { + dmAuthRev.value + return !!getSiteDmToken() +}) +const dmDisplayName = computed(() => { + dmAuthRev.value + return getSiteDmUsername() || '用户' +}) const enterUrl = computed(() => (rawLiveUrl.value || '').trim()) @@ -218,7 +241,7 @@ function connectDanmaku() { } catch (_) {} dmWs = null } - const url = liveDanmakuWsURL() + const url = liveDanmakuWsURL(getSiteDmToken()) dmHint.value = dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…' try { @@ -250,6 +273,10 @@ function connectDanmaku() { } if (j.type === 'dm' && typeof j.text === 'string' && j.text) { pushDmLine(j.text) + return + } + if (j.type === 'error' && j.code === 'login_required') { + dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕' } } dmWs.onclose = () => { @@ -262,9 +289,20 @@ function connectDanmaku() { } } +function logoutDmUser() { + clearSiteDmSession() + dmAuthRev.value += 1 + dmSendQueue.length = 0 + connectDanmaku() +} + function sendDm() { const t = dmDraft.value.trim() if (!t) return + if (!getSiteDmToken()) { + dmHint.value = '发弹幕请先登录或注册' + return + } dmDraft.value = '' if (dmWs && dmWs.readyState === WebSocket.OPEN) { try { @@ -429,6 +467,45 @@ onUnmounted(() => { transform: translateX(-105%); } } +.live-dm-auth-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px 14px; + max-width: 480px; + margin: 12px auto 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} +.live-dm-user { + color: rgba(255, 255, 255, 0.85); +} +.live-dm-auth-link { + color: #00d4ff; + text-decoration: none; + font-weight: 600; +} +.live-dm-auth-link:hover { + text-decoration: underline; +} +.live-dm-auth-sep { + color: rgba(255, 255, 255, 0.35); + user-select: none; +} +.live-dm-auth-btn { + padding: 4px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.85); + font-size: 12px; + cursor: pointer; +} +.live-dm-auth-btn:hover { + border-color: rgba(0, 212, 255, 0.5); + color: #00d4ff; +} .live-dm-bar { display: flex; gap: 10px; @@ -450,6 +527,10 @@ onUnmounted(() => { .live-dm-input::placeholder { color: rgba(255, 255, 255, 0.35); } +.live-dm-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} .live-dm-send { flex-shrink: 0; padding: 10px 18px; @@ -464,6 +545,10 @@ onUnmounted(() => { .live-dm-send:hover { background: rgba(0, 212, 255, 0.25); } +.live-dm-send:disabled { + opacity: 0.45; + cursor: not-allowed; +} .live-dm-hint { max-width: 480px; margin: 10px auto 0;