package api import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson/primitive" "scheduler-backend/internal/executor" "scheduler-backend/internal/jobdef" "scheduler-backend/internal/store/model" ) type handlerItem struct { Key string `json:"key"` Name string `json:"name"` Description string `json:"description"` } type handlerListResponse struct { List []handlerItem `json:"list"` } type jobItem struct { ID string `json:"id"` Name string `json:"name"` HandlerKey string `json:"handlerKey"` Enabled bool `json:"enabled"` ScheduleType string `json:"scheduleType"` ScheduleValue string `json:"scheduleValue"` DefaultParams string `json:"defaultParams"` LastStatus string `json:"lastStatus"` LastRunAt string `json:"lastRunAt,omitempty"` NextRunAt string `json:"nextRunAt,omitempty"` } type jobListResponse struct { List []jobItem `json:"list"` Total int `json:"total"` } type executionItem struct { ID string `json:"id"` JobName string `json:"jobName"` TriggerType string `json:"triggerType"` Status string `json:"status"` StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` ResultSummary string `json:"resultSummary"` } type executionDetailItem struct { ID string `json:"id"` JobConfigID string `json:"jobConfigId"` TriggerType string `json:"triggerType"` Status string `json:"status"` ParamsSnapshot string `json:"paramsSnapshot"` StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` DurationMs int64 `json:"durationMs"` ResultSummary string `json:"resultSummary"` ErrorMessage string `json:"errorMessage"` LogText string `json:"logText"` LogFile string `json:"logFile,omitempty"` CreatedAt string `json:"createdAt"` } type executionListResponse struct { List []executionItem `json:"list"` Total int `json:"total"` } type profileItem struct { ID string `json:"id"` Account string `json:"account"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` Remark string `json:"remark"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type adminUserItem struct { ID string `json:"id"` Account string `json:"account"` Nickname string `json:"nickname"` Status string `json:"status"` Role string `json:"role"` Remark string `json:"remark"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type adminUserListResponse struct { List []adminUserItem `json:"list"` Total int `json:"total"` } type systemConfigItem struct { ID string `json:"id"` Key string `json:"key"` Title string `json:"title"` Value string `json:"value"` ValueType string `json:"valueType"` Description string `json:"description"` Enabled bool `json:"enabled"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type systemConfigListResponse struct { List []systemConfigItem `json:"list"` Total int `json:"total"` } type dashboardOverviewResponse struct { JobTotal int `json:"jobTotal"` EnabledJobTotal int `json:"enabledJobTotal"` ExecutionToday int `json:"executionToday"` FailedToday int `json:"failedToday"` LastExecutionAt string `json:"lastExecutionAt,omitempty"` FailedJobTotal int `json:"failedJobTotal"` AdminUserTotal int `json:"adminUserTotal"` SystemConfigTotal int `json:"systemConfigTotal"` RecentExecutions []executionItem `json:"recentExecutions"` } type JobConfigLister interface { List(ctx context.Context) ([]model.JobConfig, error) GetByID(ctx context.Context, id primitive.ObjectID) (*model.JobConfig, error) Create(ctx context.Context, item *model.JobConfig) error Update(ctx context.Context, item *model.JobConfig) error ToggleEnabled(ctx context.Context, id primitive.ObjectID, enabled bool) error UpdateRunState(ctx context.Context, id primitive.ObjectID, status string, lastRunAt *time.Time) error } type JobExecutionLister interface { List(ctx context.Context) ([]model.JobExecution, error) GetByID(ctx context.Context, id primitive.ObjectID) (*model.JobExecution, error) Create(ctx context.Context, item *model.JobExecution) error UpdateResult(ctx context.Context, item *model.JobExecution) error } type AdminProfileStore interface { Get(ctx context.Context) (*model.AdminProfile, error) Upsert(ctx context.Context, profile *model.AdminProfile) error ChangePassword(ctx context.Context, oldPassword, newPassword string) error Authenticate(ctx context.Context, account, password string) (*model.AdminProfile, error) } type AdminUserStore interface { List(ctx context.Context) ([]model.AdminUser, error) GetByID(ctx context.Context, id primitive.ObjectID) (*model.AdminUser, error) Create(ctx context.Context, item *model.AdminUser) error Update(ctx context.Context, item *model.AdminUser) error ChangePassword(ctx context.Context, id primitive.ObjectID, newPassword string) error Authenticate(ctx context.Context, account, password string) (*model.AdminUser, error) } type SystemConfigStore interface { List(ctx context.Context) ([]model.SystemConfig, error) GetByID(ctx context.Context, id primitive.ObjectID) (*model.SystemConfig, error) Create(ctx context.Context, item *model.SystemConfig) error Update(ctx context.Context, item *model.SystemConfig) error ToggleEnabled(ctx context.Context, id primitive.ObjectID, enabled bool) error } type RouterDeps struct { Registry *jobdef.Registry JobConfigStore JobConfigLister ExecutionStore JobExecutionLister ProfileStore AdminProfileStore AdminUserStore AdminUserStore ConfigStore SystemConfigStore Executor *executor.Service } type upsertJobRequest struct { Name string `json:"name" binding:"required"` HandlerKey string `json:"handlerKey" binding:"required"` Enabled bool `json:"enabled"` ScheduleType string `json:"scheduleType" binding:"required"` ScheduleValue string `json:"scheduleValue"` DefaultParams string `json:"defaultParams"` } type toggleJobRequest struct { Enabled bool `json:"enabled"` } type runJobRequest struct { Params json.RawMessage `json:"params"` } type updateProfileRequest struct { Account string `json:"account" binding:"required"` Nickname string `json:"nickname" binding:"required"` Avatar string `json:"avatar"` Remark string `json:"remark"` } type changeProfilePasswordRequest struct { OldPassword string `json:"oldPassword" binding:"required"` NewPassword string `json:"newPassword" binding:"required"` } type loginRequest struct { Account string `json:"account" binding:"required"` Password string `json:"password" binding:"required"` } type upsertAdminUserRequest struct { Account string `json:"account" binding:"required"` Nickname string `json:"nickname" binding:"required"` Status string `json:"status"` Role string `json:"role"` Remark string `json:"remark"` Password string `json:"password"` } type changeAdminPasswordRequest struct { NewPassword string `json:"newPassword" binding:"required"` } type upsertSystemConfigRequest struct { Key string `json:"key" binding:"required"` Title string `json:"title" binding:"required"` Value string `json:"value"` ValueType string `json:"valueType" binding:"required"` Description string `json:"description"` Enabled bool `json:"enabled"` } type toggleSystemConfigRequest struct { Enabled bool `json:"enabled"` } func NewRouter(deps RouterDeps) *gin.Engine { router := gin.Default() router.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) router.POST("/admin/account/login", func(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } profile, err := deps.ProfileStore.Authenticate(c.Request.Context(), req.Account, req.Password) if err == nil { c.JSON(http.StatusOK, toProfileItem(*profile)) return } adminUser, userErr := deps.AdminUserStore.Authenticate(c.Request.Context(), req.Account, req.Password) if userErr != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid account or password"}) return } c.JSON(http.StatusOK, adminUserItem{ ID: objectIDToString(adminUser.ID), Account: adminUser.Account, Nickname: adminUser.Nickname, Status: adminUser.Status, Role: adminUser.Role, Remark: adminUser.Remark, CreatedAt: adminUser.CreatedAt.Format(time.RFC3339), UpdatedAt: adminUser.UpdatedAt.Format(time.RFC3339), }) }) router.GET("/admin/dashboard/overview", func(c *gin.Context) { jobs, err := deps.JobConfigStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } executions, err := deps.ExecutionStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } adminUsers, err := deps.AdminUserStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } systemConfigs, err := deps.ConfigStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } enabledCount := 0 failedJobCount := 0 jobNameMap := make(map[string]string, len(jobs)) for _, item := range jobs { jobNameMap[item.ID.Hex()] = item.Name if item.Enabled { enabledCount++ } if item.LastStatus == "failed" { failedJobCount++ } } now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) executionToday := 0 failedToday := 0 lastExecutionAt := "" recent := make([]executionItem, 0, min(len(executions), 5)) for idx, item := range executions { if idx < 5 { recent = append(recent, executionItem{ ID: objectIDToString(item.ID), JobName: jobDisplayName(jobNameMap, item.JobConfigID), TriggerType: item.TriggerType, Status: item.Status, StartedAt: formatTime(item.StartedAt), FinishedAt: formatTime(item.FinishedAt), ResultSummary: item.ResultSummary, }) } if lastExecutionAt == "" && item.StartedAt != nil { lastExecutionAt = item.StartedAt.Format(time.RFC3339) } if item.CreatedAt.After(todayStart) { executionToday++ if item.Status == "failed" { failedToday++ } } } c.JSON(http.StatusOK, dashboardOverviewResponse{ JobTotal: len(jobs), EnabledJobTotal: enabledCount, ExecutionToday: executionToday, FailedToday: failedToday, LastExecutionAt: lastExecutionAt, FailedJobTotal: failedJobCount, AdminUserTotal: len(adminUsers), SystemConfigTotal: len(systemConfigs), RecentExecutions: recent, }) }) router.GET("/admin/scheduler/handlers", func(c *gin.Context) { items := make([]handlerItem, 0, len(deps.Registry.List())) for _, handler := range deps.Registry.List() { items = append(items, handlerItem{ Key: handler.Key(), Name: handler.Name(), Description: handler.Description(), }) } c.JSON(http.StatusOK, handlerListResponse{List: items}) }) router.GET("/admin/scheduler/jobs", func(c *gin.Context) { items, err := deps.JobConfigStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } responseItems := make([]jobItem, 0, len(items)) for _, item := range items { responseItems = append(responseItems, toJobItem(item)) } c.JSON(http.StatusOK, jobListResponse{ List: responseItems, Total: len(responseItems), }) }) router.GET("/admin/scheduler/jobs/:id", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } item, err := deps.JobConfigStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) return } c.JSON(http.StatusOK, toJobItem(*item)) }) router.POST("/admin/scheduler/jobs", func(c *gin.Context) { var req upsertJobRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } item := model.NewJobConfig(req.Name, req.HandlerKey) item.Enabled = req.Enabled item.ScheduleType = req.ScheduleType item.ScheduleValue = req.ScheduleValue item.DefaultParams = req.DefaultParams if err := deps.JobConfigStore.Create(c.Request.Context(), &item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, toJobItem(item)) }) router.PUT("/admin/scheduler/jobs/:id", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } current, err := deps.JobConfigStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if current == nil { c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) return } var req upsertJobRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } current.Name = req.Name current.HandlerKey = req.HandlerKey current.Enabled = req.Enabled current.ScheduleType = req.ScheduleType current.ScheduleValue = req.ScheduleValue current.DefaultParams = req.DefaultParams if err := deps.JobConfigStore.Update(c.Request.Context(), current); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, toJobItem(*current)) }) router.POST("/admin/scheduler/jobs/:id/toggle", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req toggleJobRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := deps.JobConfigStore.ToggleEnabled(c.Request.Context(), id, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) router.POST("/admin/scheduler/jobs/:id/run", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } job, err := deps.JobConfigStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if job == nil { c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) return } var req runJobRequest if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } params := req.Params params, err = mergeExecutionParams(job.DefaultParams, req.Params) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } now := time.Now() execRecord := model.JobExecution{ JobConfigID: job.ID, TriggerType: "manual", Status: "running", ParamsSnapshot: string(params), StartedAt: &now, CreatedAt: now, } if err := deps.ExecutionStore.Create(c.Request.Context(), &execRecord); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } execReq := jobdef.ExecuteRequest{ ExecutionID: execRecord.ID.Hex(), JobID: job.ID.Hex(), TriggerType: "manual", Params: params, LogCollector: jobdef.NewLogCollector(), } runErr := deps.Executor.Execute(c.Request.Context(), job.HandlerKey, execReq) finishedAt := time.Now() execRecord.FinishedAt = &finishedAt execRecord.DurationMs = finishedAt.Sub(now).Milliseconds() flushed := execReq.LogCollector.Flush(execRecord.ID.Hex()) if runErr != nil { execRecord.Status = "failed" execRecord.ErrorMessage = runErr.Error() execRecord.ResultSummary = "manual execution failed" if flushed.LogText != "" { execRecord.LogText = flushed.LogText } else { execRecord.LogText = runErr.Error() } execRecord.LogFile = flushed.LogFile _ = deps.JobConfigStore.UpdateRunState(c.Request.Context(), job.ID, "failed", &finishedAt) } else { execRecord.Status = "success" execRecord.ResultSummary = "manual execution succeeded" if flushed.LogText != "" { execRecord.LogText = flushed.LogText } else { execRecord.LogText = "handler executed successfully" } execRecord.LogFile = flushed.LogFile _ = deps.JobConfigStore.UpdateRunState(c.Request.Context(), job.ID, "success", &finishedAt) } if err := deps.ExecutionStore.UpdateResult(c.Request.Context(), &execRecord); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "executionID": execRecord.ID.Hex(), "status": execRecord.Status, "resultSummary": execRecord.ResultSummary, "errorMessage": execRecord.ErrorMessage, }) }) router.GET("/admin/scheduler/executions", func(c *gin.Context) { jobs, err := deps.JobConfigStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } items, err := deps.ExecutionStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } jobNameMap := make(map[string]string, len(jobs)) for _, item := range jobs { jobNameMap[item.ID.Hex()] = item.Name } responseItems := make([]executionItem, 0, len(items)) for _, item := range items { responseItems = append(responseItems, executionItem{ ID: objectIDToString(item.ID), JobName: jobDisplayName(jobNameMap, item.JobConfigID), TriggerType: item.TriggerType, Status: item.Status, StartedAt: formatTime(item.StartedAt), FinishedAt: formatTime(item.FinishedAt), ResultSummary: item.ResultSummary, }) } c.JSON(http.StatusOK, executionListResponse{ List: responseItems, Total: len(responseItems), }) }) router.GET("/admin/scheduler/executions/:id", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } item, err := deps.ExecutionStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil { c.JSON(http.StatusNotFound, gin.H{"error": "execution not found"}) return } c.JSON(http.StatusOK, toExecutionDetailItem(*item)) }) router.GET("/admin/scheduler/executions/:id/logfile", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } item, err := deps.ExecutionStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if item == nil || item.LogFile == "" { c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"}) return } filePath := filepath.Join("logs", "joblog", item.LogFile) if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { c.JSON(http.StatusNotFound, gin.H{"error": "log file not found on disk"}) return } c.FileAttachment(filePath, item.LogFile) }) router.GET("/admin/account/profile", func(c *gin.Context) { profile, err := deps.ProfileStore.Get(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, toProfileItem(*profile)) }) router.PUT("/admin/account/profile", func(c *gin.Context) { current, err := deps.ProfileStore.Get(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var req updateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } current.Account = req.Account current.Nickname = req.Nickname current.Avatar = req.Avatar current.Remark = req.Remark if err := deps.ProfileStore.Upsert(c.Request.Context(), current); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, toProfileItem(*current)) }) router.POST("/admin/account/change-password", func(c *gin.Context) { var req changeProfilePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := deps.ProfileStore.ChangePassword(c.Request.Context(), req.OldPassword, req.NewPassword); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) router.GET("/admin/admin-users", func(c *gin.Context) { items, err := deps.AdminUserStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } result := make([]adminUserItem, 0, len(items)) for _, item := range items { result = append(result, toAdminUserItem(item)) } c.JSON(http.StatusOK, adminUserListResponse{List: result, Total: len(result)}) }) router.POST("/admin/admin-users", func(c *gin.Context) { var req upsertAdminUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } item := model.AdminUser{ Account: req.Account, Nickname: req.Nickname, Status: req.Status, Role: req.Role, Remark: req.Remark, PasswordHash: modelPasswordHash(req.Password), } if item.PasswordHash == "" { item.PasswordHash = modelPasswordHash("admin123") } if err := deps.AdminUserStore.Create(c.Request.Context(), &item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, toAdminUserItem(item)) }) router.PUT("/admin/admin-users/:id", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } current, err := deps.AdminUserStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if current == nil { c.JSON(http.StatusNotFound, gin.H{"error": "admin user not found"}) return } var req upsertAdminUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } current.Account = req.Account current.Nickname = req.Nickname current.Status = req.Status current.Role = req.Role current.Remark = req.Remark if err := deps.AdminUserStore.Update(c.Request.Context(), current); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, toAdminUserItem(*current)) }) router.POST("/admin/admin-users/:id/change-password", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req changeAdminPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := deps.AdminUserStore.ChangePassword(c.Request.Context(), id, req.NewPassword); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) router.GET("/admin/system-configs", func(c *gin.Context) { items, err := deps.ConfigStore.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } result := make([]systemConfigItem, 0, len(items)) for _, item := range items { result = append(result, toSystemConfigItem(item)) } c.JSON(http.StatusOK, systemConfigListResponse{List: result, Total: len(result)}) }) router.POST("/admin/system-configs", func(c *gin.Context) { var req upsertSystemConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } item := model.SystemConfig{ Key: req.Key, Title: req.Title, Value: req.Value, ValueType: req.ValueType, Description: req.Description, Enabled: req.Enabled, } if err := deps.ConfigStore.Create(c.Request.Context(), &item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, toSystemConfigItem(item)) }) router.PUT("/admin/system-configs/:id", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } current, err := deps.ConfigStore.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if current == nil { c.JSON(http.StatusNotFound, gin.H{"error": "system config not found"}) return } var req upsertSystemConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } current.Key = req.Key current.Title = req.Title current.Value = req.Value current.ValueType = req.ValueType current.Description = req.Description current.Enabled = req.Enabled if err := deps.ConfigStore.Update(c.Request.Context(), current); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, toSystemConfigItem(*current)) }) router.POST("/admin/system-configs/:id/toggle", func(c *gin.Context) { id, err := primitive.ObjectIDFromHex(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req toggleSystemConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := deps.ConfigStore.ToggleEnabled(c.Request.Context(), id, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) return router } func toJobItem(item model.JobConfig) jobItem { return jobItem{ ID: objectIDToString(item.ID), Name: item.Name, HandlerKey: item.HandlerKey, Enabled: item.Enabled, ScheduleType: item.ScheduleType, ScheduleValue: item.ScheduleValue, DefaultParams: item.DefaultParams, LastStatus: item.LastStatus, LastRunAt: formatTime(item.LastRunAt), NextRunAt: formatTime(item.NextRunAt), } } func toExecutionDetailItem(item model.JobExecution) executionDetailItem { return executionDetailItem{ ID: objectIDToString(item.ID), JobConfigID: objectIDToString(item.JobConfigID), TriggerType: item.TriggerType, Status: item.Status, ParamsSnapshot: item.ParamsSnapshot, StartedAt: formatTime(item.StartedAt), FinishedAt: formatTime(item.FinishedAt), DurationMs: item.DurationMs, ResultSummary: item.ResultSummary, ErrorMessage: item.ErrorMessage, LogText: item.LogText, LogFile: item.LogFile, CreatedAt: item.CreatedAt.Format(time.RFC3339), } } func toProfileItem(item model.AdminProfile) profileItem { return profileItem{ ID: objectIDToString(item.ID), Account: item.Account, Nickname: item.Nickname, Avatar: item.Avatar, Remark: item.Remark, CreatedAt: item.CreatedAt.Format(time.RFC3339), UpdatedAt: item.UpdatedAt.Format(time.RFC3339), } } func toAdminUserItem(item model.AdminUser) adminUserItem { return adminUserItem{ ID: objectIDToString(item.ID), Account: item.Account, Nickname: item.Nickname, Status: item.Status, Role: item.Role, Remark: item.Remark, CreatedAt: item.CreatedAt.Format(time.RFC3339), UpdatedAt: item.UpdatedAt.Format(time.RFC3339), } } func toSystemConfigItem(item model.SystemConfig) systemConfigItem { return systemConfigItem{ ID: objectIDToString(item.ID), Key: item.Key, Title: item.Title, Value: item.Value, ValueType: item.ValueType, Description: item.Description, Enabled: item.Enabled, CreatedAt: item.CreatedAt.Format(time.RFC3339), UpdatedAt: item.UpdatedAt.Format(time.RFC3339), } } func mergeExecutionParams(defaultParams string, customParams json.RawMessage) (json.RawMessage, error) { if len(customParams) == 0 { if defaultParams == "" { return nil, nil } if !json.Valid([]byte(defaultParams)) { return nil, errInvalidJSON("defaultParams") } return json.RawMessage(defaultParams), nil } if !json.Valid(customParams) { return nil, errInvalidJSON("params") } if defaultParams == "" { return customParams, nil } if !json.Valid([]byte(defaultParams)) { return nil, errInvalidJSON("defaultParams") } var base map[string]any var override map[string]any if err := json.Unmarshal([]byte(defaultParams), &base); err != nil { return nil, errInvalidJSON("defaultParams") } if err := json.Unmarshal(customParams, &override); err != nil { return nil, errInvalidJSON("params") } if base == nil || override == nil { return customParams, nil } for key, value := range override { base[key] = value } merged, err := json.Marshal(base) if err != nil { return nil, err } return merged, nil } func errInvalidJSON(field string) error { return fmt.Errorf("%s must be valid JSON", field) } func formatTime(value *time.Time) string { if value == nil { return "" } return value.Format(time.RFC3339) } func objectIDToString(value primitive.ObjectID) string { if value.IsZero() { return "" } return value.Hex() } func jobDisplayName(jobNameMap map[string]string, id primitive.ObjectID) string { if name := jobNameMap[id.Hex()]; name != "" { return name } return id.Hex() } func min(a, b int) int { if a < b { return a } return b } func modelPasswordHash(password string) string { if password == "" { return "" } sum := sha256.Sum256([]byte(password)) return hex.EncodeToString(sum[:]) }