直播弹幕:前台注册登录与 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

@@ -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

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/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")
{

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"}},
"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 线上集合信息(名称、文档数、索引)

View File

@@ -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