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
+27
View File
@@ -0,0 +1,27 @@
FROM eclipse-temurin:21-jdk AS builder
EXPOSE 18978/tcp
EXPOSE 8116/tcp
EXPOSE 8116/udp
EXPOSE 8080/tcp
RUN java -version && javac -version
RUN apt-get update && \
apt-get install -y maven && \
mkdir -p /root/.m2 && \
printf '<settings><mirrors><mirror><id>aliyun</id><name>Aliyun</name><url>https://maven.aliyun.com/repository/public</url><mirrorOf>central</mirrorOf></mirror></mirrors></settings>' > /root/.m2/settings.xml && \
rm -rf /var/lib/apt/lists/*
COPY . /build
WORKDIR /build
RUN ls && mvn clean package -Dmaven.test.skip=true
WORKDIR /build/target
RUN mv wvp-pro-*.jar wvp.jar
FROM eclipse-temurin:21-jdk
RUN mkdir -p /opt/wvp
WORKDIR /opt/wvp
COPY --from=builder /build/target /opt/wvp
COPY ./docker/wvp/wvp /opt/wvp
ENTRYPOINT ["java", "-Xms512m", "-Xmx1024m", "-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=/opt/ylcx/", "-jar", "wvp.jar", "--spring.config.location=/opt/ylcx/wvp/application.yml"]
+8
View File
@@ -0,0 +1,8 @@
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://mirror.ccs.tencentyun.com",
"https://docker.mirrors.ustc.edu.cn",
"https://dockerhub.icu"
]
}
+124
View File
@@ -0,0 +1,124 @@
services:
polaris-redis:
image: redis:latest
restart: unless-stopped
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 15s
timeout: 5s
retries: 10
start_period: 10s
networks:
- media-net
volumes:
- ./redis/conf/redis.conf:/opt/polaris/redis/redis.conf
- ./volumes/redis/data/:/data
environment:
TZ: "Asia/Shanghai"
command: redis-server /opt/polaris/redis/redis.conf --appendonly yes
polaris-mysql:
image: mysql:8
restart: unless-stopped
healthcheck:
test: [ "CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/3306" ]
interval: 15s
timeout: 5s
retries: 10
start_period: 10s
networks:
- media-net
environment:
MYSQL_DATABASE: wvp
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: wvp_user
MYSQL_PASSWORD: wvp_password
TZ: Asia/Shanghai
volumes:
- ./mysql/conf:/etc/mysql/conf.d
- ./logs/mysql:/logs
- ./volumes/mysql/data:/var/lib/mysql
- ../数据库/2.7.4/初始化-mysql-2.7.4.sql:/docker-entrypoint-initdb.d/init.sql
command: [
'--innodb-buffer-pool-size=80M',
'--character-set-server=utf8mb4',
'--collation-server=utf8mb4_general_ci',
'--default-time-zone=+8:00',
'--lower-case-table-names=1'
]
polaris-media:
image: zlmediakit/zlmediakit:master
restart: always
networks:
- media-net
ports:
- "${MediaRtmp:-10935}:${MediaRtmp:-10935}/tcp"
- "${MediaRtmp:-10935}:${MediaRtmp:-10935}/udp"
- "${MediaRtsp:-5540}:${MediaRtsp:-5540}/tcp"
- "${MediaRtsp:-5540}:${MediaRtsp:-5540}/udp"
- "${MediaRtp:-10000}:${MediaRtp:-10000}/tcp"
- "${MediaRtp:-10000}:${MediaRtp:-10000}/udp"
- "8081:80/tcp"
volumes:
- ./volumes/video:/opt/media/bin/www/record/
- ./logs/media:/opt/media/log/
- ./media/config.ini:/conf/config.ini
command: [
'MediaServer',
'-c', '/conf/config.ini',
'-l', '0'
]
polaris-wvp:
image: docker-polaris-wvp:latest
restart: always
networks:
- media-net
ports:
- "18978:18978"
- "18080:18080"
- "${SIP_Port:-8116}:${SIP_Port:-8116}/udp"
- "${SIP_Port:-8116}:${SIP_Port:-8116}/tcp"
depends_on:
- polaris-redis
- polaris-mysql
- polaris-media
volumes:
- ./wvp/config:/opt/wvp/config
- ./wvp/wvp/:/opt/ylcx/wvp/
- ./logs/wvp:/opt/wvp/logs/
environment:
TZ: "Asia/Shanghai"
RUN_ENV: "docker"
Stream_IP: ${Stream_IP}
SDP_IP: ${SDP_IP}
ZLM_HOOK_HOST: polaris-wvp
ZLM_HOST: polaris-media
ZLM_SERCERT: su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf
MediaHttp: ${WebHttp:-8080}
MediaRtmp: ${MediaRtmp:-10935}
MediaRtsp: ${MediaRtsp:-5540}
MediaRtp: ${MediaRtp:-10000}
REDIS_HOST: polaris-redis
REDIS_PORT: 6379
DATABASE_HOST: polaris-mysql
DATABASE_PORT: 3306
DATABASE_USER: wvp_user
DATABASE_PASSWORD: wvp_password
SIP_ShowIP: ${SIP_ShowIP}
SIP_Port: ${SIP_Port:-8116}
SIP_Domain: ${SIP_Domain}
SIP_Id: ${SIP_Id}
SIP_Password: ${SIP_Password}
RecordSip: ${RecordSip}
RecordPushLive: ${RecordPushLive}
networks:
media-net:
driver: bridge
+27
View File
@@ -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
)
+36
View File
@@ -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=
+492
View File
@@ -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 = ""
// 拉流 contextstop 时取消以中断阻塞的 Read)
streamCtx, streamCancel := context.WithCancel(context.Background())
r.streamCtx = streamCtx
r.streamCancel = streamCancel
defer streamCancel()
// 1. CreateMultipartUpload(用独立 contextstop 不影响)
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 拉流(用 streamCtxstop 时可中断)
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.
+102
View File
@@ -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)`);
});
+3
View File
@@ -0,0 +1,3 @@
[boot]
systemd=true
command=/usr/local/bin/fix-static-ip.sh
BIN
View File
Binary file not shown.