From dcca17e3712c4caa4b6b6b8f652e14fdee3d0e6a Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 20:53:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E9=A1=B9=E7=9B=AE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=B8=8E=E5=BE=85=E4=BF=AE=E5=A4=8D=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 1 + .../app/project_lifecycle_api_test.go | 53 ++++ .../internal/httpapi/project_lifecycle_api.go | 135 ++++++++ .../httpapi/project_lifecycle_api_test.go | 162 ++++++++++ .../project_lifecycle_boundary_test.go | 115 +++++++ .../project_members_test_helpers_test.go | 2 +- backend/internal/httpapi/server.go | 4 + .../internal/repository/project_directory.go | 19 +- .../repository/project_directory_test.go | 5 +- .../internal/repository/project_lifecycle.go | 155 +++++++++ .../repository/project_lifecycle_failure.go | 43 +++ .../repository/project_lifecycle_test.go | 300 ++++++++++++++++++ doc/Scert整改任务执行清单.md | 12 +- doc/Scert整改合并优化清单.md | 8 +- frontend/package.json | 2 +- .../ProjectManagement/ProjectLifecycle.js | 111 +++++++ .../ProjectLifecycle.test.js | 173 ++++++++++ .../ProjectManagement/ProjectManagement.css | 44 +++ .../ProjectManagement/ProjectManagement.js | 29 +- .../ProjectManagement/ProjectManagement.vue | 37 ++- 20 files changed, 1383 insertions(+), 27 deletions(-) create mode 100644 backend/internal/app/project_lifecycle_api_test.go create mode 100644 backend/internal/httpapi/project_lifecycle_api.go create mode 100644 backend/internal/httpapi/project_lifecycle_api_test.go create mode 100644 backend/internal/httpapi/project_lifecycle_boundary_test.go create mode 100644 backend/internal/repository/project_lifecycle.go create mode 100644 backend/internal/repository/project_lifecycle_failure.go create mode 100644 backend/internal/repository/project_lifecycle_test.go create mode 100644 frontend/src/views/ProjectManagement/ProjectLifecycle.js create mode 100644 frontend/src/views/ProjectManagement/ProjectLifecycle.test.js diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 073a42a..68b17ce 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -157,6 +157,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore}, ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), + ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker, IssueCertificate: workflow.IssueForHTTP, IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) { diff --git a/backend/internal/app/project_lifecycle_api_test.go b/backend/internal/app/project_lifecycle_api_test.go new file mode 100644 index 0000000..f5f7bbd --- /dev/null +++ b/backend/internal/app/project_lifecycle_api_test.go @@ -0,0 +1,53 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// 走真实应用装配和会话登录,不能以测试夹具注入证明生产接线正确。 +func TestApplicationWiresProjectLifecycleAPI(t *testing.T) { + cfg := readyApplicationConfig(t) + application, err := NewWithConfigPath(t.Context(), cfg, filepath.Join(cfg.Paths.Config, "scert.json")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + var cookies []*http.Cookie + request := func(method, path, body string, status int) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + response := httptest.NewRecorder() + application.HTTP.Handler().ServeHTTP(response, req) + if response.Code != status { + t.Fatalf("实际应用请求失败:%s %s,%d,%s", method, path, response.Code, response.Body.String()) + } + return response + } + login := request(http.MethodPost, "/api/v1/auth/login", `{"identifier":"admin","password":"Test1234!"}`, 200) + cookies = login.Result().Cookies() + created := request(http.MethodPost, "/api/v1/projects", `{"name":"实际应用项目","description":"接线验证"}`, 201) + var item domain.Project + if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil || item.ID == "" { + t.Fatalf("创建未返回标识:%v", err) + } + path := "/api/v1/projects/" + item.ID + request(http.MethodPut, path, `{"name":"修改后的项目"}`, 200) + directory := request(http.MethodGet, "/api/v1/projects?page_size=30", "", 200) + if !strings.Contains(directory.Body.String(), `"can_create_project":true`) || !strings.Contains(directory.Body.String(), "修改后的项目") { + t.Fatalf("真实目录未更新或缺少能力:%s", directory.Body.String()) + } + request(http.MethodDelete, path, fmt.Sprintf(`{"confirmation":%q}`, item.ID), 204) + request(http.MethodPut, path, `{"name":"已删除项目"}`, 403) +} diff --git a/backend/internal/httpapi/project_lifecycle_api.go b/backend/internal/httpapi/project_lifecycle_api.go new file mode 100644 index 0000000..ee0917e --- /dev/null +++ b/backend/internal/httpapi/project_lifecycle_api.go @@ -0,0 +1,135 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func (s *Server) registerProjectLifecycleRoutes() { + api := s.router.Group("/api/v1/projects", privateProjectResponse) + api.POST("", s.projectFailureAudit("project.create"), s.requireAuthenticated, s.createProject) + api.PUT("/:projectID", s.projectFailureAudit("project.update"), s.requireAuthenticated, s.updateProject) + api.DELETE("/:projectID", s.projectFailureAudit("project.delete"), s.requireAuthenticated, s.deleteProject) +} + +func (s *Server) projectLifecycleReady(c *gin.Context, creating bool) bool { + if s.projectLifecycle == nil { + s.writeError(c, http.StatusServiceUnavailable, "项目管理服务尚未就绪", nil) + return false + } + if c.Request.URL.RawQuery != "" || (!creating && !validMemberIdentifier(c.Param("projectID"))) { + s.writeError(c, http.StatusBadRequest, "项目请求参数无效", nil) + return false + } + return true +} + +type projectDetailsRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (s *Server) projectDetails(c *gin.Context) (projectDetailsRequest, bool) { + var input projectDetailsRequest + if err := bindJSON(c, &input, jsonRequestBodyLimit); err != nil { + s.writeError(c, http.StatusBadRequest, "项目资料格式无效", nil) + return input, false + } + item := (domain.Project{ID: "validation", Name: input.Name, Description: input.Description}).Normalize() + if err := item.Validate(); err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), nil) + return input, false + } + return projectDetailsRequest{Name: item.Name, Description: item.Description}, true +} + +func (s *Server) createProject(c *gin.Context) { + if !s.projectLifecycleReady(c, true) { + return + } + input, ok := s.projectDetails(c) + if !ok { + return + } + item, err := s.projectLifecycle.CreateManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), input.Name, input.Description) + if err != nil { + s.writeProjectLifecycleError(c, err) + return + } + c.JSON(http.StatusCreated, item) +} + +func (s *Server) updateProject(c *gin.Context) { + if !s.projectLifecycleReady(c, false) { + return + } + input, ok := s.projectDetails(c) + if !ok { + return + } + if err := s.projectLifecycle.UpdateManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), input.Name, input.Description); err != nil { + s.writeProjectLifecycleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "项目资料已更新"}) +} + +func (s *Server) deleteProject(c *gin.Context) { + if !s.projectLifecycleReady(c, false) { + return + } + var input struct { + Confirmation string `json:"confirmation"` + } + if err := bindJSON(c, &input, jsonRequestBodyLimit); err != nil || input.Confirmation != c.Param("projectID") { + s.writeError(c, http.StatusBadRequest, "请确认要删除的项目,确认标识必须与目标项目一致", nil) + return + } + if err := s.projectLifecycle.DeleteManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID")); err != nil { + s.writeProjectLifecycleError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (s *Server) writeProjectLifecycleError(c *gin.Context, err error) { + switch { + case errors.Is(err, repository.ErrProjectAccessDenied): + s.writeError(c, http.StatusForbidden, "无权访问项目或执行此操作", nil) + case errors.Is(err, repository.ErrDefaultProject): + s.writeError(c, http.StatusConflict, "默认项目不能删除", nil) + case errors.Is(err, repository.ErrProjectReferenced): + s.writeError(c, http.StatusConflict, "项目仍有关联资源或成员,请先移出后重试", nil) + default: + s.writeError(c, http.StatusInternalServerError, "项目操作失败,请稍后重试", nil) + } +} + +func (s *Server) projectFailureAudit(action string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if c.Writer.Status() < 400 || s.projectLifecycle == nil { + return + } + projectID := c.Param("projectID") + if !validMemberIdentifier(projectID) { + projectID = "" + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second) + defer cancel() + err := s.projectLifecycle.RecordProjectFailure(ctx, repository.ProjectFailure{ + ActorID: c.GetString(authenticatedActorKey), ProjectID: projectID, Action: action, + StatusCode: c.Writer.Status(), RequestID: requestIDFromContext(c), + }) + if err != nil && s.logger != nil { + s.logger.Error("项目失败审计未保存", "request_id", requestIDFromContext(c), "status", c.Writer.Status()) + } + } +} diff --git a/backend/internal/httpapi/project_lifecycle_api_test.go b/backend/internal/httpapi/project_lifecycle_api_test.go new file mode 100644 index 0000000..05f4335 --- /dev/null +++ b/backend/internal/httpapi/project_lifecycle_api_test.go @@ -0,0 +1,162 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestProjectLifecycleAPIFlow(t *testing.T) { + f := newProjectMemberAPIFixture(t) + owner := f.cookies["primary-super-admin"] + created := serveUserRequest(f.server, owner, http.MethodPost, "/api/v1/projects", `{"name":"新项目","description":"项目说明"}`) + var item domain.Project + if created.Code != http.StatusCreated || json.Unmarshal(created.Body.Bytes(), &item) != nil || item.ID == "" || item.Name != "新项目" { + t.Fatalf("项目创建失败:%d,%s", created.Code, created.Body.String()) + } + path := "/api/v1/projects/" + item.ID + updated := serveUserRequest(f.server, owner, http.MethodPut, path, `{"name":"更新项目","description":"更新说明"}`) + if updated.Code != http.StatusOK { + t.Fatalf("项目更新失败:%d,%s", updated.Code, updated.Body.String()) + } + stored, err := repository.NewSQLProjectRepository(f.db).GetProject(t.Context(), item.ID) + if err != nil || stored.Name != "更新项目" || !stored.CreatedAt.Equal(item.CreatedAt) { + t.Fatalf("项目资料或创建时间错误:%+v,%v", stored, err) + } + deleted := serveUserRequest(f.server, owner, http.MethodDelete, path, fmt.Sprintf(`{"confirmation":%q}`, item.ID)) + if deleted.Code != http.StatusNoContent || deleted.Body.Len() != 0 { + t.Fatalf("项目删除失败:%d,%s", deleted.Code, deleted.Body.String()) + } + for _, response := range []string{created.Header().Get("Cache-Control"), updated.Header().Get("Cache-Control"), deleted.Header().Get("Cache-Control")} { + if !strings.Contains(response, "no-store") { + t.Fatal("项目变更响应允许缓存") + } + } + log := f.lastAudit(t) + if log.Action != "project.delete" || log.Status != "success" || log.ProjectID != item.ID || log.OperatorID != "primary-super-admin" { + t.Fatalf("删除后的审计快照错误:%+v", log) + } +} + +func TestProjectLifecycleAPIAuthorization(t *testing.T) { + f := newProjectMemberAPIFixture(t) + for _, tc := range []struct { + name, actor, method, project string + status int + }{ + {"anonymous", "", http.MethodPost, "", 401}, + {"global_admin_create", "global-admin", http.MethodPost, "", 403}, + {"project_admin_create", "manager", http.MethodPost, "", 403}, + {"global_admin_update", "global-admin", http.MethodPut, "project-a", 403}, + {"viewer_update", "viewer", http.MethodPut, "project-a", 403}, + {"cross_project", "manager", http.MethodPut, "project-b", 403}, + {"missing", "primary-super-admin", http.MethodPut, "missing", 403}, + {"manager_update", "manager", http.MethodPut, "project-a", 200}, + {"default_protected", "primary-super-admin", http.MethodDelete, "default", 409}, + {"member_reference", "primary-super-admin", http.MethodDelete, "project-a", 409}, + {"manager_reference", "manager", http.MethodDelete, "project-a", 409}, + {"cross_project_delete", "manager", http.MethodDelete, "project-b", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + path, body := "/api/v1/projects", `{"name":"新名称","description":"不能进入审计的说明"}` + if tc.project != "" { + path += "/" + tc.project + } + if tc.method == http.MethodDelete { + body = fmt.Sprintf(`{"confirmation":%q}`, tc.project) + } + response := serveUserRequest(f.server, f.cookies[tc.actor], tc.method, path, body) + if response.Code != tc.status { + t.Fatalf("项目权限结果错误:%d,%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Header().Get("Cache-Control"), "no-store") { + t.Fatal("错误响应允许缓存") + } + if tc.status >= 400 { + log := f.lastAudit(t) + if log.OperatorID != tc.actor || log.Status != "failed" || !strings.Contains(log.Message, response.Header().Get(requestIDHeader)) || strings.Contains(log.Message, "不能进入审计") { + t.Fatalf("失败审计身份、关联或脱敏错误:%+v", log) + } + if (tc.project == "missing" || tc.project == "") && (log.ScopeType != repository.ScopeUnknown || log.ProjectID != "") { + t.Fatalf("伪造项目归属:%+v", log) + } + } + }) + } +} + +func TestProjectLifecycleAPIRejectsInvalidInput(t *testing.T) { + f := newProjectMemberAPIFixture(t) + for _, tc := range []struct{ method, suffix, body string }{ + {http.MethodPost, "", `{"name":"名称","id":"forged"}`}, + {http.MethodPost, "", `{"name":"名称","actor_id":"forged"}`}, + {http.MethodPost, "", `{"name":"名称","created_at":"2026-01-01"}`}, + {http.MethodPost, "", `{"name":"名称"} {}`}, + {http.MethodPost, "", `null`}, + {http.MethodPost, "", `{"name":" "}`}, + {http.MethodPost, "", `{"name":"` + strings.Repeat("a", 129) + `"}`}, + {http.MethodPut, "/project-b", `{"name":"名称","owner_id":"forged"}`}, + {http.MethodPut, "/project-b?ignored=1", `{"name":"名称"}`}, + {http.MethodPut, "/project-b", strings.Repeat("x", 70*1024)}, + {http.MethodDelete, "/project-b", `{}`}, + {http.MethodDelete, "/project-b", `{"confirmation":"project-a"}`}, + {http.MethodDelete, "/project-b", `{"confirmation":"project-b","actor_id":"forged"}`}, + {http.MethodDelete, "/project-b?ignored=1", `{"confirmation":"project-b"}`}, + } { + t.Run(fmt.Sprintf("%s-%d", tc.method, len(tc.body))+tc.suffix, func(t *testing.T) { + response := serveUserRequest(f.server, f.cookies["primary-super-admin"], tc.method, "/api/v1/projects"+tc.suffix, tc.body) + if response.Code != http.StatusBadRequest { + t.Fatalf("非法输入未拒绝:%d,%s", response.Code, response.Body.String()) + } + if log := f.lastAudit(t); log.Status != "failed" || strings.Contains(log.Message, "forged") { + t.Fatalf("失败审计错误:%+v", log) + } + }) + } + var count int + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 3 { + t.Fatalf("非法请求改变项目:%d,%v", count, err) + } +} + +func TestProjectLifecycleAPIAuditFailureRollsBack(t *testing.T) { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + for _, mode := range []string{"ABORT, 'sensitive-marker'", "IGNORE"} { + t.Run(method+mode, func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + _, err := f.db.ExecContext(t.Context(), `CREATE TRIGGER reject_project_audit BEFORE INSERT ON audit_logs + WHEN NEW.resource_type='project' AND NEW.status='success' BEGIN SELECT RAISE(`+mode+`); END`) + if err != nil { + t.Fatal(err) + } + path, body := "/api/v1/projects", `{"name":"不能保存","description":"sensitive-marker"}` + if method != http.MethodPost { + path += "/project-b" + } + if method == http.MethodDelete { + body = `{"confirmation":"project-b"}` + } + response := serveUserRequest(f.server, f.cookies["primary-super-admin"], method, path, body) + if response.Code != http.StatusInternalServerError || strings.Contains(response.Body.String(), "sensitive-marker") { + t.Fatalf("审计失败误报或泄密:%d,%s", response.Code, response.Body.String()) + } + var count int + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 3 { + t.Fatalf("项目数量未回滚:%d,%v", count, err) + } + var name string + if err := f.db.QueryRowContext(t.Context(), "SELECT name FROM projects WHERE id='project-b'").Scan(&name); err != nil || name != "project-b" { + t.Fatalf("项目内容未回滚:%s,%v", name, err) + } + if log := f.lastAudit(t); log.Status != "failed" || log.OperatorID != "primary-super-admin" || strings.Contains(log.Message, "sensitive-marker") { + t.Fatalf("失败审计未保存或泄密:%+v", log) + } + }) + } + } +} diff --git a/backend/internal/httpapi/project_lifecycle_boundary_test.go b/backend/internal/httpapi/project_lifecycle_boundary_test.go new file mode 100644 index 0000000..219f202 --- /dev/null +++ b/backend/internal/httpapi/project_lifecycle_boundary_test.go @@ -0,0 +1,115 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +type projectLifecycleBoundaryRepository struct { + repository.ProjectLifecycleRepository + before func() + audit func(context.Context) +} + +func (r projectLifecycleBoundaryRepository) CreateManagedProject(ctx context.Context, actor, name, description string) (domain.Project, error) { + r.before() + return r.ProjectLifecycleRepository.CreateManagedProject(ctx, actor, name, description) +} + +func (r projectLifecycleBoundaryRepository) UpdateManagedProject(ctx context.Context, actor, id, name, description string) error { + r.before() + return r.ProjectLifecycleRepository.UpdateManagedProject(ctx, actor, id, name, description) +} + +func (r projectLifecycleBoundaryRepository) DeleteManagedProject(ctx context.Context, actor, id string) error { + r.before() + return r.ProjectLifecycleRepository.DeleteManagedProject(ctx, actor, id) +} + +func (r projectLifecycleBoundaryRepository) RecordProjectFailure(ctx context.Context, failure repository.ProjectFailure) error { + if r.audit != nil { + r.audit(ctx) + } + return r.ProjectLifecycleRepository.RecordProjectFailure(ctx, failure) +} + +func TestProjectLifecycleAPIRechecksAfterAuthentication(t *testing.T) { + for _, change := range []string{"disable", "demote"} { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + t.Run(change+method, func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + called := false + f.server.projectLifecycle = projectLifecycleBoundaryRepository{ProjectLifecycleRepository: f.members, before: func() { + called = true + query := "UPDATE users SET status='disabled' WHERE id='primary-super-admin'" + if change == "demote" { + query = "UPDATE users SET role='admin' WHERE id='primary-super-admin'" + } + if _, err := f.db.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + }} + path, body := "/api/v1/projects", `{"name":"不能保存"}` + if method != http.MethodPost { + path += "/project-b" + } + if method == http.MethodDelete { + body = `{"confirmation":"project-b"}` + } + response := serveUserRequest(f.server, f.cookies["primary-super-admin"], method, path, body) + log := f.lastAudit(t) + if !called || response.Code != http.StatusForbidden || log.OperatorID != "primary-super-admin" || log.Status != "failed" { + t.Fatalf("会话后的权限变化未生效:%d,%+v", response.Code, log) + } + var count int + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 3 { + t.Fatalf("撤权后项目发生变化:%d,%v", count, err) + } + }) + } + } + t.Run("project_role_revoked", func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + f.server.projectLifecycle = projectLifecycleBoundaryRepository{ProjectLifecycleRepository: f.members, before: func() { + if _, err := f.db.ExecContext(t.Context(), "DELETE FROM project_members WHERE user_id='manager'"); err != nil { + t.Fatal(err) + } + }} + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/projects/project-a", `{"name":"不能保存"}`) + if response.Code != 403 { + t.Fatalf("项目角色撤销后仍允许修改:%d", response.Code) + } + }) +} + +func TestProjectLifecycleAPICanceledRequestKeepsBoundedAudit(t *testing.T) { + f := newProjectMemberAPIFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + audited := false + f.server.projectLifecycle = projectLifecycleBoundaryRepository{ + ProjectLifecycleRepository: f.members, before: cancel, + audit: func(ctx context.Context) { + audited = true + deadline, ok := ctx.Deadline() + if ctx.Err() != nil || !ok || time.Until(deadline) > 3*time.Second { + t.Fatal("失败审计没有独立有界上下文") + } + }, + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/projects", strings.NewReader(`{"name":"取消项目"}`)).WithContext(ctx) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(f.cookies["primary-super-admin"]) + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, request) + if !audited || response.Code != 500 || f.lastAudit(t).Status != "failed" { + t.Fatalf("取消请求后审计丢失:%d", response.Code) + } +} diff --git a/backend/internal/httpapi/project_members_test_helpers_test.go b/backend/internal/httpapi/project_members_test_helpers_test.go index 72d2bc6..573b45e 100644 --- a/backend/internal/httpapi/project_members_test_helpers_test.go +++ b/backend/internal/httpapi/project_members_test_helpers_test.go @@ -48,7 +48,7 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture { db: db.DB, members: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver), audit: repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver), cookies: make(map[string]*http.Cookie), } - f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, Audit: f.audit}) + f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, ProjectLifecycle: f.members, Audit: f.audit}) for _, account := range []struct{ id, role string }{ {"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser}, {"global-admin", repository.UserRoleAdmin}, {"viewer", repository.UserRoleUser}, {"target", repository.UserRoleUser}, diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index f2db92b..8348156 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -50,6 +50,7 @@ type Dependencies struct { Users repository.UserRepository ProjectMembers repository.ProjectMemberRepository ProjectDirectory repository.ProjectDirectoryRepository + ProjectLifecycle repository.ProjectLifecycleRepository SSHKeys *sshkey.Service DeploymentFactory deployment.ClientFactory SetupDNS setupDNSVerifier @@ -83,6 +84,7 @@ type Server struct { users repository.UserRepository projectMembers repository.ProjectMemberRepository projectDirectory repository.ProjectDirectoryRepository + projectLifecycle repository.ProjectLifecycleRepository sshKeys *sshkey.Service deploymentFactory deployment.ClientFactory setupDNS setupDNSVerifier @@ -145,6 +147,7 @@ func New(deps Dependencies) *Server { users: deps.Users, projectMembers: deps.ProjectMembers, projectDirectory: deps.ProjectDirectory, + projectLifecycle: deps.ProjectLifecycle, sshKeys: deps.SSHKeys, deploymentFactory: deps.DeploymentFactory, issueCertificate: deps.IssueCertificate, @@ -193,6 +196,7 @@ func (s *Server) registerRoutes() { userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser) userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser) s.registerProjectMemberRoutes() + s.registerProjectLifecycleRoutes() s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects) adminAPI.GET("/domains", s.listDomains) adminAPI.POST("/domains", s.dnsOperation, s.createDomain) diff --git a/backend/internal/repository/project_directory.go b/backend/internal/repository/project_directory.go index 3a42024..fc6b37c 100644 --- a/backend/internal/repository/project_directory.go +++ b/backend/internal/repository/project_directory.go @@ -17,13 +17,16 @@ type VisibleProject struct { Name string `json:"name"` Description string `json:"description"` CanManageMembers bool `json:"can_manage_members"` + CanManageProject bool `json:"can_manage_project"` + CanDeleteProject bool `json:"can_delete_project"` } type VisibleProjectPage struct { - Items []VisibleProject `json:"items"` - PageSize int `json:"page_size"` - HasMore bool `json:"has_more"` - NextCursor string `json:"next_cursor"` + Items []VisibleProject `json:"items"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` + CanCreateProject bool `json:"can_create_project"` } var _ ProjectDirectoryRepository = (*SQLProjectMemberRepository)(nil) @@ -38,7 +41,7 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac } // 授权、可见性过滤与读取共用一条 SQL;有效账户没有项目时仍返回授权门行。 // 使用 EXISTS 避免一个账户的多个角色造成重复项目和错误分页。 - rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT a.id, p.id, p.name, p.description, + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT a.id, a.role, p.id, p.name, p.description, CASE WHEN a.role = 'super_admin' OR EXISTS ( SELECT 1 FROM project_members manager WHERE manager.project_id = p.id AND manager.user_id = a.id AND manager.role = 'project_admin' @@ -57,16 +60,18 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac defer rows.Close() allowed := false for rows.Next() { - var actor string + var actor, globalRole string var id, name, description sql.NullString var manager int - if err := rows.Scan(&actor, &id, &name, &description, &manager); err != nil { + if err := rows.Scan(&actor, &globalRole, &id, &name, &description, &manager); err != nil { return page, fmt.Errorf("读取可见项目失败:%w", err) } allowed = true + page.CanCreateProject = globalRole == UserRoleSuperAdmin if id.Valid { page.Items = append(page.Items, VisibleProject{ ID: id.String, Name: name.String, Description: description.String, CanManageMembers: manager == 1, + CanManageProject: manager == 1, CanDeleteProject: manager == 1 && id.String != "default", }) } } diff --git a/backend/internal/repository/project_directory_test.go b/backend/internal/repository/project_directory_test.go index 8c65d56..674b833 100644 --- a/backend/internal/repository/project_directory_test.go +++ b/backend/internal/repository/project_directory_test.go @@ -67,6 +67,9 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) { t.Fatalf("项目可见性错误:%s,%+v,%v", tc.actor, page, err) } for _, item := range page.Items { + if page.CanCreateProject != (tc.actor == "root") || item.CanManageProject != tc.manage || item.CanDeleteProject != (tc.manage && item.ID != domain.DefaultProjectID) { + t.Fatalf("生命周期能力错误:%s,%+v,%+v", tc.actor, page, item) + } if item.CanManageMembers != tc.manage || (tc.actor != "root" && item.ID == domain.DefaultProjectID) { t.Fatalf("全局身份被错误映射为项目权限:%s,%+v", tc.actor, item) } @@ -113,7 +116,7 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) { t.Fatal(err) } page, err := repo.ListVisibleProjects(t.Context(), "manager", "", 5) - if err != nil || len(page.Items) == 0 || page.Items[0].CanManageMembers { + if err != nil || len(page.Items) == 0 || page.Items[0].CanManageMembers || page.Items[0].CanManageProject || page.Items[0].CanDeleteProject || page.CanCreateProject { t.Fatalf("撤销管理角色未生效:%+v,%v", page, err) } if err := repo.RevokeProjectRole(t.Context(), "root", "visible-00", "manager", domain.ProjectRoleReadOnly); err != nil { diff --git a/backend/internal/repository/project_lifecycle.go b/backend/internal/repository/project_lifecycle.go new file mode 100644 index 0000000..d8ca4e2 --- /dev/null +++ b/backend/internal/repository/project_lifecycle.go @@ -0,0 +1,155 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ErrProjectReferenced = errors.New("项目仍有关联资源或成员,请先移出后重试") + +// ProjectLifecycleRepository 是面向已认证业务入口的仓储,不能用基础项目仓储替代授权检查。 +type ProjectLifecycleRepository interface { + CreateManagedProject(context.Context, string, string, string) (domain.Project, error) + UpdateManagedProject(context.Context, string, string, string, string) error + DeleteManagedProject(context.Context, string, string) error + RecordProjectFailure(context.Context, ProjectFailure) error +} + +var _ ProjectLifecycleRepository = (*SQLProjectMemberRepository)(nil) + +func (r *SQLProjectMemberRepository) CreateManagedProject(ctx context.Context, actorID, name, description string) (domain.Project, error) { + item, err := domain.NewProject(name, description) + if err != nil { + return domain.Project{}, err + } + err = r.changeManagedProject(ctx, actorID, item, "project.create") + if err != nil { + return domain.Project{}, err + } + return item, nil +} + +func (r *SQLProjectMemberRepository) UpdateManagedProject(ctx context.Context, actorID, id, name, description string) error { + item := (domain.Project{ID: id, Name: name, Description: description}).Normalize() + if err := item.Validate(); err != nil { + return err + } + item.UpdatedAt = time.Now().UTC() + return r.changeManagedProject(ctx, actorID, item, "project.update") +} + +func (r *SQLProjectMemberRepository) DeleteManagedProject(ctx context.Context, actorID, id string) error { + return r.changeManagedProject(ctx, actorID, domain.Project{ID: strings.TrimSpace(id)}, "project.delete") +} + +// 授权在实际变更语句内完成;成功审计与变更同事务,不删除成员或业务资源来迎合删除请求。 +func (r *SQLProjectMemberRepository) changeManagedProject(ctx context.Context, actorID string, item domain.Project, action string) error { + if err := r.ensureDB(); err != nil { + return err + } + actorID = strings.TrimSpace(actorID) + if actorID == "" || item.ID == "" { + return ErrProjectAccessDenied + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开始项目变更事务失败:%w", err) + } + defer tx.Rollback() + if r.driver == "mysql" { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name IN ('projects', 'audit_logs') AND engine='InnoDB'`).Scan(&count); err != nil { + return fmt.Errorf("检查项目审计存储失败:%w", err) + } + if count != 2 { + return errors.New("项目与审计表必须使用 InnoDB,拒绝无法保证回滚的变更") + } + } + if err := r.writeManagedProject(ctx, tx, actorID, item, action); err != nil { + return err + } + // 不把用户输入的名称、说明或请求正文写入审计;删除后仍保留真实项目范围快照。 + result, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + VALUES (?, 'project', ?, 'success', ?, ?, 'project', ?, '', ?)`), + action, item.ID, "项目变更已完成", time.Now().UTC(), item.ID, actorID) + if err != nil { + return fmt.Errorf("保存项目变更审计失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil || count != 1 { + return errors.New("项目变更审计未写入,已回滚本次变更") + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交项目变更失败:%w", err) + } + return nil +} + +const projectHasReferences = `(EXISTS (SELECT 1 FROM domains d WHERE d.project_id = projects.id) + OR EXISTS (SELECT 1 FROM servers s WHERE s.project_id = projects.id) + OR EXISTS (SELECT 1 FROM domain_servers b WHERE b.project_id = projects.id) + OR EXISTS (SELECT 1 FROM project_members m WHERE m.project_id = projects.id))` + +func (r *SQLProjectMemberRepository) writeManagedProject(ctx context.Context, tx *sql.Tx, actorID string, item domain.Project, action string) error { + var query string + var args []any + switch action { + case "project.create": + query = `INSERT INTO projects (id, name, description, created_at, updated_at) + SELECT ?, ?, ?, ?, ? FROM users a WHERE a.id = ? AND a.status = 'active' AND a.role = 'super_admin'` + args = []any{item.ID, item.Name, item.Description, item.CreatedAt, item.UpdatedAt, actorID} + case "project.update": + query = `UPDATE projects SET name = ?, description = ?, updated_at = ? WHERE id = ? AND ` + projectManagerPredicate + args = []any{item.Name, item.Description, item.UpdatedAt, item.ID, actorID, item.ID} + case "project.delete": + query = `DELETE FROM projects WHERE id = ? AND id <> ? AND ` + projectManagerPredicate + ` AND NOT ` + projectHasReferences + args = []any{item.ID, domain.DefaultProjectID, actorID, item.ID} + default: + return errors.New("项目变更动作无效") + } + result, err := tx.ExecContext(ctx, Rebind(r.driver, query), args...) + if err != nil { + return fmt.Errorf("执行项目变更失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取项目变更结果失败:%w", err) + } + if count == 1 { + return nil + } + if count != 0 || action == "project.create" { + return ErrProjectAccessDenied + } + var name, description string + var referenced bool + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT name, description, `+projectHasReferences+ + ` FROM projects WHERE id = ? AND `+projectManagerPredicate), item.ID, actorID, item.ID).Scan(&name, &description, &referenced) + if errors.Is(err, sql.ErrNoRows) { + return ErrProjectAccessDenied + } + if err != nil { + return fmt.Errorf("核对项目变更条件失败:%w", err) + } + if action == "project.delete" { + if item.ID == domain.DefaultProjectID { + return ErrDefaultProject + } + if referenced { + return ErrProjectReferenced + } + } + // MySQL 秒级时间字段与相同资料可能得到零变更行;仅在当前仍有权限且内容一致时认可幂等更新。 + if action == "project.update" && r.driver == "mysql" && name == item.Name && description == item.Description { + return nil + } + return errors.New("项目变更未实际生效,请刷新后重试") +} diff --git a/backend/internal/repository/project_lifecycle_failure.go b/backend/internal/repository/project_lifecycle_failure.go new file mode 100644 index 0000000..9372f58 --- /dev/null +++ b/backend/internal/repository/project_lifecycle_failure.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" +) + +type ProjectFailure struct { + ActorID string + ProjectID string + Action string + StatusCode int + RequestID string +} + +// 失败记录只保留已验证身份、实际范围和安全诊断,不包含请求正文或数据库原始错误。 +func (r *SQLProjectMemberRepository) RecordProjectFailure(ctx context.Context, failure ProjectFailure) error { + if err := r.ensureDB(); err != nil { + return err + } + if failure.Action != "project.create" && failure.Action != "project.update" && failure.Action != "project.delete" { + return errors.New("项目失败审计动作无效") + } + if failure.StatusCode < 400 || failure.StatusCode > 599 || len(failure.ActorID) > 128 || len(failure.ProjectID) > 128 || len(failure.RequestID) > 128 { + return errors.New("项目失败审计参数无效") + } + result, err := r.db.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + SELECT ?, 'project', ?, 'failed', ?, ?, CASE WHEN p.id IS NULL THEN 'unknown' ELSE 'project' END, + COALESCE(p.id, ''), '', ? FROM (SELECT 1 AS marker) audit_event LEFT JOIN projects p ON p.id = ?`), + failure.Action, failure.ProjectID, fmt.Sprintf("请求被拒绝或执行失败:HTTP %d;请求编号:%s", failure.StatusCode, failure.RequestID), + time.Now().UTC(), failure.ActorID, failure.ProjectID) + if err != nil { + return fmt.Errorf("保存项目失败审计失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil || count != 1 { + return errors.New("项目失败审计未写入") + } + return nil +} diff --git a/backend/internal/repository/project_lifecycle_test.go b/backend/internal/repository/project_lifecycle_test.go new file mode 100644 index 0000000..9170f56 --- /dev/null +++ b/backend/internal/repository/project_lifecycle_test.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestProjectLifecycleSQLite(t *testing.T) { + checkProjectLifecycle(t, func(t *testing.T) config.DatabaseConfig { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "project-lifecycle.db") + return cfg + }) +} + +func TestProjectLifecycleMySQLReal(t *testing.T) { + server := mysqltest.New(t) + checkProjectLifecycle(t, server.Database) +} + +func checkProjectLifecycle(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig) { + t.Helper() + run := func(name string, check func(*testing.T, *sql.DB, *SQLProjectMemberRepository, string)) { + t.Run(name, func(t *testing.T) { + cfg := makeConfig(t) + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + for _, user := range []struct{ id, role, status string }{ + {"root", "super_admin", "active"}, {"manager", "user", "active"}, + {"global-admin", "admin", "active"}, {"viewer", "user", "active"}, {"disabled", "super_admin", "disabled"}, + } { + _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users + (id, username, email, password_hash, role, status) VALUES (?, ?, ?, 'test-only-no-login', ?, ?)`), + user.id, user.id, user.id+"@example.invalid", user.role, user.status) + if err != nil { + t.Fatal(err) + } + } + check(t, db.DB, NewSQLProjectMemberRepository(db.DB, cfg.Driver), cfg.Driver) + }) + } + run("create_update_delete_and_history", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + item, err := repo.CreateManagedProject(t.Context(), "root", " 项目一 ", "说明") + if err != nil || item.ID == "" || item.ID == domain.DefaultProjectID || item.Name != "项目一" { + t.Fatalf("创建失败:%+v,%v", item, err) + } + projects := NewSQLProjectRepository(db, driver) + before, err := projects.GetProject(t.Context(), item.ID) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := repo.UpdateManagedProject(t.Context(), "root", item.ID, "项目二", "新说明"); err != nil { + t.Fatal(err) + } + } + after, err := projects.GetProject(t.Context(), item.ID) + if err != nil || !after.CreatedAt.Equal(before.CreatedAt) || after.ID != before.ID || after.Name != "项目二" { + t.Fatalf("更新破坏身份:%+v,%v", after, err) + } + if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil { + t.Fatal(err) + } + if _, err := projects.GetProject(t.Context(), item.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("项目未删除:%v", err) + } + var count int + if err := db.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs WHERE resource_type='project' + AND resource_id=? AND project_id=? AND scope_type='project' AND operator_id='root' AND status='success'`), item.ID, item.ID).Scan(&count); err != nil || count != 4 { + t.Fatalf("删除后丢失真实审计:%d,%v", count, err) + } + }) + for _, actor := range []string{"", "missing", "manager", "global-admin", "viewer", "disabled"} { + run("create_denied_"+actor, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + if _, err := repo.CreateManagedProject(t.Context(), actor, "无权创建", "不应保存"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("错误授权:%v", err) + } + var count int + if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM projects`).Scan(&count); err != nil || count != 1 { + t.Fatalf("拒绝后产生项目:%d,%v", count, err) + } + }) + } + run("project_manager_and_revocation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + item, err := repo.CreateManagedProject(t.Context(), "root", "权限测试", "") + if err != nil { + t.Fatal(err) + } + if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "合法修改", ""); err != nil { + t.Fatal(err) + } + for _, actor := range []string{"viewer", "global-admin", "disabled"} { + if err := repo.UpdateManagedProject(t.Context(), actor, item.ID, "越权修改", ""); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("错误更新授权:%s,%v", actor, err) + } + if err := repo.DeleteManagedProject(t.Context(), actor, item.ID); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("错误删除授权:%s,%v", actor, err) + } + } + if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectReferenced) { + t.Fatalf("不能自动清理成员:%v", err) + } + if err := repo.RevokeProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "撤权后修改", ""); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("撤权未生效:%v", err) + } + if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("撤权后仍可删除:%v", err) + } + if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil { + t.Fatal(err) + } + }) + for _, kind := range []string{"domain", "server", "member"} { + run("reference_"+kind, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + item, err := repo.CreateManagedProject(t.Context(), "root", "引用保护", "") + if err != nil { + t.Fatal(err) + } + switch kind { + case "domain": + resource, err := domain.NewDomain("lifecycle.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + resource.ProjectID = item.ID + if err := NewSQLDomainRepository(db, driver).Create(t.Context(), resource); err != nil { + t.Fatal(err) + } + case "server": + resource := deployment.Server{ID: "server-a", Name: "项目服务器", Address: "192.0.2.10", Port: 22, Username: "deploy", + PrivateKeyRef: "secret://ssh/test", HostKeyFingerprint: "SHA256:test", CertificatePath: "/test/cert.pem", + PrivateKeyPath: "/test/key.pem", CertificateChainPath: "/test/chain.pem", WebServer: deployment.WebServerNginx} + resource.ProjectID = item.ID + if err := NewSQLServerRepository(db, driver).CreateServer(t.Context(), resource); err != nil { + t.Fatal(err) + } + case "member": + if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "viewer", domain.ProjectRoleReadOnly); err != nil { + t.Fatal(err) + } + } + if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); !errors.Is(err, ErrProjectReferenced) { + t.Fatalf("引用保护失败:%v", err) + } + if _, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID); err != nil { + t.Fatal(err) + } + }) + } + run("default_missing_and_cancelled", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + if err := repo.DeleteManagedProject(t.Context(), "root", domain.DefaultProjectID); !errors.Is(err, ErrDefaultProject) { + t.Fatalf("默认项目删除:%v", err) + } + if err := repo.DeleteManagedProject(t.Context(), "viewer", domain.DefaultProjectID); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("默认项目泄露权限:%v", err) + } + if err := repo.DeleteManagedProject(t.Context(), "root", "missing"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("不存在项目:%v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := repo.CreateManagedProject(ctx, "root", "取消", ""); err == nil { + t.Fatal("取消后创建成功") + } + }) + for _, action := range []string{"create", "update", "delete"} { + run("audit_rollback_"+action, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + item, err := repo.CreateManagedProject(t.Context(), "root", "必须保留", "") + if err != nil { + t.Fatal(err) + } + trigger := `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END` + if driver == "mysql" { + trigger = `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'` + } + if _, err := db.ExecContext(t.Context(), trigger); err != nil { + t.Fatal(err) + } + switch action { + case "create": + _, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "") + case "update": + err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "") + case "delete": + err = repo.DeleteManagedProject(t.Context(), "root", item.ID) + } + if err == nil { + t.Fatal("审计异常仍返回成功") + } + loaded, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID) + if err != nil || loaded.Name != "必须保留" { + t.Fatalf("项目未回滚:%+v,%v", loaded, err) + } + var count int + if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 2 { + t.Fatalf("产生部分写入:%d,%v", count, err) + } + }) + } + run("nontransactional_audit_is_rejected", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + if driver != "mysql" { + t.Skip("非事务引擎边界仅适用于 MySQL") + } + if _, err := db.ExecContext(t.Context(), "ALTER TABLE audit_logs ENGINE=MyISAM"); err != nil { + t.Fatal(err) + } + if _, err := repo.CreateManagedProject(t.Context(), "root", "不能保存", ""); err == nil { + t.Fatal("非事务审计表仍被允许") + } + var count int + if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 1 { + t.Fatalf("非事务场景产生变更:%d,%v", count, err) + } + }) + run("duplicate_names_and_validation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + a, err := repo.CreateManagedProject(t.Context(), "root", "同名", "") + if err != nil { + t.Fatal(err) + } + b, err := repo.CreateManagedProject(t.Context(), "root", "同名", "") + if err != nil || a.ID == b.ID { + t.Fatalf("重名项目没有独立标识:%v", err) + } + for _, name := range []string{"", strings.Repeat("中", 129), "控制\x00字符"} { + if _, err := repo.CreateManagedProject(t.Context(), "root", name, ""); err == nil { + t.Fatal("无效名称创建成功") + } + } + }) +} + +func TestProjectLifecycleSQLiteAuditRollback(t *testing.T) { + for _, action := range []string{"create", "update", "delete"} { + for _, mode := range []string{"ABORT, 'audit-failure'", "IGNORE"} { + t.Run(action+mode, func(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + if _, err := db.DB.Exec(`INSERT INTO users (id, username, email, password_hash, role, status) VALUES ('root', 'root', '', 'test-only', 'super_admin', 'active')`); err != nil { + t.Fatal(err) + } + repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver) + item, err := repo.CreateManagedProject(t.Context(), "root", "保留原名", "") + if err != nil { + t.Fatal(err) + } + if _, err := db.DB.Exec(`CREATE TRIGGER reject_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(` + mode + `); END`); err != nil { + t.Fatal(err) + } + switch action { + case "create": + _, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "") + case "update": + err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "") + case "delete": + err = repo.DeleteManagedProject(t.Context(), "root", item.ID) + } + if err == nil { + t.Fatal("审计故障后仍然成功") + } + items, err := NewSQLProjectRepository(db.DB, cfg.Driver).ListProjects(t.Context()) + if err != nil || len(items) != 2 { + t.Fatalf("事务没有回滚:%+v,%v", items, err) + } + loaded, err := NewSQLProjectRepository(db.DB, cfg.Driver).GetProject(t.Context(), item.ID) + if err != nil || loaded.Name != "保留原名" { + t.Fatalf("资料或删除未回滚:%+v,%v", loaded, err) + } + }) + } + } +} diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 5504144..192dcb3 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,18 +1,18 @@ # Scert 整改任务执行清单 -## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:36 同步) +## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:47 同步) 需求确认:继续 P0-06/P0-07,补齐项目创建、资料修改和空项目删除的正式入口;项目不是用户分组。创建仅允许当前有效超级管理员,修改沿用项目管理员实时权限;删除仍遵守默认项目与域名、服务器、成员引用保护,不自动删除业务资源或成员,不清理现有业务数据。名称允许重名,标识由服务端生成且不可修改。所有成功变更与审计同事务,失败记录不泄露请求正文。 - [x] 核对上一批提交 `61652f0` 与干净工作区;浏览器工具问题不阻止本批源码整改。 - [x] 已确认本批权限、引用及事务审计需求;修改前任务检查点 `dcede93` 已提交推送,行为正确性仍须后续测试证明。 -- [~] 项目生命周期仓储、实时判权、事务审计及失败回滚正式测试源码已开始编写,尚未执行测试;不得只在 HTTP 层先判权再写入。 -- [~] 项目创建、编辑、删除 API、失败审计和应用接线已有未提交实现;非法字段、确认、撤权、缓存及审计失败边界测试尚待补齐,不能提前认定拒绝策略通过。 -- [ ] 页面增加创建、编辑和二次确认删除,保持现有分页、角色管理及旧请求隔离。 -- [ ] 执行后端与前端回归,按真实范围补充数据库与构建证据;真实浏览器、PostgreSQL 当前实库和生产验收仍单独保留。 +- [~] 项目生命周期仓储、实时判权、事务审计及失败回滚已有实现;首轮局部测试通过,随后补充的跨数据库回滚及非事务引擎边界尚待运行,不以 HTTP 层检查代替仓储实时授权。 +- [~] 项目创建、编辑、删除 API、失败审计及真实应用接线首轮通过,包含非法字段、确认、引用、缓存和审计失败回滚;新增会话验证后停用/降权及请求取消审计测试尚待执行。 +- [~] 页面已接入创建、编辑、二次确认删除与能力提示;局部组合式逻辑及模板回归通过,完整前端与真实浏览器尚未验证。 +- [~] 首轮仓储/HTTP/应用组合共 136 个叶子通过、5 条条件跳过,退出 0;项目页面与生命周期专项 35 项通过、0 失败、0 跳过,退出 0。完整回归、MySQL 双版本、竞态及构建尚待执行;PostgreSQL 当前实库和生产验收仍单独保留。 - [~] 已同步总清单顶部及 P0-06/P0-07 的实际进行中状态;模型和验证记录待本批实现及测试后更新,本次只提交进度文档,不夹带未验证代码、运行包、业务库、凭据或日志。 -20:36 核对范围:工作区有 7 个未提交的后端源码/测试文件,前端生命周期操作尚未修改,本批没有新增测试通过证据。上一批正式包与回归结果不得套用于当前未提交代码。 +20:47 证据:`artifacts/project-lifecycle-20260928/api-first.jsonl` 与 `frontend-first.log`。首轮结果仅证明执行时的候选及对应测试范围;此后新增测试尚未执行,全部源码仍在工作区,未发布部署。旧批次正式包与回归结果不得套用于当前代码。 原始 44 项及其验收标准不缩减。删除引用冲突必须明确提示且不产生部分删除,授权能力提示不能替代服务端每次检查。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index e2c86a5..c5f56fa 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,6 +1,6 @@ # Scert 整改、合并与优化清单 -## 当前进度(2026 年 9 月 28 日 20:36,项目生命周期与事务审计正在实现,尚未测试) +## 当前进度(2026 年 9 月 28 日 20:47,项目生命周期已通过首轮局部测试,完整回归未完成) ### 总项状态 @@ -14,6 +14,12 @@ ### 本轮新增进展与下一步 +- [~] 20:47 补充:首轮仓储/接口/真实应用接线组合测试退出 0,136 个叶子用例通过、5 条实库条件跳过;覆盖创建、更新、删除、权限拒绝、引用保护、非法输入及审计失败回滚。随后新增的会话验证后停用/降权、取消请求审计和跨数据库回滚测试尚未运行,不能套用首轮结果。 +- [~] 前端已接入创建、编辑、二次确认删除及权限能力提示;项目页面与生命周期专项 35 项通过、0 失败、0 跳过,退出 0。覆盖重复提交、切换/卸载隔离、冲突刷新、末页删除回首页等;这是组合式逻辑和模板测试,不是真实浏览器验收。 +- [ ] 当前候选的完整后端、完整前端、MySQL 双版本、竞态检测和构建尚待执行;本批源码尚未提交。下方 20:36 记录保留为历史状态,不代表当前仍未开始页面开发或从未测试。 + +#### 20:36 阶段记录(历史) + - [x] 核对当前工作区和执行清单:20:21 已开始项目生命周期与事务审计整改,修改前检查点为 `dcede93`;上批正式包验证记录已随 `61652f0` 提交。 - [~] 项目创建、编辑、删除仓储和成功变更事务审计已开始编写;默认项目及资源/成员引用保护、失败审计与回滚测试源码已新增,尚未执行测试,不记为实现完成或验证通过。 - [~] 创建、编辑、删除 API 及应用接线已有未提交修改;项目目录新增创建、管理和删除能力字段。HTTP 边界、真实应用接线及目录能力测试仍待补齐,权限和事务行为尚待验证。 diff --git a/frontend/package.json b/frontend/package.json index f9781f2..ceaa5ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ } }, "scripts": { - "test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/ProjectManagement/ProjectManagement.test.js src/views/components/HelpTip/HelpTip.test.js", + "test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/ProjectManagement/ProjectManagement.test.js src/views/ProjectManagement/ProjectLifecycle.test.js src/views/components/HelpTip/HelpTip.test.js", "dev": "vite", "build": "vite build", "preview": "vite preview" diff --git a/frontend/src/views/ProjectManagement/ProjectLifecycle.js b/frontend/src/views/ProjectManagement/ProjectLifecycle.js new file mode 100644 index 0000000..b04f2f7 --- /dev/null +++ b/frontend/src/views/ProjectManagement/ProjectLifecycle.js @@ -0,0 +1,111 @@ +import { computed, onUnmounted, ref, watch } from "vue"; +import { handleProjectSession, requestProjectAPI } from "./ProjectRequest.js"; + +export function useProjectLifecycle(project, canCreateProject, busy, refresh) { + const projectEditor = ref(null); + const pendingDeleteProject = ref(null); + const savingProject = ref(false); + const lifecycleError = ref(""); + const lifecycleNotice = ref(""); + const canEditProject = computed(() => Boolean(project.value?.can_manage_project)); + const canDeleteProject = computed(() => Boolean(project.value?.can_delete_project)); + let generation = 0; + let refreshing = false; + + function clear() { + generation += 1; + projectEditor.value = null; + pendingDeleteProject.value = null; + savingProject.value = false; + lifecycleError.value = ""; + lifecycleNotice.value = ""; + } + + function openProjectEditor(create = false) { + if (busy.value || savingProject.value || !(create ? canCreateProject.value : canEditProject.value)) return; + lifecycleError.value = ""; + lifecycleNotice.value = ""; + pendingDeleteProject.value = null; + projectEditor.value = { + create, id: create ? "" : project.value.id, + name: create ? "" : project.value.name, + description: create ? "" : project.value.description || "", + }; + } + + function requestDeleteProject() { + if (busy.value || savingProject.value || !canDeleteProject.value) return; + projectEditor.value = null; + lifecycleError.value = ""; + lifecycleNotice.value = ""; + pendingDeleteProject.value = { id: project.value.id, name: project.value.name }; + } + + function cancelProjectAction() { + if (savingProject.value) return; + projectEditor.value = null; + pendingDeleteProject.value = null; + lifecycleError.value = ""; + } + + async function mutateProject(action, id, body) { + if (busy.value || savingProject.value) return; + const current = generation; + savingProject.value = true; + lifecycleError.value = ""; + lifecycleNotice.value = ""; + try { + const result = await requestProjectAPI(`/api/v1/projects${action === "create" ? "" : `/${encodeURIComponent(id)}`}`, { + method: { create: "POST", update: "PUT", delete: "DELETE" }[action], + body: JSON.stringify(body), + }); + if (current !== generation) return; + projectEditor.value = null; + pendingDeleteProject.value = null; + lifecycleNotice.value = action === "create" + ? `项目已创建(ID:${result.id})。已返回项目首页,未显示时可翻页查看。` + : action === "delete" ? "项目已删除,已返回项目首页" : "项目资料已更新"; + refreshing = true; + await refresh(action); + } catch (error) { + if (current !== generation) return; + lifecycleError.value = error.status ? handleProjectSession(error) : "请求结果未确认,请刷新项目后核对,勿重复提交"; + if (error.status === 403 || error.status === 409) { + projectEditor.value = null; + pendingDeleteProject.value = null; + refreshing = true; + await refresh("conflict"); + } + } finally { + refreshing = false; + if (current === generation) savingProject.value = false; + } + } + + function saveProject() { + const form = projectEditor.value; + if (!form || !(form.create ? canCreateProject.value : canEditProject.value && form.id === project.value?.id)) return; + const name = form.name.trim(); + const description = form.description.trim(); + if (!name || [...name].length > 128 || [...description].length > 4096 || /[\u0000-\u001f\u007f]/.test(name)) { + lifecycleError.value = "请填写有效项目名称(最多 128 字),说明最多 4096 字"; + return; + } + return mutateProject(form.create ? "create" : "update", form.id, { name, description }); + } + + function confirmDeleteProject() { + const target = pendingDeleteProject.value; + if (!target || !canDeleteProject.value || target.id !== project.value?.id) return; + return mutateProject("delete", target.id, { confirmation: target.id }); + } + + watch(project, () => { if (!refreshing) clear(); }, { flush: "sync" }); + onUnmounted(clear); + + return { + projectEditor, pendingDeleteProject, savingProject, lifecycleError, lifecycleNotice, + canEditProject, canDeleteProject, openProjectEditor, requestDeleteProject, + cancelProjectAction, saveProject, confirmDeleteProject, + }; +} diff --git a/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js b/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js new file mode 100644 index 0000000..a01a6a3 --- /dev/null +++ b/frontend/src/views/ProjectManagement/ProjectLifecycle.test.js @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ref } from "vue"; +import { mountComposable, deferred } from "../../test-support/mount.js"; +import { useProjectLifecycle } from "./ProjectLifecycle.js"; +import { useProjectManagement } from "./ProjectManagement.js"; + +const projectA = { id: "project-a", name: "项目甲", description: "说明", can_manage_project: true, can_delete_project: true, can_manage_members: false }; +const projectB = { ...projectA, id: "project-b", name: "项目乙" }; + +async function setup(t, fetcher, refresh = async () => {}) { + const project = ref({ ...projectA }); + const canCreate = ref(true); + const busy = ref(false); + const mounted = await mountComposable(t, () => useProjectLifecycle(project, canCreate, busy, refresh), fetcher); + return { ...mounted, project, canCreate, busy }; +} + +test("创建项目仅发送资料,编辑及删除使用固定项目标识", async (t) => { + const calls = []; + const refreshed = []; + const { state } = await setup(t, async (path, options) => { + calls.push([path, options]); + return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ id: "created" }); + }, async (action) => refreshed.push(action)); + state.openProjectEditor(true); + state.projectEditor.value.name = " 新项目 "; + state.projectEditor.value.description = " 新说明 "; + await state.saveProject(); + assert.deepEqual(JSON.parse(calls[0][1].body), { name: "新项目", description: "新说明" }); + assert.equal(calls[0][0], "/api/v1/projects"); + assert.equal(calls[0][1].method, "POST"); + assert.match(state.lifecycleNotice.value, /created/); + state.openProjectEditor(false); + state.projectEditor.value.name = "修改项目"; + await state.saveProject(); + assert.equal(calls[1][0], "/api/v1/projects/project-a"); + assert.equal(calls[1][1].method, "PUT"); + state.requestDeleteProject(); + assert.equal(calls.length, 2); + await state.confirmDeleteProject(); + assert.equal(calls[2][1].method, "DELETE"); + assert.deepEqual(JSON.parse(calls[2][1].body), { confirmation: "project-a" }); + assert.deepEqual(refreshed, ["create", "update", "delete"]); + assert.ok(calls.every(([, options]) => options.credentials === "include" && options.cache === "no-store")); +}); + +test("生命周期权限和成员保存状态阻止发送变更", async (t) => { + const { state, project, canCreate, busy } = await setup(t, () => assert.fail("不应发送请求")); + canCreate.value = false; + state.openProjectEditor(true); + assert.equal(state.projectEditor.value, null); + project.value = { ...projectA, can_manage_project: false, can_delete_project: false }; + state.openProjectEditor(false); + state.requestDeleteProject(); + assert.equal(state.projectEditor.value, null); + assert.equal(state.pendingDeleteProject.value, null); + project.value = { ...projectA }; + busy.value = true; + state.openProjectEditor(false); + assert.equal(state.projectEditor.value, null); +}); + +test("项目名称校验、二次确认取消和切换项目清理", async (t) => { + const { state, project } = await setup(t, () => assert.fail("不应发送请求")); + state.openProjectEditor(true); + state.projectEditor.value.name = " "; + await state.saveProject(); + assert.match(state.lifecycleError.value, /项目名称/); + state.cancelProjectAction(); + assert.equal(state.projectEditor.value, null); + state.requestDeleteProject(); + state.cancelProjectAction(); + await state.confirmDeleteProject(); + state.requestDeleteProject(); + project.value = { ...projectB }; + assert.equal(state.pendingDeleteProject.value, null); + await state.confirmDeleteProject(); +}); + +test("重复提交、切换及卸载后的迟到结果不修改新页面", async (t) => { + for (const change of ["switch", "unmount"]) { + await t.test(change, async (t) => { + const pending = deferred(); + let calls = 0; + let refreshes = 0; + const { state, project, unmount } = await setup(t, () => { calls += 1; return pending.promise; }, async () => { refreshes += 1; }); + state.requestDeleteProject(); + const first = state.confirmDeleteProject(); + await state.confirmDeleteProject(); + assert.equal(calls, 1); + if (change === "switch") project.value = { ...projectB }; + else unmount(); + pending.resolve(new Response(null, { status: 204 })); + await first; + assert.equal(refreshes, 0); + assert.equal(state.lifecycleNotice.value, ""); + assert.equal(state.savingProject.value, false); + }); + } +}); + +test("拒绝及引用冲突刷新权限且不显示删除成功", async (t) => { + for (const status of [403, 409]) { + await t.test(String(status), async (t) => { + let refreshes = 0; + const { state, project } = await setup(t, async () => Response.json({ error: "存在引用或权限失效", request_id: "request-test" }, { status }), async () => { + refreshes += 1; + project.value = { ...projectA, can_manage_project: false, can_delete_project: false }; + }); + state.requestDeleteProject(); + await state.confirmDeleteProject(); + assert.equal(refreshes, 1); + assert.equal(state.lifecycleNotice.value, ""); + assert.match(state.lifecycleError.value, /request-test/); + assert.equal(state.canDeleteProject.value, false); + assert.equal(state.pendingDeleteProject.value, null); + }); + } +}); + +test("会话过期跳登录,网络中断明确结果未确认", async (t) => { + let mode = "auth"; + const { state } = await setup(t, async () => { + if (mode === "network") throw new TypeError("network"); + return Response.json({ error: "登录失效" }, { status: 401 }); + }); + const redirects = []; + window.location.assign = (path) => redirects.push(path); + state.requestDeleteProject(); + await state.confirmDeleteProject(); + assert.deepEqual(redirects, ["/login"]); + mode = "network"; + await state.confirmDeleteProject(); + assert.match(state.lifecycleError.value, /结果未确认/); + assert.equal(state.lifecycleNotice.value, ""); +}); + +test("删除末页项目后回到首页,刷新期间不可切换或重复操作", async (t) => { + let deleted = false; + const pending = deferred(); + const { state } = await mountComposable(t, useProjectManagement, async (path, options) => { + if (options.method === "DELETE") return pending.promise; + const second = new URL(path, "http://local").searchParams.get("after"); + return Response.json({ items: deleted || !second ? [projectA] : [projectB], can_create_project: true, has_more: !deleted && !second, next_cursor: "project-a" }); + }); + await state.nextProjectPage(); + assert.equal(state.selectedProject.value.id, "project-b"); + state.requestDeleteProject(); + const saving = state.confirmDeleteProject(); + state.selectProject("project-a"); + await state.previousProjectPage(); + assert.equal(state.projectPage.value, 1); + deleted = true; + pending.resolve(new Response(null, { status: 204 })); + await saving; + assert.equal(state.projectPage.value, 0); + assert.equal(state.selectedProject.value.id, "project-a"); + assert.match(state.lifecycleNotice.value, /已删除/); + assert.equal(state.savingProject.value, false); +}); + +test("新建项目未在首页出现时保留明确成功标识,不伪造目录条目", async (t) => { + const { state } = await mountComposable(t, useProjectManagement, async (_path, options) => options.method === "POST" + ? Response.json({ id: "outside-first-page" }, { status: 201 }) + : Response.json({ items: [projectA], can_create_project: true })); + state.openProjectEditor(true); + state.projectEditor.value.name = "末页项目"; + await state.saveProject(); + assert.deepEqual(state.projects.value.map((item) => item.id), ["project-a"]); + assert.match(state.lifecycleNotice.value, /outside-first-page/); + assert.match(state.lifecycleNotice.value, /翻页/); +}); diff --git a/frontend/src/views/ProjectManagement/ProjectManagement.css b/frontend/src/views/ProjectManagement/ProjectManagement.css index 56f31ed..1e06df0 100644 --- a/frontend/src/views/ProjectManagement/ProjectManagement.css +++ b/frontend/src/views/ProjectManagement/ProjectManagement.css @@ -45,6 +45,7 @@ .project-management button, .project-management input, +.project-management textarea, .project-management select { min-height: 36px; padding: 7px 10px; @@ -209,6 +210,45 @@ background: #fff4df; } +.project-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.project-editor { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(240px, 2fr); + align-items: end; + gap: 12px; + margin-bottom: 18px; + padding: 16px; + border: 1px solid #dfe6f0; + border-radius: 10px; + background: #f8faff; +} + +.project-editor h3, +.project-editor .project-actions { + grid-column: 1 / -1; +} + +.project-editor label { + display: grid; + gap: 6px; + min-width: 0; +} + +.project-editor textarea { + resize: vertical; +} + +.project-management .project-danger { + color: #a12c3b; + border-color: #e2b8c0; +} + @media (max-width: 1000px) { .project-layout { grid-template-columns: 1fr; @@ -227,4 +267,8 @@ .project-grant-form { grid-template-columns: 1fr; } + + .project-editor { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/views/ProjectManagement/ProjectManagement.js b/frontend/src/views/ProjectManagement/ProjectManagement.js index d93d0d8..12e8dd5 100644 --- a/frontend/src/views/ProjectManagement/ProjectManagement.js +++ b/frontend/src/views/ProjectManagement/ProjectManagement.js @@ -1,6 +1,7 @@ -import { onMounted, onUnmounted, ref } from "vue"; +import { computed, onMounted, onUnmounted, ref } from "vue"; import { handleProjectSession, pageSizes, requestProjectAPI } from "./ProjectRequest.js"; import { useProjectMembers } from "./ProjectMembers.js"; +import { useProjectLifecycle } from "./ProjectLifecycle.js"; export function useProjectManagement() { const projects = ref([]); @@ -12,11 +13,15 @@ export function useProjectManagement() { const projectPage = ref(0); const projectHasMore = ref(false); const projectNextCursor = ref(""); + const canCreateProject = ref(false); const members = useProjectMembers(selectedProject); + const lifecycle = useProjectLifecycle(selectedProject, canCreateProject, + computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation); let generation = 0; let controller; - async function loadProjects() { + async function loadProjects(options = {}) { + if (members.savingMember.value || (lifecycle.savingProject.value && options.internal !== true)) return; const current = ++generation; controller?.abort(); controller = new AbortController(); @@ -27,6 +32,7 @@ export function useProjectManagement() { selectedProject.value = null; projectHasMore.value = false; projectNextCursor.value = ""; + canCreateProject.value = false; const query = new URLSearchParams({ page_size: String(projectPageSize.value), after: projectCursors.value[projectPage.value], @@ -35,6 +41,7 @@ export function useProjectManagement() { const result = await requestProjectAPI(`/api/v1/projects?${query}`, { signal: controller.signal }); if (current !== generation) return; projects.value = result.items || []; + canCreateProject.value = Boolean(result.can_create_project); projectHasMore.value = Boolean(result.has_more && result.next_cursor); projectNextCursor.value = result.next_cursor || ""; selectedProject.value = projects.value.find((item) => item.id === selectedID) || projects.value[0] || null; @@ -45,13 +52,21 @@ export function useProjectManagement() { } } + async function refreshAfterMutation(action) { + if (action === "create" || action === "delete") { + projectPage.value = 0; + projectCursors.value = [""]; + } + await loadProjects({ internal: true }); + } + function selectProject(id) { - if (loadingProjects.value || selectedProject.value?.id === id) return; + if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || selectedProject.value?.id === id) return; selectedProject.value = projects.value.find((item) => item.id === id) || null; } async function changeProjectPageSize(value) { - if (!pageSizes.includes(Number(value))) return; + if (lifecycle.savingProject.value || members.savingMember.value || !pageSizes.includes(Number(value))) return; projectPageSize.value = Number(value); projectPage.value = 0; projectCursors.value = [""]; @@ -59,14 +74,14 @@ export function useProjectManagement() { } async function nextProjectPage() { - if (loadingProjects.value || !projectHasMore.value) return; + if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || !projectHasMore.value) return; projectCursors.value = [...projectCursors.value.slice(0, projectPage.value + 1), projectNextCursor.value]; projectPage.value += 1; await loadProjects(); } async function previousProjectPage() { - if (loadingProjects.value || projectPage.value === 0) return; + if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || projectPage.value === 0) return; projectPage.value -= 1; await loadProjects(); } @@ -78,7 +93,7 @@ export function useProjectManagement() { }); return { - ...members, projects, selectedProject, loadingProjects, projectError, + ...members, ...lifecycle, canCreateProject, projects, selectedProject, loadingProjects, projectError, projectPageSize, projectPage, projectHasMore, pageSizes, loadProjects, selectProject, changeProjectPageSize, nextProjectPage, previousProjectPage, }; diff --git a/frontend/src/views/ProjectManagement/ProjectManagement.vue b/frontend/src/views/ProjectManagement/ProjectManagement.vue index e8fda8e..8793e1d 100644 --- a/frontend/src/views/ProjectManagement/ProjectManagement.vue +++ b/frontend/src/views/ProjectManagement/ProjectManagement.vue @@ -9,6 +9,9 @@ const { memberNotice, canManageMembers, memberPageSize, memberPage, memberHasMore, memberForm, projectRoles, pendingRevoke, loadMembers, changeMemberPageSize, nextMemberPage, previousMemberPage, grantMember, requestRevoke, confirmRevoke, roleLabel, + canCreateProject, projectEditor, pendingDeleteProject, savingProject, + lifecycleError, lifecycleNotice, canEditProject, canDeleteProject, + openProjectEditor, requestDeleteProject, cancelProjectAction, saveProject, confirmDeleteProject, } = useProjectManagement(); @@ -16,10 +19,34 @@ const {

项目与成员

- +
+ + +
-
+ +

{{ lifecycleNotice }}

+
+

{{ projectEditor.create ? "创建项目" : "编辑项目" }}

+ + +
+ + +
+
+ +

{{ memberNotice }}