chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+288
View File
@@ -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)
}
}