diff --git a/backend/internal/cli/sqlite_upgrade_cli_test.go b/backend/internal/cli/sqlite_upgrade_cli_test.go index 2bb31ef..d62f1af 100644 --- a/backend/internal/cli/sqlite_upgrade_cli_test.go +++ b/backend/internal/cli/sqlite_upgrade_cli_test.go @@ -13,6 +13,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" "git.pcux.cn/xuans/Scert/backend/internal/secretstore" "golang.org/x/crypto/bcrypt" @@ -82,6 +83,7 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) { runSQLiteProductCLI(t, binary, fixture, "backup", "--output", archive) runSQLiteProductCLI(t, binary, fixture, "certificate", "list") assertSQLiteCLIUpgradeUsable(t, fixture) + createSQLiteProjectForBackup(t, fixture) upgraded := snapshotSQLiteCLIState(t, *cfg) currentArchive := filepath.Join(t.TempDir(), "upgraded-backup") runSQLiteProductCLI(t, binary, fixture, "backup", "--output", currentArchive) @@ -99,6 +101,10 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) { if err != nil { t.Fatal(err) } + if _, err := current.DB.ExecContext(t.Context(), "UPDATE projects SET name = ?", "待恢复的项目名称"); err != nil { + _ = current.Close() + t.Fatal(err) + } if _, err := current.DB.ExecContext(t.Context(), "UPDATE tasks SET message = '不应保留'; DELETE FROM domain_servers"); err != nil { _ = current.Close() t.Fatal(err) @@ -130,6 +136,19 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) { } } +func createSQLiteProjectForBackup(t *testing.T, fixture sqliteBusinessFixture) { + t.Helper() + db, err := database.Open(t.Context(), fixture.config.Database) + if err != nil { + t.Fatal(err) + } + defer db.Close() + item := domain.Project{ID: "backup-project", Name: "独立程序备份项目", Description: "恢复时必须保留的项目说明"} + if err := repository.NewSQLProjectRepository(db.DB).CreateProject(t.Context(), item); err != nil { + t.Fatal(err) + } +} + func runSQLiteProductCLI(t *testing.T, binary string, fixture sqliteBusinessFixture, args ...string) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) @@ -199,9 +218,13 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) { } defer db.Close() var version, bindings int - if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 9 { + if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 10 { t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err) } + project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID) + if err != nil || project.Name != "默认项目" { + t.Fatalf("正式应用入口未保留默认项目:%+v,%v", project, err) + } task, err := repository.NewSQLTaskRepository(db.DB).GetTask(t.Context(), "task") if err != nil || task.Message != "历史任务" || task.DomainID != "domain" || task.Status != "success" { t.Fatalf("历史任务不可用:%v", err) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index b972af2..6e9f8e9 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -11,6 +11,7 @@ import ( //go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/sqlite/0009_server_ssh_key_status.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/mysql/0009_server_ssh_key_status.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql migrations/postgres/0009_server_ssh_key_status.sql //go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql +//go:embed migrations/sqlite/0010_projects.sql migrations/mysql/0010_projects.sql migrations/postgres/0010_projects.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -35,7 +36,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 9 +const latestMigrationVersion = 10 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -164,6 +165,8 @@ func migrationName(version int) string { return "user_groups" case 9: return "server_ssh_key_status" + case 10: + return "projects" default: return "unknown" } diff --git a/backend/internal/database/migration_catalog_test.go b/backend/internal/database/migration_catalog_test.go index 09ef53d..4170739 100644 --- a/backend/internal/database/migration_catalog_test.go +++ b/backend/internal/database/migration_catalog_test.go @@ -58,7 +58,7 @@ func TestMigrationCatalogDetectsIncompleteChanges(t *testing.T) { mutate func(fstest.MapFS, fstest.MapFS) }{ {"unregistered_source", func(source, embedded fstest.MapFS) { - source["migrations/mysql/0010_extra.sql"] = &fstest.MapFile{Data: []byte("SELECT 1;")} + source[fmt.Sprintf("migrations/mysql/%04d_extra.sql", latestMigrationVersion+1)] = &fstest.MapFile{Data: []byte("SELECT 1;")} }}, {"missing_embed", func(source, embedded fstest.MapFS) { delete(embedded, "migrations/postgres/0001_initial.sql") }}, {"missing_source", func(source, embedded fstest.MapFS) { delete(source, "migrations/sqlite/0001_initial.sql") }}, @@ -73,7 +73,7 @@ func TestMigrationCatalogDetectsIncompleteChanges(t *testing.T) { delete(source, "migrations/mysql/0001_initial.sql") }}, {"unregistered_embed", func(source, embedded fstest.MapFS) { - embedded["migrations/postgres/0010_extra.sql"] = &fstest.MapFile{Data: []byte("SELECT 1;")} + embedded[fmt.Sprintf("migrations/postgres/%04d_extra.sql", latestMigrationVersion+1)] = &fstest.MapFile{Data: []byte("SELECT 1;")} }}, } for _, tc := range cases { diff --git a/backend/internal/database/migrations/mysql/0010_projects.sql b/backend/internal/database/migrations/mysql/0010_projects.sql new file mode 100644 index 0000000..faa375a --- /dev/null +++ b/backend/internal/database/migrations/mysql/0010_projects.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS projects ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +INSERT INTO projects (id, name, description) +SELECT 'default', '默认项目', '历史资源的默认归属项目' +WHERE NOT EXISTS (SELECT 1 FROM projects WHERE id = 'default'); diff --git a/backend/internal/database/migrations/postgres/0010_projects.sql b/backend/internal/database/migrations/postgres/0010_projects.sql new file mode 100644 index 0000000..9e05e3a --- /dev/null +++ b/backend/internal/database/migrations/postgres/0010_projects.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS projects ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO projects (id, name, description) +SELECT 'default', '默认项目', '历史资源的默认归属项目' +WHERE NOT EXISTS (SELECT 1 FROM projects WHERE id = 'default'); diff --git a/backend/internal/database/migrations/sqlite/0010_projects.sql b/backend/internal/database/migrations/sqlite/0010_projects.sql new file mode 100644 index 0000000..3783e55 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0010_projects.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO projects (id, name, description) +SELECT 'default', '默认项目', '历史资源的默认归属项目' +WHERE NOT EXISTS (SELECT 1 FROM projects WHERE id = 'default'); diff --git a/backend/internal/database/project_migration_test.go b/backend/internal/database/project_migration_test.go new file mode 100644 index 0000000..187e975 --- /dev/null +++ b/backend/internal/database/project_migration_test.go @@ -0,0 +1,78 @@ +package database + +import ( + "testing" +) + +func TestSQLiteProjectMigrationPreservesBusiness(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + createSQLiteHistoricalBusiness(t, db.DB, 9) + before := snapshotSQLiteHistoricalBusiness(t, db.DB) + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + var name string + if err := db.DB.QueryRowContext(t.Context(), "SELECT name FROM projects WHERE id = 'default'").Scan(&name); err != nil || name != "默认项目" { + t.Fatalf("默认项目未创建:%q,%v", name, err) + } + if _, err := db.DB.ExecContext(t.Context(), "UPDATE projects SET name = '调整后的默认项目' WHERE id = 'default'"); err != nil { + t.Fatal(err) + } + // 模拟建表、种子写入已成功,但版本记录尚未落盘的可重试情况。 + if _, err := db.DB.ExecContext(t.Context(), "DELETE FROM schema_migrations WHERE version = 10"); err != nil { + t.Fatal(err) + } + for round := 0; round < 2; round++ { + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 1 { + t.Fatalf("重复迁移改变项目数量:%d,%v", count, err) + } + if err := db.DB.QueryRowContext(t.Context(), "SELECT name FROM projects WHERE id = 'default'").Scan(&name); err != nil || name != "调整后的默认项目" { + t.Fatalf("重复迁移覆盖用户修改:%q,%v", name, err) + } + assertSQLiteHistoricalBusiness(t, db.DB, before) +} + +func TestSQLiteProjectMigrationRollsBackAndRetries(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + createSQLiteHistoricalBusiness(t, db.DB, 9) + before := snapshotSQLiteHistoricalBusiness(t, db.DB) + // 在登记版本时制造失败,验证同一事务中的新表及默认项目也会回滚。 + _, err = db.DB.ExecContext(t.Context(), `CREATE TRIGGER fail_project_version BEFORE INSERT ON schema_migrations + WHEN NEW.version = 10 BEGIN SELECT RAISE(ABORT, 'test-only'); END`) + if err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err == nil { + t.Fatal("版本登记故障未阻断迁移") + } + version, err := currentMigrationVersion(t.Context(), db.DB) + if err != nil || version != 9 { + t.Fatalf("失败迁移错误登记版本:%d,%v", version, err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM sqlite_master WHERE name = 'projects'").Scan(&count); err != nil || count != 0 { + t.Fatalf("失败迁移未回滚项目表:%d,%v", count, err) + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + if _, err := db.DB.ExecContext(t.Context(), "DROP TRIGGER fail_project_version"); err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + assertSQLiteHistoricalBusiness(t, db.DB, before) +} diff --git a/backend/internal/database/project_mysql_test.go b/backend/internal/database/project_mysql_test.go new file mode 100644 index 0000000..39e68c1 --- /dev/null +++ b/backend/internal/database/project_mysql_test.go @@ -0,0 +1,72 @@ +package database + +import ( + "strings" + "testing" +) + +func TestMySQLRealProjectMigrationRetry(t *testing.T) { + fixture := startRealMySQL(t) + db := fixture.database(t) + ctx := t.Context() + if _, err := db.DB.ExecContext(ctx, migrationTableSQL("mysql")); err != nil { + t.Fatal(err) + } + for version := 1; version <= 9; version++ { + if err := applyMigration(ctx, db.DB, "mysql", version); err != nil { + t.Fatal(err) + } + } + if _, err := db.DB.ExecContext(ctx, `INSERT INTO tasks (id, domain_id, task_type, status, message) + VALUES ('historical-task', 'historical-domain', 'deploy', 'success', '保留历史任务')`); err != nil { + t.Fatal(err) + } + // MySQL 的 DDL 不会随事务回滚,必须验证建表后版本登记失败可安全重试。 + var schema string + if err := db.DB.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil { + t.Fatal(err) + } + schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`" + // 故障注入仅使用隔离实例管理连接,不给产品连接增加全局权限。 + if _, err := fixture.admin.ExecContext(ctx, `CREATE TRIGGER `+schema+`.fail_project_version BEFORE INSERT ON `+schema+`.schema_migrations + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'test-only'`); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, db.DB, "mysql"); err == nil { + t.Fatal("版本登记故障未阻断 MySQL 迁移") + } + version, err := currentMigrationVersion(ctx, db.DB) + if err != nil || version != 9 { + t.Fatalf("失败迁移错误登记版本:%d,%v", version, err) + } + var name string + if err := db.DB.QueryRowContext(ctx, "SELECT name FROM projects WHERE id = 'default'").Scan(&name); err != nil || name != "默认项目" { + t.Fatalf("MySQL 部分迁移的默认项目缺失:%q,%v", name, err) + } + if _, err := db.DB.ExecContext(ctx, "UPDATE projects SET name = '保留用户调整' WHERE id = 'default'"); err != nil { + t.Fatal(err) + } + if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_project_version"); err != nil { + t.Fatal(err) + } + for repeat := 0; repeat < 2; repeat++ { + if err := Migrate(ctx, db.DB, "mysql"); err != nil { + t.Fatal(err) + } + } + var count int + if err := db.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 1 { + t.Fatalf("重试后项目数量错误:%d,%v", count, err) + } + if err := db.DB.QueryRowContext(ctx, "SELECT name FROM projects WHERE id = 'default'").Scan(&name); err != nil || name != "保留用户调整" { + t.Fatalf("重试覆盖已有默认项目:%q,%v", name, err) + } + var message string + if err := db.DB.QueryRowContext(ctx, "SELECT message FROM tasks WHERE id = 'historical-task'").Scan(&message); err != nil || message != "保留历史任务" { + t.Fatalf("迁移影响历史任务:%q,%v", message, err) + } + version, err = currentMigrationVersion(ctx, db.DB) + if err != nil || version != latestMigrationVersion { + t.Fatalf("重试后迁移版本不完整:%d,%v", version, err) + } +} diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go new file mode 100644 index 0000000..caced17 --- /dev/null +++ b/backend/internal/domain/project.go @@ -0,0 +1,62 @@ +package domain + +import ( + "errors" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +const DefaultProjectID = "default" + +var projectIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,35}$`) + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewProject(name, description string) (Project, error) { + now := time.Now().UTC() + item := (Project{ + ID: NewID(), Name: name, Description: description, + CreatedAt: now, UpdatedAt: now, + }).Normalize() + if err := item.Validate(); err != nil { + return Project{}, err + } + return item, nil +} + +func (p Project) Normalize() Project { + p.ID = strings.TrimSpace(p.ID) + p.Name = strings.TrimSpace(p.Name) + p.Description = strings.TrimSpace(p.Description) + return p +} + +func (p Project) Validate() error { + if !projectIDPattern.MatchString(p.ID) { + return errors.New("项目 ID 必须为 1~36 位小写字母、数字、连字符或下划线,并以字母或数字开头") + } + if !utf8.ValidString(p.Name) || strings.TrimSpace(p.Name) == "" || utf8.RuneCountInString(p.Name) > 128 { + return errors.New("项目名称必须为 1~128 个字符") + } + if strings.ContainsFunc(p.Name, unicode.IsControl) { + return errors.New("项目名称不能包含控制字符") + } + if !utf8.ValidString(p.Description) || utf8.RuneCountInString(p.Description) > 4096 { + return errors.New("项目说明不能超过 4096 个字符") + } + if strings.ContainsFunc(p.Description, func(r rune) bool { + return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' + }) { + return errors.New("项目说明包含无效控制字符") + } + return nil +} diff --git a/backend/internal/domain/project_test.go b/backend/internal/domain/project_test.go new file mode 100644 index 0000000..b5dd7cd --- /dev/null +++ b/backend/internal/domain/project_test.go @@ -0,0 +1,56 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestNewProject(t *testing.T) { + item, err := NewProject(" 证书项目 ", " 项目说明\n支持换行 ") + if err != nil { + t.Fatal(err) + } + if item.ID == DefaultProjectID || item.Name != "证书项目" || item.Description != "项目说明\n支持换行" { + t.Fatalf("项目规范化失败:%+v", item) + } + if item.CreatedAt.IsZero() || !item.CreatedAt.Equal(item.UpdatedAt) || item.CreatedAt.Location().String() != "UTC" { + t.Fatal("项目时间戳未初始化为统一 UTC 时间") + } + other, err := NewProject(item.Name, "") + if err != nil || other.ID == item.ID { + t.Fatal("独立项目 ID 必须唯一,同名项目不应混淆") + } +} + +func TestProjectValidation(t *testing.T) { + cases := []struct { + name string + item Project + valid bool + }{ + {"default", Project{ID: DefaultProjectID, Name: "默认项目"}, true}, + {"unicode_limit", Project{ID: "project-1", Name: strings.Repeat("证", 128), Description: strings.Repeat("书", 4096)}, true}, + {"multiline", Project{ID: "project_2", Name: "项目", Description: "一\r\n二\t三"}, true}, + {"empty_id", Project{Name: "项目"}, false}, + {"long_id", Project{ID: strings.Repeat("a", 37), Name: "项目"}, false}, + {"uppercase_id", Project{ID: "Default", Name: "项目"}, false}, + {"unsafe_id", Project{ID: "../a", Name: "项目"}, false}, + {"empty_name", Project{ID: "a", Name: " "}, false}, + {"long_name", Project{ID: "a", Name: strings.Repeat("证", 129)}, false}, + {"name_control", Project{ID: "a", Name: "证\x00书"}, false}, + {"invalid_name_encoding", Project{ID: "a", Name: string([]byte{0xff})}, false}, + {"long_description", Project{ID: "a", Name: "项目", Description: strings.Repeat("书", 4097)}, false}, + {"description_control", Project{ID: "a", Name: "项目", Description: "证\x00书"}, false}, + {"invalid_description_encoding", Project{ID: "a", Name: "项目", Description: string([]byte{0xff})}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.item.Validate(); (err == nil) != tc.valid { + t.Fatalf("项目校验结果不符:%v", err) + } + }) + } + if _, err := NewProject(" ", ""); err == nil { + t.Fatal("构造函数未拒绝空项目名称") + } +} diff --git a/backend/internal/repository/project_repository.go b/backend/internal/repository/project_repository.go new file mode 100644 index 0000000..5933064 --- /dev/null +++ b/backend/internal/repository/project_repository.go @@ -0,0 +1,153 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ErrDefaultProject = errors.New("默认项目不能删除或重复创建") + +type ProjectRepository interface { + CreateProject(context.Context, domain.Project) error + GetProject(context.Context, string) (domain.Project, error) + ListProjects(context.Context) ([]domain.Project, error) + UpdateProject(context.Context, domain.Project) error + DeleteProject(context.Context, string) error +} + +type SQLProjectRepository struct { + db *sql.DB + driver string +} + +var _ ProjectRepository = (*SQLProjectRepository)(nil) + +func NewSQLProjectRepository(db *sql.DB, driver ...string) *SQLProjectRepository { + return &SQLProjectRepository{db: db, driver: repositoryDriver(driver)} +} + +func (r *SQLProjectRepository) query(query string) string { + return Rebind(r.driver, query) +} + +func (r *SQLProjectRepository) ensureDB() error { + if r == nil || r.db == nil { + return errors.New("数据库连接不能为空") + } + return nil +} + +func (r *SQLProjectRepository) CreateProject(ctx context.Context, item domain.Project) error { + if err := r.ensureDB(); err != nil { + return err + } + item = item.Normalize() + if err := item.Validate(); err != nil { + return err + } + if item.ID == domain.DefaultProjectID { + return ErrDefaultProject + } + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.query(`INSERT INTO projects + (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`), + item.ID, item.Name, item.Description, now, now) + if err != nil { + return fmt.Errorf("创建项目失败:%w", err) + } + return nil +} + +const projectSelect = `SELECT id, name, description, created_at, updated_at FROM projects` + +func (r *SQLProjectRepository) GetProject(ctx context.Context, id string) (domain.Project, error) { + if err := r.ensureDB(); err != nil { + return domain.Project{}, err + } + return scanProject(r.db.QueryRowContext(ctx, r.query(projectSelect+` WHERE id = ?`), strings.TrimSpace(id))) +} + +func (r *SQLProjectRepository) ListProjects(ctx context.Context) ([]domain.Project, error) { + if err := r.ensureDB(); err != nil { + return nil, err + } + rows, err := r.db.QueryContext(ctx, r.query(projectSelect+` + ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, created_at, id`), domain.DefaultProjectID) + if err != nil { + return nil, fmt.Errorf("查询项目列表失败:%w", err) + } + defer rows.Close() + items := make([]domain.Project, 0) + for rows.Next() { + item, err := scanProject(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取项目列表失败:%w", err) + } + return items, nil +} + +func (r *SQLProjectRepository) UpdateProject(ctx context.Context, item domain.Project) error { + if err := r.ensureDB(); err != nil { + return err + } + item = item.Normalize() + if err := item.Validate(); err != nil { + return err + } + result, err := r.db.ExecContext(ctx, r.query(`UPDATE projects SET name = ?, description = ?, updated_at = ? WHERE id = ?`), + item.Name, item.Description, time.Now().UTC(), item.ID) + if err != nil { + return fmt.Errorf("更新项目失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取项目更新结果失败:%w", err) + } + if count == 0 { + // MySQL 未实际改变字段时也可能返回零,不能将其直接解释为项目不存在。 + _, err = r.GetProject(ctx, item.ID) + return err + } + return nil +} + +func (r *SQLProjectRepository) DeleteProject(ctx context.Context, id string) error { + if err := r.ensureDB(); err != nil { + return err + } + id = strings.TrimSpace(id) + if id == domain.DefaultProjectID { + return ErrDefaultProject + } + result, err := r.db.ExecContext(ctx, r.query(`DELETE FROM projects WHERE id = ?`), id) + if err != nil { + return fmt.Errorf("删除项目失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取项目删除结果失败:%w", err) + } + if count == 0 { + return sql.ErrNoRows + } + return nil +} + +func scanProject(row rowScanner) (domain.Project, error) { + var item domain.Project + if err := row.Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt, &item.UpdatedAt); err != nil { + return domain.Project{}, fmt.Errorf("读取项目失败:%w", err) + } + return item, nil +} diff --git a/backend/internal/repository/project_repository_test.go b/backend/internal/repository/project_repository_test.go new file mode 100644 index 0000000..5cc72b9 --- /dev/null +++ b/backend/internal/repository/project_repository_test.go @@ -0,0 +1,190 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestSQLProjectRepositorySQLite(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "projects.db") + 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) + } + checkProjectRepository(t, NewSQLProjectRepository(db.DB)) + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + items, err := NewSQLProjectRepository(db.DB).ListProjects(t.Context()) + if err != nil || len(items) != 2 || items[0].Name != "调整后的默认项目" || items[1].ID != "retained" { + t.Fatalf("关闭重开后项目未保留:%+v,%v", items, err) + } +} + +func TestSQLProjectRepositoryMySQLReal(t *testing.T) { + server := mysqltest.New(t) + cfg := server.Database(t) + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal("打开独立 MySQL 项目测试库失败") + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + checkProjectRepository(t, NewSQLProjectRepository(db.DB, cfg.Driver)) +} + +func checkProjectRepository(t *testing.T, repo *SQLProjectRepository) { + t.Helper() + ctx := t.Context() + items, err := repo.ListProjects(ctx) + if err != nil || len(items) != 1 || items[0].ID != domain.DefaultProjectID || items[0].Name != "默认项目" { + t.Fatalf("默认项目缺失:%+v,%v", items, err) + } + initial := items[0] + t.Run("reserved_default", func(t *testing.T) { + if err := repo.CreateProject(ctx, initial); !errors.Is(err, ErrDefaultProject) { + t.Fatalf("未拒绝重复创建默认项目:%v", err) + } + if err := repo.DeleteProject(ctx, " default "); !errors.Is(err, ErrDefaultProject) { + t.Fatalf("未阻止删除默认项目:%v", err) + } + }) + item := domain.Project{ID: "project-a", Name: " 证书项目🔒 ", Description: " 项目说明\n第二行 "} + item.CreatedAt = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + t.Run("create_and_read", func(t *testing.T) { + if err := repo.CreateProject(ctx, item); err != nil { + t.Fatal(err) + } + loaded, err := repo.GetProject(ctx, " project-a ") + if err != nil || loaded.Name != strings.TrimSpace(item.Name) || loaded.Description != strings.TrimSpace(item.Description) { + t.Fatalf("项目数据读取不符:%+v,%v", loaded, err) + } + if loaded.CreatedAt.Equal(item.CreatedAt) || loaded.CreatedAt.IsZero() || !loaded.CreatedAt.Equal(loaded.UpdatedAt) { + t.Fatal("仓储必须生成自己的创建和更新时间") + } + item = loaded + }) + t.Run("duplicate_and_invalid", func(t *testing.T) { + if err := repo.CreateProject(ctx, item); err == nil { + t.Fatal("重复项目主键未拒绝") + } + bad := item + bad.Name = " " + if err := repo.CreateProject(ctx, bad); err == nil { + t.Fatal("创建未校验项目") + } + if err := repo.UpdateProject(ctx, bad); err == nil { + t.Fatal("更新未校验项目") + } + }) + t.Run("update_and_order", func(t *testing.T) { + before, err := repo.GetProject(ctx, item.ID) + if err != nil { + t.Fatal(err) + } + item.Name = "更新后的项目" + item.CreatedAt = time.Time{} + for round := 0; round < 2; round++ { + if err := repo.UpdateProject(ctx, item); err != nil { + t.Fatal(err) + } + } + loaded, err := repo.GetProject(ctx, item.ID) + if err != nil || loaded.Name != item.Name || !loaded.CreatedAt.Equal(before.CreatedAt) || loaded.UpdatedAt.Before(before.UpdatedAt) { + t.Fatalf("更新改变了创建时间或丢失内容:%+v,%v", loaded, err) + } + if err := repo.CreateProject(ctx, domain.Project{ID: "retained", Name: item.Name}); err != nil { + t.Fatal(err) + } + items, err := repo.ListProjects(ctx) + if err != nil || len(items) != 3 || items[0].ID != domain.DefaultProjectID { + t.Fatalf("项目列表必须默认项目在前:%+v,%v", items, err) + } + initial.Name = "调整后的默认项目" + if err := repo.UpdateProject(ctx, initial); err != nil { + t.Fatal(err) + } + }) + t.Run("cancelled_context", func(t *testing.T) { + cancelled, cancel := context.WithCancel(ctx) + cancel() + if err := repo.CreateProject(cancelled, domain.Project{ID: "cancelled", Name: "不应写入"}); !errors.Is(err, context.Canceled) { + t.Fatalf("取消后创建未被拒绝:%v", err) + } + if _, err := repo.ListProjects(cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("取消后查询未被拒绝:%v", err) + } + if err := repo.DeleteProject(cancelled, item.ID); !errors.Is(err, context.Canceled) { + t.Fatalf("取消后删除未被拒绝:%v", err) + } + }) + t.Run("missing_and_injection", func(t *testing.T) { + for _, id := range []string{"missing", "' OR 1=1 --"} { + if _, err := repo.GetProject(ctx, id); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("查询不存在项目应保留未找到语义:%v", err) + } + if err := repo.DeleteProject(ctx, id); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("删除不存在项目应保留未找到语义:%v", err) + } + } + if err := repo.UpdateProject(ctx, domain.Project{ID: "missing", Name: "不存在"}); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("更新不存在项目应保留未找到语义:%v", err) + } + }) + t.Run("delete", func(t *testing.T) { + if err := repo.DeleteProject(ctx, item.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.GetProject(ctx, item.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("删除后仍能读取项目:%v", err) + } + items, err := repo.ListProjects(ctx) + if err != nil || len(items) != 2 || items[1].ID != "retained" { + t.Fatalf("删除项目影响了其他项目:%+v,%v", items, err) + } + }) +} + +func TestSQLProjectRepositoryNilDatabase(t *testing.T) { + for _, repo := range []*SQLProjectRepository{nil, NewSQLProjectRepository(nil)} { + if err := repo.CreateProject(t.Context(), domain.Project{}); err == nil { + t.Fatal("创建没有拒绝空数据库") + } + if _, err := repo.GetProject(t.Context(), "a"); err == nil { + t.Fatal("查询没有拒绝空数据库") + } + if _, err := repo.ListProjects(t.Context()); err == nil { + t.Fatal("列表没有拒绝空数据库") + } + if err := repo.UpdateProject(t.Context(), domain.Project{}); err == nil { + t.Fatal("更新没有拒绝空数据库") + } + if err := repo.DeleteProject(t.Context(), "a"); err == nil { + t.Fatal("删除没有拒绝空数据库") + } + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 201ff78..a8afd6a 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,7 @@ # Scert 开发任务清单 +> 2026 年 9 月 28 日当前进度:P0-06 项目根实体子项 `[x]`,已新增三库第 10 号迁移、默认项目、项目模型和仓储;SQLite、MySQL 5.7/8.0、竞态与后端全量测试,以及独立 Windows 程序项目备份恢复通过。资源归属字段、回填、引用保护及项目授权仍未实现,P0-06/P0-07 总项保持 `[~]`。新证据及命令见支持矩阵第十八节;旧模式 1~9 的历史证据不自动升级为模式 10 的全部数据库验收。 + > 2026-09-27 任务规格:完整 44 项及六阶段验收规范见《整改任务规格与验收约定》和《Scert整改任务执行清单》。以下历史勾选不替代当前整改状态,不把旧测试结果当成新增需求已经实现。 > 当前进度(源码 `fd2810b`):P0-05 `[~]`。已复现并修复 SQLite 历史模式 1~3 有任务数据时升级到迁移 4 失败的问题;模式 1~9 的带数据升级、依赖对象保留及失败重试已有回归。本批干净提交构建的独立 Windows 程序完成历史模式 3 备份、升级、恢复后再次升级,以及升级后备份恢复。SQLite 专项通过 71 个叶子用例,27 个升级/恢复用例连续 3 次竞态检测通过,详见《构建版本与数据库支持矩阵》第十七节。旧发行包真实数据迁移、完整发行包安装、真实服务重启、Linux 原生执行、取消边界及其余数据库和生产门禁仍未完成;全部 44 项范围不变。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index c2a855a..f832ed5 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,29 @@ # Scert 整改任务执行清单 +## 2026 年 9 月 28 日:项目根实体最终验证结果 + +本节更新下方启动阶段记录;详细命令与限制见《构建版本与数据库支持矩阵》第十八节。 + +- [x] 项目根实体、三库第 10 号迁移与仓储实现;默认项目保护、资料保留和错误语义专项通过。 +- [x] SQLite 历史模式 0~10 的升级覆盖(模式 1~10 含代表性业务),项目迁移失败回滚和重试通过;三套迁移目录、登记和嵌入检查通过。 +- [x] MySQL 5.7.44、8.0.45 各 8 个叶子用例通过,0 失败、0 跳过;包含严格 TLS 实连下的项目 CRUD 和 DDL 部分成功后的重试。 +- [x] 54 个项目/迁移叶子用例连续 3 次竞态检测通过;外部 MySQL 用例在该命令中条件跳过,已由上述实库专项分别执行。 +- [x] 最终后端全量 1409 个叶子通过,含父项 1535 条通过记录,0 失败、46 项条件跳过;静态分析与模块校验通过。 +- [x] 当批 Windows 产品程序通过 2 条独立 CLI 恢复路径,核对默认项目与普通项目、历史业务、凭据及证书;不是完整发行包或实机服务验收。 +- [ ] PostgreSQL 原生项目实库验证;全部资源归属回填、关联约束、项目成员权限、Web/API、Linux 原生运行及完整生产门禁。 + +P0-06、P0-07 均保持 `[~]`。当前完成的是项目根实体,不是统一项目资源模型或权限隔离;不更改原始 44 项范围。 + +## 2026 年 9 月 28 日:P0-06 项目根实体实施 + +- [x] 重新核对干净工作区和 P0-06/P0-07 原始要求;修改前检查点 `adb7d7f` 已推送。 +- [x] 新增项目模型、三库 `0010_projects.sql`、统一登记与嵌入、项目 SQL 仓储;旧迁移文件未改写。 +- [x] 本地专项验证项目校验、CRUD、默认项目保护、中文及四字节字符、取消上下文、缺失对象、重复写入、关闭重开,以及 SQLite 迁移回滚重试和历史业务保留。 +- [~] 完整后端与 MySQL 5.7/8.0 隔离实库回归正在执行,证据目录 `artifacts/project-model-20260928/`;跳过项不算通过。 +- [ ] PostgreSQL 实库专项、资源项目字段与历史回填、关联及引用删除约束、项目成员权限、接口越权、Web 页面和生产验收。 + +需求确认、实体关系与后续验收条件见《项目资源关联模型.md》。本批只建立项目根实体,未将现有资源视为已经分配到默认项目;P0-06/P0-07 和全部 44 项范围不缩减,均不提升为整项完成。 + > 本清单依据《Scert整改合并优化清单.md》建立,用于跟踪实际代码整改、自动化验证、隔离环境验证和生产验收。 > > 当前日期:2026 年 9 月 27 日 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index d2b03a9..863afce 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,9 @@ # Scert 整改、合并与优化清单 +> 本批最终验证补充:项目根实体已通过 SQLite 专项、MySQL 5.7/8.0 实库、3 次竞态检测及完整后端回归;默认和普通项目也已进入独立 Windows 程序的备份恢复验证。下方“待汇总”为启动阶段快照,最新证据以执行清单的“项目根实体最终验证结果”及支持矩阵第十八节为准。项目资源归属、关联约束和权限隔离仍未实现,P0-06/P0-07 保持 `[~]`。 + +> 2026 年 9 月 28 日补充进度:P0-06 已进入项目根实体实施,新增项目模型、三库第 10 号迁移及仓储,本地专项通过;完整后端与 MySQL 隔离测试待汇总。当前资源尚未增加项目归属,不代表 P0-07 权限隔离完成。详细需求、后续关联和删除约束见《项目资源关联模型.md》,实际验证见《Scert整改任务执行清单.md》。全部 44 项原始要求及各项验收标准不变。 + > 适用项目:`D:\Desktop\Scert` > 产品基准:Certimate + Certd > 产品范围:Web 管理页面 + Windows/Linux 服务端 diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index c25cbb2..5b916e7 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -1,5 +1,7 @@ # 全面审查、测试与优化记录 +> 2026 年 9 月 28 日最新批次:新增项目根实体、三库第 10 号迁移及仓储。最终后端 1409 个叶子通过、含父项 1535 条、0 失败、46 项条件跳过;54 个项目/迁移叶子用例连续 3 次竞态检测通过;MySQL 5.7.44/8.0.45 各 8 个叶子用例实库通过。独立 Windows 产品程序完成旧库升级及含普通项目的数据备份恢复。MySQL 8.0 首轮故障注入受触发器权限限制,已改用隔离管理连接注入并重测通过,没有降低安全配置或提高产品账号权限。证据见支持矩阵第十八节;项目资源字段和权限隔离仍未实现,生产未放行。 + > 2026-09-27 证据索引:当前 44 项整改的逐项范围、命令、实机步骤和复审字段见《整改任务规格与验收约定》及四份分级规格;实际进展以《Scert整改任务执行清单》为准。本文历史结果须按当时源码与环境解释。 > 本批进度(源码 `fd2810b`):P0-05 `[~]`。修复 SQLite 历史模式带任务数据升级时的非固定默认值加列失败,保留业务数据、索引、视图、触发器和外键关联,并验证失败回滚及重试。SQLite 专项 71 个叶子用例通过,27 个升级/恢复用例连续 3 次竞态检测通过。后端全量 1384 条叶子通过记录、含父项 1509 条,0 失败、43 项条件跳过;vet、模块校验及 Linux 数据库/CLI 测试包交叉编译通过。独立 Windows 产品程序实际执行历史模式备份、升级和恢复;证据见支持矩阵第十七节。不是旧发行包实际数据升级、完整发行包安装、真实服务重启、Linux 原生运行或生产验收。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 70976aa..8a793e3 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -1,5 +1,15 @@ # 基础整改任务规格 +> 2026 年 9 月 28 日最终验证:项目根实体的 SQLite、MySQL 5.7/8.0 实库及后端回归已通过,替代下方启动阶段“持续验证”的状态;仅该子项为 `[x]`。完整资源关联与 P0-07 权限隔离仍为 `[~]`。详细证据、46 项条件跳过及 PostgreSQL/生产缺口见支持矩阵第十八节。 + +## 2026 年 9 月 28 日实施补充 + +- [x] P0-06 新增项目根实体、三库第 10 号迁移与仓储,本地项目及 SQLite 历史升级专项通过。 +- [~] 完整后端、MySQL 隔离验证及后续项目资源关联持续推进,详见《Scert整改任务执行清单.md》《项目资源关联模型.md》。 +- [ ] PostgreSQL 项目实库验证、资源回填、跨项目关联保护、成员授权及危险操作审计关联仍未完成。 + +P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收不得因新增项目表提前勾选。P0-05 的旧证据只适用于原对应版本,模式 10 的完整数据库矩阵仍需重新验证。 + 本文件覆盖原始清单 P0 的全部 12 项。命令编号、六阶段定义和实机前置条件见《整改任务规格与验收约定.md》。文件路径相对仓库根目录;“拟新增”明确表示尚未存在,不作为现成功能证据。 ## P0-01 明确产品范围 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index 194e341..bce19ec 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -669,3 +669,69 @@ P0-05 保持 `[~]`,全部 44 项范围不变;本批修复和回归不作为 - `[ ]` PostgreSQL 当前实库、MySQL 剩余门禁、Web/外部服务、双平台生产门禁和真实生产验收;交叉编译、隔离夹具和独立程序不能替代上述证据。 P0-05 保持 `[~]`,全部 44 项范围不变;本节只记录当前局部修复和回归,不构成生产放行结论。 + +## 十八、项目根实体与模式 10 验证(2026 年 9 月 28 日) + +### 18.1 实现范围与安全边界 + +新增 `domain.Project`、项目 SQL 仓储、三套 `0010_projects.sql` 及迁移登记。应用启动后创建项目表和默认项目,但既有域名、服务器、任务和审计尚未增加项目字段;本节不证明项目资源隔离。 + +默认项目 ID 固定为 `default`;仓储禁止重复创建或删除默认项目,允许调整名称和说明。迁移重试不覆盖已有资料;项目 ID 与创建时间不随资料更新改写。项目名称与说明支持中文及四字节字符,校验长度、编码和控制字符。项目仓储尚未接入 HTTP 路由,不能作为已交付的 Web 功能。 + +SQLite 版本登记失败时回滚项目表及种子,原业务不变;MySQL 建表后版本登记失败时保留部分 DDL 状态,解除故障后可重复迁移并保留用户资料,不承诺 MySQL DDL 自动回滚。PostgreSQL 文件已登记并通过目录一致性检查,但当前批次未执行 PostgreSQL 原生实库项目验证。 + +### 18.2 验证证据 + +本批目录:`artifacts/project-model-20260928/`。该目录及构建产物保持 Git 忽略,不包含在源码提交中。 + +| 层级 | 证据文件 | 实际结果 | +|---|---|---| +| 最终后端全量 | `backend-all-final.jsonl` | 1409 个叶子通过、含父项 1535 条,0 失败、46 项条件跳过 | +| 项目及迁移竞态 | `race.jsonl` | 每轮 54 个叶子,连续 3 轮共 162 个叶子通过;6 条外部 MySQL 条件跳过由下两行单独补验 | +| MySQL 5.7.44 | `mysql57-final.jsonl` | 8 个叶子、含父项 9 条通过,0 失败、0 跳过;独立临时实例、仅回环监听、严格 TLS | +| MySQL 8.0.45 | `mysql80-final.jsonl` | 8 个叶子、含父项 9 条通过,0 失败、0 跳过;独立临时实例、仅回环监听、严格 TLS | +| 独立 Windows 产品程序 | `standalone-projects.jsonl` | 旧备份恢复再升级、模式 10 备份恢复两条路径通过,核对全表/模式及默认和普通项目 | +| 静态与模块 | `go vet ./...`、`go mod verify` | 均退出 0,模块校验显示全部通过 | + +MySQL 8.0 首轮 `mysql80.jsonl` 中,项目仓储通过,迁移故障测试在创建触发器时因产品连接缺少全局权限失败。故障注入已移到同一隔离实例的管理连接,产品连接的权限及安全配置不变;最终两版均从新的独立实例重新运行。首轮失败证据保留,不当作产品迁移成功或抹除失败记录。 + +完整后端命令: + +```powershell +cd D:\Desktop\Scert\backend +go test -json -count=1 -timeout=10m ./... +go vet ./... +go mod verify +``` + +项目与迁移重复竞态命令: + +```powershell +go test -race -json -count=3 -timeout=5m -run 'Project|MigrationCatalog|SQLiteUpgradeFromEverySupportedVersion|SQLiteBusinessUpgradeFromEveryVersion' ./internal/domain ./internal/repository ./internal/database +``` + +MySQL 专项分别设置 `SCERT_MYSQL_TEST_BIN` 为 `E:\DB\5.7\bin`、`E:\DB\8.0\bin`,`SCERT_MYSQL_TEST_ROOT` 分别为本批证据目录下的 `mysql57-final`、`mysql80-final`;不接受已有业务实例的连接串。 + +```powershell +go test -json -p 1 -count=1 -timeout=5m -run 'TestMySQLRealProjectMigrationRetry|TestSQLProjectRepositoryMySQLReal' ./internal/database ./internal/repository +``` + +独立产品程序验证使用当批源码构建的 `build/review-scert-project-windows-amd64.exe`,通过 `SCERT_SQLITE_TEST_CLI` 显式指定程序绝对路径: + +```powershell +go build -o ..\build\review-scert-project-windows-amd64.exe ./cmd/scert +$env:SCERT_SQLITE_TEST_CLI = 'D:\Desktop\Scert\build\review-scert-project-windows-amd64.exe' +go test -json -count=1 -timeout=3m -run '^TestSQLiteBusinessUpgradeStandaloneCLI$' ./internal/cli +``` + +测试保留完整历史库模式和业务快照;模式 10 备份前通过项目仓储创建普通项目,恢复前修改项目名称,恢复后对全部表逐项比对。旧模式备份恢复后必须重新通过正式产品命令升级,而不是由测试直接调用迁移代替正式入口。该程序验证不等于完整发行包安装、后台服务重启或生产验收。 + +### 18.3 当前任务状态 + +- [x] 项目根实体、默认项目、三库迁移文件和 SQL 仓储实现,等待复审。 +- [x] SQLite 模式 0~10 升级(1~10 含代表性业务)、默认项目不覆盖、失败回滚与重试;MySQL 两版实际 CRUD 和部分 DDL 重试。 +- [x] 默认与普通项目随 Windows 产品程序备份恢复;其他项目不会因单个项目删除而丢失。 +- [ ] PostgreSQL 原生项目实库验证、完整三库备份矩阵、Linux 原生执行、旧发行程序实际数据、完整发布/安装/重启和生产门禁。 +- [ ] 资源 `project_id` 及归属字段、历史回填、外键/引用删除保护、跨项目部署约束、成员授权、Web/API 与越权测试。 + +P0-06、P0-07 保持 `[~]`,P0-05 的完整支持矩阵也不提升;原始全部 44 项继续执行。本节不提供生产放行结论。 diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index ad9d881..2708fcc 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -1,5 +1,7 @@ # Scert 生产部署与运维说明 +> 2026 年 9 月 28 日模式版本提示:新增第 10 号项目根实体迁移,只建立项目表和默认项目,不代表旧资源已经完成项目归属或权限隔离。SQLite、MySQL 5.7/8.0 的局部验证及独立 Windows 程序备份恢复证据见支持矩阵第十八节;PostgreSQL 项目实库、完整发行包、服务重启和生产门禁仍未完成。不要直接用只支持模式 9 的旧程序打开已升级数据库,也不要删除迁移记录强行降级;升级回退仍需兼容版本及经过验证的备份恢复流程。该说明不是生产放行许可。 + > 2026-09-27 验收入口:部署当前候选前,先按《整改任务规格与验收约定》及《Scert整改任务执行清单》核对版本、环境、授权与 12 层生产门禁。下文历史拓扑不是当前服务器状态,不因旧批次通过而跳过本次实机检查。源码、候选包与证据边界见《仓库目录与产物边界说明》。 > 2026-09-27 正式范围:仅交付 Web 和 Windows/Linux 服务端。Wails 历史源码不参与发布,桌面窗口、托盘、更新、桌面配置及 UI 测试不作为生产门禁。CLI 仅作为服务端运维工具;本文件的 Linux 部署步骤不代表 Windows Service 已验收。 diff --git a/doc/项目资源关联模型.md b/doc/项目资源关联模型.md new file mode 100644 index 0000000..b1aaff8 --- /dev/null +++ b/doc/项目资源关联模型.md @@ -0,0 +1,62 @@ +# 项目资源关联模型 + +> 本批最终验证:SQLite、MySQL 5.7/8.0 项目专项、3 次竞态检测、完整后端及独立 Windows CLI 备份恢复已通过;第五节保留测试启动时状态,最终以执行清单和支持矩阵第十八节为准。P0-06/P0-07 仍为 `[~]`。 + +> 对应 P0-06、P0-07、P0-10;2026 年 9 月 28 日建立。本文区分已实现模型与后续目标,不以建表代替权限验收。 + +## 一、确认的需求与约束 + +- 全部 44 项整改范围保持不变;本轮从缺失的项目根实体开始,逐步接入原定全部核心资源。 +- 项目不是用户分组:用户分组的现有展示与成员约束不能被解释为项目授权。 +- 资源关联、项目成员授权和 HTTP 接口分别交付;授权闭环前不开放项目管理接口或项目全量列表接口。 +- 历史迁移只能增加关联,不删除原任务、服务器、证书、凭据或审计。禁止凭项目种子存在就宣称历史资源已回填。 +- 历史资源的默认项目 ID 固定为 `default`;已有默认项目名称和说明在迁移重试中不得覆盖。 +- 非默认项目 ID 由后端生成;名称和说明可修改,项目 ID 与创建时间不能通过更新接口改写。 +- 所有最终资源引用需要防止跨项目混用;不能仅靠前端筛选保证隔离。 + +## 二、实体及持久化分批计划 + +| 实体 | 当前状态 | 后续关系与交付要求 | +|---|---|---| +| `projects` | 已新增模型、三库第 10 号迁移和 SQL 仓储 | 作为项目根实体,统一资源引用;尚无对外接口 | +| `users`、`roles` | 既有用户和角色实现;项目成员与角色分配待补 | 用户经项目成员关系获得权限,不能按用户组名称推导项目权限 | +| `domains`、`servers` | 既有业务表;尚未增加项目字段 | 增加项目、所有者、创建人和更新人;历史数据回填默认项目 | +| `domain_servers` | 既有绑定表;尚未校验项目一致性 | 同一部署绑定的域名与服务器必须属于同一项目,失败须保持原绑定 | +| `tasks`、`task_attempts` | 既有任务与尝试表;尚未增加项目隔离 | 项目及资源归属、执行人、幂等与任务尝试关联;执行前重新判权 | +| `certificates`、`certificate_versions`、`certificate_orders` | 证书业务已有实现;统一实体关系待补 | 项目、域名、订单、版本、任务和签发结果可追溯 | +| `dns_accounts`、`dns_delegations` | DNS 配置业务已有实现;统一实体关系待补 | 项目、所有者、凭据引用、委派及验证任务关联 | +| `deployment_targets`、`deployment_records` | 部署业务已有实现;统一实体关系待补 | 关联项目、服务器、证书版本、任务尝试和回滚记录 | +| `renewal_policies` | 续期业务已有实现;统一实体关系待补 | 关联项目、证书、计划和执行任务 | +| `notification_channels`、`notification_records` | 通知业务已有实现;统一实体关系待补 | 关联项目、事件、任务、通道和发送结果 | +| `audit_logs` | 既有审计表;尚未增加项目隔离 | 项目、操作者、资源、任务与危险操作结果可追踪;全局操作单独定义范围 | +| `backup_records` | 数据库备份恢复已有实现;统一实体关系待补 | 区分系统备份与项目备份,记录范围、校验及恢复任务,不泄露私钥 | + +## 三、当前第 10 号迁移 + +仅新增 `projects` 表和默认项目种子,不改写既有业务字段,不将不存在的资源归属伪装成已完成。 + +字段为 `id`、`name`、`description`、`created_at`、`updated_at`。项目名称允许重名,以 ID 区分;名称最多 128 个字符,说明最多 4096 个字符,拒绝无效编码和危险控制字符。MySQL 项目表显式采用 InnoDB 与 `utf8mb4`,支持中文和四字节字符。 + +SQLite、PostgreSQL 迁移与版本记录使用同一事务。MySQL 不承诺 DDL 事务回滚:建表和种子写入之后登记版本失败时,保留已执行部分,重试补全版本且不覆盖现有项目资料。 + +项目仓储提供创建、读取、列表、资料更新和删除。默认项目不能经仓储删除或重复创建;列表默认项目优先。创建/更新时间由仓储生成,更新不覆盖创建时间。当前没有业务资源引用项目,因此普通项目的引用删除保护须随资源迁移一起实现,不能提前标记完成。 + +## 四、下一批关联迁移的验收条件 + +- [ ] MySQL 项目外键的引用字段采用与项目主键一致的字符集和排序规则,不因既有表默认配置不同而导致部分迁移失败。 + +- [ ] 域名、服务器、任务、审计等核心资源增加项目及归属字段,旧数据可确定地回填。 +- [ ] 三库外键或等效强约束保持一致;项目不存在、跨项目关联、删除仍被引用项目必须拒绝。 +- [ ] 域名服务器绑定的替换在同一事务中校验归属,失败不清空原绑定。 +- [ ] 全部相关 SQL 查询、写入、扫描和后台任务同步处理项目,不能仅增加 JSON 字段。 +- [ ] 原始证书、凭据、通知和备份状态全部能关联,禁止留下隐式共享全局资源。 +- [ ] 两项目实测列表、详情、写入、导出、删除、回滚和撤权后的排队任务,覆盖 P0-07。 +- [ ] 三库历史数据迁移、失败重试、备份恢复及独立发行程序验证完成后,再推进实机验收。 + +## 五、本轮状态 + +- [x] 项目模型、三库迁移及仓储实现,等待复审。 +- [x] SQLite 独立临时文件库读写、关闭重开、迁移回滚重试和历史业务保留测试通过。 +- [~] MySQL 5.7/8.0 独立实库测试及完整后端回归正在执行,结果见执行清单后续记录。 +- [ ] PostgreSQL 原生实库项目验证、资源归属回填、成员授权、Web/API 和生产验收。 +- [~] P0-06、P0-07 总状态均为进行中;第 10 号迁移不是完整统一领域模型交付。