chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
module recorder-go
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
|
||||
github.com/aws/smithy-go v1.20.3 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 h1:sZXIzO38GZOU+O0C+INqbH7C2yALwfMWpd64tONS/NE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
|
||||
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
|
||||
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
@@ -0,0 +1,492 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
// ===== 配置 =====
|
||||
|
||||
var (
|
||||
zlmBase = getEnv("ZLM_BASE", "http://localhost:8081")
|
||||
s3Endpoint = getEnv("S3_ENDPOINT", "http://localhost:7480")
|
||||
s3AccessKey = getEnv("S3_ACCESS_KEY", "silk-app")
|
||||
s3SecretKey = getEnv("S3_SECRET_KEY", "Silk-App-Secret-2026!")
|
||||
s3Bucket = getEnv("S3_BUCKET", "silk-video-events")
|
||||
s3Region = getEnv("S3_REGION", "us-east-1")
|
||||
nestjsBase = getEnv("NESTJS_BASE", "http://localhost:3000/api/v1")
|
||||
nestjsUser = getEnv("NESTJS_USER", "admin")
|
||||
nestjsPass = getEnv("NESTJS_PASSWORD", "silk@123")
|
||||
internalKey = getEnv("INTERNAL_API_KEY", "silk-internal-2026")
|
||||
segmentDur = getEnvInt("SEGMENT_DURATION", 300)
|
||||
chunkSize = getEnvInt("CHUNK_SIZE", 5*1024*1024)
|
||||
httpPort = getEnv("HTTP_PORT", "9090")
|
||||
)
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ===== S3 客户端 =====
|
||||
|
||||
var s3Client *s3.Client
|
||||
|
||||
func initS3() {
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||||
config.WithRegion(s3Region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(s3AccessKey, s3SecretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("S3 config error: %v", err)
|
||||
}
|
||||
s3Client = s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(s3Endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
// ===== StreamRecorder =====
|
||||
//
|
||||
// 连续录制模式:循环录制 5 分钟分段,直到 stop() 或流 EOF。
|
||||
// 每个分段独立写入 S3 并创建 DB 记录,实现"一直录制、按 5 分钟落地"。
|
||||
|
||||
type StreamRecorder struct {
|
||||
Stream string
|
||||
CameraID string
|
||||
DeviceID string
|
||||
ChannelID string
|
||||
StartedAt time.Time // 当前分段开始时间(每段重置)
|
||||
UploadID string
|
||||
S3Key string
|
||||
Parts []types.CompletedPart
|
||||
PartNum int32
|
||||
TotalBytes int64
|
||||
Running bool
|
||||
SegmentCount int
|
||||
streamCtx context.Context
|
||||
streamCancel context.CancelFunc
|
||||
}
|
||||
|
||||
var (
|
||||
recordings = make(map[string]*StreamRecorder)
|
||||
recMu sync.RWMutex
|
||||
)
|
||||
|
||||
// start 启动录制线程
|
||||
func (r *StreamRecorder) start() {
|
||||
r.Running = true
|
||||
go r.record()
|
||||
}
|
||||
|
||||
// stop 设置 Running=false,当前分段会在下一次循环检查时收尾,且不再开始新段
|
||||
func (r *StreamRecorder) stop() {
|
||||
r.Running = false
|
||||
// 取消拉流 context 以便及时退出阻塞的 Read(S3 操作使用独立 context,不受影响)
|
||||
if r.streamCancel != nil {
|
||||
r.streamCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// record 核心录制逻辑:循环录制分段
|
||||
func (r *StreamRecorder) record() {
|
||||
defer func() {
|
||||
r.Running = false
|
||||
recMu.Lock()
|
||||
delete(recordings, r.Stream)
|
||||
recMu.Unlock()
|
||||
log.Printf("[%s] 录制线程退出(共完成 %d 段)", r.Stream, r.SegmentCount)
|
||||
|
||||
// 通知 Go 后端录制已结束,清理 activeRecordings
|
||||
notifyRecordingEnd(r.CameraID, r.Stream)
|
||||
}()
|
||||
|
||||
for r.Running {
|
||||
reason := r.recordOneSegment()
|
||||
if reason == "eof" || reason == "error" || reason == "stopped" {
|
||||
break
|
||||
}
|
||||
// reason == "segment_done":继续下一段
|
||||
if r.Running {
|
||||
log.Printf("[%s] 立即开始下一段录制(已累计 %d 段)", r.Stream, r.SegmentCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// segmentReason 分段结束原因
|
||||
type segmentReason string
|
||||
|
||||
const (
|
||||
segmentDone segmentReason = "segment_done" // 达到分段时长,正常结束
|
||||
reasonEOF segmentReason = "eof" // 流结束
|
||||
reasonError segmentReason = "error" // 出错
|
||||
reasonStopped segmentReason = "stopped" // 收到 stop
|
||||
)
|
||||
|
||||
// recordOneSegment 录制单个 5 分钟分段
|
||||
func (r *StreamRecorder) recordOneSegment() segmentReason {
|
||||
segStart := time.Now()
|
||||
r.StartedAt = segStart
|
||||
r.Parts = nil
|
||||
r.PartNum = 0
|
||||
r.TotalBytes = 0
|
||||
r.S3Key = ""
|
||||
r.UploadID = ""
|
||||
|
||||
// 拉流 context(stop 时取消以中断阻塞的 Read)
|
||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
||||
r.streamCtx = streamCtx
|
||||
r.streamCancel = streamCancel
|
||||
defer streamCancel()
|
||||
|
||||
// 1. CreateMultipartUpload(用独立 context,stop 不影响)
|
||||
r.S3Key = fmt.Sprintf("%s/%d.mp4", segStart.Format("2006/01/02"), segStart.UnixMilli())
|
||||
createOut, err := s3Client.CreateMultipartUpload(context.Background(), &s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(s3Bucket),
|
||||
Key: aws.String(r.S3Key),
|
||||
ContentType: aws.String("video/mp4"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[%s] CreateMultipartUpload 失败: %v", r.Stream, err)
|
||||
return reasonError
|
||||
}
|
||||
r.UploadID = *createOut.UploadId
|
||||
log.Printf("[%s] 开始分段 #%d CreateMultipartUpload: s3://%s/%s",
|
||||
r.Stream, r.SegmentCount+1, s3Bucket, r.S3Key)
|
||||
|
||||
// 2. HTTP 拉流(用 streamCtx,stop 时可中断)
|
||||
url := fmt.Sprintf("%s/rtp/%s.live.mp4", zlmBase, r.Stream)
|
||||
req, err := http.NewRequestWithContext(streamCtx, "GET", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("[%s] 创建请求失败: %v", r.Stream, err)
|
||||
r.abortMultipart()
|
||||
return reasonError
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if streamCtx.Err() != nil {
|
||||
// stop 导致的
|
||||
r.abortMultipart()
|
||||
return reasonStopped
|
||||
}
|
||||
log.Printf("[%s] 拉流失败: %v", r.Stream, err)
|
||||
r.abortMultipart()
|
||||
return reasonError
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 3. 循环读取并上传
|
||||
buf := make([]byte, 0, chunkSize)
|
||||
tmp := make([]byte, 64*1024)
|
||||
stopped := false
|
||||
|
||||
for {
|
||||
if !r.Running {
|
||||
stopped = true
|
||||
break
|
||||
}
|
||||
if time.Since(segStart) >= time.Duration(segmentDur)*time.Second {
|
||||
log.Printf("[%s] 达到分段时长 %ds,结束本段", r.Stream, segmentDur)
|
||||
break
|
||||
}
|
||||
|
||||
n, err := resp.Body.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err == io.EOF {
|
||||
log.Printf("[%s] 流结束(EOF)", r.Stream)
|
||||
r.finalizeSegment(buf, segStart)
|
||||
return reasonEOF
|
||||
}
|
||||
if err != nil {
|
||||
if streamCtx.Err() != nil {
|
||||
// stop 导致的中断
|
||||
stopped = true
|
||||
break
|
||||
}
|
||||
log.Printf("[%s] 读取流错误: %v", r.Stream, err)
|
||||
r.finalizeSegment(buf, segStart)
|
||||
return reasonError
|
||||
}
|
||||
|
||||
if len(buf) >= chunkSize {
|
||||
if err := r.uploadPart(buf[:chunkSize]); err != nil {
|
||||
log.Printf("[%s] UploadPart 失败: %v", r.Stream, err)
|
||||
r.abortMultipart()
|
||||
return reasonError
|
||||
}
|
||||
buf = make([]byte, 0, chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾:上传最后一块 + CompleteMultipartUpload + DB 记录
|
||||
r.finalizeSegment(buf, segStart)
|
||||
|
||||
if stopped {
|
||||
return reasonStopped
|
||||
}
|
||||
return segmentDone
|
||||
}
|
||||
|
||||
// finalizeSegment 上传最后一块并完成 MultipartUpload + 创建 DB 记录
|
||||
func (r *StreamRecorder) finalizeSegment(buf []byte, segStart time.Time) {
|
||||
if len(buf) > 0 {
|
||||
if err := r.uploadPart(buf); err != nil {
|
||||
log.Printf("[%s] 最后一块上传失败: %v", r.Stream, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Parts) == 0 {
|
||||
log.Printf("[%s] 本段无数据,AbortMultipartUpload", r.Stream)
|
||||
r.abortMultipart()
|
||||
return
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload(用独立 context)
|
||||
_, err := s3Client.CompleteMultipartUpload(context.Background(), &s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(s3Bucket),
|
||||
Key: aws.String(r.S3Key),
|
||||
UploadId: aws.String(r.UploadID),
|
||||
MultipartUpload: &types.CompletedMultipartUpload{
|
||||
Parts: r.Parts,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[%s] CompleteMultipartUpload 失败: %v", r.Stream, err)
|
||||
r.abortMultipart()
|
||||
return
|
||||
}
|
||||
log.Printf("[%s] CompleteMultipartUpload parts=%d bytes=%d", r.Stream, len(r.Parts), r.TotalBytes)
|
||||
|
||||
// 创建数据库记录
|
||||
dur := int(time.Since(segStart).Seconds())
|
||||
r.createClipRecord(dur)
|
||||
r.SegmentCount++
|
||||
log.Printf("[%s] 分段 #%d 完成 ✓ bytes=%d dur=%ds", r.Stream, r.SegmentCount, r.TotalBytes, dur)
|
||||
}
|
||||
|
||||
func (r *StreamRecorder) uploadPart(data []byte) error {
|
||||
r.PartNum++
|
||||
out, err := s3Client.UploadPart(context.Background(), &s3.UploadPartInput{
|
||||
Bucket: aws.String(s3Bucket),
|
||||
Key: aws.String(r.S3Key),
|
||||
UploadId: aws.String(r.UploadID),
|
||||
PartNumber: aws.Int32(r.PartNum),
|
||||
Body: bytes.NewReader(data),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Parts = append(r.Parts, types.CompletedPart{
|
||||
PartNumber: aws.Int32(r.PartNum),
|
||||
ETag: out.ETag,
|
||||
})
|
||||
r.TotalBytes += int64(len(data))
|
||||
log.Printf("[%s] UploadPart #%d size=%d", r.Stream, r.PartNum, len(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *StreamRecorder) abortMultipart() {
|
||||
if r.UploadID == "" {
|
||||
return
|
||||
}
|
||||
_, err := s3Client.AbortMultipartUpload(context.Background(), &s3.AbortMultipartUploadInput{
|
||||
Bucket: aws.String(s3Bucket),
|
||||
Key: aws.String(r.S3Key),
|
||||
UploadId: aws.String(r.UploadID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[%s] AbortMultipartUpload 失败: %v", r.Stream, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *StreamRecorder) createClipRecord(dur int) {
|
||||
body := map[string]interface{}{
|
||||
"cameraId": r.CameraID,
|
||||
"trigger": "schedule",
|
||||
"format": "mp4",
|
||||
"startAt": r.StartedAt.Format(time.RFC3339),
|
||||
"durationSec": dur,
|
||||
"sizeBytes": strconv.FormatInt(r.TotalBytes, 10),
|
||||
"s3Bucket": s3Bucket,
|
||||
"s3Key": r.S3Key,
|
||||
"notes": "自动录制(不落地-Go)",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", nestjsBase+"/video/clips/internal", bytes.NewReader(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", internalKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[%s] 创建数据库记录失败: %v", r.Stream, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Printf("[%s] 创建数据库记录: %s", r.Stream, nestjsBase+"/video/clips/internal")
|
||||
}
|
||||
|
||||
// notifyRecordingEnd 通知 Go 后端录制已结束(流 EOF / 错误 / 停止)
|
||||
// Go 后端收到后清理 activeRecordings 并停止 WVP play session
|
||||
func notifyRecordingEnd(cameraID, stream string) {
|
||||
body := map[string]string{
|
||||
"cameraId": cameraID,
|
||||
"stream": stream,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", nestjsBase+"/video/recordings/internal/end", bytes.NewReader(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", internalKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[%s] 通知录制结束失败: %v", stream, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Printf("[%s] 已通知 Go 后端录制结束", stream)
|
||||
}
|
||||
|
||||
// ===== HTTP 控制服务 =====
|
||||
|
||||
func startHTTPServer() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/record/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "Bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
recMu.Lock()
|
||||
if _, exists := recordings[body.Stream]; exists {
|
||||
recMu.Unlock()
|
||||
http.Error(w, "already recording", 409)
|
||||
return
|
||||
}
|
||||
rec := &StreamRecorder{
|
||||
Stream: body.Stream,
|
||||
CameraID: body.CameraID,
|
||||
DeviceID: body.DeviceID,
|
||||
ChannelID: body.ChannelID,
|
||||
}
|
||||
recordings[body.Stream] = rec
|
||||
recMu.Unlock()
|
||||
|
||||
rec.start()
|
||||
log.Printf("HTTP /record/start: %s (camera=%s) — 连续录制模式", body.Stream, body.CameraID)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"stream": body.Stream,
|
||||
"startedAt": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/record/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
recMu.RLock()
|
||||
rec, exists := recordings[body.Stream]
|
||||
recMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "not recording", 404)
|
||||
return
|
||||
}
|
||||
log.Printf("HTTP /record/stop: %s (已完成 %d 段,等待当前分段收尾)", body.Stream, rec.SegmentCount)
|
||||
rec.stop()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"stream": body.Stream,
|
||||
"segmentCount": rec.SegmentCount,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/record/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
type StatusItem struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
SegmentCount int `json:"segmentCount"`
|
||||
}
|
||||
items := make([]StatusItem, 0)
|
||||
recMu.RLock()
|
||||
for _, rec := range recordings {
|
||||
items = append(items, StatusItem{
|
||||
Stream: rec.Stream,
|
||||
CameraID: rec.CameraID,
|
||||
StartedAt: rec.StartedAt.Format(time.RFC3339),
|
||||
TotalBytes: rec.TotalBytes,
|
||||
SegmentCount: rec.SegmentCount,
|
||||
})
|
||||
}
|
||||
recMu.RUnlock()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"recordings": items})
|
||||
})
|
||||
|
||||
log.Printf("HTTP 控制服务监听 0.0.0.0:%s", httpPort)
|
||||
log.Fatal(http.ListenAndServe(":"+httpPort, mux))
|
||||
}
|
||||
|
||||
// ===== 主函数 =====
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags)
|
||||
log.Println("=== Go 录制服务启动 ===")
|
||||
log.Printf("ZLM: %s, S3: %s, Backend: %s", zlmBase, s3Endpoint, nestjsBase)
|
||||
log.Printf("分段时长: %ds, 分块大小: %dMB", segmentDur, chunkSize/1024/1024)
|
||||
log.Printf("录制模式: 连续录制,每 %ds 落地一个 S3 分段,由 /record/stop 停止", segmentDur)
|
||||
|
||||
initS3()
|
||||
|
||||
// 注:autoCheck 自动检查已移除——录制完全由前端"开始/停止录制"按钮通过 Go 后端驱动
|
||||
startHTTPServer()
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,102 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const DIST = '/home/pan/silk/web/dist';
|
||||
const PORT = 5174;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
|
||||
// API proxy to NestJS
|
||||
if (req.url.startsWith('/api')) {
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: 3000,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: 'localhost:3000' },
|
||||
};
|
||||
const proxy = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxy.on('error', (e) => {
|
||||
console.error('API proxy error:', e.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Bad Gateway', detail: e.message }));
|
||||
});
|
||||
req.pipe(proxy);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static file serving with gzip
|
||||
let urlPath = req.url.split('?')[0];
|
||||
let filePath = path.join(DIST, urlPath === '/' ? 'index.html' : urlPath);
|
||||
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST, 'index.html');
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = MIME[ext] || 'application/octet-stream';
|
||||
const acceptEncoding = req.headers['accept-encoding'] || '';
|
||||
const useGzip = acceptEncoding.includes('gzip') && data.length > 1024;
|
||||
|
||||
if (useGzip) {
|
||||
zlib.gzip(data, (err, compressed) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end('Compression Error');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Length': compressed.length,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.end(compressed);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (e) => {
|
||||
console.error('Server error:', e);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (e) => {
|
||||
console.error('Uncaught:', e);
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on http://0.0.0.0:${PORT} (gzip enabled)`);
|
||||
});
|
||||
Reference in New Issue
Block a user