Files
scheduler-backend/internal/api/router.go
2026-05-28 13:29:24 +08:00

1103 lines
33 KiB
Go

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"`
ScheduleType string `json:"scheduleType,omitempty"`
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"`
ScheduleType string `json:"scheduleType,omitempty"`
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 globalConfigItem struct {
Config string `json:"config"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
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 GlobalConfigStore interface {
Get(ctx context.Context, key string) (*model.GlobalConfig, error)
Upsert(ctx context.Context, item *model.GlobalConfig) error
}
type RouterDeps struct {
Registry *jobdef.Registry
JobConfigStore JobConfigLister
ExecutionStore JobExecutionLister
ProfileStore AdminProfileStore
AdminUserStore AdminUserStore
ConfigStore SystemConfigStore
GlobalConfigStore GlobalConfigStore
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"`
}
type updateGlobalConfigRequest struct {
Config json.RawMessage `json:"config" binding:"required"`
}
const schedulerGlobalConfigKey = "scheduler"
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/global-config", func(c *gin.Context) {
item, err := deps.GlobalConfigStore.Get(c.Request.Context(), schedulerGlobalConfigKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toGlobalConfigItem(*item))
})
router.PUT("/admin/scheduler/global-config", func(c *gin.Context) {
var req updateGlobalConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !json.Valid(req.Config) {
c.JSON(http.StatusBadRequest, gin.H{"error": errInvalidJSON("config").Error()})
return
}
item := &model.GlobalConfig{
Key: schedulerGlobalConfigKey,
Config: string(req.Config),
UpdatedBy: "admin",
}
if err := deps.GlobalConfigStore.Upsert(c.Request.Context(), item); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toGlobalConfigItem(*item))
})
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))
jobScheduleTypeMap := make(map[string]string, len(jobs))
for _, item := range jobs {
jobNameMap[item.ID.Hex()] = item.Name
jobScheduleTypeMap[item.ID.Hex()] = item.ScheduleType
}
responseItems := make([]executionItem, 0, len(items))
for _, item := range items {
jobID := item.JobConfigID.Hex()
responseItems = append(responseItems, executionItem{
ID: objectIDToString(item.ID),
JobName: jobDisplayName(jobNameMap, item.JobConfigID),
TriggerType: item.TriggerType,
ScheduleType: jobScheduleTypeMap[jobID],
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
}
detail := toExecutionDetailItem(*item)
job, err := deps.JobConfigStore.GetByID(c.Request.Context(), item.JobConfigID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if job != nil {
detail.ScheduleType = job.ScheduleType
}
c.JSON(http.StatusOK, detail)
})
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 toGlobalConfigItem(item model.GlobalConfig) globalConfigItem {
return globalConfigItem{
Config: item.Config,
UpdatedBy: item.UpdatedBy,
UpdatedAt: formatTime(&item.UpdatedAt),
}
}
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[:])
}