chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAlarmRoutes 注册告警路由
|
||||
func RegisterAlarmRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/alarms", middleware.RequirePermission(db, "alarm:read"), listAlarms(db))
|
||||
rg.GET("/alarms/:id", middleware.RequirePermission(db, "alarm:read"), getAlarm(db))
|
||||
rg.POST("/alarms/:id/ack", middleware.RequirePermission(db, "alarm:ack"), ackAlarm(db))
|
||||
rg.POST("/alarms/:id/resolve", middleware.RequirePermission(db, "alarm:ack"), resolveAlarm(db))
|
||||
}
|
||||
|
||||
// listAlarms 告警列表(query: openOnly,默认上限500,按 triggeredAt DESC)
|
||||
func listAlarms(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 500
|
||||
q := db.Model(&model.Alarm{}).Order("triggered_at DESC").Limit(limit)
|
||||
if c.Query("openOnly") == "true" {
|
||||
q = q.Where("open = true")
|
||||
}
|
||||
var alarms []model.Alarm
|
||||
q.Find(&alarms)
|
||||
c.JSON(http.StatusOK, alarms)
|
||||
}
|
||||
}
|
||||
|
||||
// getAlarm 告警详情
|
||||
func getAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
|
||||
// ackAlarm 确认告警(设置 acknowledged=true, acknowledgedAt=now)
|
||||
func ackAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.Alarm{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"acknowledged": true,
|
||||
"acknowledged_at": now,
|
||||
})
|
||||
db.Where("id = ?", id).First(&alarm)
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAlarm 解除告警(设置 open=false, resolvedAt=now)
|
||||
func resolveAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.Alarm{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"open": false,
|
||||
"resolved_at": now,
|
||||
})
|
||||
db.Where("id = ?", id).First(&alarm)
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAlarmClipRoutes 注册告警视频片段路由
|
||||
func RegisterAlarmClipRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/alarms/:id/clip", middleware.RequirePermission(db, "alarm:read"), getAlarmClip(db))
|
||||
}
|
||||
|
||||
// getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC)
|
||||
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查告警是否存在
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var clips []model.VideoClip
|
||||
db.Where("alarm_id = ?", id).Order("start_at DESC").Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clips)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAuditRoutes 注册审计日志路由
|
||||
func RegisterAuditRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "audit:read")
|
||||
rg.GET("/audit-logs", perm, listAuditLogs(db))
|
||||
rg.POST("/audit-logs", perm, createAuditLog(db))
|
||||
}
|
||||
|
||||
// listAuditLogs 审计日志列表(query: limit,默认100,上限500)
|
||||
func listAuditLogs(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 100
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
var logs []model.AuditLog
|
||||
db.Order("created_at DESC").Limit(limit).Find(&logs)
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// createAuditLog 手动写入审计日志,自动补充 userId/username/IP/UA
|
||||
func createAuditLog(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Resource *string `json:"resource"`
|
||||
TargetID *string `json:"targetId"`
|
||||
Description *string `json:"description"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从 context 获取当前用户信息
|
||||
var userID, username *string
|
||||
if user, ok := c.Get("user"); ok {
|
||||
if userMap, ok := user.(map[string]interface{}); ok {
|
||||
if sub, ok := userMap["sub"].(string); ok {
|
||||
s := sub
|
||||
userID = &s
|
||||
}
|
||||
if uname, ok := userMap["username"].(string); ok {
|
||||
s := uname
|
||||
username = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Action: body.Action,
|
||||
Resource: body.Resource,
|
||||
TargetID: body.TargetID,
|
||||
Description: body.Description,
|
||||
IPAddress: &ip,
|
||||
UserAgent: &ua,
|
||||
Metadata: body.Metadata,
|
||||
}
|
||||
if err := db.Create(&log).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "写入审计日志失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, log)
|
||||
}
|
||||
}
|
||||
|
||||
// recordAudit 记录审计日志(供其他 handler 调用)
|
||||
func recordAudit(db *gorm.DB, log *model.AuditLog) {
|
||||
if log == nil || log.Action == "" {
|
||||
return
|
||||
}
|
||||
if err := db.Create(log).Error; err != nil {
|
||||
slog.Warn("记录审计日志失败", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 默认令牌有效期:访问令牌 2 小时,刷新令牌 7 天
|
||||
const (
|
||||
defaultAccessExpiry = 2 * time.Hour
|
||||
defaultRefreshExpiry = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// RegisterAuthRoutes 注册认证相关路由
|
||||
func RegisterAuthRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
// 启动时确保默认 admin 用户存在
|
||||
ensureDefaultAdmin(db, cfg)
|
||||
|
||||
rg.POST("/auth/register", registerHandler(db, cfg))
|
||||
rg.POST("/auth/login", loginHandler(db, cfg))
|
||||
rg.POST("/auth/refresh", refreshHandler(db, cfg))
|
||||
rg.POST("/auth/logout", logoutHandler(cfg))
|
||||
rg.POST("/auth/change-password", changePasswordHandler(db, cfg))
|
||||
rg.GET("/auth/me", meHandler(db))
|
||||
}
|
||||
|
||||
// registerHandler 注册用户(bcrypt 哈希密码),返回 accessToken + refreshToken + user
|
||||
func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FullName *string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(body.Username) < 3 || len(body.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username 至少3位,password 至少6位"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户名或邮箱是否已存在
|
||||
var exists model.User
|
||||
if db.Where("username = ? OR email = ?", body.Username, body.Email).First(&exists).Error == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already taken"})
|
||||
return
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码哈希失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色)
|
||||
role := model.RoleViewer
|
||||
user := model.User{
|
||||
Username: body.Username,
|
||||
Email: body.Email,
|
||||
PasswordHash: string(hash),
|
||||
FullName: body.FullName,
|
||||
Role: role,
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uid := user.ID
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "user registered"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "create",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// loginHandler 登录(用户名或邮箱 + 密码),返回 token
|
||||
func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 登录限流:检查 IP+用户名是否被锁定
|
||||
if middleware.CheckLoginLock(c, body.Username) {
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil {
|
||||
middleware.RecordLoginFail(c, body.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||
middleware.RecordLoginFail(c, body.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "账号已禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功,清空失败计数
|
||||
middleware.RecordLoginSuccess(c, body.Username)
|
||||
|
||||
// 记录审计日志
|
||||
uid := user.ID
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "user login"
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "login",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
IPAddress: &ip,
|
||||
UserAgent: &ua,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// refreshHandler 刷新访问令牌(body 传 refreshToken)
|
||||
func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.RefreshToken) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refreshToken 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, token, err := middleware.ExtractClaims(body.RefreshToken, cfg.JWTSecret)
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||
return
|
||||
}
|
||||
if claims.TokenType != "refresh" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||
return
|
||||
}
|
||||
if middleware.IsRevoked(claims) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户,确保仍然有效
|
||||
var user model.User
|
||||
if db.Where("id = ?", claims.Subject).First(&user).Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if !user.Active {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "账号已禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 吊销旧刷新令牌(一次性使用),签发新令牌对
|
||||
middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time)
|
||||
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// logoutHandler 登出:将当前访问令牌加入黑名单
|
||||
func logoutHandler(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
||||
if claims.ExpiresAt != nil {
|
||||
middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// changePasswordHandler 修改当前用户密码
|
||||
func changePasswordHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, _ := userVal.(map[string]interface{})
|
||||
uid, _ := userMap["sub"].(string)
|
||||
|
||||
var body struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(body.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if db.Where("id = ?", uid).First(&user).Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.OldPassword)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码哈希失败"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", uid).Update("password_hash", string(hash)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新密码失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "password changed"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "update",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// meHandler 返回当前用户信息(从 context 获取 user)+ 权限列表
|
||||
func meHandler(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, _ := userVal.(map[string]interface{})
|
||||
role, _ := userMap["role"].(string)
|
||||
permissions := getUserPermissionCodes(db, role)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sub": userMap["sub"],
|
||||
"username": userMap["username"],
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getUserPermissionCodes 查询指定角色的权限码列表
|
||||
func getUserPermissionCodes(db *gorm.DB, role string) []string {
|
||||
if role == model.RoleAdmin {
|
||||
// admin 拥有全部权限
|
||||
codes := make([]string, 0, len(model.AllPermissions))
|
||||
for _, p := range model.AllPermissions {
|
||||
codes = append(codes, p.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
var codes []string
|
||||
db.Table("role_permissions").
|
||||
Select("permissions.code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Where("role_permissions.role = ?", role).
|
||||
Scan(&codes)
|
||||
return codes
|
||||
}
|
||||
|
||||
// buildLoginPayload 构建登录返回数据(accessToken + refreshToken + user + permissions)
|
||||
func buildLoginPayload(db *gorm.DB, user model.User, cfg *config.Config) gin.H {
|
||||
return gin.H{
|
||||
"accessToken": signToken(user, cfg, parseDuration(cfg.JWTExpiresIn, defaultAccessExpiry), "access"),
|
||||
"refreshToken": signToken(user, cfg, defaultRefreshExpiry, "refresh"),
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"fullName": user.FullName,
|
||||
"role": user.Role,
|
||||
"permissions": getUserPermissionCodes(db, user.Role),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// signToken 签发 JWT(payload: sub/username/role/tokenType/jti,HS256 + JWTSecret)
|
||||
func signToken(user model.User, cfg *config.Config, expiry time.Duration, tokenType string) string {
|
||||
claims := middleware.JWTClaims{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
TokenType: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: randomJTI(),
|
||||
Subject: user.ID,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString([]byte(cfg.JWTSecret))
|
||||
if err != nil {
|
||||
slog.Error("签发 JWT 失败", "error", err)
|
||||
return ""
|
||||
}
|
||||
return tokenStr
|
||||
}
|
||||
|
||||
// randomJTI 生成 16 字节随机 token ID
|
||||
func randomJTI() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// parseDuration 解析过期时间字符串(支持 "7d"、"1h"、"30m" 等),失败返回默认值
|
||||
func parseDuration(s string, fallback time.Duration) time.Duration {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
// 支持 "7d" 格式(Go 原生 time.ParseDuration 不支持天)
|
||||
if strings.HasSuffix(s, "d") {
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
||||
if err == nil {
|
||||
return time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ensureDefaultAdmin 确保默认 admin 用户存在
|
||||
func ensureDefaultAdmin(db *gorm.DB, cfg *config.Config) {
|
||||
username := cfg.DefaultAdminUsername
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
var existing model.User
|
||||
if db.Where("username = ?", username).First(&existing).Error == nil {
|
||||
return // 已存在
|
||||
}
|
||||
|
||||
password := cfg.DefaultAdminPassword
|
||||
if password == "" {
|
||||
password = "silk@123"
|
||||
}
|
||||
email := cfg.DefaultAdminEmail
|
||||
if email == "" {
|
||||
email = "admin@silk.local"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||
if err != nil {
|
||||
slog.Error("默认 admin 密码哈希失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
fullName := "系统管理员"
|
||||
admin := model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
FullName: &fullName,
|
||||
Role: "admin",
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
slog.Error("创建默认 admin 失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uid := admin.ID
|
||||
uname := admin.Username
|
||||
res := "users"
|
||||
desc := "default admin created"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "create",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
slog.Info("默认 admin 用户已创建", "username", username)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MQTTPublisher MQTT 发布接口(后续 MQTT 模块实现后注入)
|
||||
type MQTTPublisher interface {
|
||||
Publish(topic string, payload interface{}) error
|
||||
}
|
||||
|
||||
// DeviceCommander 设备命令发送接口(向特定设备发送命令)
|
||||
type DeviceCommander interface {
|
||||
PublishToDevice(deviceKey string, payload interface{}) error
|
||||
GetIRResult(deviceKey string) interface{}
|
||||
GetIRLearnedCodes(deviceKey string) []int
|
||||
}
|
||||
|
||||
// mqttPublisher 全局 MQTT 发布者实例(后续 MQTT 模块初始化后赋值)
|
||||
var mqttPublisher MQTTPublisher
|
||||
|
||||
// deviceCommander 全局设备命令发送者(用于 GSTMB1 等设备特定命令)
|
||||
var deviceCommander DeviceCommander
|
||||
|
||||
// SetMQTTPublisher 设置全局 MQTT 发布者(供 main 或 MQTT 模块调用)
|
||||
func SetMQTTPublisher(p MQTTPublisher) {
|
||||
mqttPublisher = p
|
||||
}
|
||||
|
||||
// SetDeviceCommander 设置全局设备命令发送者
|
||||
func SetDeviceCommander(d DeviceCommander) {
|
||||
deviceCommander = d
|
||||
}
|
||||
|
||||
// ControlCommand 控制命令
|
||||
type ControlCommand struct {
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
Action string `json:"action"`
|
||||
Value interface{} `json:"value"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// RegisterControlRoutes 注册控制命令路由
|
||||
func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "device:control")
|
||||
rg.POST("/control/send", perm, sendControl())
|
||||
rg.POST("/control/batch", perm, batchControl())
|
||||
rg.POST("/devices/:id/gstmb1/command", perm, sendGSTMB1Command(db))
|
||||
rg.POST("/devices/:id/gstmb1/info", perm, sendGSTMB1Info(db))
|
||||
rg.POST("/devices/:id/gstmb1/restart", perm, sendGSTMB1Restart(db))
|
||||
rg.POST("/devices/:id/gstmb1/interval", perm, sendGSTMB1Interval(db))
|
||||
rg.POST("/devices/:id/plug/on", perm, sendPlugOn(db))
|
||||
rg.POST("/devices/:id/plug/off", perm, sendPlugOff(db))
|
||||
rg.POST("/devices/:id/plug/statistic", perm, sendPlugStatistic(db))
|
||||
rg.POST("/devices/:id/plug/info", perm, sendGSTMB1Info(db)) // 插座 info 命令与传感器相同
|
||||
// GSCU1B-4G 红外控制器
|
||||
rg.POST("/devices/:id/ir/learn", perm, sendIRLearn(db))
|
||||
rg.POST("/devices/:id/ir/emit", perm, sendIREmit(db))
|
||||
rg.POST("/devices/:id/ir/cancel", perm, sendIRCancel(db))
|
||||
rg.POST("/devices/:id/ir/erase", perm, sendIRErase(db))
|
||||
rg.GET("/devices/:id/ir/status", perm, getIRStatus(db))
|
||||
}
|
||||
|
||||
// sendControl 下发单条控制命令(通过 MQTT 发布到 devices/{deviceKey}/cmd 主题)
|
||||
func sendControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cmd ControlCommand
|
||||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := publishControl(cmd)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GSPE1B 智能插座控制 =====
|
||||
|
||||
// sendPlugOn 插座通电 {"type":"event","key":1}
|
||||
func sendPlugOn(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "event",
|
||||
"key": 1,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendPlugOff 插座断电 {"type":"event","key":0}
|
||||
func sendPlugOff(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "event",
|
||||
"key": 0,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendPlugStatistic 查询插座电量信息 {"type":"statistic"}
|
||||
func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "statistic",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GSCU1B-4G 红外控制器 =====
|
||||
|
||||
// sendIRLearn 学习红外码 {"type":"infrared","action":"learn","data":{"no":N}}
|
||||
func sendIRLearn(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
No int `json:"no"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.No < 1 || body.No > 248 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no 必须为 1-248 的整数"})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "learn",
|
||||
"data": map[string]int{"no": body.No},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIREmit 发射红外码 {"type":"infrared","action":"emit","data":{"no":N}}
|
||||
func sendIREmit(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
No int `json:"no"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.No < 1 || body.No > 248 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no 必须为 1-248 的整数"})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "emit",
|
||||
"data": map[string]int{"no": body.No},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIRCancel 取消学习 {"type":"infrared","action":"learnCancel"}
|
||||
func sendIRCancel(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "learnCancel",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIRErase 擦除全部红外码 {"type":"infrared","action":"erase"}
|
||||
func sendIRErase(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "erase",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// getIRStatus 查询红外操作结果
|
||||
func getIRStatus(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey, err := getDeviceKey(db, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if deviceCommander == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "设备命令服务未初始化"})
|
||||
return
|
||||
}
|
||||
result := deviceCommander.GetIRResult(deviceKey)
|
||||
learnedCodes := deviceCommander.GetIRLearnedCodes(deviceKey)
|
||||
if result == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"hasResult": false, "learnedCodes": learnedCodes})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"hasResult": true, "result": result, "learnedCodes": learnedCodes})
|
||||
}
|
||||
}
|
||||
|
||||
// batchControl 批量下发控制命令
|
||||
func batchControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Commands []ControlCommand `json:"commands"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]gin.H, 0, len(body.Commands))
|
||||
for _, cmd := range body.Commands {
|
||||
result, err := publishControl(cmd)
|
||||
if err != nil {
|
||||
results = append(results, gin.H{"error": err.Error(), "deviceKey": cmd.DeviceKey})
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
|
||||
// publishControl 发布控制命令到 MQTT 主题 devices/{deviceKey}/cmd
|
||||
func publishControl(cmd ControlCommand) (gin.H, error) {
|
||||
if cmd.DeviceKey == "" || cmd.Action == "" {
|
||||
return nil, &controlError{"deviceKey/action required"}
|
||||
}
|
||||
|
||||
topic := "devices/" + cmd.DeviceKey + "/cmd"
|
||||
|
||||
// 构建消息体:action + value + payload 展开 + ts
|
||||
payload := map[string]interface{}{
|
||||
"action": cmd.Action,
|
||||
"value": cmd.Value,
|
||||
}
|
||||
for k, v := range cmd.Payload {
|
||||
payload[k] = v
|
||||
}
|
||||
payload["ts"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
// 通过 MQTT 发布(若未注入则仅记录日志)
|
||||
if mqttPublisher != nil {
|
||||
if err := mqttPublisher.Publish(topic, payload); err != nil {
|
||||
slog.Warn("MQTT 发布失败", "topic", topic, "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("MQTT 发布者未注入,跳过实际发布", "topic", topic)
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"topic": topic,
|
||||
"payload": payload,
|
||||
"ok": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// controlError 控制命令错误
|
||||
type controlError struct{ msg string }
|
||||
|
||||
func (e *controlError) Error() string { return e.msg }
|
||||
|
||||
// ===== GSTMB1 命令处理 =====
|
||||
|
||||
// getDeviceKey 从数据库查找设备的 deviceKey
|
||||
func getDeviceKey(db *gorm.DB, deviceID string) (string, error) {
|
||||
var device struct {
|
||||
DeviceKey string `gorm:"column:device_key"`
|
||||
}
|
||||
if err := db.Table("devices").Where("id = ?", deviceID).First(&device).Error; err != nil {
|
||||
return "", fmt.Errorf("设备不存在")
|
||||
}
|
||||
return device.DeviceKey, nil
|
||||
}
|
||||
|
||||
// publishGSTMB1Command 向 GSTMB1 设备发送命令
|
||||
func publishGSTMB1Command(db *gorm.DB, deviceID string, payload map[string]interface{}) (gin.H, error) {
|
||||
deviceKey, err := getDeviceKey(db, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deviceCommander == nil {
|
||||
return nil, fmt.Errorf("设备命令服务未初始化")
|
||||
}
|
||||
if err := deviceCommander.PublishToDevice(deviceKey, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("GSTMB1 命令已发送", "deviceKey", deviceKey, "payload", payload)
|
||||
return gin.H{"ok": true, "deviceKey": deviceKey, "payload": payload}, nil
|
||||
}
|
||||
|
||||
// sendGSTMB1Command 发送自定义 GSTMB1 命令(body 原样转发)
|
||||
func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Info 获取设备信息 {"type":"info"}
|
||||
func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "info",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Restart 重启设备 {"type":"setting","system":"restart"}
|
||||
func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "setting",
|
||||
"system": "restart",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Interval 设置定时上报间隔 {"type":"setting","timerEnable":1,"timerInterval":N}
|
||||
func sendGSTMB1Interval(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
interval := body.Interval
|
||||
if interval < 5 {
|
||||
interval = 60 // 默认 60 秒
|
||||
}
|
||||
if interval > 86400 {
|
||||
interval = 86400
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "setting",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
"timerEnable": 1,
|
||||
"timerInterval": interval,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterDeviceRoutes 注册设备路由
|
||||
func RegisterDeviceRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/devices", middleware.RequirePermission(db, "device:read"), listDevices(db))
|
||||
rg.GET("/devices/:id", middleware.RequirePermission(db, "device:read"), getDevice(db))
|
||||
rg.POST("/devices", middleware.RequirePermission(db, "room:write"), createDevice(db))
|
||||
rg.PATCH("/devices/:id", middleware.RequirePermission(db, "room:write"), updateDevice(db))
|
||||
rg.DELETE("/devices/:id", middleware.RequirePermission(db, "room:write"), deleteDevice(db))
|
||||
}
|
||||
|
||||
// listDevices 设备列表
|
||||
func listDevices(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var devices []model.Device
|
||||
query := db.Order("created_at DESC")
|
||||
if kind := c.Query("kind"); kind != "" {
|
||||
query = query.Where("kind = ?", kind)
|
||||
}
|
||||
query.Find(&devices)
|
||||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
}
|
||||
|
||||
// getDevice 设备详情
|
||||
func getDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, device)
|
||||
}
|
||||
}
|
||||
|
||||
// createDevice 新建设备(检查 deviceKey 唯一)
|
||||
func createDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var device model.Device
|
||||
if err := c.ShouldBindJSON(&device); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if device.DeviceKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceKey 不能为空"})
|
||||
return
|
||||
}
|
||||
// 检查 deviceKey 是否已存在
|
||||
var existing model.Device
|
||||
if db.Where("device_key = ?", device.DeviceKey).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "deviceKey already exists"})
|
||||
return
|
||||
}
|
||||
device.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, device)
|
||||
}
|
||||
}
|
||||
|
||||
// updateDevice 更新设备
|
||||
func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Device{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&device)
|
||||
c.JSON(http.StatusOK, device)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDevice 删除设备
|
||||
func deleteDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Device{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterHealthRoutes 注册健康检查路由
|
||||
func RegisterHealthRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// notificationItem 通知项
|
||||
type notificationItem struct {
|
||||
ID string `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// 通知内存存储(后续可替换为 Redis)
|
||||
var (
|
||||
notificationStore []notificationItem
|
||||
notificationMu sync.Mutex
|
||||
)
|
||||
|
||||
// RegisterNotificationRoutes 注册通知路由
|
||||
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
readPerm := middleware.RequirePermission(db, "alarm:read")
|
||||
rg.GET("/notifications", readPerm, listNotifications())
|
||||
rg.POST("/notifications", readPerm, createNotification())
|
||||
}
|
||||
|
||||
// listNotifications 通知列表(上限200)
|
||||
func listNotifications() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
notificationMu.Lock()
|
||||
defer notificationMu.Unlock()
|
||||
|
||||
limit := 200
|
||||
if len(notificationStore) < limit {
|
||||
limit = len(notificationStore)
|
||||
}
|
||||
// 返回最新的 limit 条(存储已按新到旧排序)
|
||||
result := make([]notificationItem, limit)
|
||||
copy(result, notificationStore[:limit])
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// createNotification 手动发通知
|
||||
func createNotification() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ntf := notificationItem{
|
||||
ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)),
|
||||
Channel: body.Channel,
|
||||
Target: body.Target,
|
||||
Title: body.Title,
|
||||
Body: body.Body,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
notificationMu.Lock()
|
||||
// 插入到头部(最新在前)
|
||||
notificationStore = append([]notificationItem{ntf}, notificationStore...)
|
||||
// 保留最近 500 条
|
||||
if len(notificationStore) > 500 {
|
||||
notificationStore = notificationStore[:500]
|
||||
}
|
||||
notificationMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusCreated, ntf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterPermissionRoutes 注册权限和角色查询路由(需要 user:manage 权限)
|
||||
func RegisterPermissionRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "user:manage")
|
||||
rg.GET("/permissions", perm, listPermissions(db))
|
||||
rg.GET("/roles", perm, listRoles(db))
|
||||
}
|
||||
|
||||
// listPermissions 权限列表
|
||||
func listPermissions(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var perms []model.Permission
|
||||
db.Order("code ASC").Find(&perms)
|
||||
c.JSON(http.StatusOK, perms)
|
||||
}
|
||||
}
|
||||
|
||||
// listRoles 角色列表(含权限码)
|
||||
func listRoles(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
type roleInfo struct {
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// 查询角色-权限映射
|
||||
var rows []struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Code string `gorm:"column:code"`
|
||||
}
|
||||
db.Table("role_permissions").
|
||||
Select("role_permissions.role AS role, permissions.code AS code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Order("role_permissions.role, permissions.code").
|
||||
Scan(&rows)
|
||||
|
||||
rolePermMap := make(map[string][]string)
|
||||
for _, r := range rows {
|
||||
rolePermMap[r.Role] = append(rolePermMap[r.Role], r.Code)
|
||||
}
|
||||
|
||||
result := make([]roleInfo, 0, len(model.AllRoles))
|
||||
for _, role := range model.AllRoles {
|
||||
result = append(result, roleInfo{
|
||||
Role: role,
|
||||
Name: model.RoleNames[role],
|
||||
Permissions: rolePermMap[role],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterRoomRoutes 注册蚕房路由
|
||||
func RegisterRoomRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/rooms", middleware.RequirePermission(db, "room:read"), listRooms(db))
|
||||
rg.GET("/rooms/:id", middleware.RequirePermission(db, "room:read"), getRoom(db))
|
||||
rg.POST("/rooms", middleware.RequirePermission(db, "room:write"), createRoom(db))
|
||||
rg.PATCH("/rooms/:id", middleware.RequirePermission(db, "room:write"), updateRoom(db))
|
||||
rg.DELETE("/rooms/:id", middleware.RequirePermission(db, "room:write"), deleteRoom(db))
|
||||
}
|
||||
|
||||
// listRooms 蚕房列表
|
||||
func listRooms(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var rooms []model.Room
|
||||
db.Order("created_at DESC").Find(&rooms)
|
||||
c.JSON(http.StatusOK, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
// getRoom 蚕房详情
|
||||
func getRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, room)
|
||||
}
|
||||
}
|
||||
|
||||
// createRoom 新建蚕房
|
||||
func createRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var room model.Room
|
||||
if err := c.ShouldBindJSON(&room); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
room.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, room)
|
||||
}
|
||||
}
|
||||
|
||||
// updateRoom 更新蚕房
|
||||
func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Room{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&room)
|
||||
c.JSON(http.StatusOK, room)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteRoom 删除蚕房
|
||||
func deleteRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Room{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// camelToSnake 将 camelCase 转为 snake_case(供所有 CRUD handler 复用)
|
||||
func camelToSnake(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && unicode.IsUpper(r) {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// bindUpdates 绑定 JSON body 并转为 snake_case 的 map(排除不可更新字段)
|
||||
func bindUpdates(c *gin.Context) (map[string]interface{}, error) {
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
for k, v := range body {
|
||||
// 排除不可更新字段
|
||||
if k == "id" || k == "createdAt" || k == "updatedAt" {
|
||||
continue
|
||||
}
|
||||
updates[camelToSnake(k)] = v
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterSensorRoutes 注册传感器路由
|
||||
func RegisterSensorRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/sensors", middleware.RequirePermission(db, "device:read"), listSensors(db))
|
||||
rg.GET("/sensors/:id", middleware.RequirePermission(db, "device:read"), getSensor(db))
|
||||
rg.POST("/sensors", middleware.RequirePermission(db, "room:write"), createSensor(db))
|
||||
rg.PATCH("/sensors/:id", middleware.RequirePermission(db, "room:write"), updateSensor(db))
|
||||
rg.DELETE("/sensors/:id", middleware.RequirePermission(db, "room:write"), deleteSensor(db))
|
||||
}
|
||||
|
||||
// listSensors 传感器列表
|
||||
func listSensors(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var sensors []model.Sensor
|
||||
db.Order("created_at DESC").Find(&sensors)
|
||||
c.JSON(http.StatusOK, sensors)
|
||||
}
|
||||
}
|
||||
|
||||
// getSensor 传感器详情
|
||||
func getSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// createSensor 新建传感器
|
||||
func createSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var sensor model.Sensor
|
||||
if err := c.ShouldBindJSON(&sensor); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sensor.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&sensor).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// updateSensor 更新传感器
|
||||
func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Sensor{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&sensor)
|
||||
c.JSON(http.StatusOK, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteSensor 删除传感器
|
||||
func deleteSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Sensor{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterStorageRoutes 注册存储状态路由
|
||||
func RegisterStorageRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/storage/ceph", middleware.RequirePermission(db, "video:read"), getCephStorage())
|
||||
}
|
||||
|
||||
// getCephStorage 获取 Ceph 存储使用情况
|
||||
func getCephStorage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. ceph df --format json
|
||||
dfCmd := exec.Command("ceph", "df", "--format", "json")
|
||||
dfOutput, err := dfCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph df 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var dfData map[string]interface{}
|
||||
if err := json.Unmarshal(dfOutput, &dfData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph df 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. ceph osd tree --format json
|
||||
treeCmd := exec.Command("ceph", "osd", "tree", "--format", "json")
|
||||
treeOutput, err := treeCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph osd tree 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var treeData map[string]interface{}
|
||||
if err := json.Unmarshal(treeOutput, &treeData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph osd tree 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. ceph health --format json
|
||||
healthCmd := exec.Command("ceph", "health", "--format", "json")
|
||||
healthOutput, err := healthCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph health 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var healthData map[string]interface{}
|
||||
if err := json.Unmarshal(healthOutput, &healthData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph health 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"df": dfData,
|
||||
"osdTree": treeData,
|
||||
"health": healthData,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
)
|
||||
|
||||
// RegisterTelemetryRoutes 注册遥测数据查询路由
|
||||
func RegisterTelemetryRoutes(rg *gin.RouterGroup, db *gorm.DB, iotdb *service.IoTDBService) {
|
||||
readPerm := middleware.RequirePermission(db, "device:read")
|
||||
rg.GET("/telemetry", readPerm, listTelemetry(db, iotdb))
|
||||
rg.GET("/telemetry/:deviceKey/metrics", readPerm, listMetrics(iotdb, db))
|
||||
rg.GET("/telemetry/:deviceKey/latest", readPerm, latestTelemetry(iotdb, db))
|
||||
rg.GET("/telemetry/:deviceKey/:metric/history", readPerm, historyBucket(iotdb, db))
|
||||
}
|
||||
|
||||
// GET /telemetry?deviceKey=&metric=&from=&to=&limit=
|
||||
func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Query("deviceKey")
|
||||
metric := c.Query("metric")
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "2000"))
|
||||
if limit <= 0 {
|
||||
limit = 2000
|
||||
}
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() && deviceKey != "" && metric != "" {
|
||||
fromTime, toTime := parseTimeRange(from, to)
|
||||
rows, err := iotdb.QueryHistory(deviceKey, metric, fromTime, toTime, limit)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PostgreSQL
|
||||
q := db.Model(&model.Telemetry{}).Order("timestamp DESC").Limit(limit)
|
||||
if deviceKey != "" {
|
||||
q = q.Where("device_key = ?", deviceKey)
|
||||
}
|
||||
if metric != "" {
|
||||
q = q.Where("metric = ?", metric)
|
||||
}
|
||||
if from != "" || to != "" {
|
||||
fromVal, toVal := parseTimeRangeStr(from, to)
|
||||
q = q.Where("timestamp BETWEEN ? AND ?", fromVal, toVal)
|
||||
}
|
||||
var rows []model.Telemetry
|
||||
q.Find(&rows)
|
||||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/metrics
|
||||
func listMetrics(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() {
|
||||
metrics, err := iotdb.ListMetrics(deviceKey)
|
||||
if err == nil && len(metrics) > 0 {
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PG
|
||||
var metrics []string
|
||||
db.Model(&model.Telemetry{}).Distinct("metric").Where("device_key = ?", deviceKey).Pluck("metric", &metrics)
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/latest
|
||||
func latestTelemetry(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
|
||||
// 查询 PG 中该设备每个指标的最新记录
|
||||
var records []model.Telemetry
|
||||
if err := db.Raw(`SELECT DISTINCT ON (metric) * FROM telemetry WHERE device_key = ? ORDER BY metric, timestamp DESC`, deviceKey).Scan(&records).Error; err != nil || len(records) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到遥测数据"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/:metric/history?from=&to=&bucketMin=
|
||||
func historyBucket(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
metric := c.Param("metric")
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
if from == "" || to == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "from/to required"})
|
||||
return
|
||||
}
|
||||
bucketMin, _ := strconv.Atoi(c.DefaultQuery("bucketMin", "5"))
|
||||
if bucketMin <= 0 {
|
||||
bucketMin = 5
|
||||
}
|
||||
|
||||
fromTime, _ := time.Parse(time.RFC3339, from)
|
||||
toTime, _ := time.Parse(time.RFC3339, to)
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() {
|
||||
rows, err := iotdb.AggregateByBucket(deviceKey, metric, fromTime, toTime, bucketMin)
|
||||
if err == nil && len(rows) > 0 {
|
||||
c.JSON(http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PG
|
||||
type BucketResult struct {
|
||||
Bucket time.Time `json:"time"`
|
||||
Avg float64 `json:"value"`
|
||||
}
|
||||
var results []BucketResult
|
||||
sql := `SELECT time_bucket(?, timestamp) AS bucket, AVG(value) AS avg
|
||||
FROM telemetry WHERE device_key = ? AND metric = ? AND timestamp BETWEEN ? AND ?
|
||||
GROUP BY 1 ORDER BY 1 ASC`
|
||||
if err := db.Raw(sql, strconv.Itoa(bucketMin)+" minutes", deviceKey, metric, fromTime, toTime).Scan(&results).Error; err != nil || len(results) == 0 {
|
||||
// time_bucket 不可用(无 TimescaleDB),降级为原始数据
|
||||
var raw []model.Telemetry
|
||||
db.Where("device_key = ? AND metric = ? AND timestamp BETWEEN ? AND ?", deviceKey, metric, fromTime, toTime).
|
||||
Order("timestamp ASC").Limit(500).Find(&raw)
|
||||
for _, r := range raw {
|
||||
results = append(results, BucketResult{Bucket: r.Timestamp, Avg: r.Value})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
func parseTimeRange(from, to string) (time.Time, time.Time) {
|
||||
var fromTime, toTime time.Time
|
||||
if from != "" {
|
||||
fromTime, _ = time.Parse(time.RFC3339, from)
|
||||
}
|
||||
if to != "" {
|
||||
toTime, _ = time.Parse(time.RFC3339, to)
|
||||
} else {
|
||||
toTime = time.Now()
|
||||
}
|
||||
return fromTime, toTime
|
||||
}
|
||||
|
||||
func parseTimeRangeStr(from, to string) (string, string) {
|
||||
if from == "" {
|
||||
from = "1970-01-01T00:00:00Z"
|
||||
}
|
||||
if to == "" {
|
||||
to = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterThresholdRoutes 注册阈值路由
|
||||
func RegisterThresholdRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/thresholds", middleware.RequirePermission(db, "threshold:read"), listThresholds(db))
|
||||
rg.GET("/thresholds/:id", middleware.RequirePermission(db, "threshold:read"), getThreshold(db))
|
||||
rg.POST("/thresholds", middleware.RequirePermission(db, "threshold:write"), createThreshold(db))
|
||||
rg.PATCH("/thresholds/:id", middleware.RequirePermission(db, "threshold:write"), updateThreshold(db))
|
||||
rg.DELETE("/thresholds/:id", middleware.RequirePermission(db, "threshold:write"), deleteThreshold(db))
|
||||
}
|
||||
|
||||
// listThresholds 阈值列表
|
||||
func listThresholds(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var thresholds []model.Threshold
|
||||
db.Find(&thresholds)
|
||||
c.JSON(http.StatusOK, thresholds)
|
||||
}
|
||||
}
|
||||
|
||||
// getThreshold 阈值详情
|
||||
func getThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// createThreshold 新建阈值
|
||||
func createThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var threshold model.Threshold
|
||||
if err := c.ShouldBindJSON(&threshold); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
threshold.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&threshold).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// updateThreshold 更新阈值
|
||||
func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Threshold{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&threshold)
|
||||
c.JSON(http.StatusOK, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteThreshold 删除阈值
|
||||
func deleteThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Threshold{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterUserRoutes 注册用户管理路由(均需 user:manage 权限)
|
||||
func RegisterUserRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "user:manage")
|
||||
rg.GET("/users", perm, listUsers(db))
|
||||
rg.GET("/users/:id", perm, getUser(db))
|
||||
rg.PATCH("/users/:id", perm, updateUser(db))
|
||||
}
|
||||
|
||||
// toUserPublic 将 User 转为公开信息(不含密码哈希)
|
||||
func toUserPublic(u model.User) gin.H {
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"fullName": u.FullName,
|
||||
"role": u.Role,
|
||||
"active": u.Active,
|
||||
"createdAt": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// listUsers 用户列表
|
||||
func listUsers(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var users []model.User
|
||||
db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Order("created_at DESC").Find(&users)
|
||||
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, toUserPublic(u))
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// getUser 用户详情
|
||||
func getUser(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var user model.User
|
||||
if db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Where("id = ?", id).First(&user).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toUserPublic(user))
|
||||
}
|
||||
}
|
||||
|
||||
// updateUser 更新用户(角色/active/fullName/email),记录审计日志
|
||||
func updateUser(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查用户是否存在
|
||||
var user model.User
|
||||
if db.Where("id = ?", id).First(&user).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许更新 fullName/email/role/active
|
||||
updates := make(map[string]interface{})
|
||||
if v, ok := body["fullName"]; ok {
|
||||
updates["full_name"] = v
|
||||
}
|
||||
if v, ok := body["email"]; ok {
|
||||
updates["email"] = v
|
||||
}
|
||||
if v, ok := body["role"]; ok {
|
||||
roleStr, _ := v.(string)
|
||||
valid := false
|
||||
for _, r := range model.AllRoles {
|
||||
if r == roleStr {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色,可选:" + strings.Join(model.AllRoles, ", ")})
|
||||
return
|
||||
}
|
||||
updates["role"] = v
|
||||
}
|
||||
if v, ok := body["active"]; ok {
|
||||
updates["active"] = v
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
var actorID, actorName *string
|
||||
if u, ok := c.Get("user"); ok {
|
||||
if userMap, ok := u.(map[string]interface{}); ok {
|
||||
if sub, ok := userMap["sub"].(string); ok {
|
||||
s := sub
|
||||
actorID = &s
|
||||
}
|
||||
if uname, ok := userMap["username"].(string); ok {
|
||||
s := uname
|
||||
actorName = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
action := "update"
|
||||
description := "user updated"
|
||||
if _, ok := body["role"]; ok {
|
||||
action = "role_change"
|
||||
if r, ok := body["role"].(string); ok {
|
||||
description = "role changed to " + r
|
||||
}
|
||||
}
|
||||
res := "users"
|
||||
tid := id
|
||||
metaBytes, _ := json.Marshal(body)
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: actorID,
|
||||
Username: actorName,
|
||||
Action: action,
|
||||
Resource: &res,
|
||||
TargetID: &tid,
|
||||
Description: &description,
|
||||
Metadata: metaBytes,
|
||||
})
|
||||
|
||||
// 返回更新后的用户
|
||||
db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Where("id = ?", id).First(&user)
|
||||
c.JSON(http.StatusOK, toUserPublic(user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoCameraRoutes 注册摄像头管理路由
|
||||
func RegisterVideoCameraRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService) {
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
writePerm := middleware.RequirePermission(db, "video:record")
|
||||
rg.GET("/video/cameras", readPerm, listCameras(db, media))
|
||||
rg.GET("/video/cameras/:id", readPerm, getCamera(db))
|
||||
rg.POST("/video/cameras", writePerm, createCamera(db, media))
|
||||
rg.PATCH("/video/cameras/:id", writePerm, updateCamera(db, media))
|
||||
rg.DELETE("/video/cameras/:id", writePerm, deleteCamera(db, media))
|
||||
rg.POST("/video/cameras/:id/play", readPerm, playCamera(db, media))
|
||||
rg.POST("/video/cameras/:id/live", readPerm, playCamera(db, media))
|
||||
rg.GET("/video/cameras/:id/playback", readPerm, playbackCamera(db))
|
||||
rg.GET("/video/wvp-config", readPerm, getWvpConfig(media))
|
||||
rg.GET("/video/wvp/devices", readPerm, listWvpDevices(media))
|
||||
rg.GET("/video/wvp/devices/:deviceId/channels", readPerm, listWvpChannels(media))
|
||||
rg.POST("/video/wvp/devices/:deviceId/sync", writePerm, syncWvpDevice(media))
|
||||
}
|
||||
|
||||
// listCameras 摄像头列表(按 createdAt DESC,同步 WVP 设备信息:在线状态 + 共有参数)
|
||||
func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cameras []model.Camera
|
||||
db.Order("created_at DESC").Find(&cameras)
|
||||
|
||||
// 先用 DB 中的 is_online 初始化 Online 字段(gorm:"-" 不会自动填充)
|
||||
for i := range cameras {
|
||||
cameras[i].Online = cameras[i].IsOnline
|
||||
}
|
||||
|
||||
// 同步 WVP 设备信息(WVP → silk,失败时保留 DB 状态)
|
||||
if devMap, err := media.SyncWvpDevices(); err == nil {
|
||||
for i := range cameras {
|
||||
if cameras[i].GbDeviceID == nil || *cameras[i].GbDeviceID == "" {
|
||||
continue
|
||||
}
|
||||
info, ok := devMap[*cameras[i].GbDeviceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 同步在线状态
|
||||
cameras[i].Online = info.OnLine
|
||||
// 同步共有参数(WVP → silk),仅当 WVP 侧有值时才覆盖
|
||||
updates := map[string]interface{}{}
|
||||
if info.OnLine != cameras[i].IsOnline {
|
||||
updates["is_online"] = info.OnLine
|
||||
cameras[i].IsOnline = info.OnLine
|
||||
}
|
||||
if info.Name != "" && cameras[i].Name != info.Name {
|
||||
updates["name"] = info.Name
|
||||
cameras[i].Name = info.Name
|
||||
}
|
||||
if info.Manufacturer != "" {
|
||||
if cameras[i].GbManufacturer == nil || *cameras[i].GbManufacturer != info.Manufacturer {
|
||||
updates["gb_manufacturer"] = info.Manufacturer
|
||||
cameras[i].GbManufacturer = &info.Manufacturer
|
||||
}
|
||||
}
|
||||
if info.Password != "" {
|
||||
if cameras[i].GbAuthPassword == nil || *cameras[i].GbAuthPassword != info.Password {
|
||||
updates["gb_auth_password"] = info.Password
|
||||
cameras[i].GbAuthPassword = &info.Password
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Camera{}).Where("id = ?", cameras[i].ID).Updates(updates)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cameras)
|
||||
}
|
||||
}
|
||||
|
||||
// getCamera 摄像头详情
|
||||
func getCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// createCamera 新建摄像头
|
||||
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var camera model.Camera
|
||||
if err := c.ShouldBindJSON(&camera); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
camera.ID = 0 // 让数据库自动生成
|
||||
if camera.RoomID == nil {
|
||||
defaultRoom := "1"
|
||||
camera.RoomID = &defaultRoom
|
||||
}
|
||||
if err := db.Create(&camera).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
// 预添加设备到 WVP(设置独立密码,摄像头注册前 WVP 已有设备记录)
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
manufacturer := ""
|
||||
if camera.GbManufacturer != nil {
|
||||
manufacturer = *camera.GbManufacturer
|
||||
}
|
||||
password := ""
|
||||
if camera.GbAuthPassword != nil {
|
||||
password = *camera.GbAuthPassword
|
||||
}
|
||||
if err := media.AddWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err != nil {
|
||||
// 设备可能已存在(之前添加过),尝试更新
|
||||
if err2 := media.UpdateWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err2 != nil {
|
||||
slog.Warn("预添加/同步设备到 WVP 均失败", "deviceId", *camera.GbDeviceID, "addError", err, "updateError", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// updateCamera 更新摄像头
|
||||
func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.Camera{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", id).First(&camera)
|
||||
// 同步共有参数到 WVP(silk → WVP,更新了 name/manufacturer/password 任一字段就触发)
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
needSync := false
|
||||
for _, key := range []string{"name", "gb_manufacturer", "gb_auth_password"} {
|
||||
if _, ok := updates[key]; ok {
|
||||
needSync = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needSync {
|
||||
manufacturer := ""
|
||||
if camera.GbManufacturer != nil {
|
||||
manufacturer = *camera.GbManufacturer
|
||||
}
|
||||
password := ""
|
||||
if camera.GbAuthPassword != nil {
|
||||
password = *camera.GbAuthPassword
|
||||
}
|
||||
if err := media.UpdateWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err != nil {
|
||||
slog.Warn("同步摄像头信息到 WVP 失败", "deviceId", *camera.GbDeviceID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteCamera 删除摄像头
|
||||
func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
// 同步删除 WVP 中的设备
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
if err := media.DeleteWvpDevice(*camera.GbDeviceID); err != nil {
|
||||
slog.Warn("从 WVP 删除设备失败", "deviceId", *camera.GbDeviceID, "error", err)
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Camera{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay)
|
||||
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if !camera.IsOnline {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头离线,无法播放"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "hls"
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
|
||||
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
||||
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
||||
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
|
||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
url := result.HLS
|
||||
if format == "flv" && result.FLV != "" {
|
||||
url = result.FLV
|
||||
} else if format == "webrtc" && result.WebRtc != "" {
|
||||
url = result.WebRtc
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": camera.GbDeviceID,
|
||||
"gbChannelId": camera.GbChannelID,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"expiresAt": expiresAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback:使用摄像头自身的流地址
|
||||
var url string
|
||||
switch format {
|
||||
case "flv":
|
||||
if camera.FlvURL != nil {
|
||||
url = *camera.FlvURL
|
||||
}
|
||||
case "webrtc":
|
||||
if camera.WebrtcURL != nil {
|
||||
url = *camera.WebrtcURL
|
||||
}
|
||||
default:
|
||||
if camera.HlsURL != nil {
|
||||
url = *camera.HlsURL
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": nil,
|
||||
"gbChannelId": nil,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"expiresAt": expiresAt,
|
||||
"mock": url == "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// playbackCamera 查询摄像头的历史录像片段(query: from/to/limit)
|
||||
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
}
|
||||
|
||||
q := db.Where("camera_id = ?", id).Order("start_at DESC").Limit(limit)
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("start_at >= ?", from)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("start_at <= ?", to)
|
||||
}
|
||||
|
||||
var clips []model.VideoClip
|
||||
q.Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clips)
|
||||
}
|
||||
}
|
||||
|
||||
// getWvpConfig 查询 WVP SIP 配置(供前端展示,便于配置摄像头硬件)
|
||||
func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
config, err := media.GetServerConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取 WVP 配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
sip, ok := config["sip"].(map[string]interface{})
|
||||
if !ok {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WVP 配置中未找到 SIP 部分"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sipId": sip["id"],
|
||||
"sipDomain": sip["domain"],
|
||||
"sipPassword": sip["password"],
|
||||
"sipPort": sip["port"],
|
||||
"sipShowIp": sip["showIp"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// listWvpDevices 查询 WVP 已注册设备列表
|
||||
func listWvpDevices(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
query := c.Query("query")
|
||||
devices, err := media.ListWvpDevices(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取 WVP 设备列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
}
|
||||
|
||||
// listWvpChannels 查询 WVP 设备的通道列表
|
||||
func listWvpChannels(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceId := c.Param("deviceId")
|
||||
if deviceId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"})
|
||||
return
|
||||
}
|
||||
channels, err := media.ListWvpChannels(deviceId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取通道列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
}
|
||||
|
||||
// syncWvpDevice 触发 WVP 设备通道同步
|
||||
func syncWvpDevice(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceId := c.Param("deviceId")
|
||||
if deviceId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"})
|
||||
return
|
||||
}
|
||||
if err := media.SyncWvpDevice(deviceId); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "同步失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoClipRoutes 注册录像片段管理路由
|
||||
func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
rg.GET("/video/clips", readPerm, listClips(db))
|
||||
rg.GET("/video/clips/:clipId/play", readPerm, playClip(db))
|
||||
rg.POST("/video/clips/internal", createClipInternal(db, cfg)) // 白名单接口,无需权限
|
||||
}
|
||||
|
||||
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
||||
func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
}
|
||||
|
||||
q := db.Model(&model.VideoClip{}).Order("start_at DESC").Limit(limit)
|
||||
if cameraId := c.Query("cameraId"); cameraId != "" {
|
||||
q = q.Where("camera_id = ?", cameraId)
|
||||
}
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("start_at >= ?", from)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("start_at <= ?", to)
|
||||
}
|
||||
|
||||
clips := make([]model.VideoClip, 0)
|
||||
q.Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": clips, "total": len(clips)})
|
||||
}
|
||||
}
|
||||
|
||||
// playClip 获取片段播放地址
|
||||
func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
var clip model.VideoClip
|
||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"})
|
||||
return
|
||||
}
|
||||
|
||||
format := clip.Format
|
||||
if format == "" {
|
||||
format = "mp4"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clipId": clip.ID,
|
||||
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream", clip.ID),
|
||||
"format": format,
|
||||
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createClipInternal 内部接口创建录像片段记录(校验 x-api-key header)
|
||||
func createClipInternal(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 校验 x-api-key
|
||||
apiKey := c.GetHeader("x-api-key")
|
||||
if apiKey != cfg.InternalAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
CameraID string `json:"cameraId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Format string `json:"format"`
|
||||
StartAt string `json:"startAt"`
|
||||
DurationSec float64 `json:"durationSec"`
|
||||
SizeBytes string `json:"sizeBytes"`
|
||||
S3Bucket string `json:"s3Bucket"`
|
||||
S3Key string `json:"s3Key"`
|
||||
Resolution string `json:"resolution"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 去重:同一 s3Key 不重复创建
|
||||
var existing model.VideoClip
|
||||
if db.Where("s3_key = ?", body.S3Key).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析开始时间
|
||||
startAt, err := time.Parse(time.RFC3339, body.StartAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid startAt, expected RFC3339 format"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
trigger := body.Trigger
|
||||
if trigger == "" {
|
||||
trigger = "schedule"
|
||||
}
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "mp4"
|
||||
}
|
||||
notes := body.Notes
|
||||
if notes == "" {
|
||||
notes = "自动录制归档"
|
||||
}
|
||||
|
||||
clip := model.VideoClip{
|
||||
CameraID: body.CameraID,
|
||||
Trigger: trigger,
|
||||
Format: format,
|
||||
StartAt: startAt,
|
||||
DurationSec: body.DurationSec,
|
||||
S3Bucket: &body.S3Bucket,
|
||||
S3Key: &body.S3Key,
|
||||
Notes: ¬es,
|
||||
}
|
||||
// 从 camera 查询 room_id
|
||||
var camera model.Camera
|
||||
if err := db.Where("id = ?", body.CameraID).First(&camera).Error; err == nil {
|
||||
clip.RoomID = camera.RoomID
|
||||
}
|
||||
if body.SizeBytes != "" {
|
||||
if n, err := strconv.ParseInt(body.SizeBytes, 10, 64); err == nil {
|
||||
clip.SizeBytes = &n
|
||||
}
|
||||
}
|
||||
if body.Resolution != "" {
|
||||
clip.Resolution = &body.Resolution
|
||||
}
|
||||
|
||||
if err := db.Create(&clip).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ActiveRecording 活跃录制记录(内存维护)
|
||||
type ActiveRecording struct {
|
||||
CameraID string `json:"cameraId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
Stream string `json:"stream"`
|
||||
App string `json:"app"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
|
||||
// 活跃录制内存表(cameraId -> recording)
|
||||
var (
|
||||
activeRecordings = make(map[string]*ActiveRecording)
|
||||
activeMu sync.Mutex
|
||||
)
|
||||
|
||||
// RegisterVideoRecordRoutes 注册录制管理路由
|
||||
func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||
recordPerm := middleware.RequirePermission(db, "video:record")
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg))
|
||||
rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg))
|
||||
rg.GET("/video/recordings/active", readPerm, listActiveRecordings(cfg))
|
||||
rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限
|
||||
}
|
||||
|
||||
// startRecording 开始录制:查摄像头 → media.StartPlay → POST recorderApiBase/record/start
|
||||
func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181 或不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已在录制
|
||||
activeMu.Lock()
|
||||
if _, ok := activeRecordings[cameraId]; ok {
|
||||
activeMu.Unlock()
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该摄像头正在录制中"})
|
||||
return
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
// 1. 确保视频流在线(WVP play/start)
|
||||
_, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("开始播放失败,跳过录制", "cameraId", cameraId, "error", err)
|
||||
// StartPlay 失败,标记摄像头离线
|
||||
db.Model(&model.Camera{}).Where("id = ?", cameraId).Update("is_online", false)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 通知 Python 录制服务开始录制
|
||||
stream := *camera.GbDeviceID + "_" + *camera.GbChannelID
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"stream": stream,
|
||||
"cameraId": cameraId,
|
||||
"deviceId": *camera.GbDeviceID,
|
||||
"channelId": *camera.GbChannelID,
|
||||
})
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(recorderBase+"/record/start", "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "录制服务不可用: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": fmt.Sprintf("录制服务返回 %d: %s", resp.StatusCode, string(respBody))})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 记录活跃录制
|
||||
rec := &ActiveRecording{
|
||||
CameraID: cameraId,
|
||||
DeviceID: *camera.GbDeviceID,
|
||||
ChannelID: *camera.GbChannelID,
|
||||
Stream: stream,
|
||||
App: "rtp",
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
activeMu.Lock()
|
||||
activeRecordings[cameraId] = rec
|
||||
activeMu.Unlock()
|
||||
|
||||
slog.Info("录制已开始", "cameraId", cameraId, "stream", stream)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": cameraId,
|
||||
"stream": stream,
|
||||
"startedAt": rec.StartedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stopRecording 停止录制:通知录制服务 + WVP play/stop 清理 session
|
||||
func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
activeMu.Lock()
|
||||
rec, ok := activeRecordings[cameraId]
|
||||
if ok {
|
||||
delete(activeRecordings, cameraId)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
// 即使没有活跃录制,也尝试用摄像头的 GB 信息停止 WVP play
|
||||
var deviceID, channelID string
|
||||
if ok {
|
||||
deviceID = rec.DeviceID
|
||||
channelID = rec.ChannelID
|
||||
} else {
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error == nil &&
|
||||
camera.GbDeviceID != nil && camera.GbChannelID != nil {
|
||||
deviceID = *camera.GbDeviceID
|
||||
channelID = *camera.GbChannelID
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通知录制服务停止
|
||||
if ok {
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"stream": rec.Stream,
|
||||
})
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(recorderBase+"/record/stop", "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
slog.Warn("停止录制服务失败", "cameraId", cameraId, "error", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止 WVP play session(清理 ZLMediaKit 流)
|
||||
if deviceID != "" && channelID != "" {
|
||||
media.StopPlay(deviceID, channelID)
|
||||
}
|
||||
|
||||
slog.Info("录制已停止", "cameraId", cameraId, "hadActiveRecording", ok)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": cameraId,
|
||||
"stopped": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// endRecordingInternal 录制服务内部接口:通知录制已结束(流 EOF / 错误 / 停止)
|
||||
// 清理 activeRecordings 并停止 WVP play session
|
||||
func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("x-api-key")
|
||||
if apiKey != cfg.InternalAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
activeMu.Lock()
|
||||
rec, ok := activeRecordings[body.CameraID]
|
||||
if ok {
|
||||
delete(activeRecordings, body.CameraID)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
if ok {
|
||||
slog.Info("录制服务通知录制结束", "cameraId", body.CameraID, "stream", body.Stream)
|
||||
// 停止 WVP play session,清理 ZLMediaKit 流
|
||||
media.StopPlay(rec.DeviceID, rec.ChannelID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// listActiveRecordings 返回活跃录制列表
|
||||
// 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制)
|
||||
func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. 查询 recorder-go 实际录制状态
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(recorderBase + "/record/status")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var statusResp struct {
|
||||
Recordings []struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
SegmentCount int `json:"segmentCount"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&statusResp); err == nil && len(statusResp.Recordings) > 0 {
|
||||
list := make([]*ActiveRecording, 0, len(statusResp.Recordings))
|
||||
for _, r := range statusResp.Recordings {
|
||||
startedAt, _ := time.Parse(time.RFC3339, r.StartedAt)
|
||||
if startedAt.IsZero() {
|
||||
startedAt = time.Now()
|
||||
}
|
||||
// 拆分 stream 得到 deviceId/channelId
|
||||
deviceID, channelID := "", ""
|
||||
parts := strings.SplitN(r.Stream, "_", 2)
|
||||
if len(parts) == 2 {
|
||||
deviceID, channelID = parts[0], parts[1]
|
||||
}
|
||||
list = append(list, &ActiveRecording{
|
||||
CameraID: r.CameraID,
|
||||
DeviceID: deviceID,
|
||||
ChannelID: channelID,
|
||||
Stream: r.Stream,
|
||||
App: "rtp",
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
// 同步更新内存 map(自愈:Go 后端重启后内存丢失,从 recorder-go 恢复)
|
||||
activeMu.Lock()
|
||||
if _, ok := activeRecordings[r.CameraID]; !ok {
|
||||
activeRecordings[r.CameraID] = &ActiveRecording{
|
||||
CameraID: r.CameraID,
|
||||
DeviceID: deviceID,
|
||||
ChannelID: channelID,
|
||||
Stream: r.Stream,
|
||||
App: "rtp",
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
}
|
||||
activeMu.Unlock()
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. recorder-go 不可用时回退到内存 map
|
||||
activeMu.Lock()
|
||||
list := make([]*ActiveRecording, 0, len(activeRecordings))
|
||||
for _, rec := range activeRecordings {
|
||||
list = append(list, rec)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由(公开接口,不需要 JWT)
|
||||
func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.TranscodeService, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||
rg.GET("/video/clips/:clipId/stream", streamClip(transcode))
|
||||
rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg))
|
||||
rg.GET("/video/cameras/:id/live/proxy", proxyLive(db, media, cfg))
|
||||
}
|
||||
|
||||
// streamClip 视频流代理 + ffmpeg 转码
|
||||
func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
|
||||
if err := transcode.StreamClip(clipId, c.Writer); err != nil {
|
||||
slog.Error("视频流转码失败", "clipId", clipId, "error", err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamLive 实时直播流代理(直接转发,不转码)
|
||||
func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 WVP 启动流(非阻塞,超时也不影响后续代理)
|
||||
sourceURL := ""
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("直播流代理:StartPlay 失败,尝试直接代理", "cameraId", cameraId, "error", err)
|
||||
// StartPlay 失败时,直接构造 ZLM FLV 地址
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + "/rtp/" + *camera.GbDeviceID + "_" + *camera.GbChannelID + ".live.flv?originTypeStr=rtp_push&videoCodec=H264"
|
||||
} else {
|
||||
sourceURL = result.FLV
|
||||
}
|
||||
|
||||
if sourceURL == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "无法获取视频流地址"})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保地址是完整的 ZLM 地址(WVP 返回的可能是相对路径 /rtp/...)
|
||||
if strings.HasPrefix(sourceURL, "/") {
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
|
||||
}
|
||||
|
||||
slog.Info("直播流代理", "cameraId", cameraId, "sourceURL", sourceURL)
|
||||
|
||||
resp, err := http.Get(sourceURL)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:获取源流失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "获取视频流失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:转发流失败", "cameraId", cameraId, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyLive 实时直播流直接代理(不转码,性能更好)
|
||||
func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
// 1. 查摄像头
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 确保 WVP 流在线
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("直播流代理:StartPlay 失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 获取 FLV 源地址
|
||||
sourceURL := result.FLV
|
||||
if sourceURL == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "无法获取视频流地址"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 确保地址是完整的 ZLM 地址
|
||||
if strings.HasPrefix(sourceURL, "/") {
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
|
||||
}
|
||||
|
||||
slog.Info("直播流代理", "cameraId", cameraId, "sourceURL", sourceURL)
|
||||
|
||||
// 5. 直接代理转发
|
||||
resp, err := http.Get(sourceURL)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:获取源流失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "获取视频流失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 复制响应头
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 复制响应体
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:转发流失败", "cameraId", cameraId, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user