检查点:保存项目生命周期实现与待修复回归

This commit is contained in:
xuans 2026-09-28 20:53:17 +08:00
parent fca7b796a9
commit dcca17e371
20 changed files with 1383 additions and 27 deletions

View File

@ -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) {

View File

@ -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)
}

View File

@ -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())
}
}
}

View File

@ -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)
}
})
}
}
}

View File

@ -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)
}
}

View File

@ -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},

View File

@ -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)

View File

@ -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",
})
}
}

View File

@ -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 {

View File

@ -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("项目变更未实际生效,请刷新后重试")
}

View File

@ -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
}

View File

@ -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)
}
})
}
}
}

View File

@ -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 项及其验收标准不缩减。删除引用冲突必须明确提示且不产生部分删除,授权能力提示不能替代服务端每次检查。

View File

@ -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 边界、真实应用接线及目录能力测试仍待补齐,权限和事务行为尚待验证。

View File

@ -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"

View File

@ -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,
};
}

View File

@ -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, /翻页/);
});

View File

@ -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;
}
}

View File

@ -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,
};

View File

@ -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();
</script>
@ -16,10 +19,34 @@ const {
<section class="project-management" aria-label="项目与成员">
<header class="project-heading">
<h2>项目与成员</h2>
<button type="button" :disabled="loadingProjects || savingMember" @click="loadProjects">刷新项目</button>
<div class="project-actions">
<button v-if="canCreateProject" class="project-primary" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="openProjectEditor(true)">创建项目</button>
<button type="button" :disabled="loadingProjects || savingMember || savingProject" @click="loadProjects">刷新项目</button>
</div>
</header>
<p v-if="projectError" class="project-error" role="alert">{{ projectError }}</p>
<div class="project-layout">
<p v-if="lifecycleError" class="project-error" role="alert">{{ lifecycleError }}</p>
<p v-if="lifecycleNotice" class="project-notice" role="status">{{ lifecycleNotice }}</p>
<form v-if="projectEditor" class="project-editor" aria-label="项目资料" @submit.prevent="saveProject">
<h3>{{ projectEditor.create ? "创建项目" : "编辑项目" }}</h3>
<label>项目名称
<input v-model="projectEditor.name" required maxlength="128" autocomplete="off" :disabled="savingProject" />
</label>
<label>项目说明
<textarea v-model="projectEditor.description" maxlength="4096" rows="2" :disabled="savingProject"></textarea>
</label>
<div class="project-actions">
<button class="project-primary" type="submit" :disabled="savingProject || loadingProjects || savingMember">{{ savingProject ? "正在保存……" : "保存项目" }}</button>
<button type="button" :disabled="savingProject" @click="cancelProjectAction">取消</button>
</div>
</form>
<div v-if="pendingDeleteProject" class="project-confirm" role="alert">
<span>确认删除项目“{{ pendingDeleteProject.name }}”({{ pendingDeleteProject.id }})?</span>
<span class="project-help" tabindex="0" title="默认项目不能删除;存在域名、服务器、绑定或成员时会拒绝删除,不会自动删除关联资源。">ⓘ</span>
<button class="project-danger" type="button" :disabled="savingProject || loadingProjects || savingMember" @click="confirmDeleteProject">{{ savingProject ? "正在删除……" : "确认删除项目" }}</button>
<button type="button" :disabled="savingProject" @click="cancelProjectAction">取消</button>
</div>
<div class="project-layout" :inert="savingProject" :aria-busy="savingProject">
<aside class="project-directory" aria-label="可见项目">
<div class="project-toolbar">
<h3>我的项目</h3>
@ -56,7 +83,11 @@ const {
<h3 :title="selectedProject.description">{{ selectedProject.name }}</h3>
<small :title="selectedProject.id">项目 ID:{{ selectedProject.id }}</small>
</div>
<button v-if="canManageMembers" type="button" :disabled="loadingMembers || savingMember" @click="loadMembers">刷新成员</button>
<div class="project-actions">
<button v-if="canEditProject" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="openProjectEditor(false)">编辑项目</button>
<button v-if="canDeleteProject" class="project-danger" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="requestDeleteProject">删除项目</button>
<button v-if="canManageMembers" type="button" :disabled="loadingMembers || savingMember || savingProject" @click="loadMembers">刷新成员</button>
</div>
</div>
<p v-if="memberError" class="project-error" role="alert">{{ memberError }}</p>
<p v-if="memberNotice" class="project-notice" role="status">{{ memberNotice }}</p>