一、弹幕滚动时间不再整数除零 write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时 分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底 (退回半个停留时长,至少让它正常显示出来)。 二、点停止不再要等整个块渲完 jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中, job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。 现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped, 卡片显示的是「已停止」而不是「失败」。 三、暂停没挂住就回滚状态 jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了: 界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。 jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。 四、.ass 还原的临时 jsonl 用完就删 还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理, 每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除; 只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl, 删了就是丢数据。 五、带 BOM 的 ASS 能整份解析 带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报 「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。 六、头像下载期间暂停/停止不用再干等 单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒 (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃, 不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。 验证: - gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过 - go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s) - 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过 (每项临时退回源码 → 对应测试确实失败 → 恢复) - 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
596 lines
16 KiB
Go
596 lines
16 KiB
Go
// 转换任务(合并 / 烧录弹幕)的实时进度与暂停、停止控制。
|
||
// 进度只保留一份全局状态:同一时间只允许一个合并任务在跑,串行靠下面的 mergeSlot 保证
|
||
// (mergeRun.busy 只拦得住手动合并之间,拦不住自动合并,也拦不住手动与自动互相穿插)。
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/http"
|
||
"os/exec"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 任务阶段,前端按这个顺序画步骤条
|
||
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
||
|
||
// 阶段序号,和 jobPhaseNames 一一对应
|
||
const (
|
||
jobPhasePrepare = iota // 准备(读弹幕、探测视频、生成字幕)
|
||
jobPhaseJoin // 分段拼接(断流分成多段时才有事做)
|
||
jobPhaseAvatar // 获取头像
|
||
jobPhaseRender // 画面渲染(最耗时)
|
||
jobPhasePlace // 拼接分块与落盘
|
||
)
|
||
|
||
// 各阶段在总进度里占的权重(加起来是 1)。渲染最耗时,权重给得最多;
|
||
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
||
var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05}
|
||
|
||
// 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟)
|
||
const (
|
||
jobSampleInterval = time.Second
|
||
jobMaxSamples = 120
|
||
)
|
||
|
||
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
||
var errJobStopped = errors.New("任务已被手动停止")
|
||
|
||
// 速度折线上的一个点
|
||
type jobSample struct {
|
||
T float64 `json:"t"` // 任务开始后的秒数
|
||
Speed float64 `json:"speed"` // 当时的渲染倍速
|
||
}
|
||
|
||
// 给前端的任务快照
|
||
type jobInfo struct {
|
||
Active bool `json:"active"`
|
||
Kind string `json:"kind"` // 手动合并 / 自动合并
|
||
OutName string `json:"outName"` // 成片文件名
|
||
PhaseIdx int `json:"phaseIdx"` // 当前阶段序号,-1 表示已全部完成
|
||
Phases []string `json:"phases"` // 阶段名列表
|
||
PhasePercent float64 `json:"phasePercent"`
|
||
Percent float64 `json:"percent"` // 总进度 0~100
|
||
Paused bool `json:"paused"`
|
||
Stopping bool `json:"stopping"`
|
||
Speed float64 `json:"speed"` // 渲染倍速,例如 1.37
|
||
FPS float64 `json:"fps"`
|
||
DoneSec float64 `json:"doneSec"` // 已渲染时长
|
||
TotalSec float64 `json:"totalSec"` // 视频总时长
|
||
ChunkDone int `json:"chunkDone"`
|
||
ChunkTotal int `json:"chunkTotal"`
|
||
AvatarDone int `json:"avatarDone"`
|
||
AvatarTotal int `json:"avatarTotal"`
|
||
InputBytes int64 `json:"inputBytes"` // 源视频总大小
|
||
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
|
||
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
|
||
OutBytes int64 `json:"outBytes"` // 已经写出多少字节
|
||
ETASec float64 `json:"etaSec"` // 预计剩余秒数,0 表示算不出来
|
||
ElapsedSec float64 `json:"elapsedSec"` // 已用时间
|
||
Samples []jobSample `json:"samples"` // 速度折线
|
||
Message string `json:"message"` // 结束后的提示(完成 / 失败原因)
|
||
Err string `json:"err"` // 失败原因,成功为空
|
||
DoneAt int64 `json:"doneAt"` // 结束时刻(Unix 秒)
|
||
Pausable bool `json:"pausable"` // 现在是否能暂停
|
||
}
|
||
|
||
// 全局唯一的任务状态
|
||
type jobState struct {
|
||
sync.RWMutex
|
||
active bool
|
||
kind string
|
||
outName string
|
||
phaseIdx int
|
||
phasePct float64
|
||
started time.Time
|
||
paused bool
|
||
stopped bool
|
||
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
||
|
||
totalSec float64
|
||
doneSec float64
|
||
speed float64
|
||
fps float64
|
||
|
||
chunkDone int
|
||
chunkTotal int
|
||
avatarDone int
|
||
avatarTotal int
|
||
|
||
inputBytes int64
|
||
// 按目标码率算出来的预计成片大小
|
||
planOutBytes int64
|
||
// 按「已写出字节 ÷ 已渲染比例」外推出来的预计成片大小,渲染到 5% 后才有值
|
||
liveOutBytes int64
|
||
outBytes int64
|
||
|
||
samples []jobSample
|
||
lastSample time.Time
|
||
|
||
errText string
|
||
message string
|
||
doneAt int64
|
||
}
|
||
|
||
var job jobState
|
||
|
||
// 合并任务的串行锁。全局 job 状态只有一份,两个任务同时跑的话后启动的会把前一个的
|
||
// 进度和输出名清掉,「停止」也会杀掉别家任务的 ffmpeg,所以必须排队。
|
||
// 手动合并用 TryLock(抢不到立刻告诉用户),自动合并用 Lock(后台任务,排队等就行)。
|
||
var mergeSlot sync.Mutex
|
||
|
||
// 尝试占用合并任务位,被占着返回 false。给手动合并用:不能让 HTTP 请求干等着
|
||
func tryTakeMergeSlot() bool {
|
||
return mergeSlot.TryLock()
|
||
}
|
||
|
||
// 占用合并任务位,被占着就等到前一个跑完。给自动合并用
|
||
func takeMergeSlot() {
|
||
mergeSlot.Lock()
|
||
}
|
||
|
||
func releaseMergeSlot() {
|
||
mergeSlot.Unlock()
|
||
}
|
||
|
||
// 排队跑完一整个合并任务:先独占任务位,跑完后一定释放,被调用方 panic 也不漏。
|
||
// 内部出错会被翻成 error 而不是让上层去 recover —— 任务卡片只有走到 jobFinish 才会收尾,
|
||
// 漏掉的话进度会永远停在界面上(之前 panic 后卡片一直转就是这么来的)
|
||
func runMergeExclusive(f func() error) (err error) {
|
||
takeMergeSlot()
|
||
defer releaseMergeSlot()
|
||
defer func() {
|
||
if p := recover(); p != nil {
|
||
err = fmt.Errorf("合并任务内部出错:%v", p)
|
||
lPrintErrf("合并任务内部出错,已兜住:%v", p)
|
||
}
|
||
}()
|
||
return f()
|
||
}
|
||
|
||
// 开始一个新任务。kind 是任务类型,totalSec 是视频总时长(拿不到填 0)。
|
||
// 已经有任务在跑时返回 false 并且什么都不动:调用方应该先拿到 mergeSlot,
|
||
// 这里再做一次判断是为了让误用(忘记排队)只会失败而不会互相覆盖
|
||
func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if job.active {
|
||
return false
|
||
}
|
||
job.active = true
|
||
job.kind = kind
|
||
job.outName = outName
|
||
job.phaseIdx = 0
|
||
job.phasePct = 0
|
||
job.started = time.Now()
|
||
job.paused = false
|
||
job.stopped = false
|
||
job.proc = nil
|
||
job.totalSec = totalSec
|
||
job.doneSec = 0
|
||
job.speed = 0
|
||
job.fps = 0
|
||
job.chunkDone, job.chunkTotal = 0, 0
|
||
job.avatarDone, job.avatarTotal = 0, 0
|
||
job.inputBytes = inputBytes
|
||
job.planOutBytes = 0
|
||
job.liveOutBytes = 0
|
||
job.outBytes = 0
|
||
job.samples = nil
|
||
job.lastSample = time.Time{}
|
||
job.errText = ""
|
||
job.message = ""
|
||
job.doneAt = 0
|
||
return true
|
||
}
|
||
|
||
// 当前是否有任务在跑。给界面提示和「现在能不能接新任务」的判断用
|
||
func jobActive() bool {
|
||
job.RLock()
|
||
defer job.RUnlock()
|
||
return job.active
|
||
}
|
||
|
||
// 当前任务的名字(没任务时为空),拒绝新任务时用它告诉用户在忙什么
|
||
func jobBusyName() string {
|
||
job.RLock()
|
||
defer job.RUnlock()
|
||
if !job.active {
|
||
return ""
|
||
}
|
||
return job.outName
|
||
}
|
||
|
||
// 任务结束。errText 为空表示成功,errJobStopped.Error() 表示被手动停止
|
||
func jobFinish(errText string) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
job.active = false
|
||
job.paused = false
|
||
job.stopped = false
|
||
job.proc = nil
|
||
job.speed = 0
|
||
job.errText = errText
|
||
job.doneAt = time.Now().Unix()
|
||
switch errText {
|
||
case "":
|
||
job.phaseIdx = len(jobPhaseNames)
|
||
job.phasePct = 1
|
||
job.message = "已完成"
|
||
case errJobStopped.Error():
|
||
job.message = "已停止"
|
||
default:
|
||
job.message = "失败"
|
||
}
|
||
}
|
||
|
||
// 是否已经被要求停止
|
||
func jobStopRequested() bool {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
return job.stopped
|
||
}
|
||
|
||
// 各阶段在进入下一步前调用这个「闸门」:暂停时在这里等,被停止时立刻返回错误。
|
||
// 放在循环体里,这样下载头像、逐块渲染这些阶段都能被暂停和停止。
|
||
func jobGate() error {
|
||
for {
|
||
job.Lock()
|
||
stopped, paused := job.stopped, job.paused
|
||
job.Unlock()
|
||
if stopped {
|
||
return errJobStopped
|
||
}
|
||
if !paused {
|
||
return nil
|
||
}
|
||
time.Sleep(120 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// 切换阶段。序号只往前走,重复设置同一阶段不会把进度打回 0
|
||
func jobSetPhase(idx int) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
if idx > job.phaseIdx {
|
||
job.phaseIdx = idx
|
||
job.phasePct = 0
|
||
}
|
||
}
|
||
|
||
// 直接设置当前阶段的完成比例(0~1),只取更大值,避免折返
|
||
func jobSetPhasePercent(p float64) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
if p > job.phasePct {
|
||
job.phasePct = clamp01(p)
|
||
}
|
||
}
|
||
|
||
// 渲染进度:doneSec 是累计已渲染的秒数(分块时已折算成整片时间轴),
|
||
// cumBytes 是累计已写出的字节数(分块时是「已完成块 + 当前块」)
|
||
func jobRender(doneSec, speed, fps float64, cumBytes int64) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
if doneSec > job.doneSec {
|
||
job.doneSec = doneSec
|
||
}
|
||
if speed > 0 {
|
||
job.speed = speed
|
||
}
|
||
if fps > 0 {
|
||
job.fps = fps
|
||
}
|
||
if cumBytes > job.outBytes {
|
||
job.outBytes = cumBytes
|
||
}
|
||
ratio := 0.0
|
||
if job.totalSec > 0 {
|
||
ratio = clamp01(job.doneSec / job.totalSec)
|
||
}
|
||
if ratio > job.phasePct {
|
||
job.phasePct = ratio
|
||
}
|
||
// 渲染到 5% 之后,用「已写出 ÷ 已完成比例」外推整片体积,比按码率估的准
|
||
if ratio >= 0.05 && job.outBytes > 0 {
|
||
if est := int64(float64(job.outBytes) / ratio); est > 0 {
|
||
job.liveOutBytes = est
|
||
}
|
||
}
|
||
// 速度折线:最多 1 秒一个点
|
||
now := time.Now()
|
||
if job.lastSample.IsZero() || now.Sub(job.lastSample) >= jobSampleInterval {
|
||
job.lastSample = now
|
||
job.samples = append(job.samples, jobSample{T: now.Sub(job.started).Seconds(), Speed: speed})
|
||
if len(job.samples) > jobMaxSamples {
|
||
job.samples = append([]jobSample(nil), job.samples[len(job.samples)-jobMaxSamples:]...)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 已分块渲染的块数,给界面显示「第 3/12 块」
|
||
func jobChunks(done, total int) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
job.chunkDone, job.chunkTotal = done, total
|
||
}
|
||
|
||
// 头像下载进度(按用户数)
|
||
func jobAvatars(done, total int) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
job.avatarDone, job.avatarTotal = done, total
|
||
if total > 0 {
|
||
p := float64(done) / float64(total)
|
||
if p > job.phasePct {
|
||
job.phasePct = clamp01(p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它
|
||
func jobSetTotal(sec float64) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
job.totalSec = sec
|
||
}
|
||
|
||
// 按目标码率算出来的预计成片大小(byte)
|
||
func jobPlanSize(bytes int64) {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
if !job.active {
|
||
return
|
||
}
|
||
job.planOutBytes = bytes
|
||
}
|
||
|
||
// 把当前 ffmpeg 进程注册进来,暂停/停止才作用得到。
|
||
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况);
|
||
// 如果任务已经被要求停止,就地杀掉 —— jobGate 已经过去了,不补这一刀的话
|
||
// 用户点了停止还得等整个块渲完(大块的渲染可能要几分钟)
|
||
func jobAttach(cmd *exec.Cmd) {
|
||
job.Lock()
|
||
job.proc = cmd
|
||
paused, stopped := job.paused, job.stopped
|
||
job.Unlock()
|
||
if cmd == nil || cmd.Process == nil {
|
||
return
|
||
}
|
||
if stopped {
|
||
_ = cmd.Process.Kill()
|
||
return
|
||
}
|
||
if paused {
|
||
_ = suspendProcess(cmd.Process)
|
||
}
|
||
}
|
||
|
||
func jobDetach(cmd *exec.Cmd) {
|
||
job.Lock()
|
||
if job.proc == cmd {
|
||
job.proc = nil
|
||
}
|
||
job.Unlock()
|
||
}
|
||
|
||
// 暂停:挂起当前 ffmpeg 进程;不在 ffmpeg 阶段时靠 jobGate 在下一步生效
|
||
func jobPause() error {
|
||
job.Lock()
|
||
if !job.active {
|
||
job.Unlock()
|
||
return errors.New("当前没有正在运行的合并任务")
|
||
}
|
||
if job.paused {
|
||
job.Unlock()
|
||
return nil
|
||
}
|
||
job.paused = true
|
||
cmd := job.proc
|
||
job.Unlock()
|
||
if cmd != nil && cmd.Process != nil {
|
||
if err := suspendProcess(cmd.Process); err != nil {
|
||
// 没挂住就别留着「已暂停」:界面会显示暂停了、ffmpeg 其实还在跑,
|
||
// 用户以为停住了却在烧 CPU。只在进程还是同一个时回滚,免得踩到已经换过的进程
|
||
job.Lock()
|
||
if job.proc == cmd {
|
||
job.paused = false
|
||
}
|
||
job.Unlock()
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 恢复
|
||
func jobResume() error {
|
||
job.Lock()
|
||
if !job.active {
|
||
job.Unlock()
|
||
return errors.New("当前没有正在运行的合并任务")
|
||
}
|
||
if !job.paused {
|
||
job.Unlock()
|
||
return nil
|
||
}
|
||
job.paused = false
|
||
cmd := job.proc
|
||
job.Unlock()
|
||
if cmd != nil && cmd.Process != nil {
|
||
if err := resumeProcess(cmd.Process); err != nil {
|
||
// 没恢复成就把状态放回「已暂停」:否则界面显示在跑、ffmpeg 其实还停着
|
||
job.Lock()
|
||
if job.proc == cmd {
|
||
job.paused = true
|
||
}
|
||
job.Unlock()
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 停止:杀掉当前 ffmpeg,并置位让各层在下一个闸门返回 errJobStopped
|
||
func jobStop() error {
|
||
job.Lock()
|
||
if !job.active {
|
||
job.Unlock()
|
||
return errors.New("当前没有正在运行的合并任务")
|
||
}
|
||
job.stopped = true
|
||
job.paused = false
|
||
cmd := job.proc
|
||
job.Unlock()
|
||
if cmd != nil && cmd.Process != nil {
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func clamp01(v float64) float64 {
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
||
func (j *jobState) percentLocked() float64 {
|
||
p := 0.0
|
||
for i, w := range jobPhaseWeights {
|
||
switch {
|
||
case i < j.phaseIdx:
|
||
p += w
|
||
case i == j.phaseIdx:
|
||
p += w * clamp01(j.phasePct)
|
||
}
|
||
}
|
||
return math.Min(p*100, 100)
|
||
}
|
||
|
||
// 预计剩余时间:渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推
|
||
func (j *jobState) etaLocked(now time.Time) float64 {
|
||
if !j.active {
|
||
return 0
|
||
}
|
||
if j.phaseIdx == jobPhaseRender && j.speed > 0 && j.totalSec > j.doneSec {
|
||
return (j.totalSec - j.doneSec) / j.speed
|
||
}
|
||
pct := j.percentLocked()
|
||
if pct <= 0.5 {
|
||
return 0
|
||
}
|
||
return now.Sub(j.started).Seconds() * (100 - pct) / pct
|
||
}
|
||
|
||
// 给页面的快照
|
||
func jobSnapshot() jobInfo {
|
||
job.Lock()
|
||
defer job.Unlock()
|
||
|
||
info := jobInfo{
|
||
Active: job.active,
|
||
Kind: job.kind,
|
||
OutName: job.outName,
|
||
PhaseIdx: job.phaseIdx,
|
||
Phases: jobPhaseNames,
|
||
Paused: job.paused,
|
||
Stopping: job.stopped,
|
||
Speed: job.speed,
|
||
FPS: job.fps,
|
||
DoneSec: job.doneSec,
|
||
TotalSec: job.totalSec,
|
||
ChunkDone: job.chunkDone,
|
||
ChunkTotal: job.chunkTotal,
|
||
AvatarDone: job.avatarDone,
|
||
AvatarTotal: job.avatarTotal,
|
||
InputBytes: job.inputBytes,
|
||
OutBytes: job.outBytes,
|
||
Message: job.message,
|
||
Err: job.errText,
|
||
DoneAt: job.doneAt,
|
||
}
|
||
info.PhasePercent = clamp01(job.phasePct) * 100
|
||
info.Percent = job.percentLocked()
|
||
// 预计成片大小:有实测外推就用实测,否则用按码率估的
|
||
info.EstOutBytes, info.EstSource = job.planOutBytes, "bitrate"
|
||
if job.liveOutBytes > 0 {
|
||
info.EstOutBytes, info.EstSource = job.liveOutBytes, "measured"
|
||
}
|
||
now := time.Now()
|
||
info.ETASec = job.etaLocked(now)
|
||
if job.active {
|
||
info.ElapsedSec = now.Sub(job.started).Seconds()
|
||
} else if job.doneAt > 0 && !job.started.IsZero() {
|
||
info.ElapsedSec = time.Unix(job.doneAt, 0).Sub(job.started).Seconds()
|
||
}
|
||
if len(job.samples) > 0 {
|
||
info.Samples = append([]jobSample(nil), job.samples...)
|
||
}
|
||
// 暂停靠 jobGate 生效:ffmpeg 在跑时直接挂起进程,不在跑时在下一个闸门处停住
|
||
info.Pausable = job.active
|
||
return info
|
||
}
|
||
|
||
// GET /api/job:当前转换任务的快照,页面轮询它来画进度
|
||
func jobHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if err := json.NewEncoder(w).Encode(jobSnapshot()); err != nil {
|
||
lPrintErrf("输出任务进度失败:%v", err)
|
||
}
|
||
}
|
||
|
||
// POST /api/job/control:暂停 / 恢复 / 停止当前任务
|
||
func jobControlHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Action string `json:"action"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
var err error
|
||
switch req.Action {
|
||
case "pause":
|
||
err = jobPause()
|
||
case "resume":
|
||
err = jobResume()
|
||
case "stop":
|
||
err = jobStop()
|
||
default:
|
||
http.Error(w, "不认识的操作:"+req.Action, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||
}
|