0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
>4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cron implements the standard five-field cron format: minute, hour,
|
||||
// day-of-month, month, day-of-week. Lists, ranges and steps are supported.
|
||||
type Cron struct {
|
||||
minute, hour, day, month, weekday field
|
||||
dayWildcard, weekdayWildcard bool
|
||||
}
|
||||
|
||||
type field struct {
|
||||
min, max int
|
||||
values map[int]bool
|
||||
}
|
||||
|
||||
func ParseCron(expression string) (Cron, error) {
|
||||
parts := strings.Fields(expression)
|
||||
if len(parts) != 5 {
|
||||
return Cron{}, errors.New("cron 表达式必须包含 5 段: 分 时 日 月 周")
|
||||
}
|
||||
definitions := [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
|
||||
fields := make([]field, 5)
|
||||
for i, part := range parts {
|
||||
parsed, err := parseField(part, definitions[i][0], definitions[i][1], i == 4)
|
||||
if err != nil {
|
||||
return Cron{}, fmt.Errorf("cron 第 %d 段无效: %w", i+1, err)
|
||||
}
|
||||
fields[i] = parsed
|
||||
}
|
||||
return Cron{minute: fields[0], hour: fields[1], day: fields[2], month: fields[3], weekday: fields[4], dayWildcard: parts[2] == "*", weekdayWildcard: parts[4] == "*"}, nil
|
||||
}
|
||||
|
||||
func parseField(raw string, minimum, maximum int, weekday bool) (field, error) {
|
||||
result := field{min: minimum, max: maximum, values: map[int]bool{}}
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return field{}, errors.New("存在空列表项")
|
||||
}
|
||||
base, stepText, hasStep := strings.Cut(item, "/")
|
||||
step := 1
|
||||
if hasStep {
|
||||
var err error
|
||||
step, err = strconv.Atoi(stepText)
|
||||
if err != nil || step < 1 || step > maximum-minimum+1 {
|
||||
return field{}, errors.New("步长无效")
|
||||
}
|
||||
}
|
||||
start, end := minimum, maximum
|
||||
switch {
|
||||
case base == "*":
|
||||
case strings.Contains(base, "-"):
|
||||
left, right, _ := strings.Cut(base, "-")
|
||||
var err error
|
||||
start, err = cronNumber(left, minimum, maximum, weekday)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
end, err = cronNumber(right, minimum, maximum, weekday)
|
||||
if err != nil || start > end {
|
||||
return field{}, errors.New("范围无效")
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
start, err = cronNumber(base, minimum, maximum, weekday)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
end = start
|
||||
if hasStep {
|
||||
end = maximum
|
||||
}
|
||||
}
|
||||
for value := start; value <= end; value += step {
|
||||
if weekday && value == 7 {
|
||||
value = 0
|
||||
result.values[value] = true
|
||||
break
|
||||
}
|
||||
result.values[value] = true
|
||||
}
|
||||
}
|
||||
if len(result.values) == 0 {
|
||||
return field{}, errors.New("没有可用取值")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cronNumber(raw string, minimum, maximum int, weekday bool) (int, error) {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < minimum || value > maximum {
|
||||
return 0, fmt.Errorf("%q 超出 %d-%d", raw, minimum, maximum)
|
||||
}
|
||||
if weekday && value == 7 {
|
||||
return 7, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c Cron) Matches(value time.Time) bool {
|
||||
dayMatch := c.day.values[value.Day()]
|
||||
weekdayMatch := c.weekday.values[int(value.Weekday())]
|
||||
calendarMatch := dayMatch && weekdayMatch
|
||||
// Vixie cron semantics: when both day fields are restricted, either may match.
|
||||
if !c.dayWildcard && !c.weekdayWildcard {
|
||||
calendarMatch = dayMatch || weekdayMatch
|
||||
}
|
||||
return c.minute.values[value.Minute()] && c.hour.values[value.Hour()] && c.month.values[int(value.Month())] && calendarMatch
|
||||
}
|
||||
|
||||
func (c Cron) Next(after time.Time, location *time.Location) (time.Time, error) {
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
candidate := after.UTC().Truncate(time.Minute).Add(time.Minute)
|
||||
deadline := candidate.AddDate(5, 0, 0)
|
||||
for candidate.Before(deadline) {
|
||||
if c.Matches(candidate.In(location)) {
|
||||
return candidate, nil
|
||||
}
|
||||
candidate = candidate.Add(time.Minute)
|
||||
}
|
||||
return time.Time{}, errors.New("未来 5 年内无匹配执行时间")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCronNext(t *testing.T) {
|
||||
cases := []struct {
|
||||
expression, after, want string
|
||||
}{
|
||||
{"*/15 * * * *", "2026-08-12T10:07:00Z", "2026-08-12T10:15:00Z"},
|
||||
{"0 9 * * 1-5", "2026-08-14T09:01:00Z", "2026-08-17T09:00:00Z"},
|
||||
{"30 8 1 * *", "2026-08-12T00:00:00Z", "2026-09-01T08:30:00Z"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
schedule, err := ParseCron(tc.expression)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := time.Parse(time.RFC3339, tc.after)
|
||||
got, err := schedule.Next(after, time.UTC)
|
||||
if err != nil || got.Format(time.RFC3339) != tc.want {
|
||||
t.Errorf("%s next=%s err=%v want=%s", tc.expression, got.Format(time.RFC3339), err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRejectsInvalid(t *testing.T) {
|
||||
for _, expression := range []string{"* * *", "60 * * * *", "*/0 * * * *", "* 24 * * *"} {
|
||||
if _, err := ParseCron(expression); err == nil {
|
||||
t.Errorf("expected %q to be rejected", expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
)
|
||||
|
||||
const maxExecutionResponseBytes = 2 << 20
|
||||
|
||||
type Engine struct {
|
||||
service *Service
|
||||
baseURL string
|
||||
client *http.Client
|
||||
workerID string
|
||||
batchSize int
|
||||
maxAttempts int
|
||||
executionTTL time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewEngine(service *Service, baseURL, workerID string, batchSize, maxAttempts int, executionTTL time.Duration, logger *slog.Logger) *Engine {
|
||||
if batchSize < 1 {
|
||||
batchSize = 10
|
||||
}
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 3
|
||||
}
|
||||
if executionTTL < time.Minute {
|
||||
executionTTL = 5 * time.Minute
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Engine{service: service, baseURL: strings.TrimRight(baseURL, "/"), client: &http.Client{Timeout: executionTTL}, workerID: workerID, batchSize: batchSize, maxAttempts: maxAttempts, executionTTL: executionTTL, logger: logger}
|
||||
}
|
||||
|
||||
// Tick coalesces overdue schedules into durable pending runs, reclaims stale
|
||||
// executions, and processes a bounded batch. SKIP LOCKED makes it safe for
|
||||
// multiple scheduler replicas to call Tick concurrently.
|
||||
func (e *Engine) Tick(ctx context.Context, now time.Time) (int, error) {
|
||||
if err := e.scheduleDue(ctx, now.UTC()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := e.reclaim(ctx, now.UTC()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
runs, err := e.claim(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, run := range runs {
|
||||
task, getErr := e.service.Get(ctx, run.TaskID)
|
||||
if getErr != nil {
|
||||
_ = e.complete(ctx, run, Task{ID: run.TaskID, Code: run.TaskCode}, nil, getErr)
|
||||
continue
|
||||
}
|
||||
response, executeErr := e.execute(ctx, task, run)
|
||||
if executeErr != nil && run.Attempts < e.maxAttempts {
|
||||
if retryErr := e.retry(ctx, run, executeErr); retryErr != nil {
|
||||
return len(runs), retryErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if completeErr := e.complete(ctx, run, task, response, executeErr); completeErr != nil {
|
||||
return len(runs), completeErr
|
||||
}
|
||||
}
|
||||
return len(runs), nil
|
||||
}
|
||||
|
||||
func (e *Engine) scheduleDue(ctx context.Context, now time.Time) error {
|
||||
tx, err := e.service.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
rows, err := tx.Query(ctx, `SELECT id::text,cron_expression,timezone,next_run_at FROM gateway.scheduled_tasks WHERE enabled AND next_run_at <= $1 ORDER BY next_run_at,id FOR UPDATE SKIP LOCKED LIMIT $2`, now, e.batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type due struct {
|
||||
id, expression, timezone string
|
||||
scheduledFor time.Time
|
||||
}
|
||||
items := []due{}
|
||||
for rows.Next() {
|
||||
var item due
|
||||
if err = rows.Scan(&item.id, &item.expression, &item.timezone, &item.scheduledFor); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
schedule, parseErr := ParseCron(item.expression)
|
||||
location, locationErr := time.LoadLocation(item.timezone)
|
||||
if parseErr != nil || locationErr != nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET enabled=false,next_run_at=NULL,last_status='failed',last_error='cron 或时区配置无效',updated_at=clock_timestamp() WHERE id=$1`, item.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 补跑停机期间漏掉的执行:从旧的 next_run_at 起逐个 occurrence
|
||||
// 落一条 pending run,直到越过 now,而不是只补最新一次。否则调度器
|
||||
// 宕机超过一个周期后,中间所有计划执行被静默丢弃。
|
||||
runID, idErr := platformid.NewUUID()
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'scheduled',$3) ON CONFLICT(task_id,trigger_type,scheduled_for) DO NOTHING`, runID, item.id, item.scheduledFor); err != nil {
|
||||
return err
|
||||
}
|
||||
nextRun := item.scheduledFor
|
||||
var nextErr error
|
||||
inserted := 1
|
||||
for nextRun.Before(now) || nextRun.Equal(now) {
|
||||
if inserted >= catchUpLimit {
|
||||
break
|
||||
}
|
||||
nextRun, nextErr = schedule.Next(nextRun, location)
|
||||
if nextErr != nil {
|
||||
return nextErr
|
||||
}
|
||||
if nextRun.After(now) {
|
||||
break
|
||||
}
|
||||
runID, idErr = platformid.NewUUID()
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'scheduled',$3) ON CONFLICT(task_id,trigger_type,scheduled_for) DO NOTHING`, runID, item.id, nextRun); err != nil {
|
||||
return err
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
next, nextErr := schedule.Next(now, location)
|
||||
if nextErr != nil {
|
||||
return nextErr
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET next_run_at=$2,updated_at=clock_timestamp() WHERE id=$1`, item.id, next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// catchUpLimit 是单任务单次补跑的最大执行数;超过部分丢弃,防止调度器长期
|
||||
// 停机后瞬间插入海量补跑记录。
|
||||
const catchUpLimit = 100
|
||||
|
||||
func (e *Engine) reclaim(ctx context.Context, now time.Time) error {
|
||||
cutoff := now.Add(-e.executionTTL)
|
||||
tx, err := e.service.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='pending',worker_id='',started_at=NULL,error='上次执行超时,已回收重试' WHERE status='running' AND started_at < $1 AND attempts < $2`, cutoff, e.maxAttempts); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `WITH picked AS (SELECT id FROM gateway.scheduled_task_runs WHERE status='running' AND started_at < $1 AND attempts >= $2 ORDER BY started_at,id FOR UPDATE SKIP LOCKED LIMIT $3) UPDATE gateway.scheduled_task_runs r SET worker_id=$4,started_at=$5 FROM picked WHERE r.id=picked.id RETURNING r.id::text`, cutoff, e.maxAttempts, e.batchSize, e.workerID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
run, getErr := e.service.getRun(ctx, id)
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
task, taskErr := e.service.Get(ctx, run.TaskID)
|
||||
if taskErr != nil {
|
||||
task = Task{ID: run.TaskID, Code: run.TaskCode}
|
||||
}
|
||||
if err = e.complete(ctx, run, task, nil, errors.New("执行超时且达到最大重试次数")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) claim(ctx context.Context) ([]Run, error) {
|
||||
tx, err := e.service.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
rows, err := tx.Query(ctx, `WITH picked AS (SELECT id FROM gateway.scheduled_task_runs WHERE status='pending' ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT $1) UPDATE gateway.scheduled_task_runs r SET status='running',attempts=attempts+1,worker_id=$2,started_at=clock_timestamp(),error='' FROM picked WHERE r.id=picked.id RETURNING r.id::text`, e.batchSize, e.workerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runs := make([]Run, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
run, getErr := e.service.getRun(ctx, id)
|
||||
if getErr != nil {
|
||||
return nil, getErr
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func (e *Engine) conversationMessages(ctx context.Context, task Task, currentRunID string) ([]map[string]any, error) {
|
||||
messages := []map[string]any{}
|
||||
if task.ConversationID != "" {
|
||||
rows, err := e.service.pool.Query(ctx, `SELECT response FROM gateway.scheduled_task_runs WHERE task_id=$1 AND id<>$2 AND status='success' AND response IS NOT NULL ORDER BY created_at DESC LIMIT 5`, task.ID, currentRunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
answers := []string{}
|
||||
for rows.Next() {
|
||||
var response json.RawMessage
|
||||
if err = rows.Scan(&response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if answer := responseAnswer(response); answer != "" {
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
}
|
||||
for i := len(answers) - 1; i >= 0; i-- {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": task.Prompt}, map[string]any{"role": "assistant", "content": answers[i]})
|
||||
}
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": "user", "content": task.Prompt})
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func responseAnswer(raw json.RawMessage) string {
|
||||
var response map[string]any
|
||||
if json.Unmarshal(raw, &response) != nil {
|
||||
return ""
|
||||
}
|
||||
choices, _ := response["choices"].([]any)
|
||||
if len(choices) == 0 {
|
||||
return ""
|
||||
}
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
answer, _ := message["content"].(string)
|
||||
return answer
|
||||
}
|
||||
|
||||
func (e *Engine) execute(ctx context.Context, task Task, run Run) (json.RawMessage, error) {
|
||||
secret, err := e.service.decryptAPIKey(task)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密执行 API Key: %w", err)
|
||||
}
|
||||
messages, err := e.conversationMessages(ctx, task, run.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var variables map[string]any
|
||||
if json.Unmarshal(task.Variables, &variables) != nil {
|
||||
variables = map[string]any{}
|
||||
}
|
||||
payload := map[string]any{"messages": messages, "variables": variables}
|
||||
path := "/v1/applications/" + task.TargetCode + "/chat/completions"
|
||||
if task.TargetType == "digital_employee" {
|
||||
path = "/v1/digital-employees/" + task.TargetCode + "/chat/completions"
|
||||
payload["skill_ids"] = task.SkillIDs
|
||||
payload["mcp_server_ids"] = task.MCPServerIDs
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gateway-API-Key", secret)
|
||||
request.Header.Set("X-Request-ID", "scheduled-"+run.ID)
|
||||
response, err := e.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, maxExecutionResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxExecutionResponseBytes {
|
||||
return nil, errors.New("模型响应超过 2MB 上限")
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return nil, errors.New("模型响应不是合法 JSON")
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("模型调用失败(HTTP %d): %s", response.StatusCode, truncate(string(data), 1000))
|
||||
}
|
||||
return json.RawMessage(data), nil
|
||||
}
|
||||
|
||||
func truncate(value string, maximum int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= maximum {
|
||||
return value
|
||||
}
|
||||
cut := value[:maximum]
|
||||
// 按字节截断可能把多字节 rune 切半,产生无效 UTF-8;PostgreSQL text 列
|
||||
// 会拒绝写入,导致任务永远停在重试循环。回退到最近的 rune 边界。
|
||||
for len(cut) > 0 && !utf8.RuneStart(cut[len(cut)-1]) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return cut
|
||||
}
|
||||
|
||||
func (e *Engine) retry(ctx context.Context, run Run, executeErr error) error {
|
||||
errorText := truncate(executeErr.Error(), 4000)
|
||||
tag, err := e.service.pool.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='pending',worker_id='',started_at=NULL,response=NULL,error=$2 WHERE id=$1 AND status='running' AND worker_id=$3`, run.ID, errorText, e.workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("定时任务执行租约已失效")
|
||||
}
|
||||
e.logger.Warn("scheduled task execution will retry", "task", run.TaskCode, "run_id", run.ID, "attempt", run.Attempts, "error", executeErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) complete(ctx context.Context, run Run, task Task, response json.RawMessage, executeErr error) error {
|
||||
status := "success"
|
||||
errorText := ""
|
||||
eventType := "scheduled_task.completed"
|
||||
if executeErr != nil {
|
||||
status = "failed"
|
||||
eventType = "scheduled_task.failed"
|
||||
errorText = truncate(executeErr.Error(), 4000)
|
||||
e.logger.Warn("scheduled task execution failed", "task", task.Code, "run_id", run.ID, "error", executeErr)
|
||||
}
|
||||
tx, err := e.service.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
tag, err := tx.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status=$2,response=$3,error=$4,finished_at=clock_timestamp() WHERE id=$1 AND status='running' AND worker_id=$5`, run.ID, status, response, errorText, e.workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("定时任务执行租约已失效")
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET last_run_at=clock_timestamp(),last_status=$2,last_error=$3,updated_at=clock_timestamp() WHERE id=$1`, task.ID, status, errorText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"scheduled_task_id": task.ID, "task_code": task.Code, "run_id": run.ID, "status": status, "error": errorText, "actor_id": task.CreatedBy, "notification_channel_id": task.NotificationChannelID})
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'scheduled_task',$3,$4)`, eventID, eventType, run.ID, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
)
|
||||
|
||||
type AdminHTTPHandler struct {
|
||||
service *Service
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler {
|
||||
h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks", h.list)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks", h.create)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks/{id}", h.get)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/scheduled-tasks/{id}", h.update)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/scheduled-tasks/{id}", h.delete)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/start", h.start)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/pause", h.pause)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/run", h.runNow)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks/{id}/runs", h.runs)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
|
||||
|
||||
func (h *AdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) {
|
||||
account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
if !identity.HasPermission(account, permission) {
|
||||
apiresponse.Error(w, http.StatusForbidden, "缺少定时任务权限")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
return account, true
|
||||
}
|
||||
|
||||
func decodeTask(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func taskError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apiresponse.Error(w, http.StatusNotFound, "定时任务不存在")
|
||||
return
|
||||
}
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(r.Context())
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.require(w, r, identity.PermissionScheduledTaskManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input TaskInput
|
||||
if !decodeTask(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Save(r.Context(), "", input, account.ID)
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.require(w, r, identity.PermissionScheduledTaskManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input TaskInput
|
||||
if !decodeTask(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Save(r.Context(), r.PathValue("id"), input, account.ID)
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) setEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.SetEnabled(r.Context(), r.PathValue("id"), enabled)
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) start(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, true) }
|
||||
func (h *AdminHTTPHandler) pause(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, false) }
|
||||
|
||||
func (h *AdminHTTPHandler) runNow(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
|
||||
return
|
||||
}
|
||||
run, err := h.service.QueueManual(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, run)
|
||||
}
|
||||
|
||||
func (h *AdminHTTPHandler) runs(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.service.Runs(r.Context(), r.PathValue("id"), limit)
|
||||
if err != nil {
|
||||
taskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/platform/config"
|
||||
"aigateway.local/core/internal/platform/cryptox"
|
||||
"aigateway.local/core/internal/platform/database"
|
||||
)
|
||||
|
||||
func TestSchedulerPostgreSQLLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("SCHEDULER_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("SCHEDULER_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
adminID := "64444444-4444-4444-8444-444444444444"
|
||||
appID := "65555555-5555-4555-8555-555555555555"
|
||||
versionID := "66666666-6666-4666-8666-666666666666"
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.scheduled_tasks WHERE code='scheduler_test_task'`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.applications WHERE id=$1`, appID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1`, adminID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
if _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role,active) VALUES($1,'scheduler-test-admin','test','superadmin',true)`, adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configJSON := `{"model":"test-model","knowledge_base_ids":[],"tool_ids":[],"retrieval_top_k":4,"temperature":0.2,"max_tool_rounds":1}`
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.applications(id,code,name,status,draft_config,created_by) VALUES($1,'scheduler_test_app','Scheduler Test App','active',$2,$3)`, appID, configJSON, adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.application_versions(id,application_id,version,config,published_by) VALUES($1,$2,1,$3,$4)`, versionID, appID, configJSON, adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE gateway.applications SET published_version=1 WHERE id=$1`, appID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestCount := 0
|
||||
failRequests := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if r.URL.Path != "/v1/applications/scheduler_test_app/chat/completions" || r.Header.Get("X-Gateway-API-Key") != "gw_scheduler_test" {
|
||||
http.Error(w, "unexpected request", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if failRequests {
|
||||
http.Error(w, `{"error":"temporary failure"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"scheduled answer"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cipher, err := cryptox.NewKeyring("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 1, "", "scheduled-task-api-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(pool, cipher)
|
||||
task, err := service.Save(ctx, "", TaskInput{Code: "scheduler_test_task", Name: "Scheduler Test", CronExpression: "*/5 * * * *", Timezone: "UTC", TargetType: "application", TargetCode: "scheduler_test_app", Prompt: "create report", Variables: json.RawMessage(`{"scope":"daily"}`), APIKey: "gw_scheduler_test"}, adminID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !task.HasAPIKey || task.Enabled {
|
||||
t.Fatalf("unexpected task: %+v", task)
|
||||
}
|
||||
if _, err = service.QueueManual(ctx, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := NewEngine(service, server.URL, "integration-worker", 10, 3, time.Minute, nil)
|
||||
processed, err := engine.Tick(ctx, time.Now())
|
||||
if err != nil || processed != 1 || requestCount != 1 {
|
||||
t.Fatalf("tick processed=%d requests=%d err=%v", processed, requestCount, err)
|
||||
}
|
||||
runs, err := service.Runs(ctx, task.ID, 10)
|
||||
if err != nil || len(runs) != 1 || runs[0].Status != "success" || responseAnswer(runs[0].Response) != "scheduled answer" {
|
||||
t.Fatalf("runs=%+v err=%v", runs, err)
|
||||
}
|
||||
var completed bool
|
||||
if err = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.outbox_events WHERE aggregate_type='scheduled_task' AND aggregate_id=$1 AND event_type='scheduled_task.completed')`, runs[0].ID).Scan(&completed); err != nil || !completed {
|
||||
t.Fatalf("completion event=%v err=%v", completed, err)
|
||||
}
|
||||
|
||||
// Force a due schedule in the past. Tick must enqueue it once, execute it,
|
||||
// and advance next_run_at beyond now rather than replay every missed slot.
|
||||
if _, err = service.SetEnabled(ctx, task.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forcedNow := time.Now().UTC()
|
||||
if _, err = pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET next_run_at=$2 WHERE id=$1`, task.ID, forcedNow.Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processed, err = engine.Tick(ctx, forcedNow)
|
||||
if err != nil || processed != 1 || requestCount != 2 {
|
||||
t.Fatalf("due tick processed=%d requests=%d err=%v", processed, requestCount, err)
|
||||
}
|
||||
task, err = service.Get(ctx, task.ID)
|
||||
if err != nil || task.NextRunAt == nil || !task.NextRunAt.After(forcedNow) {
|
||||
t.Fatalf("next run was not advanced: %+v err=%v", task.NextRunAt, err)
|
||||
}
|
||||
|
||||
// Ordinary gateway failures remain pending until the configured attempt
|
||||
// limit, then become failed and emit exactly one terminal event.
|
||||
if _, err = service.SetEnabled(ctx, task.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failRequests = true
|
||||
failedRun, err := service.QueueManual(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
processed, err = engine.Tick(ctx, time.Now())
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("retry tick attempt=%d processed=%d err=%v", attempt, processed, err)
|
||||
}
|
||||
current, getErr := service.getRun(ctx, failedRun.ID)
|
||||
wantStatus := "pending"
|
||||
if attempt == 3 {
|
||||
wantStatus = "failed"
|
||||
}
|
||||
if getErr != nil || current.Status != wantStatus || current.Attempts != attempt {
|
||||
t.Fatalf("retry attempt=%d run=%+v err=%v", attempt, current, getErr)
|
||||
}
|
||||
}
|
||||
var failedEvents int
|
||||
if err = pool.QueryRow(ctx, `SELECT count(*) FROM gateway.outbox_events WHERE aggregate_type='scheduled_task' AND aggregate_id=$1 AND event_type='scheduled_task.failed'`, failedRun.ID).Scan(&failedEvents); err != nil || failedEvents != 1 {
|
||||
t.Fatalf("failure events=%d err=%v", failedEvents, err)
|
||||
}
|
||||
|
||||
// A worker lease that stays running past its timeout is finalized through
|
||||
// the same failed-run and outbox path once it reaches the attempt limit.
|
||||
staleRun, err := service.QueueManual(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = pool.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='running',attempts=3,worker_id='dead-worker',started_at=$2 WHERE id=$1`, staleRun.ID, time.Now().Add(-2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processed, err = engine.Tick(ctx, time.Now())
|
||||
if err != nil || processed != 0 {
|
||||
t.Fatalf("stale tick processed=%d err=%v", processed, err)
|
||||
}
|
||||
staleRun, err = service.getRun(ctx, staleRun.ID)
|
||||
if err != nil || staleRun.Status != "failed" || staleRun.Error != "执行超时且达到最大重试次数" {
|
||||
t.Fatalf("stale run=%+v err=%v", staleRun, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/apikey"
|
||||
"aigateway.local/core/internal/platform/cryptox"
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("scheduled task not found")
|
||||
codePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
|
||||
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CronExpression string `json:"cron_expression"`
|
||||
Timezone string `json:"timezone"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetCode string `json:"target_code"`
|
||||
Prompt string `json:"prompt"`
|
||||
Variables json.RawMessage `json:"variables"`
|
||||
SkillIDs []string `json:"skill_ids"`
|
||||
MCPServerIDs []string `json:"mcp_server_ids"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
NotificationChannelID *string `json:"notification_channel_id,omitempty"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastStatus string `json:"last_status"`
|
||||
LastError string `json:"last_error"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
encryptedAPIKey []byte
|
||||
apiKeyKEKVersion int
|
||||
}
|
||||
|
||||
type TaskInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CronExpression string `json:"cron_expression"`
|
||||
Timezone string `json:"timezone"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetCode string `json:"target_code"`
|
||||
Prompt string `json:"prompt"`
|
||||
Variables json.RawMessage `json:"variables"`
|
||||
SkillIDs []string `json:"skill_ids"`
|
||||
MCPServerIDs []string `json:"mcp_server_ids"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
NotificationChannelID *string `json:"notification_channel_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskCode string `json:"task_code"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
ScheduledFor time.Time `json:"scheduled_for"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Response json.RawMessage `json:"response,omitempty"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
cipher cryptox.Cipher
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, cipher cryptox.Cipher) *Service {
|
||||
return &Service{pool: pool, cipher: cipher, now: time.Now}
|
||||
}
|
||||
|
||||
const taskSelect = `SELECT id::text,code,name,description,cron_expression,timezone,target_type,target_code,prompt,variables,skill_ids::text[],mcp_server_ids::text[],conversation_id,notification_channel_id::text,encrypted_api_key,api_key_kek_version,enabled,next_run_at,last_run_at,last_status,last_error,created_by::text,revision,created_at,updated_at FROM gateway.scheduled_tasks`
|
||||
|
||||
func scanTask(row pgx.Row) (Task, error) {
|
||||
var task Task
|
||||
err := row.Scan(&task.ID, &task.Code, &task.Name, &task.Description, &task.CronExpression, &task.Timezone, &task.TargetType, &task.TargetCode, &task.Prompt, &task.Variables, &task.SkillIDs, &task.MCPServerIDs, &task.ConversationID, &task.NotificationChannelID, &task.encryptedAPIKey, &task.apiKeyKEKVersion, &task.Enabled, &task.NextRunAt, &task.LastRunAt, &task.LastStatus, &task.LastError, &task.CreatedBy, &task.Revision, &task.CreatedAt, &task.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Task{}, ErrNotFound
|
||||
}
|
||||
task.HasAPIKey = len(task.encryptedAPIKey) > 0
|
||||
if task.SkillIDs == nil {
|
||||
task.SkillIDs = []string{}
|
||||
}
|
||||
if task.MCPServerIDs == nil {
|
||||
task.MCPServerIDs = []string{}
|
||||
}
|
||||
return task, err
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Task, error) {
|
||||
rows, err := s.pool.Query(ctx, taskSelect+` ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Task{}
|
||||
for rows.Next() {
|
||||
item, scanErr := scanTask(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (Task, error) {
|
||||
return scanTask(s.pool.QueryRow(ctx, taskSelect+` WHERE id=$1`, id))
|
||||
}
|
||||
|
||||
func normalizeIDs(values []string, maximum int) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
if !uuidPattern.MatchString(value) {
|
||||
return nil, errors.New("资源 ID 格式无效")
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
if len(result) > maximum {
|
||||
return nil, fmt.Errorf("资源绑定最多允许 %d 项", maximum)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) validate(ctx context.Context, input *TaskInput, current *Task) (time.Time, []byte, int, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Description = strings.TrimSpace(input.Description)
|
||||
input.CronExpression = strings.TrimSpace(input.CronExpression)
|
||||
input.Timezone = strings.TrimSpace(input.Timezone)
|
||||
input.TargetType = strings.TrimSpace(input.TargetType)
|
||||
input.TargetCode = strings.ToLower(strings.TrimSpace(input.TargetCode))
|
||||
input.Prompt = strings.TrimSpace(input.Prompt)
|
||||
input.ConversationID = strings.TrimSpace(input.ConversationID)
|
||||
if !codePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 {
|
||||
return time.Time{}, nil, 0, errors.New("任务编码、名称或描述格式无效")
|
||||
}
|
||||
if len(input.Prompt) < 1 || len(input.Prompt) > 100000 || len(input.ConversationID) > 128 {
|
||||
return time.Time{}, nil, 0, errors.New("提示词或会话 ID 格式无效")
|
||||
}
|
||||
if input.Timezone == "" {
|
||||
input.Timezone = "UTC"
|
||||
}
|
||||
location, err := time.LoadLocation(input.Timezone)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, 0, errors.New("时区名称无效")
|
||||
}
|
||||
schedule, err := ParseCron(input.CronExpression)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, 0, err
|
||||
}
|
||||
next, err := schedule.Next(s.now(), location)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, 0, err
|
||||
}
|
||||
if len(input.Variables) == 0 {
|
||||
input.Variables = json.RawMessage(`{}`)
|
||||
}
|
||||
var variables map[string]any
|
||||
if json.Unmarshal(input.Variables, &variables) != nil {
|
||||
return time.Time{}, nil, 0, errors.New("变量必须是 JSON 对象")
|
||||
}
|
||||
input.Variables, _ = json.Marshal(variables)
|
||||
if input.SkillIDs, err = normalizeIDs(input.SkillIDs, 100); err != nil {
|
||||
return time.Time{}, nil, 0, err
|
||||
}
|
||||
if input.MCPServerIDs, err = normalizeIDs(input.MCPServerIDs, 100); err != nil {
|
||||
return time.Time{}, nil, 0, err
|
||||
}
|
||||
if err = s.validateTarget(ctx, input); err != nil {
|
||||
return time.Time{}, nil, 0, err
|
||||
}
|
||||
if input.NotificationChannelID != nil {
|
||||
trimmed := strings.TrimSpace(*input.NotificationChannelID)
|
||||
if trimmed == "" {
|
||||
input.NotificationChannelID = nil
|
||||
} else {
|
||||
var exists bool
|
||||
if err = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.notification_channels WHERE id=$1 AND enabled)`, trimmed).Scan(&exists); err != nil || !exists {
|
||||
return time.Time{}, nil, 0, errors.New("通知渠道不存在或未启用")
|
||||
}
|
||||
input.NotificationChannelID = &trimmed
|
||||
}
|
||||
}
|
||||
secret := strings.TrimSpace(input.APIKey)
|
||||
if secret == "" && current == nil {
|
||||
return time.Time{}, nil, 0, errors.New("首次创建必须填写执行 API Key")
|
||||
}
|
||||
if secret == "" {
|
||||
return next, current.encryptedAPIKey, current.apiKeyKEKVersion, nil
|
||||
}
|
||||
if len(secret) > 512 {
|
||||
return time.Time{}, nil, 0, errors.New("执行 API Key 过长")
|
||||
}
|
||||
encrypted, version, err := s.cipher.Encrypt([]byte(secret))
|
||||
if err != nil {
|
||||
return time.Time{}, nil, 0, fmt.Errorf("加密执行 API Key: %w", err)
|
||||
}
|
||||
return next, encrypted, version, nil
|
||||
}
|
||||
|
||||
func subset(selected, allowed []string) bool {
|
||||
set := map[string]bool{}
|
||||
for _, id := range allowed {
|
||||
set[id] = true
|
||||
}
|
||||
for _, id := range selected {
|
||||
if !set[id] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) validateTarget(ctx context.Context, input *TaskInput) error {
|
||||
switch input.TargetType {
|
||||
case "application":
|
||||
if len(input.SkillIDs) > 0 || len(input.MCPServerIDs) > 0 {
|
||||
return errors.New("应用任务不支持额外绑定 Skill 或 MCP")
|
||||
}
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.applications WHERE code=$1 AND status='active' AND published_version IS NOT NULL)`, input.TargetCode).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
return errors.New("目标应用不存在或未发布")
|
||||
}
|
||||
var departmentIDs []string
|
||||
_ = s.pool.QueryRow(ctx, `SELECT COALESCE(department_ids,'{}'::text[]) FROM gateway.applications WHERE code=$1`, input.TargetCode).Scan(&departmentIDs)
|
||||
if len(departmentIDs) > 0 {
|
||||
if err := s.requireKeyTenant(ctx, input.APIKey, departmentIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "digital_employee":
|
||||
var skillIDs, mcpIDs, departmentIDs []string
|
||||
var enabled bool
|
||||
var status string
|
||||
err := s.pool.QueryRow(ctx, `SELECT skill_ids::text[],mcp_server_ids::text[],enabled,status FROM gateway.digital_employees WHERE code=$1`, input.TargetCode).Scan(&skillIDs, &mcpIDs, &enabled, &status)
|
||||
if err != nil || !enabled || status != "published" {
|
||||
return errors.New("目标数字员工不存在或未发布")
|
||||
}
|
||||
if !subset(input.SkillIDs, skillIDs) || !subset(input.MCPServerIDs, mcpIDs) {
|
||||
return errors.New("任务选择的 Skill/MCP 必须已绑定到目标数字员工")
|
||||
}
|
||||
_ = s.pool.QueryRow(ctx, `SELECT COALESCE(department_ids,'{}'::text[]) FROM gateway.digital_employees WHERE code=$1`, input.TargetCode).Scan(&departmentIDs)
|
||||
if len(departmentIDs) > 0 {
|
||||
if err := s.requireKeyTenant(ctx, input.APIKey, departmentIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("目标类型必须是 application 或 digital_employee")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireKeyTenant 校验任务 API Key 的部门归属能访问部门限定目标,在保存
|
||||
// 阶段就失败,而不是让任务创建成功后永远执行失败(执行 key 无 tenant 时
|
||||
// 运行时对部门限定资源一律不可见)。secret 为空(更新时沿用旧 key)跳过。
|
||||
func (s *Service) requireKeyTenant(ctx context.Context, secret string, departmentIDs []string) error {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
hash, _ := apikey.Digest(secret)
|
||||
var tenant *string
|
||||
err := s.pool.QueryRow(ctx, `SELECT tenant_id::text FROM gateway.api_keys WHERE key_hash=$1 AND enabled`, hash).Scan(&tenant)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errors.New("执行 API Key 不存在或已停用")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenant == nil {
|
||||
return errors.New("目标资源按部门限定,但执行 API Key 未绑定部门;请使用该部门下的 API Key")
|
||||
}
|
||||
for _, id := range departmentIDs {
|
||||
if id == *tenant {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("执行 API Key 所属部门与目标资源部门不匹配")
|
||||
}
|
||||
|
||||
func (s *Service) Save(ctx context.Context, id string, input TaskInput, actorID string) (Task, error) {
|
||||
var current *Task
|
||||
if id != "" {
|
||||
item, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
current = &item
|
||||
}
|
||||
next, encrypted, version, err := s.validate(ctx, &input, current)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if id == "" {
|
||||
id, err = platformid.NewUUID()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.scheduled_tasks(id,code,name,description,cron_expression,timezone,target_type,target_code,prompt,variables,skill_ids,mcp_server_ids,conversation_id,notification_channel_id,encrypted_api_key,api_key_kek_version,enabled,next_run_at,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, id, input.Code, input.Name, input.Description, input.CronExpression, input.Timezone, input.TargetType, input.TargetCode, input.Prompt, input.Variables, input.SkillIDs, input.MCPServerIDs, input.ConversationID, input.NotificationChannelID, encrypted, version, input.Enabled, nullableNext(input.Enabled, next), actorID)
|
||||
} else {
|
||||
_, err = s.pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET code=$2,name=$3,description=$4,cron_expression=$5,timezone=$6,target_type=$7,target_code=$8,prompt=$9,variables=$10,skill_ids=$11,mcp_server_ids=$12,conversation_id=$13,notification_channel_id=$14,encrypted_api_key=$15,api_key_kek_version=$16,enabled=$17,next_run_at=$18,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.CronExpression, input.Timezone, input.TargetType, input.TargetCode, input.Prompt, input.Variables, input.SkillIDs, input.MCPServerIDs, input.ConversationID, input.NotificationChannelID, encrypted, version, input.Enabled, nullableNext(input.Enabled, next))
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func nullableNext(enabled bool, next time.Time) any {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Service) SetEnabled(ctx context.Context, id string, enabled bool) (Task, error) {
|
||||
task, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
var next any
|
||||
if enabled {
|
||||
location, _ := time.LoadLocation(task.Timezone)
|
||||
schedule, _ := ParseCron(task.CronExpression)
|
||||
value, nextErr := schedule.Next(s.now(), location)
|
||||
if nextErr != nil {
|
||||
return Task{}, nextErr
|
||||
}
|
||||
next = value
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET enabled=$2,next_run_at=$3,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, enabled, next)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return Task{}, ErrNotFound
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.scheduled_tasks WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) QueueManual(ctx context.Context, id string) (Run, error) {
|
||||
if _, err := s.Get(ctx, id); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
runID, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'manual',$3)`, runID, id, now)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return s.getRun(ctx, runID)
|
||||
}
|
||||
|
||||
const runSelect = `SELECT r.id::text,r.task_id::text,t.code,r.trigger_type,r.scheduled_for,r.status,r.attempts,r.worker_id,r.started_at,r.finished_at,r.response,r.error,r.created_at FROM gateway.scheduled_task_runs r JOIN gateway.scheduled_tasks t ON t.id=r.task_id`
|
||||
|
||||
func scanRun(row pgx.Row) (Run, error) {
|
||||
var run Run
|
||||
err := row.Scan(&run.ID, &run.TaskID, &run.TaskCode, &run.TriggerType, &run.ScheduledFor, &run.Status, &run.Attempts, &run.WorkerID, &run.StartedAt, &run.FinishedAt, &run.Response, &run.Error, &run.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Run{}, ErrNotFound
|
||||
}
|
||||
return run, err
|
||||
}
|
||||
|
||||
func (s *Service) getRun(ctx context.Context, id string) (Run, error) {
|
||||
return scanRun(s.pool.QueryRow(ctx, runSelect+` WHERE r.id=$1`, id))
|
||||
}
|
||||
|
||||
func (s *Service) Runs(ctx context.Context, taskID string, limit int) ([]Run, error) {
|
||||
if limit < 1 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, runSelect+` WHERE r.task_id=$1 ORDER BY r.created_at DESC LIMIT $2`, taskID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Run{}
|
||||
for rows.Next() {
|
||||
item, scanErr := scanRun(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) decryptAPIKey(task Task) (string, error) {
|
||||
plain, err := s.cipher.Decrypt(task.encryptedAPIKey, task.apiKeyKEKVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
Reference in New Issue
Block a user