直播弹幕:前台注册登录与 JWT;Nginx 缓解间歇 502;site_users 集合
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
178
server/handlers/site_auth.go
Normal file
178
server/handlers/site_auth.go
Normal 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": "用户名为 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())
|
||||
}
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
15
server/models/site_user.go
Normal file
15
server/models/site_user.go
Normal 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"`
|
||||
}
|
||||
@@ -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 线上集合信息(名称、文档数、索引)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user