直播弹幕:前台注册登录与 JWT;Nginx 缓解间歇 502;site_users 集合

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 14:52:28 +08:00
parent f28b80354f
commit 2e675bda51
15 changed files with 730 additions and 6 deletions

View File

@@ -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 }); 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("集合与索引处理完成。"); print("集合与索引处理完成。");

View File

@@ -37,6 +37,10 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
proxy_buffering off;
} }
location /admin/ { location /admin/ {
@@ -55,5 +59,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
} }
} }

View File

@@ -56,5 +56,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
proxy_buffering off;
} }
} }

View File

@@ -9,6 +9,9 @@ GIN_MODE=release
# 对外域名CORS、日志与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/ # 对外域名CORS、日志与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值)
# SITE_JWT_SECRET=your-long-random-secret
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭) # 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
# SKIP_PROMOTION_TRANSCODE=1 # SKIP_PROMOTION_TRANSCODE=1

View File

@@ -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": "用户名为 232 个字符"})
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())
}

View File

@@ -210,6 +210,10 @@ func main() {
r.GET("/api/web/routes", handlers.GetWebRoutes) r.GET("/api/web/routes", handlers.GetWebRoutes)
r.GET("/api/web/page", handlers.GetWebPageByPath) 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 路由组 // 前台 API 路由组
web := r.Group("/api/web") web := r.Group("/api/web")
{ {

View File

@@ -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"`
}

View File

@@ -28,6 +28,7 @@ var requiredCollections = map[string][]indexSpec{
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}}, "files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"system_config": {}, "system_config": {},
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}}, "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 { 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='消息表';", "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='文件表';", "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='系统配置表';", "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 线上集合信息(名称、文档数、索引) // CollectionInfo 线上集合信息(名称、文档数、索引)

View File

@@ -7,6 +7,8 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"yh_web/server/handlers"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -18,8 +20,18 @@ var (
danmakuClients = make(map[*websocket.Conn]struct{}) 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) { func handleDanmakuWS(c *gin.Context) {
canSend := handlers.SiteDanmakuTokenValid(c.Query("token"))
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
return return
@@ -45,6 +57,14 @@ func handleDanmakuWS(c *gin.Context) {
if mt != websocket.TextMessage { if mt != websocket.TextMessage {
continue continue
} }
if !canSend {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "login_required",
"message": "请先登录或注册后再发弹幕",
})
continue
}
text := extractDanmakuText(payload) text := extractDanmakuText(payload)
if text == "" { if text == "" {
continue continue

View File

@@ -23,6 +23,18 @@ const routes = [
component: () => import('../views/LiveRoom.vue'), component: () => import('../views/LiveRoom.vue'),
meta: { title: '直播' } 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', path: '/index',
redirect: '/' redirect: '/'

View File

@@ -16,8 +16,10 @@ export function liveInfoURL() {
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info' return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
} }
export function liveDanmakuWsURL() { /** @param {string} [token] 前台弹幕 JWT缺省则仅收广播不可发 */
const path = '/api/web/live/danmaku/ws' export function liveDanmakuWsURL(token) {
const q = token ? `?token=${encodeURIComponent(token)}` : ''
const path = `/api/web/live/danmaku/ws${q}`
if (apiBase) { if (apiBase) {
const base = apiBase.replace(/\/$/, '') const base = apiBase.replace(/\/$/, '')
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')

View File

@@ -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 (_) {}
}

175
web/src/views/LiveLogin.vue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<div class="live-auth">
<header class="live-auth-top">
<router-link to="/live" class="live-auth-back"> 返回直播</router-link>
</header>
<main class="live-auth-main">
<h1 class="live-auth-title">登录发弹幕</h1>
<p class="live-auth-desc">本站账号仅用于直播弹幕与后台管理无关</p>
<form class="live-auth-form" @submit.prevent="submit">
<label class="live-auth-label">
<span>用户名</span>
<input v-model.trim="username" class="live-auth-input" type="text" autocomplete="username" required />
</label>
<label class="live-auth-label">
<span>密码</span>
<input
v-model="password"
class="live-auth-input"
type="password"
autocomplete="current-password"
required
/>
</label>
<p v-if="err" class="live-auth-err">{{ err }}</p>
<button type="submit" class="live-auth-btn" :disabled="loading">
{{ loading ? '登录中…' : '登录' }}
</button>
</form>
<p class="live-auth-footer">
还没有账号
<router-link to="/live/register" class="live-auth-link">注册</router-link>
</p>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiBase } from '../config'
import { setSiteDmSession } from '../utils/siteUserAuth'
const router = useRouter()
const username = ref('')
const password = ref('')
const err = ref('')
const loading = ref(false)
async function submit() {
err.value = ''
loading.value = true
try {
const url = apiBase ? `${apiBase}/api/web/site/login` : '/api/web/site/login'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
password: password.value
})
})
const j = await res.json().catch(() => ({}))
if (!res.ok) {
err.value = j.error || `登录失败(${res.status}`
return
}
if (!j.token) {
err.value = '服务器未返回凭证'
return
}
setSiteDmSession(j.token, j.username || username.value)
router.push('/live')
} catch {
err.value = '网络错误,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.live-auth {
min-height: 100vh;
padding: 24px 20px 48px;
background: radial-gradient(ellipse at 30% 20%, rgba(74, 26, 107, 0.25) 0%, transparent 45%),
radial-gradient(ellipse at 70% 80%, rgba(30, 58, 95, 0.3) 0%, transparent 50%),
#0a0a12;
color: #fff;
font-family: 'Noto Sans SC', system-ui, sans-serif;
}
.live-auth-top {
max-width: 400px;
margin: 0 auto 24px;
}
.live-auth-back {
color: rgba(255, 255, 255, 0.55);
text-decoration: none;
font-size: 14px;
}
.live-auth-back:hover {
color: #00d4ff;
}
.live-auth-main {
max-width: 400px;
margin: 0 auto;
}
.live-auth-title {
font-size: 1.35rem;
margin: 0 0 10px;
letter-spacing: 0.08em;
}
.live-auth-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
margin: 0 0 24px;
}
.live-auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.live-auth-label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
text-align: left;
}
.live-auth-input {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(0, 212, 255, 0.35);
background: rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 15px;
}
.live-auth-input:focus {
outline: none;
border-color: #00d4ff;
}
.live-auth-err {
margin: 0;
font-size: 13px;
color: #ff6b6b;
}
.live-auth-btn {
margin-top: 8px;
padding: 12px 20px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, #00d4ff, #ff2d95);
color: #0a0a12;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.live-auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.live-auth-footer {
margin-top: 24px;
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
}
.live-auth-link {
color: #00d4ff;
text-decoration: none;
}
.live-auth-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="live-auth">
<header class="live-auth-top">
<router-link to="/live" class="live-auth-back"> 返回直播</router-link>
</header>
<main class="live-auth-main">
<h1 class="live-auth-title">注册发弹幕</h1>
<p class="live-auth-desc">注册后仅用于在本站直播页发送弹幕暂无其他功能</p>
<form class="live-auth-form" @submit.prevent="submit">
<label class="live-auth-label">
<span>用户名232 </span>
<input v-model.trim="username" class="live-auth-input" type="text" autocomplete="username" required />
</label>
<label class="live-auth-label">
<span>密码至少 6 </span>
<input
v-model="password"
class="live-auth-input"
type="password"
autocomplete="new-password"
required
/>
</label>
<p v-if="err" class="live-auth-err">{{ err }}</p>
<button type="submit" class="live-auth-btn" :disabled="loading">
{{ loading ? '注册中…' : '注册并登录' }}
</button>
</form>
<p class="live-auth-footer">
已有账号
<router-link to="/live/login" class="live-auth-link">登录</router-link>
</p>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiBase } from '../config'
import { setSiteDmSession } from '../utils/siteUserAuth'
const router = useRouter()
const username = ref('')
const password = ref('')
const err = ref('')
const loading = ref(false)
async function submit() {
err.value = ''
loading.value = true
try {
const url = apiBase ? `${apiBase}/api/web/site/register` : '/api/web/site/register'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
password: password.value
})
})
const j = await res.json().catch(() => ({}))
if (!res.ok) {
err.value = j.error || `注册失败(${res.status}`
return
}
if (!j.token) {
err.value = '服务器未返回凭证'
return
}
setSiteDmSession(j.token, j.username || username.value)
router.push('/live')
} catch {
err.value = '网络错误,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.live-auth {
min-height: 100vh;
padding: 24px 20px 48px;
background: radial-gradient(ellipse at 30% 20%, rgba(74, 26, 107, 0.25) 0%, transparent 45%),
radial-gradient(ellipse at 70% 80%, rgba(30, 58, 95, 0.3) 0%, transparent 50%),
#0a0a12;
color: #fff;
font-family: 'Noto Sans SC', system-ui, sans-serif;
}
.live-auth-top {
max-width: 400px;
margin: 0 auto 24px;
}
.live-auth-back {
color: rgba(255, 255, 255, 0.55);
text-decoration: none;
font-size: 14px;
}
.live-auth-back:hover {
color: #00d4ff;
}
.live-auth-main {
max-width: 400px;
margin: 0 auto;
}
.live-auth-title {
font-size: 1.35rem;
margin: 0 0 10px;
letter-spacing: 0.08em;
}
.live-auth-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
margin: 0 0 24px;
}
.live-auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.live-auth-label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
text-align: left;
}
.live-auth-input {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(0, 212, 255, 0.35);
background: rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 15px;
}
.live-auth-input:focus {
outline: none;
border-color: #00d4ff;
}
.live-auth-err {
margin: 0;
font-size: 13px;
color: #ff6b6b;
}
.live-auth-btn {
margin-top: 8px;
padding: 12px 20px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, #00d4ff, #ff2d95);
color: #0a0a12;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.live-auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.live-auth-footer {
margin-top: 24px;
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
}
.live-auth-link {
color: #00d4ff;
text-decoration: none;
}
.live-auth-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -40,17 +40,29 @@
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button> <button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
</div> </div>
</div> </div>
<div class="live-dm-auth-row">
<template v-if="dmLoggedIn">
<span class="live-dm-user">已登录{{ dmDisplayName }}</span>
<button type="button" class="live-dm-auth-btn" @click="logoutDmUser">退出</button>
</template>
<template v-else>
<router-link class="live-dm-auth-link" to="/live/login">登录发弹幕</router-link>
<span class="live-dm-auth-sep">·</span>
<router-link class="live-dm-auth-link" to="/live/register">注册</router-link>
</template>
</div>
<div class="live-dm-bar"> <div class="live-dm-bar">
<input <input
v-model="dmDraft" v-model="dmDraft"
class="live-dm-input" class="live-dm-input"
type="text" type="text"
maxlength="120" maxlength="120"
placeholder="发条弹幕…" :placeholder="dmLoggedIn ? '发条弹幕…' : '登录后可发弹幕(仍可观看)'"
autocomplete="off" autocomplete="off"
:disabled="!dmLoggedIn"
@keydown.enter.prevent="sendDm" @keydown.enter.prevent="sendDm"
/> />
<button type="button" class="live-dm-send" @click="sendDm">发送</button> <button type="button" class="live-dm-send" :disabled="!dmLoggedIn" @click="sendDm">发送</button>
</div> </div>
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p> <p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
</section> </section>
@@ -77,6 +89,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config' import { apiBase } from '../config'
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality' import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC' import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth'
const watchVideoRef = ref(null) const watchVideoRef = ref(null)
const rawLiveUrl = ref('') const rawLiveUrl = ref('')
@@ -95,6 +108,16 @@ let dmReconnectAttempt = 0
/** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */ /** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */
let dmGen = 0 let dmGen = 0
const dmSendQueue = [] 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()) const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
@@ -218,7 +241,7 @@ function connectDanmaku() {
} catch (_) {} } catch (_) {}
dmWs = null dmWs = null
} }
const url = liveDanmakuWsURL() const url = liveDanmakuWsURL(getSiteDmToken())
dmHint.value = dmHint.value =
dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…' dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…'
try { try {
@@ -250,6 +273,10 @@ function connectDanmaku() {
} }
if (j.type === 'dm' && typeof j.text === 'string' && j.text) { if (j.type === 'dm' && typeof j.text === 'string' && j.text) {
pushDmLine(j.text) pushDmLine(j.text)
return
}
if (j.type === 'error' && j.code === 'login_required') {
dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕'
} }
} }
dmWs.onclose = () => { dmWs.onclose = () => {
@@ -262,9 +289,20 @@ function connectDanmaku() {
} }
} }
function logoutDmUser() {
clearSiteDmSession()
dmAuthRev.value += 1
dmSendQueue.length = 0
connectDanmaku()
}
function sendDm() { function sendDm() {
const t = dmDraft.value.trim() const t = dmDraft.value.trim()
if (!t) return if (!t) return
if (!getSiteDmToken()) {
dmHint.value = '发弹幕请先登录或注册'
return
}
dmDraft.value = '' dmDraft.value = ''
if (dmWs && dmWs.readyState === WebSocket.OPEN) { if (dmWs && dmWs.readyState === WebSocket.OPEN) {
try { try {
@@ -429,6 +467,45 @@ onUnmounted(() => {
transform: translateX(-105%); 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 { .live-dm-bar {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -450,6 +527,10 @@ onUnmounted(() => {
.live-dm-input::placeholder { .live-dm-input::placeholder {
color: rgba(255, 255, 255, 0.35); color: rgba(255, 255, 255, 0.35);
} }
.live-dm-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.live-dm-send { .live-dm-send {
flex-shrink: 0; flex-shrink: 0;
padding: 10px 18px; padding: 10px 18px;
@@ -464,6 +545,10 @@ onUnmounted(() => {
.live-dm-send:hover { .live-dm-send:hover {
background: rgba(0, 212, 255, 0.25); background: rgba(0, 212, 255, 0.25);
} }
.live-dm-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.live-dm-hint { .live-dm-hint {
max-width: 480px; max-width: 480px;
margin: 10px auto 0; margin: 10px auto 0;