diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go
index 7d647d32..5b4b858f 100644
--- a/cmd/ace/wire_gen.go
+++ b/cmd/ace/wire_gen.go
@@ -83,6 +83,8 @@ func initWeb() (*app.Web, error) {
homeService := service.NewHomeService(locale, config, taskRepo, websiteRepo, appRepo, environmentRepo, settingRepo, cronRepo, backupRepo)
taskService := service.NewTaskService(taskRepo)
websiteService := service.NewWebsiteService(websiteRepo, settingRepo)
+ projectRepo := data.NewProjectRepo(locale, db)
+ projectService := service.NewProjectService(projectRepo)
databaseService := service.NewDatabaseService(databaseRepo)
databaseServerService := service.NewDatabaseServerService(databaseServerRepo)
databaseUserService := service.NewDatabaseUserService(databaseUserRepo)
@@ -143,7 +145,7 @@ func initWeb() (*app.Web, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
- http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, webHookService, loader)
+ http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, webHookService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
diff --git a/go.mod b/go.mod
index cd50c7bf..6a5caf94 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/bddjr/hlfhr v1.4.0
github.com/beevik/ntp v1.5.0
github.com/coder/websocket v1.8.14
+ github.com/coreos/go-systemd/v22 v22.6.0
github.com/creack/pty v1.1.24
github.com/dchest/captcha v1.1.0
github.com/expr-lang/expr v1.17.7
diff --git a/go.sum b/go.sum
index 38008f95..ac1748f3 100644
--- a/go.sum
+++ b/go.sum
@@ -50,6 +50,8 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
+github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
diff --git a/internal/biz/project.go b/internal/biz/project.go
new file mode 100644
index 00000000..54910147
--- /dev/null
+++ b/internal/biz/project.go
@@ -0,0 +1,25 @@
+package biz
+
+import (
+ "time"
+
+ "github.com/acepanel/panel/internal/http/request"
+ "github.com/acepanel/panel/pkg/types"
+)
+
+type Project struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"not null;unique" json:"name"` // 项目名称
+ Type types.ProjectType `gorm:"not null;index;default:'general'" json:"type"` // 项目类型
+ Path string `gorm:"not null;default:''" json:"path"` // 项目路径
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ProjectRepo interface {
+ List(page, limit uint) ([]*types.ProjectDetail, int64, error)
+ Get(id uint) (*types.ProjectDetail, error)
+ Create(req *request.ProjectCreate) (*types.ProjectDetail, error)
+ Update(req *request.ProjectUpdate) error
+ Delete(id uint) error
+}
diff --git a/internal/biz/website.go b/internal/biz/website.go
index 752a11da..6cba72ee 100644
--- a/internal/biz/website.go
+++ b/internal/biz/website.go
@@ -19,7 +19,7 @@ const (
type Website struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;default:'';unique" json:"name"`
- Type WebsiteType `gorm:"not null;default:'static'" json:"type"`
+ Type WebsiteType `gorm:"not null;index;default:'static'" json:"type"`
Status bool `gorm:"not null;default:true" json:"status"`
Path string `gorm:"not null;default:''" json:"path"`
SSL bool `gorm:"not null;default:false" json:"ssl"`
diff --git a/internal/data/data.go b/internal/data/data.go
index a430a0c1..b848a8b9 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
NewDatabaseUserRepo,
NewEnvironmentRepo,
NewMonitorRepo,
+ NewProjectRepo,
NewSafeRepo,
NewSettingRepo,
NewSSHRepo,
diff --git a/internal/data/project.go b/internal/data/project.go
new file mode 100644
index 00000000..47928336
--- /dev/null
+++ b/internal/data/project.go
@@ -0,0 +1,487 @@
+package data
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/coreos/go-systemd/v22/unit"
+ "github.com/leonelquinteros/gotext"
+ "github.com/samber/lo"
+ "github.com/spf13/cast"
+ "gorm.io/gorm"
+
+ "github.com/acepanel/panel/internal/biz"
+ "github.com/acepanel/panel/internal/http/request"
+ "github.com/acepanel/panel/pkg/types"
+)
+
+type projectRepo struct {
+ systemdDir string
+ t *gotext.Locale
+ db *gorm.DB
+}
+
+func NewProjectRepo(t *gotext.Locale, db *gorm.DB) biz.ProjectRepo {
+ return &projectRepo{
+ systemdDir: "/etc/systemd/system",
+ t: t,
+ db: db,
+ }
+}
+
+func (r *projectRepo) List(page, limit uint) ([]*types.ProjectDetail, int64, error) {
+ var projects []*biz.Project
+ var total int64
+
+ if err := r.db.Model(&biz.Project{}).Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ if err := r.db.Offset(int((page - 1) * limit)).Limit(int(limit)).Order("id desc").Find(&projects).Error; err != nil {
+ return nil, 0, err
+ }
+
+ details := make([]*types.ProjectDetail, 0, len(projects))
+ for _, p := range projects {
+ detail, err := r.parseProjectDetail(p)
+ if err != nil {
+ // 如果解析失败,返回基本信息
+ detail = &types.ProjectDetail{
+ ID: p.ID,
+ Name: p.Name,
+ Type: p.Type,
+ }
+ }
+ details = append(details, detail)
+ }
+
+ return details, total, nil
+}
+
+func (r *projectRepo) Get(id uint) (*types.ProjectDetail, error) {
+ project := new(biz.Project)
+ if err := r.db.First(project, id).Error; err != nil {
+ return nil, err
+ }
+ return r.parseProjectDetail(project)
+}
+
+func (r *projectRepo) Create(req *request.ProjectCreate) (*types.ProjectDetail, error) {
+ // 检查项目名是否已存在
+ var count int64
+ if err := r.db.Model(&biz.Project{}).Where("name = ?", req.Name).Count(&count).Error; err != nil {
+ return nil, err
+ }
+ if count > 0 {
+ return nil, errors.New(r.t.Get("project name already exists"))
+ }
+
+ project := &biz.Project{
+ Name: req.Name,
+ Type: req.Type,
+ Path: req.RootDir,
+ }
+
+ err := r.db.Transaction(func(tx *gorm.DB) error {
+ // 创建数据库记录
+ if err := tx.Create(project).Error; err != nil {
+ return err
+ }
+
+ // 生成 systemd unit 文件
+ if err := r.generateUnitFile(project.ID, req); err != nil {
+ return fmt.Errorf("%s: %w", r.t.Get("failed to generate systemd config"), err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return r.parseProjectDetail(project)
+}
+
+func (r *projectRepo) Update(req *request.ProjectUpdate) error {
+ project := new(biz.Project)
+ if err := r.db.First(project, req.ID).Error; err != nil {
+ return err
+ }
+
+ // 如果名称变更,需要重命名 unit 文件
+ if req.Name != project.Name {
+ oldPath := r.unitFilePath(project.Name)
+ newPath := r.unitFilePath(req.Name)
+ if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("%s: %w", r.t.Get("failed to rename systemd config"), err)
+ }
+ project.Name = req.Name
+ }
+
+ project.Path = req.RootDir
+ if err := r.db.Save(project).Error; err != nil {
+ return err
+ }
+
+ // 更新 systemd unit 文件
+ return r.updateUnitFile(project.Name, req)
+}
+
+func (r *projectRepo) Delete(id uint) error {
+ project := new(biz.Project)
+ if err := r.db.First(project, id).Error; err != nil {
+ return err
+ }
+
+ // 删除 systemd unit 文件
+ unitPath := r.unitFilePath(project.Name)
+ if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("%s: %w", r.t.Get("failed to delete systemd config"), err)
+ }
+
+ return r.db.Delete(project).Error
+}
+
+// unitFilePath 返回 systemd unit 文件路径
+func (r *projectRepo) unitFilePath(name string) string {
+ return filepath.Join(r.systemdDir, fmt.Sprintf("acepanel-project-%s.service", name))
+}
+
+// parseProjectDetail 从数据库记录和 systemd unit 文件解析项目详情
+func (r *projectRepo) parseProjectDetail(project *biz.Project) (*types.ProjectDetail, error) {
+ detail := &types.ProjectDetail{
+ ID: project.ID,
+ Name: project.Name,
+ Type: project.Type,
+ RootDir: project.Path,
+ }
+
+ // 读取并解析 systemd unit 文件
+ unitPath := r.unitFilePath(project.Name)
+ file, err := os.Open(unitPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return detail, nil
+ }
+ return nil, err
+ }
+ defer func(file *os.File) { _ = file.Close() }(file)
+
+ options, err := unit.DeserializeOptions(file)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", r.t.Get("failed to parse systemd config"), err)
+ }
+
+ // 解析各个字段
+ for _, opt := range options {
+ switch opt.Section {
+ case "Unit":
+ r.parseUnitSection(detail, opt)
+ case "Service":
+ r.parseServiceSection(detail, opt)
+ }
+ }
+
+ return detail, nil
+}
+
+// parseUnitSection 解析 [Unit] 部分
+func (r *projectRepo) parseUnitSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
+ switch opt.Name {
+ case "Description":
+ detail.Description = opt.Value
+ case "Requires":
+ detail.Requires = append(detail.Requires, opt.Value)
+ case "Wants":
+ detail.Wants = append(detail.Wants, opt.Value)
+ case "After":
+ detail.After = append(detail.After, opt.Value)
+ case "Before":
+ detail.Before = append(detail.Before, opt.Value)
+ }
+}
+
+// parseServiceSection 解析 [Service] 部分
+func (r *projectRepo) parseServiceSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
+ switch opt.Name {
+ case "WorkingDirectory":
+ detail.WorkingDir = opt.Value
+ case "ExecStartPre":
+ detail.ExecStartPre = opt.Value
+ case "ExecStartPost":
+ detail.ExecStartPost = opt.Value
+ case "ExecStart":
+ detail.ExecStart = opt.Value
+ case "ExecStop":
+ detail.ExecStop = opt.Value
+ case "ExecReload":
+ detail.ExecReload = opt.Value
+ case "User":
+ detail.User = opt.Value
+ case "Restart":
+ detail.Restart = opt.Value
+ case "RestartSec":
+ detail.RestartSec = opt.Value
+ case "StartLimitBurst":
+ if v, err := strconv.Atoi(opt.Value); err == nil {
+ detail.RestartMax = v
+ }
+ case "TimeoutStartSec":
+ if v, err := strconv.Atoi(opt.Value); err == nil {
+ detail.TimeoutStartSec = v
+ }
+ case "TimeoutStopSec":
+ if v, err := strconv.Atoi(opt.Value); err == nil {
+ detail.TimeoutStopSec = v
+ }
+ case "Environment":
+ // 格式: KEY=VALUE
+ if kv := r.parseEnvironment(opt.Value); kv != nil {
+ detail.Environments = append(detail.Environments, *kv)
+ }
+ case "StandardOutput":
+ detail.StandardOutput = opt.Value
+ case "StandardError":
+ detail.StandardError = opt.Value
+ case "MemoryLimit":
+ if v, err := r.parseBytes(opt.Value); err == nil {
+ detail.MemoryLimit = v
+ }
+ case "CPUQuota":
+ if v, err := r.parsePercent(opt.Value); err == nil {
+ detail.CPUQuota = v
+ }
+ case "NoNewPrivileges":
+ detail.NoNewPrivileges = opt.Value == "true" || opt.Value == "yes"
+ case "ProtectTmp":
+ detail.ProtectTmp = opt.Value == "true" || opt.Value == "yes"
+ case "ProtectHome":
+ detail.ProtectHome = opt.Value == "true" || opt.Value == "yes"
+ case "ProtectSystem":
+ detail.ProtectSystem = opt.Value
+ case "ReadWritePaths":
+ detail.ReadWritePaths = append(detail.ReadWritePaths, opt.Value)
+ case "ReadOnlyPaths":
+ detail.ReadOnlyPaths = append(detail.ReadOnlyPaths, opt.Value)
+ }
+}
+
+// parseEnvironment 解析环境变量
+func (r *projectRepo) parseEnvironment(value string) *types.KV {
+ parts := strings.SplitN(value, "=", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+ return &types.KV{Key: parts[0], Value: parts[1]}
+}
+
+// parseBytes 解析字节大小 (如 512M, 1G)
+func (r *projectRepo) parseBytes(value string) (float64, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 0, errors.New("empty value")
+ }
+
+ multiplier := float64(1)
+ suffix := value[len(value)-1]
+ switch suffix {
+ case 'K', 'k':
+ multiplier = 1024
+ value = value[:len(value)-1]
+ case 'M', 'm':
+ multiplier = 1024 * 1024
+ value = value[:len(value)-1]
+ case 'G', 'g':
+ multiplier = 1024 * 1024 * 1024
+ value = value[:len(value)-1]
+ }
+
+ return cast.ToFloat64(value) * multiplier, nil
+}
+
+// formatBytes 格式化字节大小
+func (r *projectRepo) formatBytes(bytes float64) string {
+ b := int64(bytes)
+ if b >= 1024*1024*1024 && b%(1024*1024*1024) == 0 {
+ return fmt.Sprintf("%dG", b/(1024*1024*1024))
+ }
+ if b >= 1024*1024 && b%(1024*1024) == 0 {
+ return fmt.Sprintf("%dM", b/(1024*1024))
+ }
+ if b >= 1024 && b%1024 == 0 {
+ return fmt.Sprintf("%dK", b/1024)
+ }
+ return strconv.FormatInt(b, 10)
+}
+
+// parsePercent 解析百分比 (如 50%)
+func (r *projectRepo) parsePercent(value string) (float64, error) {
+ value = strings.TrimSuffix(value, "%")
+ return strconv.ParseFloat(value, 64)
+}
+
+// generateUnitFile 生成 systemd unit 文件
+func (r *projectRepo) generateUnitFile(id uint, req *request.ProjectCreate) error {
+ options := []*unit.UnitOption{
+ // [Unit] section
+ unit.NewUnitOption("Unit", "Description", fmt.Sprintf("AcePanel Project: %s", req.Name)),
+ unit.NewUnitOption("Unit", "After", "network.target"),
+
+ // [Service] section
+ unit.NewUnitOption("Service", "Type", "simple"),
+ unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)),
+ }
+
+ if req.ExecStart != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
+ }
+ if req.User != "" {
+ options = append(options, unit.NewUnitOption("Service", "User", req.User))
+ }
+ if req.Restart != "" {
+ options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
+ } else {
+ options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
+ }
+
+ // 环境变量
+ for _, env := range req.Environments {
+ options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
+ }
+
+ // [Install] section
+ options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
+
+ // 写入文件
+ unitPath := r.unitFilePath(req.Name)
+ reader := unit.Serialize(options)
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(unitPath, content, 0644)
+}
+
+// updateUnitFile 更新 systemd unit 文件
+func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) error {
+ options := []*unit.UnitOption{
+ // [Unit] section
+ unit.NewUnitOption("Unit", "Description", req.Description),
+ }
+
+ // Unit 依赖
+ for _, v := range req.Requires {
+ options = append(options, unit.NewUnitOption("Unit", "Requires", v))
+ }
+ for _, v := range req.Wants {
+ options = append(options, unit.NewUnitOption("Unit", "Wants", v))
+ }
+ for _, v := range req.After {
+ options = append(options, unit.NewUnitOption("Unit", "After", v))
+ }
+ if len(req.After) == 0 {
+ options = append(options, unit.NewUnitOption("Unit", "After", "network.target"))
+ }
+ for _, v := range req.Before {
+ options = append(options, unit.NewUnitOption("Unit", "Before", v))
+ }
+
+ // [Service] section
+ options = append(options, unit.NewUnitOption("Service", "Type", "simple"))
+ options = append(options, unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)))
+
+ if req.ExecStartPre != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecStartPre", req.ExecStartPre))
+ }
+ if req.ExecStart != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
+ }
+ if req.ExecStartPost != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecStartPost", req.ExecStartPost))
+ }
+ if req.ExecStop != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecStop", req.ExecStop))
+ }
+ if req.ExecReload != "" {
+ options = append(options, unit.NewUnitOption("Service", "ExecReload", req.ExecReload))
+ }
+ if req.User != "" {
+ options = append(options, unit.NewUnitOption("Service", "User", req.User))
+ }
+ if req.Restart != "" {
+ options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
+ } else {
+ options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
+ }
+ if req.RestartSec != "" {
+ options = append(options, unit.NewUnitOption("Service", "RestartSec", req.RestartSec))
+ }
+ if req.RestartMax > 0 {
+ options = append(options, unit.NewUnitOption("Service", "StartLimitBurst", strconv.Itoa(req.RestartMax)))
+ }
+ if req.TimeoutStartSec > 0 {
+ options = append(options, unit.NewUnitOption("Service", "TimeoutStartSec", strconv.Itoa(req.TimeoutStartSec)))
+ }
+ if req.TimeoutStopSec > 0 {
+ options = append(options, unit.NewUnitOption("Service", "TimeoutStopSec", strconv.Itoa(req.TimeoutStopSec)))
+ }
+
+ // 环境变量
+ for _, env := range req.Environments {
+ options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
+ }
+
+ // 输出
+ if req.StandardOutput != "" {
+ options = append(options, unit.NewUnitOption("Service", "StandardOutput", req.StandardOutput))
+ }
+ if req.StandardError != "" {
+ options = append(options, unit.NewUnitOption("Service", "StandardError", req.StandardError))
+ }
+
+ // 资源限制
+ if req.MemoryLimit > 0 {
+ options = append(options, unit.NewUnitOption("Service", "MemoryLimit", r.formatBytes(req.MemoryLimit)))
+ }
+ if req.CPUQuota != "" {
+ options = append(options, unit.NewUnitOption("Service", "CPUQuota", req.CPUQuota))
+ }
+
+ // 安全选项
+ if req.NoNewPrivileges {
+ options = append(options, unit.NewUnitOption("Service", "NoNewPrivileges", "true"))
+ }
+ if req.ProtectTmp {
+ options = append(options, unit.NewUnitOption("Service", "ProtectTmp", "true"))
+ }
+ if req.ProtectHome {
+ options = append(options, unit.NewUnitOption("Service", "ProtectHome", "true"))
+ }
+ if req.ProtectSystem != "" {
+ options = append(options, unit.NewUnitOption("Service", "ProtectSystem", req.ProtectSystem))
+ }
+ for _, v := range req.ReadWritePaths {
+ options = append(options, unit.NewUnitOption("Service", "ReadWritePaths", v))
+ }
+ for _, v := range req.ReadOnlyPaths {
+ options = append(options, unit.NewUnitOption("Service", "ReadOnlyPaths", v))
+ }
+
+ // [Install] section
+ options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
+
+ // 写入文件
+ unitPath := r.unitFilePath(name)
+ reader := unit.Serialize(options)
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(unitPath, content, 0644)
+}
diff --git a/internal/http/request/project.go b/internal/http/request/project.go
new file mode 100644
index 00000000..373eb1c4
--- /dev/null
+++ b/internal/http/request/project.go
@@ -0,0 +1,51 @@
+package request
+
+import "github.com/acepanel/panel/pkg/types"
+
+type ProjectCreate struct {
+ Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
+ Type types.ProjectType `form:"type" json:"type" validate:"required|in:general,php,java,go,python,nodejs"`
+ Description string `form:"description" json:"description"`
+ RootDir string `form:"root_dir" json:"root_dir" validate:"required"`
+ WorkingDir string `form:"working_dir" json:"working_dir"`
+ ExecStart string `form:"exec_start" json:"exec_start"`
+ User string `form:"user" json:"user"`
+ Restart string `json:"restart"`
+ Environments []types.KV
+}
+
+type ProjectUpdate struct {
+ ID uint `form:"id" json:"id" validate:"required|exists:projects,id"`
+ Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
+ Description string `form:"description" json:"description"`
+ RootDir string `form:"root_dir" json:"root_dir" validate:"required"`
+ WorkingDir string `form:"working_dir" json:"working_dir"`
+ ExecStartPre string `form:"exec_start_pre" json:"exec_start_pre"`
+ ExecStartPost string `form:"exec_start_post" json:"exec_start_post"`
+ ExecStart string `form:"exec_start" json:"exec_start"`
+ ExecStop string `form:"exec_stop" json:"exec_stop"`
+ ExecReload string `form:"exec_reload" json:"exec_reload"`
+ User string `form:"user" json:"user"`
+ Restart string `json:"restart"`
+ RestartSec string `json:"restart_sec"`
+ RestartMax int `json:"restart_max"`
+ TimeoutStartSec int `json:"timeout_start_sec"`
+ TimeoutStopSec int `json:"timeout_stop_sec"`
+ Environments []types.KV `form:"environments" json:"environments"`
+ StandardOutput string `form:"standard_output" json:"standard_output"`
+ StandardError string `form:"standard_error" json:"standard_error"`
+ Requires []string `form:"requires" json:"requires"`
+ Wants []string `form:"wants" json:"wants"`
+ After []string `form:"after" json:"after"`
+ Before []string `form:"before" json:"before"`
+
+ MemoryLimit float64 `form:"memory_limit" json:"memory_limit"`
+ CPUQuota string `form:"cpu_quota" json:"cpu_quota"`
+
+ NoNewPrivileges bool `form:"no_new_privileges" json:"no_new_privileges"`
+ ProtectTmp bool `form:"protect_tmp" json:"protect_tmp"`
+ ProtectHome bool `form:"protect_home" json:"protect_home"`
+ ProtectSystem string `form:"protect_system" json:"protect_system"`
+ ReadWritePaths []string `form:"read_write_paths" json:"read_write_paths"`
+ ReadOnlyPaths []string `form:"read_only_paths" json:"read_only_paths"`
+}
diff --git a/internal/migration/v1.go b/internal/migration/v1.go
index 4833227d..682cc12b 100644
--- a/internal/migration/v1.go
+++ b/internal/migration/v1.go
@@ -50,4 +50,13 @@ func init() {
)
},
})
+ Migrations = append(Migrations, &gormigrate.Migration{
+ ID: "20260110-add-project",
+ Migrate: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&biz.Project{})
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Migrator().DropTable(&biz.Project{})
+ },
+ })
}
diff --git a/internal/route/http.go b/internal/route/http.go
index e385f536..4beadf25 100644
--- a/internal/route/http.go
+++ b/internal/route/http.go
@@ -22,6 +22,7 @@ type Http struct {
home *service.HomeService
task *service.TaskService
website *service.WebsiteService
+ project *service.ProjectService
database *service.DatabaseService
databaseServer *service.DatabaseServerService
databaseUser *service.DatabaseUserService
@@ -61,6 +62,7 @@ func NewHttp(
home *service.HomeService,
task *service.TaskService,
website *service.WebsiteService,
+ project *service.ProjectService,
database *service.DatabaseService,
databaseServer *service.DatabaseServerService,
databaseUser *service.DatabaseUserService,
@@ -99,6 +101,7 @@ func NewHttp(
home: home,
task: task,
website: website,
+ project: project,
database: database,
databaseServer: databaseServer,
databaseUser: databaseUser,
@@ -199,6 +202,14 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/{id}/obtain_cert", route.website.ObtainCert)
})
+ r.Route("/project", func(r chi.Router) {
+ r.Get("/", route.project.List)
+ r.Post("/", route.project.Create)
+ r.Get("/{id}", route.project.Get)
+ r.Put("/{id}", route.project.Update)
+ r.Delete("/{id}", route.project.Delete)
+ })
+
r.Route("/database", func(r chi.Router) {
r.Get("/", route.database.List)
r.Post("/", route.database.Create)
diff --git a/internal/service/project.go b/internal/service/project.go
new file mode 100644
index 00000000..f069b743
--- /dev/null
+++ b/internal/service/project.go
@@ -0,0 +1,101 @@
+package service
+
+import (
+ "net/http"
+
+ "github.com/libtnb/chix"
+
+ "github.com/acepanel/panel/internal/biz"
+ "github.com/acepanel/panel/internal/http/request"
+)
+
+type ProjectService struct {
+ projectRepo biz.ProjectRepo
+}
+
+func NewProjectService(project biz.ProjectRepo) *ProjectService {
+ return &ProjectService{
+ projectRepo: project,
+ }
+}
+
+func (s *ProjectService) List(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.Paginate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ projects, total, err := s.projectRepo.List(req.Page, req.Limit)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, chix.M{
+ "total": total,
+ "items": projects,
+ })
+}
+
+func (s *ProjectService) Get(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ID](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ project, err := s.projectRepo.Get(req.ID)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, project)
+}
+
+func (s *ProjectService) Create(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ProjectCreate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ project, err := s.projectRepo.Create(req)
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, project)
+}
+
+func (s *ProjectService) Update(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ProjectUpdate](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ if err = s.projectRepo.Update(req); err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, nil)
+}
+
+func (s *ProjectService) Delete(w http.ResponseWriter, r *http.Request) {
+ req, err := Bind[request.ID](r)
+ if err != nil {
+ Error(w, http.StatusUnprocessableEntity, "%v", err)
+ return
+ }
+
+ if err = s.projectRepo.Delete(req.ID); err != nil {
+ Error(w, http.StatusInternalServerError, "%v", err)
+ return
+ }
+
+ Success(w, nil)
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 065972ba..1a0ed291 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet(
NewHomeService,
NewMonitorService,
NewProcessService,
+ NewProjectService,
NewSafeService,
NewSettingService,
NewSSHService,
diff --git a/pkg/types/project.go b/pkg/types/project.go
new file mode 100644
index 00000000..4fdcf144
--- /dev/null
+++ b/pkg/types/project.go
@@ -0,0 +1,56 @@
+package types
+
+type ProjectType string
+
+const (
+ ProjectTypeGeneral ProjectType = "general"
+ ProjectTypePHP ProjectType = "php"
+ ProjectTypeJava ProjectType = "java"
+ ProjectTypeGo ProjectType = "go"
+ ProjectTypePython ProjectType = "python"
+ ProjectTypeNodejs ProjectType = "nodejs"
+)
+
+type ProjectDetail struct {
+ ID uint `json:"id"` // 项目 ID
+ Name string `json:"name"` // 项目名称
+ Type ProjectType `json:"type"` // 项目类型
+ Description string `json:"description"` // 项目描述
+ RootDir string `json:"root_dir"` // 项目路径
+ WorkingDir string `json:"working_dir"` // 运行目录
+ ExecStartPre string `json:"exec_start_pre"` // 启动前命令
+ ExecStartPost string `json:"exec_start_post"` // 启动后命令
+ ExecStart string `json:"exec_start"` // 启动命令
+ ExecStop string `json:"exec_stop"` // 停止命令
+ ExecReload string `json:"exec_reload"` // 重载命令
+ User string `json:"user"` // 运行用户
+ Restart string `json:"restart"` // 重启策略
+ RestartSec string `json:"restart_sec"` // 重启间隔
+ RestartMax int `json:"restart_max"` // 最大重启次数
+ TimeoutStartSec int `json:"timeout_start_sec"` // 启动超时(秒)
+ TimeoutStopSec int `json:"timeout_stop_sec"` // 停止超时(秒)
+ Environments []KV `json:"environments"` // 环境变量
+ StandardOutput string `json:"standard_output"` // 标准输出 journal/file:/path
+ StandardError string `json:"standard_error"` // 标准错误 journal/file:/path
+ Requires []string `json:"requires"` // 依赖服务(强依赖)
+ Wants []string `json:"wants"` // 依赖服务(弱依赖)
+ After []string `json:"after"` // 启动顺序(在...之后启动)
+ Before []string `json:"before"` // 启动顺序(在...之前启动)
+
+ // 运行状态
+ Status string `json:"status"` // 运行状态
+ PID int `json:"pid"` // 进程ID
+ Memory int64 `json:"memory"` // 内存使用(字节)
+ CPU float64 `json:"cpu"` // CPU使用率
+ Uptime string `json:"uptime"` // 运行时间
+ MemoryLimit float64 `json:"memory_limit"` // 内存限制(字节)
+ CPUQuota float64 `json:"cpu_quota"` // CPU限制(百分比)
+
+ // 安全相关
+ NoNewPrivileges bool `json:"no_new_privileges"` // 无新特权
+ ProtectTmp bool `json:"protect_tmp"` // 保护临时目录
+ ProtectHome bool `json:"protect_home"` // 保护主目录
+ ProtectSystem string `json:"protect_system"` // 保护系统 full/strict
+ ReadWritePaths []string `json:"read_write_paths"` // 读写路径
+ ReadOnlyPaths []string `json:"read_only_paths"` // 只读路径
+}
diff --git a/web/src/views/project/IndexView.vue b/web/src/views/project/IndexView.vue
index e9c0c773..75a8e50f 100644
--- a/web/src/views/project/IndexView.vue
+++ b/web/src/views/project/IndexView.vue
@@ -13,6 +13,7 @@ const currentTab = ref('general')
+