package service import ( "context" "encoding/json" "fmt" "log" "strings" "sync" "time" "github.com/robfig/cron/v3" "gorm.io/gorm" "gorm.io/gorm/clause" "oci-portal/internal/model" "oci-portal/internal/oci" ) // taskRunTimeout 是单次任务执行的超时时间。 const taskRunTimeout = 10 * time.Minute // taskLogKeep 是每个任务保留的执行日志条数。 const taskLogKeep = 100 // defaultSnatchIPWait 是抢机成功后等待公网 IPv4 分配的总预算(全部实例共享, // 实例从创建到 VNIC 就绪通常 1~2 分钟);taskRunTimeout 余量内,超时降级不阻塞通知。 const defaultSnatchIPWait = 3 * time.Minute // snatchIPPollInterval 是等待公网 IP 的轮询间隔。 const snatchIPPollInterval = 10 * time.Second // TaskService 管理后台任务的存储、cron 调度与执行。 type TaskService struct { db *gorm.DB configs *OciConfigService notifier *Notifier settings *SettingService cron *cron.Cron // aiGateway 供 AI 探测任务执行渠道探测,由 main 装配(可为 nil) aiGateway *AiGatewayService // snatchIPWait 是抢机成功后等待公网 IP 的预算,测试置 0 跳过等待 snatchIPWait time.Duration mu sync.Mutex entries map[uint]cron.EntryID // runMu 让租户删除等待在途执行结束,并阻止新执行读取删除前的任务快照。 runMu sync.RWMutex // snatchVarsMu 保护 snatchVars:runSnatch 抢满时写入成功通知变量, // execute 组装执行后快照时取走(一次性) snatchVarsMu sync.Mutex snatchVars map[uint]map[string]string } // NewTaskService 组装依赖;notifier 传 nil 表示整体关闭通知, // settings 供发送前按事件类型过滤(nil 视为全开)。调用 Start 后开始调度。 func NewTaskService(db *gorm.DB, configs *OciConfigService, notifier *Notifier, settings *SettingService) *TaskService { return &TaskService{ db: db, configs: configs, notifier: notifier, settings: settings, cron: cron.New(), entries: map[uint]cron.EntryID{}, snatchIPWait: defaultSnatchIPWait, snatchVars: map[uint]map[string]string{}, } } // AttachAiGateway 注入 AI 网关服务,启用 AI 探测任务的执行与自动同步。 func (s *TaskService) AttachAiGateway(gw *AiGatewayService) { s.aiGateway = gw } // Start 加载全部 active 任务注册调度并启动 cron。 func (s *TaskService) Start() error { var tasks []model.Task if err := s.db.Where("status = ?", model.TaskStatusActive).Find(&tasks).Error; err != nil { return fmt.Errorf("load active tasks: %w", err) } for i := range tasks { if err := s.schedule(&tasks[i]); err != nil { return err } } s.cron.Start() return nil } // Stop 停止调度并等待执行中的任务收尾, // 保证任务产生的异步通知都已进入 Notifier 的等待队列。 func (s *TaskService) Stop() { <-s.cron.Stop().Done() } // healthCheckPayload 是测活任务参数;ociConfigIds 为空表示全部配置。 type healthCheckPayload struct { OciConfigIDs []uint `json:"ociConfigIds"` } // costPayload 是成本同步任务参数;ociConfigIds 为空表示全部配置, // 免费类别的配置在执行时跳过,不发起 Usage API 请求。 type costPayload struct { OciConfigIDs []uint `json:"ociConfigIds"` } // snatchPayload 是抢机任务参数;count 为剩余台数(创建时即目标台数, // 每次执行把剩余写回,直到抢满),totalCount 固定为创建时的目标台数, // 供前端计算进度(旧任务缺省时前端回退用 count)。 // authFailCount 为连续 NotAuthenticated 失败计数,达阈值任务熔断停止。 type snatchPayload struct { OciConfigID uint `json:"ociConfigId"` Count int `json:"count"` TotalCount int `json:"totalCount,omitempty"` AuthFailCount int `json:"authFailCount,omitempty"` Instance oci.CreateInstanceInput `json:"instance"` } // CreateTaskInput 是创建任务的输入。 type CreateTaskInput struct { Name string Type string CronExpr string Payload json.RawMessage } // CreateTask 校验并保存任务,立即进入调度;AI 探测任务全局唯一(系统自动管理)。 func (s *TaskService) CreateTask(ctx context.Context, in CreateTaskInput) (*model.Task, error) { if in.Name == "" { return nil, fmt.Errorf("create task: name is required") } if _, err := cron.ParseStandard(in.CronExpr); err != nil { return nil, fmt.Errorf("create task: invalid cron %q: %w", in.CronExpr, err) } if err := validateTaskPayload(in.Type, in.Payload); err != nil { return nil, err } if err := s.ensureAiProbeUnique(ctx, in.Type); err != nil { return nil, err } task := &model.Task{ Name: in.Name, Type: in.Type, CronExpr: in.CronExpr, Payload: string(normalizeSnatchPayload(in.Type, in.Payload)), Status: model.TaskStatusActive, } if err := s.db.WithContext(ctx).Create(task).Error; err != nil { return nil, fmt.Errorf("create task: %w", err) } if err := s.schedule(task); err != nil { return nil, err } return task, nil } // ensureAiProbeUnique 拒绝重复创建 AI 探测任务(该类型随渠道数量自动管理)。 func (s *TaskService) ensureAiProbeUnique(ctx context.Context, taskType string) error { if taskType != model.TaskTypeAiProbe { return nil } var n int64 if err := s.db.WithContext(ctx).Model(&model.Task{}). Where("type = ?", model.TaskTypeAiProbe).Count(&n).Error; err != nil { return err } if n > 0 { return fmt.Errorf("create task: AI 探测任务已存在,由系统自动管理") } return nil } // normalizeSnatchPayload 给抢机 payload 补全目标台数:count 默认 1、 // totalCount 缺省时固定为创建时的 count,供进度展示;解析失败原样返回 // (validateTaskPayload 已在前面拦截非法 JSON)。 func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMessage { if taskType != model.TaskTypeSnatch { return payload } var p snatchPayload if err := json.Unmarshal(payload, &p); err != nil { return payload } if p.Count <= 0 { p.Count = 1 } if p.TotalCount <= 0 { p.TotalCount = p.Count } out, err := json.Marshal(p) if err != nil { return payload } return out } // validateTaskPayload 按任务类型校验参数 JSON。 func validateTaskPayload(taskType string, payload json.RawMessage) error { switch taskType { case model.TaskTypeHealthCheck: var p healthCheckPayload if len(payload) > 0 { if err := json.Unmarshal(payload, &p); err != nil { return fmt.Errorf("create task: invalid payload: %w", err) } } return nil case model.TaskTypeCost: var p costPayload if len(payload) > 0 { if err := json.Unmarshal(payload, &p); err != nil { return fmt.Errorf("create task: invalid payload: %w", err) } } return nil case model.TaskTypeSnatch: var p snatchPayload if err := json.Unmarshal(payload, &p); err != nil { return fmt.Errorf("create task: invalid payload: %w", err) } if p.OciConfigID == 0 { return fmt.Errorf("create task: snatch payload requires ociConfigId") } return validateCreateInstance(p.Instance) case model.TaskTypeAiProbe: return nil // 无参数:探测全部渠道 default: return fmt.Errorf("create task: unsupported type %q", taskType) } } // UpdateTaskInput 是更新任务的输入;nil 字段不修改。 type UpdateTaskInput struct { Name *string CronExpr *string Payload json.RawMessage Status *string } // UpdateTask 修改任务并重新调度。 func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) { task, err := s.GetTask(ctx, id) if err != nil { return nil, err } if err := applyTaskUpdate(task, in); err != nil { return nil, err } if err := s.db.WithContext(ctx).Save(task).Error; err != nil { return nil, fmt.Errorf("update task %d: %w", id, err) } s.unschedule(task.ID) if task.Status == model.TaskStatusActive { if err := s.schedule(task); err != nil { return nil, err } } return task, nil } func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error { if in.Name != nil { task.Name = *in.Name } if in.CronExpr != nil { if _, err := cron.ParseStandard(*in.CronExpr); err != nil { return fmt.Errorf("update task: invalid cron %q: %w", *in.CronExpr, err) } task.CronExpr = *in.CronExpr } if len(in.Payload) > 0 { if err := validateTaskPayload(task.Type, in.Payload); err != nil { return err } task.Payload = string(in.Payload) } if in.Status != nil { if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused { return fmt.Errorf("update task: status must be active or paused") } if task.Status == model.TaskStatusFailed && *in.Status == model.TaskStatusActive { resetSnatchAuthFail(task) } task.Status = *in.Status } return nil } // resetSnatchAuthFail 清零抢机 payload 的连续鉴权失败计数, // 供 failed 任务重新启用时调用,避免一恢复调度就再次熔断;非抢机任务不处理。 func resetSnatchAuthFail(task *model.Task) { if task.Type != model.TaskTypeSnatch { return } var p snatchPayload if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { return } p.AuthFailCount = 0 writeSnatchPayload(task, &p) } // ListTasks 返回全部任务。 func (s *TaskService) ListTasks(ctx context.Context) ([]model.Task, error) { tasks := make([]model.Task, 0) if err := s.db.WithContext(ctx).Order("id").Find(&tasks).Error; err != nil { return nil, fmt.Errorf("list tasks: %w", err) } return tasks, nil } // GetTask 返回单个任务。 func (s *TaskService) GetTask(ctx context.Context, id uint) (*model.Task, error) { var task model.Task if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { return nil, fmt.Errorf("find task %d: %w", id, err) } return &task, nil } // DeleteTask 注销调度并删除任务与其日志;AI 探测任务由系统按渠道数量 // 自动创建/删除,拒绝手动删除。 func (s *TaskService) DeleteTask(ctx context.Context, id uint) error { task, err := s.GetTask(ctx, id) if err != nil { return err } if task.Type == model.TaskTypeAiProbe { return fmt.Errorf("delete task: AI 探测任务由系统自动管理,删除最后一个渠道时自动移除") } return s.removeTask(ctx, id) } // removeTask 注销调度并删除任务与其日志(内部路径,不做类型限制)。 func (s *TaskService) removeTask(ctx context.Context, id uint) error { s.unschedule(id) if err := s.db.WithContext(ctx).Delete(&model.Task{}, id).Error; err != nil { return fmt.Errorf("delete task %d: %w", id, err) } if err := s.db.WithContext(ctx).Where("task_id = ?", id).Delete(&model.TaskLog{}).Error; err != nil { return fmt.Errorf("delete task %d logs: %w", id, err) } return nil } // TaskLogs 返回任务最近的执行日志(时间倒序)。 func (s *TaskService) TaskLogs(ctx context.Context, id uint, limit int) ([]model.TaskLog, error) { if limit <= 0 || limit > taskLogKeep { limit = 50 } logs := make([]model.TaskLog, 0) err := s.db.WithContext(ctx).Where("task_id = ?", id). Order("id desc").Limit(limit).Find(&logs).Error if err != nil { return nil, fmt.Errorf("list task %d logs: %w", id, err) } return logs, nil } // RunTaskNow 立即执行一次任务并返回本次日志。 func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog, error) { if _, err := s.GetTask(ctx, id); err != nil { return nil, err } return s.execute(id), nil } // schedule 把任务注册进 cron 调度。 func (s *TaskService) schedule(task *model.Task) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.entries[task.ID]; ok { return nil } taskID := task.ID entry, err := s.cron.AddFunc(task.CronExpr, func() { s.execute(taskID) }) if err != nil { return fmt.Errorf("schedule task %d: %w", task.ID, err) } s.entries[task.ID] = entry return nil } // unschedule 把任务移出 cron 调度。 func (s *TaskService) unschedule(taskID uint) { s.mu.Lock() defer s.mu.Unlock() if entry, ok := s.entries[taskID]; ok { s.cron.Remove(entry) delete(s.entries, taskID) } } // ApplyTenantCleanup 在租户删除事务提交后注销已删除任务并对齐 AI 探测任务。 func (s *TaskService) ApplyTenantCleanup(ctx context.Context, taskIDs []uint, channelsGone bool) { for _, id := range taskIDs { s.unschedule(id) s.forgetSnatchVars(id) } if channelsGone { s.SyncAiProbeTask(ctx) } } func (s *TaskService) forgetSnatchVars(taskID uint) { s.snatchVarsMu.Lock() delete(s.snatchVars, taskID) s.snatchVarsMu.Unlock() } func (s *TaskService) lockTenantCleanup() func() { s.runMu.Lock() return s.runMu.Unlock } // execute 执行一次任务:加载 → 分派 → 更新任务状态并写日志, // 前后状态交给通知判定,只在状态变化时推送。 func (s *TaskService) execute(taskID uint) *model.TaskLog { s.runMu.RLock() defer s.runMu.RUnlock() return s.executeLocked(taskID) } func (s *TaskService) executeLocked(taskID uint) *model.TaskLog { ctx, cancel := context.WithTimeout(context.Background(), taskRunTimeout) defer cancel() task, err := s.GetTask(ctx, taskID) if err != nil { return nil } prev := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError} start := time.Now() message, runErr := s.run(ctx, task) now := time.Now() task.LastRunAt = &now task.RunCount++ task.LastError = "" if runErr != nil { task.LastError = oci.CompactError(runErr) message = task.LastError } if !s.storeTaskRun(ctx, task) { return nil } cur := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError, Message: message, SnatchVars: s.takeSnatchVars(task.ID)} s.notify(notifyEvents(prev, cur)) return s.appendLog(task.ID, runErr == nil, message, time.Since(start)) } func (s *TaskService) storeTaskRun(ctx context.Context, task *model.Task) bool { stored, err := s.persistTaskRun(ctx, task) if err != nil { log.Printf("persist task run: %v", err) return false } return stored } func (s *TaskService) persistTaskRun(ctx context.Context, task *model.Task) (bool, error) { updates := map[string]any{ "status": task.Status, "last_run_at": task.LastRunAt, "last_error": task.LastError, "run_count": task.RunCount, } if task.Type == model.TaskTypeSnatch { updates["payload"] = task.Payload } res := s.db.WithContext(ctx).Model(&model.Task{}). Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).Updates(updates) if res.Error != nil { return false, fmt.Errorf("persist task %d run: %w", task.ID, res.Error) } return res.RowsAffected == 1, nil } // run 按类型分派任务执行。 func (s *TaskService) run(ctx context.Context, task *model.Task) (string, error) { switch task.Type { case model.TaskTypeHealthCheck: return s.runHealthCheck(ctx, task) case model.TaskTypeCost: return s.runCost(ctx, task) case model.TaskTypeSnatch: return s.runSnatch(ctx, task) case model.TaskTypeAiProbe: return s.runAiProbe(ctx) default: return "", fmt.Errorf("unsupported task type %q", task.Type) } } // runAiProbe 逐渠道探测 AI 网关号池;网关未装配时报错。 func (s *TaskService) runAiProbe(ctx context.Context) (string, error) { if s.aiGateway == nil { return "", fmt.Errorf("ai gateway not attached") } msg, err := s.aiGateway.ProbeAll(ctx) if err == nil { s.warnDeprecatingModels(ctx) } return msg, err } // warnDeprecatingModels 对 30 天内即将退役或弃用的在池模型发 Telegram 提醒; // 随每日探测执行,模型退役被同步剔除后自动停止,受通知管理 model_deprecated 开关控制。 func (s *TaskService) warnDeprecatingModels(ctx context.Context) { if s.notifier == nil { return } if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, "model_deprecated") { return } names, err := s.aiGateway.DeprecatingModels(ctx, 30*24*time.Hour) if err != nil || len(names) == 0 { return } s.notifier.SendTemplateAsync("model_deprecated", map[string]string{"models": strings.Join(names, "\n")}) } // runHealthCheck 对范围内的配置逐个测活,汇总结果并触发失联通知。 func (s *TaskService) runHealthCheck(ctx context.Context, task *model.Task) (string, error) { var p healthCheckPayload if task.Payload != "" { if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { return "", fmt.Errorf("parse payload: %w", err) } } ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs) if err != nil { return "", err } alive := 0 var deadAliases, failures []string for _, id := range ids { cfg, _, err := s.configs.Verify(ctx, id) ok := err == nil && cfg.AliveStatus == model.AliveStatusAlive s.saveCheckSnapshot(ctx, id, ok) if !ok { deadAliases = append(deadAliases, configAlias(cfg, id)) failures = append(failures, fmt.Sprintf("#%d %s", id, verifyFailReason(cfg, err))) continue } alive++ } s.notifyDeadAliases(ctx, task.ID, deadAliases) msg := fmt.Sprintf("checked %d: %d alive, %d dead", len(ids), alive, len(deadAliases)) if len(failures) > 0 { msg += "; " + strings.Join(failures, "; ") } return msg, nil } // configAlias 返回配置别名,配置加载失败时退回 #ID 表示。 func configAlias(cfg *model.OciConfig, id uint) string { if cfg != nil && cfg.Alias != "" { return cfg.Alias } return fmt.Sprintf("#%d", id) } func verifyFailReason(cfg *model.OciConfig, err error) string { if err != nil { return oci.CompactError(err) } return cfg.LastError } // saveCheckSnapshot 覆盖写入测活快照;仅存活时刷新实例数(默认区域口径), // 失联时保留上次实例数,避免总览 KPI 因 key 失效而抖动。 func (s *TaskService) saveCheckSnapshot(ctx context.Context, cfgID uint, alive bool) { status := model.AliveStatusDead if alive { status = model.AliveStatusAlive } snap := model.CheckSnapshot{OciConfigID: cfgID, AliveStatus: status, CheckedAt: time.Now()} cols := []string{"alive_status", "checked_at"} if alive { if instances, err := s.configs.Instances(ctx, cfgID, "", ""); err == nil { snap.InstanceCount = len(instances) cols = append(cols, "instance_count") } } s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "oci_config_id"}}, DoUpdates: clause.AssignmentColumns(cols), }).Create(&snap) } // runCost 对范围内配置同步近 7 天每日成本快照,免费类别跳过。 func (s *TaskService) runCost(ctx context.Context, task *model.Task) (string, error) { var p costPayload if task.Payload != "" { if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { return "", fmt.Errorf("parse payload: %w", err) } } ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs) if err != nil { return "", err } synced, skipped := 0, 0 var failures []string for _, id := range ids { switch err := s.syncCostSnapshot(ctx, id); { case err == errFreeAccountSkipped: skipped++ case err != nil: failures = append(failures, fmt.Sprintf("#%d %v", id, err)) default: synced++ } } msg := fmt.Sprintf("synced usage for %d tenants, skipped %d free", synced, skipped) if len(failures) > 0 { msg += "; " + strings.Join(failures, "; ") } return msg, nil } // errFreeAccountSkipped 标记成本同步因免费类别被跳过。 var errFreeAccountSkipped = fmt.Errorf("free account skipped") // syncCostSnapshot 拉取单配置近 7 天每日成本并按天覆盖写入快照。 func (s *TaskService) syncCostSnapshot(ctx context.Context, cfgID uint) error { cfg, err := s.configs.Get(ctx, cfgID) if err != nil { return err } if cfg.AccountType == model.AccountTypeFree { return errFreeAccountSkipped } end := time.Now().UTC() items, err := s.configs.Costs(ctx, cfgID, oci.CostQuery{ StartTime: end.AddDate(0, 0, -7), EndTime: end, }) if err != nil { return err } return s.saveCostSnapshots(ctx, cfgID, items) } // saveCostSnapshots 把成本条目按 UTC 日聚合后逐日 upsert。 func (s *TaskService) saveCostSnapshots(ctx context.Context, cfgID uint, items []oci.CostItem) error { type bucket struct { amount float64 currency string } byDay := map[string]*bucket{} for _, item := range items { if item.TimeStart == nil { continue } day := item.TimeStart.UTC().Format("2006-01-02") b, ok := byDay[day] if !ok { b = &bucket{currency: item.Currency} byDay[day] = b } b.amount += float64(item.ComputedAmount) } now := time.Now() for day, b := range byDay { snap := model.CostSnapshot{ OciConfigID: cfgID, Day: day, Amount: b.amount, Currency: b.currency, SyncedAt: now, } err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "oci_config_id"}, {Name: "day"}}, DoUpdates: clause.AssignmentColumns([]string{"amount", "currency", "synced_at"}), }).Create(&snap).Error if err != nil { return fmt.Errorf("save cost snapshot %s: %w", day, err) } } return nil } // targetConfigIDs 解析任务作用范围;未指定时返回全部配置 ID。 func (s *TaskService) targetConfigIDs(ctx context.Context, ids []uint) ([]uint, error) { if len(ids) > 0 { return ids, nil } configs, err := s.configs.List(ctx) if err != nil { return nil, err } all := make([]uint, 0, len(configs)) for _, cfg := range configs { all = append(all, cfg.ID) } return all, nil } // runSnatch 尝试创建实例;抢到目标台数后任务标记 succeeded 并停止调度, // 部分成功把剩余台数写回 payload 下次继续;成功路径一并清零并写回连续 // 鉴权失败计数,失败路径交给 snatchFailure 做连续 NotAuthenticated 熔断 // 判定。字段落库由 execute 统一 Save。 func (s *TaskService) runSnatch(ctx context.Context, task *model.Task) (string, error) { var p snatchPayload if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { return "", fmt.Errorf("parse payload: %w", err) } if p.Count < 1 { p.Count = 1 } in, adNote, err := s.snatchInstanceInput(ctx, task, &p) if err != nil { return "", s.snatchFailure(ctx, task, &p, err) } instances, failures, err := s.configs.CreateInstances(ctx, p.OciConfigID, in, p.Count) if err == nil && len(instances) == 0 { err = fmt.Errorf("no instance created%s: %s", adNote, strings.Join(failures, "; ")) } if err != nil { return "", s.snatchFailure(ctx, task, &p, err) } p.AuthFailCount = 0 ids := make([]string, 0, len(instances)) for _, in := range instances { ids = append(ids, in.ID) } remaining := p.Count - len(instances) if remaining > 0 { p.Count = remaining writeSnatchPayload(task, &p) return fmt.Sprintf("created %d (%s)%s, %d remaining", len(instances), strings.Join(ids, ","), adNote, remaining), nil } s.snatchSucceed(ctx, task, &p, in, instances) return fmt.Sprintf("created %d: %s%s", len(instances), strings.Join(ids, ","), adNote), nil } // snatchSucceed 处理抢满收尾:标记成功、停止调度,并组装成功通知变量暂存, // 供 execute 发通知时合并进 snatch_success 事件。 func (s *TaskService) snatchSucceed(ctx context.Context, task *model.Task, p *snatchPayload, in oci.CreateInstanceInput, instances []oci.Instance) { task.Status = model.TaskStatusSucceeded writeSnatchPayload(task, p) s.unschedule(task.ID) s.storeSnatchVars(task.ID, s.snatchSuccessVars(ctx, p, in, instances)) } // snatchSuccessVars 组装抢机成功通知的模板变量;单项查询失败以占位值回退, // 绝不阻塞通知发送。密码只进通知变量,不落任务日志。 func (s *TaskService) snatchSuccessVars(ctx context.Context, p *snatchPayload, in oci.CreateInstanceInput, instances []oci.Instance) map[string]string { tenant, region := "-", in.Region if cfg, err := s.configs.Get(ctx, p.OciConfigID); err == nil { tenant = cfg.Alias if region == "" { region = cfg.Region } } ips, pwds := s.snatchInstanceNet(ctx, p.OciConfigID, region, in, instances) return map[string]string{ "tenant": tenant, "region": regionLabel(region), "shape": in.Shape, "spec": specLabel(in), "image": s.snatchImageLabel(ctx, p.OciConfigID, region, in), "ip": ips, "root_password": pwds, } } // snatchInstanceNet 逐台等待公网 IPv4 并取回 root 密码;全部实例共享 // snatchIPWait 总预算,多台结果按创建顺序以「、」连接。 func (s *TaskService) snatchInstanceNet(ctx context.Context, cfgID uint, region string, in oci.CreateInstanceInput, instances []oci.Instance) (string, string) { deadline := time.Now().Add(s.snatchIPWait) ips := make([]string, 0, len(instances)) pwds := make([]string, 0, len(instances)) for _, inst := range instances { ips = append(ips, s.waitPublicIPv4(ctx, cfgID, region, inst.ID, deadline)) pwds = append(pwds, rootPasswordOf(in, inst)) } return strings.Join(ips, "、"), strings.Join(pwds, "、") } // waitPublicIPv4 轮询实例公网 IPv4 直至分配或超过 deadline; // 超时回退内网 IPv4(标注内网),再无则「待分配」。 func (s *TaskService) waitPublicIPv4(ctx context.Context, cfgID uint, region, instanceID string, deadline time.Time) string { private := "" for { inst, err := s.configs.Instance(ctx, cfgID, region, instanceID) if err == nil { if inst.PublicIP != "" { return inst.PublicIP } if inst.PrivateIP != "" { private = inst.PrivateIP } } if time.Now().After(deadline) { break } select { case <-ctx.Done(): return waitIPFallback(private) case <-time.After(snatchIPPollInterval): } } return waitIPFallback(private) } // waitIPFallback 是公网 IP 等待超时后的展示回退。 func waitIPFallback(private string) string { if private != "" { return private + "(内网)" } return "待分配" } // rootPasswordOf 取单台实例的 root 密码:随机生成模式从创建时写入的 // FreeformTags 回读(每台独立),显式密码模式各台相同;SSH 密钥模式无密码。 func rootPasswordOf(in oci.CreateInstanceInput, inst oci.Instance) string { if pwd := inst.FreeformTags["RootPassword"]; pwd != "" { return pwd } if in.RootPassword != "" { return in.RootPassword } return "—(SSH 密钥登录)" } // specLabel 拼 Flex 规格,如 4C/24G;固定规格 shape(无 ocpus 参数)为 -。 func specLabel(in oci.CreateInstanceInput) string { if in.Ocpus <= 0 { return "-" } return fmt.Sprintf("%gC/%gG", in.Ocpus, in.MemoryInGBs) } // regionLabel 把区域标识换成控制台风格别名展示,如「Japan Central (Osaka)」; // 区域名与三字码均可匹配,表中未收录时回退原值。 func regionLabel(region string) string { if region == "" { return "-" } regions, err := oci.AllRegions() if err != nil { return region } for _, r := range regions { if strings.EqualFold(r.Name, region) || strings.EqualFold(r.Key, region) { return r.Alias } } return region } // snatchImageLabel 返回启动源展示名:镜像查显示名(失败回退 OCID), // 引导卷启动源固定文案。 func (s *TaskService) snatchImageLabel(ctx context.Context, cfgID uint, region string, in oci.CreateInstanceInput) string { if in.BootVolumeID != "" { return "引导卷" } if in.ImageID == "" { return "-" } img, err := s.configs.Image(ctx, cfgID, region, in.ImageID) if err != nil || img.DisplayName == "" { return in.ImageID } return img.DisplayName } // storeSnatchVars 暂存抢机成功通知变量,由 takeSnatchVars 取走。 func (s *TaskService) storeSnatchVars(taskID uint, vars map[string]string) { s.snatchVarsMu.Lock() defer s.snatchVarsMu.Unlock() s.snatchVars[taskID] = vars } // takeSnatchVars 取走并清除暂存的成功通知变量(无则 nil)。 func (s *TaskService) takeSnatchVars(taskID uint) map[string]string { s.snatchVarsMu.Lock() defer s.snatchVarsMu.Unlock() vars := s.snatchVars[taskID] delete(s.snatchVars, taskID) return vars } // snatchInstanceInput 组装本次创建参数:可用域显式指定时原样使用; // 留空(自动)时按执行序号轮询区域全部可用域——ad-1、ad-2、ad-3 依次循环, // 分摊单可用域容量不足。附加说明串供执行日志展示本次所用可用域。 func (s *TaskService) snatchInstanceInput(ctx context.Context, task *model.Task, p *snatchPayload) (oci.CreateInstanceInput, string, error) { in := p.Instance if in.AvailabilityDomain != "" { return in, "", nil } ads, err := s.configs.AvailabilityDomains(ctx, p.OciConfigID, in.Region) if err != nil { return in, "", fmt.Errorf("list availability domains: %w", err) } if len(ads) == 0 { return in, "", fmt.Errorf("region has no availability domain") } // execute 在 run 之后才递增 RunCount,此处即 0 起的本次执行序号 in.AvailabilityDomain = ads[task.RunCount%len(ads)] return in, " @ " + in.AvailabilityDomain, nil } // snatchFailure 处理抢机单次失败:错误含 NotAuthenticated 时累计连续计数, // 达阈值把任务置 failed 并移出调度(熔断);其他错误清零计数。计数写回 payload。 func (s *TaskService) snatchFailure(ctx context.Context, task *model.Task, p *snatchPayload, cause error) error { if !strings.Contains(cause.Error(), "NotAuthenticated") { p.AuthFailCount = 0 writeSnatchPayload(task, p) return cause } p.AuthFailCount++ writeSnatchPayload(task, p) if p.AuthFailCount < s.snatchAuthFailLimit(ctx) { return cause } task.Status = model.TaskStatusFailed s.unschedule(task.ID) return fmt.Errorf("连续 %d 次 NotAuthenticated,任务已熔断停止: %w", p.AuthFailCount, cause) } // snatchAuthFailLimit 读取抢机熔断阈值;settings 未注入或读取失败按默认值。 func (s *TaskService) snatchAuthFailLimit(ctx context.Context) int { if s.settings == nil { return defaultSnatchAuthFailLimit } view, err := s.settings.TaskSettings(ctx) if err != nil { return defaultSnatchAuthFailLimit } return view.SnatchAuthFailLimit } // writeSnatchPayload 把最新抢机参数序列化写回任务(execute 统一落库)。 func writeSnatchPayload(task *model.Task, p *snatchPayload) { if raw, err := json.Marshal(p); err == nil { task.Payload = string(raw) } } // appendLog 写入执行日志并裁剪超出保留数量的旧日志。 func (s *TaskService) appendLog(taskID uint, success bool, message string, elapsed time.Duration) *model.TaskLog { entry := &model.TaskLog{ TaskID: taskID, Success: success, Message: message, DurationMs: elapsed.Milliseconds(), } s.db.Create(entry) s.db.Where("task_id = ? AND id NOT IN (?)", taskID, s.db.Model(&model.TaskLog{}).Select("id").Where("task_id = ?", taskID). Order("id desc").Limit(taskLogKeep), ).Delete(&model.TaskLog{}) return entry } // taskSnapshot 是通知判定所需的任务状态切片(执行前后各取一份)。 type taskSnapshot struct { Name string Status string LastError string Message string // 本次执行结果摘要,仅执行后快照填写 // SnatchVars 是抢机成功通知的补充变量(租户/区域/实例明细), // 仅抢满那次执行后快照携带 SnatchVars map[string]string } // notifyKind 是通知事件类型,与设置页「通知管理」开关一一对应。 type notifyKind string const ( notifyTaskFail notifyKind = "task_fail" notifyTaskRecover notifyKind = "task_recover" notifySnatchSuccess notifyKind = "snatch_success" notifyTenantDead notifyKind = "tenant_dead" notifyTaskStop notifyKind = "task_stop" // 任务熔断停止(抢机连续鉴权失败达阈值) ) // notifyEvent 是一条待发送的通知:类型供开关过滤与模板选择,Vars 为模板变量。 type notifyEvent struct { Kind notifyKind Vars map[string]string } // notifyEvents 比较执行前后的任务状态,返回需要推送的通知事件。 // 只在状态发生变化时产出:连续失败或持续正常都不重复发,防轰炸。 // 熔断翻转(置 failed)优先判定,该次只发任务停止、不叠加任务失败。 func notifyEvents(prev, cur taskSnapshot) []notifyEvent { var events []notifyEvent if prev.Status != model.TaskStatusSucceeded && cur.Status == model.TaskStatusSucceeded { vars := map[string]string{"task_name": cur.Name, "message": cur.Message} for k, v := range cur.SnatchVars { vars[k] = v } events = append(events, notifyEvent{notifySnatchSuccess, vars}) } switch { case prev.Status != model.TaskStatusFailed && cur.Status == model.TaskStatusFailed: events = append(events, notifyEvent{notifyTaskStop, map[string]string{"task_name": cur.Name, "error": cur.LastError}}) case prev.LastError == "" && cur.LastError != "": events = append(events, notifyEvent{notifyTaskFail, map[string]string{"task_name": cur.Name, "error": cur.LastError}}) case prev.LastError != "" && cur.LastError == "" && cur.Status != model.TaskStatusSucceeded: // 恢复即成功收尾(抢机达成目标)时已有抢机成功通知,不再叠加恢复通知 events = append(events, notifyEvent{notifyTaskRecover, map[string]string{"task_name": cur.Name}}) } return events } // notifyFilterTimeout 是发送前查询事件开关的超时时间(本地 SQLite,查询极快)。 const notifyFilterTimeout = 5 * time.Second // notify 逐条按事件开关过滤后异步发送;notifier 未注入(nil)时整体关闭。 // 开关读取失败按开启降级(NotifyEventEnabled 内部兜底),不因设置异常漏发。 func (s *TaskService) notify(events []notifyEvent) { if s.notifier == nil || len(events) == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), notifyFilterTimeout) defer cancel() for _, ev := range events { if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, string(ev.Kind)) { continue } s.notifier.SendTemplateAsync(string(ev.Kind), ev.Vars) } } // deadAliasKey 是测活任务留存上次失联别名集合的 Setting 键。 func deadAliasKey(taskID uint) string { return fmt.Sprintf("health_dead_alias:%d", taskID) } // notifyDeadAliases 只在失联集合发生变化时推送失联通知,并留存本次集合; // 集合不变(含持续失联)不重复发。notifier 未注入时整体跳过。 func (s *TaskService) notifyDeadAliases(ctx context.Context, taskID uint, aliases []string) { if s.notifier == nil { return } if sameStringSet(s.loadDeadAliases(ctx, taskID), aliases) { return } s.saveDeadAliases(ctx, taskID, aliases) if len(aliases) > 0 { s.notify([]notifyEvent{{notifyTenantDead, map[string]string{"tenants": strings.Join(aliases, "、")}}}) } } // loadDeadAliases 读取任务上次记录的失联别名集合;无记录视为空集。 func (s *TaskService) loadDeadAliases(ctx context.Context, taskID uint) []string { var st model.Setting err := s.db.WithContext(ctx).First(&st, "key = ?", deadAliasKey(taskID)).Error if err != nil || st.Value == "" { return nil } var aliases []string if err := json.Unmarshal([]byte(st.Value), &aliases); err != nil { return nil } return aliases } // saveDeadAliases 覆盖保存本次失联别名集合;留存失败不影响任务执行。 func (s *TaskService) saveDeadAliases(ctx context.Context, taskID uint, aliases []string) { raw, err := json.Marshal(aliases) if err != nil { return } s.db.WithContext(ctx).Save(&model.Setting{ Key: deadAliasKey(taskID), Value: string(raw), UpdatedAt: time.Now(), }) } // sameStringSet 判断两个字符串切片内容是否相同(忽略顺序,重复元素按次数计)。 func sameStringSet(a, b []string) bool { if len(a) != len(b) { return false } count := make(map[string]int, len(a)) for _, v := range a { count[v]++ } for _, v := range b { count[v]-- if count[v] < 0 { return false } } return true }