2026-07-13 01:15:30 +00:00
|
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
StatusPending = "pending"
|
|
|
|
|
|
StatusQueued = "queued"
|
|
|
|
|
|
StatusRunning = "running"
|
|
|
|
|
|
StatusSucceeded = "succeeded"
|
|
|
|
|
|
StatusFailed = "failed"
|
|
|
|
|
|
StatusCancelled = "cancelled"
|
|
|
|
|
|
|
|
|
|
|
|
TemplateDemo = "demo"
|
|
|
|
|
|
TemplateThreadsTokenRenew = "threads_token_renew"
|
|
|
|
|
|
TemplatePersonaAnalyzeAccount = "persona_analyze_account"
|
|
|
|
|
|
TemplatePersonaAnalyzeText = "persona_analyze_text"
|
|
|
|
|
|
TemplateComposeMimic = "compose_mimic"
|
|
|
|
|
|
// TemplatePlayGenerateScript — 互回/串場:一次產完整劇本(背景 job,避免 HTTP 卡住)
|
|
|
|
|
|
TemplatePlayGenerateScript = "play_generate_script"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
TemplateScoutScan = "scout_scan"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Job 生命週期契約(所有模板必須遵守,worker / API 入列時):
|
|
|
|
|
|
// 1. 建立/入列 → StatusQueued + notify(鈴鐺「已排程」)
|
|
|
|
|
|
// 2. ClaimNext → StatusRunning + notify(至少 ~5%)
|
|
|
|
|
|
// 3. 執行中每有進度 → MarkRunningProgress → notify(同一 job upsert 一則通知)
|
|
|
|
|
|
// 4. 終態 SucceedJob / FailJob → notify(已完成/失敗)
|
|
|
|
|
|
// 前端靠全域 JobLive 輪詢列表 + 通知;遠期 run_after 不進頂欄即時條。
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrNotFound = errors.New("job not found")
|
|
|
|
|
|
ErrForbidden = errors.New("job access denied")
|
|
|
|
|
|
ErrIllegalStatus = errors.New("illegal job status transition")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type Job struct {
|
|
|
|
|
|
ID string `bson:"_id" json:"id"`
|
|
|
|
|
|
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
|
|
|
|
|
|
TemplateType string `bson:"template_type" json:"template_type"`
|
|
|
|
|
|
Status string `bson:"status" json:"status"`
|
|
|
|
|
|
ProgressSummary string `bson:"progress_summary" json:"progress_summary"`
|
|
|
|
|
|
ProgressPercent int `bson:"progress_percent" json:"progress_percent"`
|
|
|
|
|
|
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
|
|
|
|
|
WorkerID string `bson:"worker_id,omitempty" json:"worker_id,omitempty"`
|
|
|
|
|
|
// RefID e.g. threads account id for renew / persona id for analyze
|
|
|
|
|
|
RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"`
|
|
|
|
|
|
// Payload JSON body for job worker (e.g. {"username":"x"})
|
|
|
|
|
|
Payload string `bson:"payload,omitempty" json:"payload,omitempty"`
|
|
|
|
|
|
// RunAfter unix nanoseconds; worker only claims when now >= RunAfter (0 = due now)
|
|
|
|
|
|
RunAfter int64 `bson:"run_after,omitempty" json:"run_after,omitempty"`
|
|
|
|
|
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
|
|
|
|
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
|
|
|
|
|
CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"`
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// Version is incremented by every persisted mutation and makes lifecycle
|
|
|
|
|
|
// writes compare-and-swap safe across gateway and worker instances.
|
|
|
|
|
|
Version int64 `bson:"version" json:"version"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DefaultTokenRenewDelayNs — 連帳後約 30 天做第一次 renew(長效 token ~60 天)
|
|
|
|
|
|
const DefaultTokenRenewDelayNs = int64(30 * 24 * time.Hour)
|
|
|
|
|
|
|
|
|
|
|
|
// TerminalRetentionNs — 終態任務(已完成/失敗/取消)保留多久後自動清除
|
|
|
|
|
|
const TerminalRetentionNs = int64(2 * 24 * time.Hour)
|
|
|
|
|
|
|
|
|
|
|
|
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
|
|
|
|
|
|
|
|
|
|
|
// IsTerminal — 不可再轉移的狀態
|
|
|
|
|
|
func IsTerminal(status string) bool {
|
|
|
|
|
|
return status == StatusSucceeded || status == StatusFailed || status == StatusCancelled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TerminalCutoff — 早於此時間的終態任務應被 purge
|
|
|
|
|
|
func TerminalCutoff(now int64) int64 {
|
|
|
|
|
|
if now <= 0 {
|
|
|
|
|
|
now = NowNano()
|
|
|
|
|
|
}
|
|
|
|
|
|
return now - TerminalRetentionNs
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CanTransition enforces JB-10 guarded status.
|
|
|
|
|
|
func CanTransition(from, to string) bool {
|
|
|
|
|
|
switch from {
|
|
|
|
|
|
case StatusPending:
|
|
|
|
|
|
return to == StatusQueued || to == StatusRunning || to == StatusFailed
|
|
|
|
|
|
case StatusQueued:
|
|
|
|
|
|
return to == StatusRunning || to == StatusFailed || to == StatusCancelled
|
|
|
|
|
|
case StatusRunning:
|
|
|
|
|
|
return to == StatusSucceeded || to == StatusFailed || to == StatusCancelled
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false // terminal
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ApplyProgress enforces monotonic percent (JB-02).
|
|
|
|
|
|
func ApplyProgress(j *Job, percent int, summary string) error {
|
|
|
|
|
|
if percent < j.ProgressPercent {
|
|
|
|
|
|
return ErrIllegalStatus
|
|
|
|
|
|
}
|
|
|
|
|
|
if percent > 100 {
|
|
|
|
|
|
percent = 100
|
|
|
|
|
|
}
|
|
|
|
|
|
j.ProgressPercent = percent
|
|
|
|
|
|
if summary != "" {
|
|
|
|
|
|
j.ProgressSummary = summary
|
|
|
|
|
|
}
|
|
|
|
|
|
j.UpdatedAt = NowNano()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-07-13 03:18:08 +00:00
|
|
|
|
|
|
|
|
|
|
// List tab buckets(任務列表頁籤)
|
|
|
|
|
|
const (
|
|
|
|
|
|
TabRecurring = "recurring" // 遠期定期/已排程未到期
|
|
|
|
|
|
TabActive = "active" // 執行中、待領取(已到期)、取消中
|
|
|
|
|
|
TabHistory = "history" // 已完成/失敗/取消
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// NormalizeListTab — 空或未知 → active
|
|
|
|
|
|
func NormalizeListTab(tab string) string {
|
|
|
|
|
|
switch tab {
|
|
|
|
|
|
case TabRecurring, TabActive, TabHistory:
|
|
|
|
|
|
return tab
|
|
|
|
|
|
default:
|
|
|
|
|
|
return TabActive
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MatchListTab — 依狀態+run_after 分桶(與 FE 一致)
|
|
|
|
|
|
func MatchListTab(j *Job, tab string, nowNs int64) bool {
|
|
|
|
|
|
if j == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if nowNs <= 0 {
|
|
|
|
|
|
nowNs = NowNano()
|
|
|
|
|
|
}
|
|
|
|
|
|
switch NormalizeListTab(tab) {
|
|
|
|
|
|
case TabHistory:
|
|
|
|
|
|
return IsTerminal(j.Status)
|
|
|
|
|
|
case TabRecurring:
|
|
|
|
|
|
// 遠期排程:pending/queued 且 run_after 尚未到
|
|
|
|
|
|
if j.Status != StatusPending && j.Status != StatusQueued {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return j.RunAfter > 0 && j.RunAfter > nowNs
|
|
|
|
|
|
default: // active
|
|
|
|
|
|
if j.Status == StatusRunning {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if j.Status == StatusPending || j.Status == StatusQueued {
|
|
|
|
|
|
// 已到期或立刻可領
|
|
|
|
|
|
return j.RunAfter <= 0 || j.RunAfter <= nowNs
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClampPage — page 從 1;pageSize 預設 10、上限 50
|
|
|
|
|
|
func ClampPage(page, pageSize int) (int, int) {
|
|
|
|
|
|
if page < 1 {
|
|
|
|
|
|
page = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if pageSize <= 0 {
|
|
|
|
|
|
pageSize = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
if pageSize > 50 {
|
|
|
|
|
|
pageSize = 50
|
|
|
|
|
|
}
|
|
|
|
|
|
return page, pageSize
|
|
|
|
|
|
}
|