From 81c1a7ead226f1c814a7cffdcf29b5770aa21996 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 2 Sep 2026 23:12:48 +0800 Subject: [PATCH] fix: add scheduled job execution history (#199) --- docs/02-architecture-and-code-map.md | 20 +-- docs/13-deployment-and-operations.md | 11 +- server/app/jobs/apis/execution_log.go | 87 ++++++++++ server/app/jobs/execution_log.go | 132 +++++++++++++++ server/app/jobs/execution_log_test.go | 113 +++++++++++++ server/app/jobs/jobbase.go | 55 +++--- .../app/jobs/models/sys_job_execution_log.go | 32 ++++ server/app/jobs/router/sys_job.go | 2 + server/app/jobs/service/execution_log.go | 107 ++++++++++++ server/app/jobs/service/execution_log_test.go | 87 ++++++++++ server/app/jobs/service/sys_job.go | 1 + .../1788357000000_sys_job_execution_log.go | 68 ++++++++ ...788357000000_sys_job_execution_log_test.go | 78 +++++++++ web/src/api/job/sys-job.js | 10 +- web/src/views/schedule/execution-log.js | 24 +++ web/src/views/schedule/index.vue | 6 +- web/src/views/schedule/log.vue | 157 ++++++++++-------- web/tests/e2e/schedule-execution-log.spec.ts | 39 +++++ web/tests/unit/schedule-execution-log.spec.js | 26 +++ 19 files changed, 947 insertions(+), 108 deletions(-) create mode 100644 server/app/jobs/apis/execution_log.go create mode 100644 server/app/jobs/execution_log.go create mode 100644 server/app/jobs/execution_log_test.go create mode 100644 server/app/jobs/models/sys_job_execution_log.go create mode 100644 server/app/jobs/service/execution_log.go create mode 100644 server/app/jobs/service/execution_log_test.go create mode 100644 server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go create mode 100644 server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log_test.go create mode 100644 web/src/views/schedule/execution-log.js create mode 100644 web/tests/e2e/schedule-execution-log.spec.ts create mode 100644 web/tests/unit/schedule-execution-log.spec.js diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 94a0e3b..b347d0d 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,16 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 568d40fd882dc520e247226c4dd4d5c6b03122d9 -synchronized_at: 2026-09-02T13:23:01Z - - - -generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) -wiki_page: Architecture-and-Code-Map -wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a -synchronized_at: 2026-08-29T01:39:35Z +wiki_revision: a8ea0f6ae4c4a05b10ba047516fda23cfb188d13 +synchronized_at: 2026-09-02T15:05:31Z # 架构与代码地图 @@ -333,3 +325,11 @@ PddProductDetailCollector - `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。 - `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。 - Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。 + +## 定时任务持久化执行历史(#199) + +- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。 +- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。 +- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。 +- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。 +- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index 272739d..5076497 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- -wiki_revision: 6b6458ea30bc2140ad39dbf0c28c0d5896ee7618 -synchronized_at: 2026-09-02T13:23:38Z +wiki_revision: 16d97e7596dc287786f702c71e25172b75cc4d56 +synchronized_at: 2026-09-02T15:05:56Z # 部署与运维 @@ -63,3 +63,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 - 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。 - 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。 - 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。 + +## 定时任务执行历史运维(#199) + +- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。 +- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。 +- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。 +- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。 diff --git a/server/app/jobs/apis/execution_log.go b/server/app/jobs/apis/execution_log.go new file mode 100644 index 0000000..86cf9cd --- /dev/null +++ b/server/app/jobs/apis/execution_log.go @@ -0,0 +1,87 @@ +package apis + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + jobservice "go-admin/app/jobs/service" +) + +func (e SysJob) ListExecutionLogs(c *gin.Context) { + jobID, err := strconv.Atoi(c.Param("id")) + if err != nil || jobID < 1 { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "jobId 无效"}) + return + } + page, err := positiveQueryInt(c.Query("pageIndex"), 1) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageIndex 必须是正整数"}) + return + } + pageSize, err := positiveQueryInt(c.Query("pageSize"), 20) + if err != nil || pageSize > 100 { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageSize 必须是 1 到 100 的整数"}) + return + } + startedFrom, err := optionalTime(c.Query("startedFrom")) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedFrom 必须是 RFC3339 时间"}) + return + } + startedTo, err := optionalTime(c.Query("startedTo")) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedTo 必须是 RFC3339 时间"}) + return + } + + e.MakeContext(c) + db, err := e.GetOrm() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"}) + return + } + response, err := jobservice.NewExecutionLogService(db).List(c.Request.Context(), jobID, jobservice.ExecutionLogListRequest{ + Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")), + StartedFrom: startedFrom, StartedTo: startedTo, + }) + if err != nil { + switch { + case errors.Is(err, jobservice.ErrExecutionLogInvalidRequest): + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": err.Error()}) + case errors.Is(err, jobservice.ErrExecutionLogJobNotFound): + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "定时任务不存在"}) + default: + e.GetLogger().Errorf("list scheduled job execution logs failed job_id=%d: %v", jobID, err) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + +func positiveQueryInt(value string, fallback int) (int, error) { + if strings.TrimSpace(value) == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 { + return 0, errors.New("invalid positive integer") + } + return parsed, nil +} + +func optionalTime(value string) (*time.Time, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, err + } + return &parsed, nil +} diff --git a/server/app/jobs/execution_log.go b/server/app/jobs/execution_log.go new file mode 100644 index 0000000..6f305c6 --- /dev/null +++ b/server/app/jobs/execution_log.go @@ -0,0 +1,132 @@ +package jobs + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/go-admin-team/go-admin-core/logger" + "github.com/google/uuid" + "gorm.io/gorm" + + "go-admin/app/jobs/models" +) + +const ( + executionErrorTargetMissing = "JOB_TARGET_NOT_FOUND" + executionErrorExecFailed = "JOB_EXECUTION_FAILED" + executionErrorHTTPFailed = "JOB_HTTP_FAILED" + executionErrorPanicked = "JOB_EXECUTION_PANICKED" + executionErrorInterrupted = "JOB_INTERRUPTED" +) + +type executionFailure struct { + code string + message string + cause error +} + +func (failure *executionFailure) Error() string { + if failure.cause != nil { + return failure.cause.Error() + } + return failure.message +} + +func newExecutionFailure(code, message string, cause error) error { + return &executionFailure{code: code, message: message, cause: cause} +} + +func runWithExecutionLog(db *gorm.DB, core JobCore, execute func() error) (executionErr error) { + startedAt := time.Now().UTC() + record := &models.SysJobExecutionLog{ + ExecutionID: uuid.NewString(), JobID: core.JobId, + JobNameSnapshot: core.Name, InvokeTargetSnapshot: core.InvokeTarget, + TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, + StartedAt: startedAt, + } + created := false + if db != nil { + if err := db.WithContext(context.Background()).Create(record).Error; err != nil { + log.Errorf("[Job] execution log create failed job_id=%d: %v", core.JobId, err) + } else { + created = true + } + } + + defer func() { + if recovered := recover(); recovered != nil { + executionErr = newExecutionFailure(executionErrorPanicked, "任务执行异常中断", nil) + if created { + finishExecutionLog(db, core.JobId, record, startedAt, executionErr) + } + return + } + if created { + finishExecutionLog(db, core.JobId, record, startedAt, executionErr) + } + }() + executionErr = execute() + return executionErr +} + +func finishExecutionLog(db *gorm.DB, jobID int, record *models.SysJobExecutionLog, startedAt time.Time, executionErr error) { + finishedAt := time.Now().UTC() + updates := map[string]any{ + "status": models.JobExecutionSucceeded, "finished_at": finishedAt, + "duration_ms": finishedAt.Sub(startedAt).Milliseconds(), "error_code": "", "error_message": "", + } + if executionErr != nil { + code, message := publicExecutionFailure(executionErr) + updates["status"] = models.JobExecutionFailed + updates["error_code"] = code + updates["error_message"] = message + } + if err := db.WithContext(context.Background()).Model(&models.SysJobExecutionLog{}). + Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(updates).Error; err != nil { + log.Errorf("[Job] execution log finish failed job_id=%d execution_id=%s: %v", jobID, record.ExecutionID, err) + } +} + +func publicExecutionFailure(err error) (string, string) { + var failure *executionFailure + if errors.As(err, &failure) { + return failure.code, failure.message + } + return executionErrorExecFailed, "任务执行失败,请查看受控服务日志" +} + +// RecoverInterruptedExecutionLogs closes invocations left running by the +// previous process. Production currently runs one scheduler per database. +func RecoverInterruptedExecutionLogs(db *gorm.DB) error { + if db == nil { + return nil + } + now := time.Now().UTC() + var records []models.SysJobExecutionLog + if err := db.WithContext(context.Background()).Where("status = ?", models.JobExecutionRunning).Find(&records).Error; err != nil { + return err + } + return db.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error { + for _, record := range records { + duration := now.Sub(record.StartedAt).Milliseconds() + if duration < 0 { + duration = 0 + } + if err := tx.Model(&models.SysJobExecutionLog{}). + Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(map[string]any{ + "status": models.JobExecutionInterrupted, "finished_at": now, + "duration_ms": duration, "error_code": executionErrorInterrupted, + "error_message": "服务重启前任务未完成", + }).Error; err != nil { + return err + } + } + return nil + }) +} + +func missingExecutionTarget(target string) error { + return newExecutionFailure(executionErrorTargetMissing, "任务调用目标未注册", fmt.Errorf("job target %q is not registered", target)) +} diff --git a/server/app/jobs/execution_log_test.go b/server/app/jobs/execution_log_test.go new file mode 100644 index 0000000..51bf61d --- /dev/null +++ b/server/app/jobs/execution_log_test.go @@ -0,0 +1,113 @@ +package jobs + +import ( + "errors" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "go-admin/app/jobs/models" +) + +func jobExecutionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&models.SysJobExecutionLog{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestRunWithExecutionLogRecordsSuccessAndSanitizedFailure(t *testing.T) { + db := jobExecutionTestDB(t) + core := JobCore{JobId: 7, Name: "测试任务", InvokeTarget: "TestTarget"} + if err := runWithExecutionLog(db, core, func() error { return nil }); err != nil { + t.Fatal(err) + } + rawSecret := "token=secret-value productSpec=private" + if err := runWithExecutionLog(db, core, func() error { return errors.New(rawSecret) }); err == nil { + t.Fatal("failed execution must return its error") + } + var records []models.SysJobExecutionLog + if err := db.Order("id asc").Find(&records).Error; err != nil { + t.Fatal(err) + } + if len(records) != 2 || records[0].Status != models.JobExecutionSucceeded || records[1].Status != models.JobExecutionFailed { + t.Fatalf("unexpected records: %+v", records) + } + if records[1].ErrorCode != executionErrorExecFailed || records[1].ErrorMessage == rawSecret || records[1].ErrorMessage == "" { + t.Fatalf("failure was not safely summarized: %+v", records[1]) + } + if records[0].FinishedAt == nil || records[1].FinishedAt == nil || records[0].ExecutionID == records[1].ExecutionID { + t.Fatal("execution lifecycle or unique IDs were not recorded") + } +} + +func TestRecoverInterruptedExecutionLogs(t *testing.T) { + db := jobExecutionTestDB(t) + started := time.Now().UTC().Add(-2 * time.Second) + record := models.SysJobExecutionLog{ + ExecutionID: "00000000-0000-4000-8000-000000000010", JobID: 9, + JobNameSnapshot: "中断任务", InvokeTargetSnapshot: "Interrupted", + TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, StartedAt: started, + } + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + if err := RecoverInterruptedExecutionLogs(db); err != nil { + t.Fatal(err) + } + if err := db.First(&record, record.ID).Error; err != nil { + t.Fatal(err) + } + if record.Status != models.JobExecutionInterrupted || record.FinishedAt == nil || record.ErrorCode != executionErrorInterrupted || record.DurationMS < 1000 { + t.Fatalf("record was not safely interrupted: %+v", record) + } +} + +func TestMissingExecutionTargetHasPublicSafeMessage(t *testing.T) { + code, message := publicExecutionFailure(missingExecutionTarget("SecretTarget")) + if code != executionErrorTargetMissing || message != "任务调用目标未注册" { + t.Fatalf("unexpected target failure %s %s", code, message) + } +} + +func TestExecJobRecordsMissingTargetAsFailure(t *testing.T) { + db := jobExecutionTestDB(t) + previousJobList := jobList + jobList = map[string]JobExec{} + t.Cleanup(func() { jobList = previousJobList }) + + job := &ExecJob{JobCore: JobCore{JobId: 10, Name: "未注册任务", InvokeTarget: "MissingTarget"}, DB: db} + job.Run() + + var record models.SysJobExecutionLog + if err := db.First(&record).Error; err != nil { + t.Fatal(err) + } + if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorTargetMissing || record.ErrorMessage != "任务调用目标未注册" { + t.Fatalf("missing target was not safely recorded: %+v", record) + } +} + +func TestRunWithExecutionLogSafelyRecordsPanic(t *testing.T) { + db := jobExecutionTestDB(t) + err := runWithExecutionLog(db, JobCore{JobId: 11, Name: "异常任务", InvokeTarget: "Panic"}, func() error { + panic("provider-secret") + }) + if code, message := publicExecutionFailure(err); code != executionErrorPanicked || message != "任务执行异常中断" { + t.Fatalf("panic was not returned as a safe failure: %s %s", code, message) + } + var record models.SysJobExecutionLog + if err := db.First(&record).Error; err != nil { + t.Fatal(err) + } + if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorPanicked || record.ErrorMessage != "任务执行异常中断" { + t.Fatalf("panic was not safely persisted: %+v", record) + } +} diff --git a/server/app/jobs/jobbase.go b/server/app/jobs/jobbase.go index 3b3a613..f2020b8 100644 --- a/server/app/jobs/jobbase.go +++ b/server/app/jobs/jobbase.go @@ -33,6 +33,7 @@ type JobCore struct { // HttpJob 任务类型 http type HttpJob struct { JobCore + DB *gorm.DB } type ExecJob struct { @@ -42,14 +43,16 @@ type ExecJob struct { func (e *ExecJob) Run() { startTime := time.Now() - var obj = jobList[e.InvokeTarget] - if obj == nil { - log.Warn("[Job] ExecJob Run job nil") - return - } - err := CallExecWithDB(obj.(JobExec), e.DB, e.Args) + err := runWithExecutionLog(e.DB, e.JobCore, func() error { + obj := jobList[e.InvokeTarget] + if obj == nil { + return missingExecutionTarget(e.InvokeTarget) + } + return CallExecWithDB(obj, e.DB, e.Args) + }) if err != nil { - log.Errorf("[Job] JobCore %s failed: %v", e.Name, err) + code, _ := publicExecutionFailure(err) + log.Errorf("[Job] JobCore %s failed error_code=%s", e.Name, code) return } // 结束时间 @@ -68,22 +71,26 @@ func (e *ExecJob) Run() { func (h *HttpJob) Run() { startTime := time.Now() - var count = 0 - var err error - var str string - /* 循环 */ -LOOP: - if count < retryCount { - /* 跳过迭代 */ - str, err = pkg.Get(h.InvokeTarget) - if err != nil { - // 如果失败暂停一段时间重试 - log.Warnf("[Job] mission failed! %v", err) - log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str) - time.Sleep(time.Duration(count+1) * 5 * time.Second) - count = count + 1 - goto LOOP + err := runWithExecutionLog(h.DB, h.JobCore, func() error { + var lastErr error + for count := 0; count < retryCount; count++ { + _, requestErr := pkg.Get(h.InvokeTarget) + if requestErr == nil { + return nil + } + lastErr = requestErr + log.Warnf("[Job] HTTP mission failed attempt=%d", count+1) + if count+1 < retryCount { + log.Warnf("[Job] Retry after the task fails %d seconds!\n", (count+1)*5) + time.Sleep(time.Duration(count+1) * 5 * time.Second) + } } + return newExecutionFailure(executionErrorHTTPFailed, "HTTP 任务请求失败", lastErr) + }) + if err != nil { + code, _ := publicExecutionFailure(err) + log.Errorf("[Job] JobCore %s failed error_code=%s", h.Name, code) + return } // 结束时间 endTime := time.Now() @@ -102,6 +109,9 @@ func Setup(dbs map[string]*gorm.DB) { fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...") for k, db := range dbs { + if err := RecoverInterruptedExecutionLogs(db); err != nil { + log.Errorf("[Job] recover interrupted execution logs failed: %v", err) + } sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds()) setup(k, db) } @@ -127,6 +137,7 @@ func setup(key string, db *gorm.DB) { for i := 0; i < len(jobList); i++ { if jobList[i].JobType == 1 { j := &HttpJob{} + j.DB = db j.InvokeTarget = jobList[i].InvokeTarget j.CronExpression = jobList[i].CronExpression j.JobId = jobList[i].JobId diff --git a/server/app/jobs/models/sys_job_execution_log.go b/server/app/jobs/models/sys_job_execution_log.go new file mode 100644 index 0000000..2b79d01 --- /dev/null +++ b/server/app/jobs/models/sys_job_execution_log.go @@ -0,0 +1,32 @@ +package models + +import "time" + +const ( + JobExecutionRunning = "running" + JobExecutionSucceeded = "succeeded" + JobExecutionFailed = "failed" + JobExecutionInterrupted = "interrupted" + JobTriggerScheduled = "scheduled" +) + +// SysJobExecutionLog stores one scheduler invocation. Job arguments and raw +// provider responses are deliberately excluded from this audit record. +type SysJobExecutionLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ExecutionID string `json:"executionId" gorm:"size:36;not null;uniqueIndex:ux_sys_job_execution_id"` + JobID int `json:"jobId" gorm:"not null;index:idx_sys_job_execution_job_started,priority:1"` + JobNameSnapshot string `json:"jobName" gorm:"size:255;not null"` + InvokeTargetSnapshot string `json:"invokeTarget" gorm:"size:255;not null"` + TriggerType string `json:"triggerType" gorm:"size:16;not null"` + Status string `json:"status" gorm:"size:16;not null;index:idx_sys_job_execution_status_started,priority:1"` + StartedAt time.Time `json:"startedAt" gorm:"not null;index:idx_sys_job_execution_job_started,priority:2;index:idx_sys_job_execution_status_started,priority:2"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + DurationMS int64 `json:"durationMs" gorm:"not null;default:0"` + ErrorCode string `json:"errorCode,omitempty" gorm:"size:64;not null;default:''"` + ErrorMessage string `json:"errorMessage,omitempty" gorm:"size:500;not null;default:''"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SysJobExecutionLog) TableName() string { return "sys_job_execution_log" } diff --git a/server/app/jobs/router/sys_job.go b/server/app/jobs/router/sys_job.go index 89723d4..6bddaab 100644 --- a/server/app/jobs/router/sys_job.go +++ b/server/app/jobs/router/sys_job.go @@ -24,6 +24,8 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew list := make([]models2.SysJob, 0) return &list })) + jobAPI := apis.SysJob{} + r.GET("/:id/execution-logs", actions.PermissionAction(), jobAPI.ListExecutionLogs) r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} { return &dto2.SysJobItem{} })) diff --git a/server/app/jobs/service/execution_log.go b/server/app/jobs/service/execution_log.go new file mode 100644 index 0000000..69d76d0 --- /dev/null +++ b/server/app/jobs/service/execution_log.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "go-admin/app/jobs/models" +) + +var ( + ErrExecutionLogInvalidRequest = errors.New("invalid execution log request") + ErrExecutionLogJobNotFound = errors.New("scheduled job not found") +) + +type ExecutionLogListRequest struct { + Page int + PageSize int + Status string + StartedFrom *time.Time + StartedTo *time.Time +} + +type ExecutionLogJob struct { + JobID int `json:"jobId"` + JobName string `json:"jobName"` + InvokeTarget string `json:"invokeTarget"` + Deleted bool `json:"deleted"` +} + +type ExecutionLogListResponse struct { + Job ExecutionLogJob `json:"job"` + Items []models.SysJobExecutionLog `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type ExecutionLogService struct{ db *gorm.DB } + +func NewExecutionLogService(db *gorm.DB) *ExecutionLogService { + return &ExecutionLogService{db: db} +} + +func (service *ExecutionLogService) List(ctx context.Context, jobID int, request ExecutionLogListRequest) (ExecutionLogListResponse, error) { + if service.db == nil || jobID < 1 { + return ExecutionLogListResponse{}, fmt.Errorf("%w: jobId 无效", ErrExecutionLogInvalidRequest) + } + if request.Page < 1 { + request.Page = 1 + } + if request.PageSize < 1 { + request.PageSize = 20 + } + if request.PageSize > 100 { + return ExecutionLogListResponse{}, fmt.Errorf("%w: pageSize 必须是 1 到 100 的整数", ErrExecutionLogInvalidRequest) + } + if request.Status != "" && !validExecutionStatus(request.Status) { + return ExecutionLogListResponse{}, fmt.Errorf("%w: status 无效", ErrExecutionLogInvalidRequest) + } + if request.StartedFrom != nil && request.StartedTo != nil && request.StartedFrom.After(*request.StartedTo) { + return ExecutionLogListResponse{}, fmt.Errorf("%w: 开始时间范围无效", ErrExecutionLogInvalidRequest) + } + + var job models.SysJob + if err := service.db.WithContext(ctx).Unscoped().First(&job, jobID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ExecutionLogListResponse{}, ErrExecutionLogJobNotFound + } + return ExecutionLogListResponse{}, err + } + + query := service.db.WithContext(ctx).Model(&models.SysJobExecutionLog{}).Where("job_id = ?", jobID) + if request.Status != "" { + query = query.Where("status = ?", request.Status) + } + if request.StartedFrom != nil { + query = query.Where("started_at >= ?", request.StartedFrom.UTC()) + } + if request.StartedTo != nil { + query = query.Where("started_at <= ?", request.StartedTo.UTC()) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ExecutionLogListResponse{}, err + } + items := make([]models.SysJobExecutionLog, 0, request.PageSize) + if err := query.Order("started_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil { + return ExecutionLogListResponse{}, err + } + return ExecutionLogListResponse{ + Job: ExecutionLogJob{JobID: job.JobId, JobName: job.JobName, InvokeTarget: job.InvokeTarget, Deleted: job.DeletedAt.Valid}, + Items: items, Total: total, Page: request.Page, PageSize: request.PageSize, + }, nil +} + +func validExecutionStatus(status string) bool { + switch status { + case models.JobExecutionRunning, models.JobExecutionSucceeded, models.JobExecutionFailed, models.JobExecutionInterrupted: + return true + default: + return false + } +} diff --git a/server/app/jobs/service/execution_log_test.go b/server/app/jobs/service/execution_log_test.go new file mode 100644 index 0000000..ba832a5 --- /dev/null +++ b/server/app/jobs/service/execution_log_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "go-admin/app/jobs/models" +) + +func executionLogTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&models.SysJob{}, &models.SysJobExecutionLog{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestExecutionLogListFiltersPaginatesAndKeepsDeletedJob(t *testing.T) { + db := executionLogTestDB(t) + job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"} + if err := db.Create(&job).Error; err != nil { + t.Fatal(err) + } + base := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) + records := []models.SysJobExecutionLog{ + {ExecutionID: "00000000-0000-4000-8000-000000000001", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionSucceeded, StartedAt: base}, + {ExecutionID: "00000000-0000-4000-8000-000000000002", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(time.Hour)}, + {ExecutionID: "00000000-0000-4000-8000-000000000003", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(2 * time.Hour)}, + } + if err := db.Create(&records).Error; err != nil { + t.Fatal(err) + } + from, to := base.Add(30*time.Minute), base.Add(3*time.Hour) + result, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{ + Page: 1, PageSize: 1, Status: models.JobExecutionFailed, StartedFrom: &from, StartedTo: &to, + }) + if err != nil { + t.Fatal(err) + } + if result.Total != 2 || len(result.Items) != 1 || result.Items[0].ExecutionID != records[2].ExecutionID { + t.Fatalf("unexpected filtered page: %+v", result) + } + if err := db.Delete(&job).Error; err != nil { + t.Fatal(err) + } + deleted, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if !deleted.Job.Deleted || deleted.Job.JobName != job.JobName || deleted.Total != 3 { + t.Fatalf("deleted job history unavailable: %+v", deleted) + } +} + +func TestExecutionLogListValidationAndNotFound(t *testing.T) { + db := executionLogTestDB(t) + service := NewExecutionLogService(db) + if _, err := service.List(context.Background(), 0, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogInvalidRequest) { + t.Fatalf("invalid job id error = %v", err) + } + if _, err := service.List(context.Background(), 1, ExecutionLogListRequest{PageSize: 101}); !errors.Is(err, ErrExecutionLogInvalidRequest) { + t.Fatalf("invalid page size error = %v", err) + } + job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"} + if err := db.Create(&job).Error; err != nil { + t.Fatal(err) + } + if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{Status: "unknown"}); !errors.Is(err, ErrExecutionLogInvalidRequest) { + t.Fatalf("invalid status error = %v", err) + } + from, to := time.Now(), time.Now().Add(-time.Hour) + if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{StartedFrom: &from, StartedTo: &to}); !errors.Is(err, ErrExecutionLogInvalidRequest) { + t.Fatalf("invalid range error = %v", err) + } + if _, err := service.List(context.Background(), 999, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogJobNotFound) { + t.Fatalf("not found error = %v", err) + } +} diff --git a/server/app/jobs/service/sys_job.go b/server/app/jobs/service/sys_job.go index 356ea00..03d72fd 100644 --- a/server/app/jobs/service/sys_job.go +++ b/server/app/jobs/service/sys_job.go @@ -62,6 +62,7 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error { if data.JobType == 1 { var j = &jobs.HttpJob{} + j.DB = e.Orm.WithContext(context.Background()) j.InvokeTarget = data.InvokeTarget j.CronExpression = data.CronExpression j.JobId = data.JobId diff --git a/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go b/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go new file mode 100644 index 0000000..81b93a9 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go @@ -0,0 +1,68 @@ +package version_local + +import ( + "fmt" + "runtime" + + jobsmodels "go-admin/app/jobs/models" + "go-admin/cmd/migrate/migration" + migrationmodels "go-admin/cmd/migrate/migration/models" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +const jobExecutionLogsAPIPath = "/api/v1/sysjob/:id/execution-logs" + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSysJobExecutionLog) +} + +func migrateSysJobExecutionLog(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := ensureSysJobExecutionLog(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +func ensureSysJobExecutionLog(db *gorm.DB) error { + if err := db.AutoMigrate(&jobsmodels.SysJobExecutionLog{}); err != nil { + return err + } + + var logMenu migrationmodels.SysMenu + if err := db.Where("menu_name = ?", "JobLog").First(&logMenu).Error; err != nil { + return fmt.Errorf("find JobLog menu: %w", err) + } + api := migrationmodels.SysApi{} + if err := db.Where(migrationmodels.SysApi{Path: jobExecutionLogsAPIPath, Action: "GET"}). + Attrs(migrationmodels.SysApi{Title: "查询定时任务执行日志", Type: "BUS"}).FirstOrCreate(&api).Error; err != nil { + return err + } + if err := db.Model(&logMenu).Association("SysApi").Append(&api); err != nil { + return err + } + + var roleIDs []int + if err := db.Table("sys_role_menu").Where("menu_id = ?", logMenu.MenuId).Distinct().Pluck("role_id", &roleIDs).Error; err != nil { + return err + } + if len(roleIDs) == 0 { + return nil + } + var roles []migrationmodels.SysRole + if err := db.Where("role_id IN ?", roleIDs).Find(&roles).Error; err != nil { + return err + } + for _, role := range roles { + rule := purchaserCasbinRule{Ptype: "p", V0: role.RoleKey, V1: jobExecutionLogsAPIPath, V2: "GET"} + if err := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.Ptype, rule.V0, rule.V1, rule.V2). + FirstOrCreate(&rule).Error; err != nil { + return err + } + } + return nil +} diff --git a/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log_test.go b/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log_test.go new file mode 100644 index 0000000..b6b363e --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log_test.go @@ -0,0 +1,78 @@ +package version_local + +import ( + "testing" + + jobsmodels "go-admin/app/jobs/models" + migrationmodels "go-admin/cmd/migrate/migration/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnsureSysJobExecutionLogIsIdempotentAndGrantsOnlyBoundRoles(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}, + ); err != nil { + t.Fatal(err) + } + menu := migrationmodels.SysMenu{MenuName: "JobLog", Title: "日志", Path: "/schedule/log", Component: "/schedule/log"} + if err := db.Create(&menu).Error; err != nil { + t.Fatal(err) + } + bound := migrationmodels.SysRole{RoleName: "日志管理员", RoleKey: "job_logger"} + unbound := migrationmodels.SysRole{RoleName: "无日志权限", RoleKey: "no_job_logs"} + if err := db.Create(&bound).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&unbound).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&bound).Association("SysMenu").Append(&menu); err != nil { + t.Fatal(err) + } + custom := purchaserCasbinRule{Ptype: "p", V0: bound.RoleKey, V1: "/custom", V2: "GET"} + if err := db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + + if err := ensureSysJobExecutionLog(db); err != nil { + t.Fatal(err) + } + if err := ensureSysJobExecutionLog(db); err != nil { + t.Fatalf("migration helper must be repeatable: %v", err) + } + if !db.Migrator().HasTable(&jobsmodels.SysJobExecutionLog{}) { + t.Fatal("execution log table was not created") + } + var apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy int64 + db.Model(&migrationmodels.SysApi{}).Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").Count(&apiCount) + var api migrationmodels.SysApi + if err := db.Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").First(&api).Error; err != nil { + t.Fatal(err) + } + db.Table("sys_menu_api_rule").Where("sys_menu_menu_id = ? AND sys_api_id = ?", menu.MenuId, api.Id).Count(&menuAPI) + db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", bound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&boundPolicy) + db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", unbound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&unboundPolicy) + db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ?", bound.RoleKey, "/custom").Count(&customPolicy) + if apiCount != 1 || menuAPI != 1 || boundPolicy != 1 || unboundPolicy != 0 || customPolicy != 1 { + t.Fatalf("unexpected permission state api=%d menu=%d bound=%d unbound=%d custom=%d", apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy) + } +} + +func TestEnsureSysJobExecutionLogRequiresExistingMenu(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}); err != nil { + t.Fatal(err) + } + if err := ensureSysJobExecutionLog(db); err == nil { + t.Fatal("missing JobLog menu must fail closed") + } +} diff --git a/web/src/api/job/sys-job.js b/web/src/api/job/sys-job.js index 83e5b5c..f30f8e4 100644 --- a/web/src/api/job/sys-job.js +++ b/web/src/api/job/sys-job.js @@ -17,6 +17,15 @@ export function getSysJob(jobId) { }) } +// 查询指定定时任务的持久化执行历史 +export function listJobExecutionLogs(jobId, query) { + return request({ + url: '/api/v1/sysjob/' + jobId + '/execution-logs', + method: 'get', + params: query + }) +} + // 新增SysJob export function addSysJob(data) { return request({ @@ -59,4 +68,3 @@ export function startJob(jobId) { method: 'get' }) } - diff --git a/web/src/views/schedule/execution-log.js b/web/src/views/schedule/execution-log.js new file mode 100644 index 0000000..9ae6999 --- /dev/null +++ b/web/src/views/schedule/execution-log.js @@ -0,0 +1,24 @@ +export function jobLogRoute(selectedIds) { + if (!Array.isArray(selectedIds) || selectedIds.length !== 1) return null + const jobId = Number(selectedIds[0]) + if (!Number.isInteger(jobId) || jobId < 1) return null + return { name: 'JobLog', query: { jobId: String(jobId) }} +} + +export function executionStatusMeta(status) { + return { + running: { label: '执行中', type: 'primary' }, + succeeded: { label: '成功', type: 'success' }, + failed: { label: '失败', type: 'danger' }, + interrupted: { label: '已中断', type: 'warning' } + }[status] || { label: status || '未知', type: 'info' } +} + +export function executionLogQuery(query, dateRange) { + const result = { ...query } + if (Array.isArray(dateRange) && dateRange.length === 2) { + result.startedFrom = new Date(dateRange[0]).toISOString() + result.startedTo = new Date(dateRange[1]).toISOString() + } + return result +} diff --git a/web/src/views/schedule/index.vue b/web/src/views/schedule/index.vue index ed7b060..88d7a82 100644 --- a/web/src/views/schedule/index.vue +++ b/web/src/views/schedule/index.vue @@ -55,7 +55,7 @@ 新增 修改 删除 - 日志 + 日志 @@ -267,6 +267,7 @@ + + diff --git a/web/tests/e2e/schedule-execution-log.spec.ts b/web/tests/e2e/schedule-execution-log.spec.ts new file mode 100644 index 0000000..2392ff0 --- /dev/null +++ b/web/tests/e2e/schedule-execution-log.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test' + +async function authenticate(context: any) { + await context.addCookies([{ name: 'Admin-Token', value: 'job-log-test-token', domain: 'localhost', path: '/' }]) +} + +test('单选定时任务后进入对应的持久化执行日志', async ({ page, context }) => { + await authenticate(context) + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()) + if (url.pathname.startsWith('/src/api/')) return route.continue() + if (url.pathname.endsWith('/api/v1/getinfo')) { + return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: ['job:sysJob:log'] } } }) + } + if (url.pathname.endsWith('/api/v1/menurole')) { + return route.fulfill({ json: { code: 200, data: [{ path: '/schedule', component: 'Layout', visible: '0', menuName: 'Schedule', title: '定时任务', icon: 'time', children: [{ path: 'manage', component: '/schedule/index', visible: '0', menuName: 'ScheduleManage', title: '定时任务' }, { path: 'log', component: '/schedule/log', visible: '1', menuName: 'JobLog', title: '执行日志' }] }] } }) + } + if (url.pathname.endsWith('/api/v1/sysjob/5/execution-logs')) { + return route.fulfill({ json: { code: 200, data: { job: { jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', deleted: false }, items: [{ id: 21, executionId: '00000000-0000-4000-8000-000000000021', jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', triggerType: 'scheduled', status: 'failed', startedAt: '2026-09-02T01:00:00Z', finishedAt: '2026-09-02T01:00:03Z', durationMs: 3000, errorCode: 'JOB_EXECUTION_FAILED', errorMessage: '任务执行失败,请查看受控服务日志' }], total: 1, page: 1, pageSize: 20 } } }) + } + if (url.pathname.endsWith('/api/v1/sysjob')) { + return route.fulfill({ json: { code: 200, data: { list: [{ jobId: 5, jobName: 'SYB 规格 AI 修复', jobGroup: 'GoAuto', cronExpression: '0 0 * * * *', invokeTarget: 'GoAutoSYBSpecAIParse', status: 2, entry_id: 0 }], count: 1 } } }) + } + return route.fulfill({ json: { code: 200, data: [] } }) + }) + + await page.goto('/#/schedule/manage') + const logButton = page.getByRole('button', { name: '日志', exact: true }) + await expect(page.getByText('SYB 规格 AI 修复', { exact: true })).toBeVisible() + await expect(logButton).toBeDisabled() + await page.locator('.el-table__body-wrapper .el-checkbox').first().click() + await expect(logButton).toBeEnabled() + await logButton.click() + + await expect(page).toHaveURL(/#\/schedule\/log\?jobId=5$/) + await expect(page.getByRole('heading', { name: 'SYB 规格 AI 修复(#5)' })).toBeVisible() + await expect(page.getByText('JOB_EXECUTION_FAILED')).toBeVisible() + await expect(page.getByText('任务执行失败,请查看受控服务日志')).toBeVisible() +}) diff --git a/web/tests/unit/schedule-execution-log.spec.js b/web/tests/unit/schedule-execution-log.spec.js new file mode 100644 index 0000000..660f0ed --- /dev/null +++ b/web/tests/unit/schedule-execution-log.spec.js @@ -0,0 +1,26 @@ +import { executionLogQuery, executionStatusMeta, jobLogRoute } from '@/views/schedule/execution-log' + +describe('scheduled job execution log helpers', () => { + test('only one valid selection can navigate to JobLog', () => { + expect(jobLogRoute([])).toBeNull() + expect(jobLogRoute([1, 2])).toBeNull() + expect(jobLogRoute(['invalid'])).toBeNull() + expect(jobLogRoute([5])).toEqual({ name: 'JobLog', query: { jobId: '5' }}) + }) + + test('builds RFC3339 date filters without mutating paging', () => { + const from = new Date('2026-09-02T10:00:00+08:00') + const to = new Date('2026-09-02T11:00:00+08:00') + expect(executionLogQuery({ pageIndex: 2, pageSize: 20, status: 'failed' }, [from, to])).toEqual({ + pageIndex: 2, pageSize: 20, status: 'failed', + startedFrom: from.toISOString(), startedTo: to.toISOString() + }) + }) + + test('maps all persisted statuses', () => { + expect(executionStatusMeta('running').label).toBe('执行中') + expect(executionStatusMeta('succeeded').type).toBe('success') + expect(executionStatusMeta('failed').type).toBe('danger') + expect(executionStatusMeta('interrupted').label).toBe('已中断') + }) +})