From 7284958819e543b2f375a3987761253813692065 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 10:45:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=9F=9F=E5=90=8D=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=A1=B9=E7=9B=AE=E5=BD=92=E5=B1=9E=E4=B8=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=85=B3=E8=81=94=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存资源项目、所有者和操作人,接口从会话记录归属,普通更新保留创建信息。 新增三库第十一版迁移、同项目绑定约束和 SQLite 触发器保护,补充迁移回滚重试、仓储、接口及备份恢复测试。 本次整理验证:后端构建、静态检查、关联改造针对性测试和 CLI 包测试通过。 范围说明:项目成员权限及其他资源归属尚未完成;本次未重新执行外部数据库实库、独立程序及生产部署验收。 --- backend/internal/cli/root.go | 2 + backend/internal/cli/server.go | 2 + .../internal/cli/sqlite_upgrade_cli_test.go | 34 ++- backend/internal/database/migrate.go | 22 +- .../mysql/0011_resource_projects.sql | 16 ++ .../postgres/0011_resource_projects.sql | 16 ++ .../sqlite/0011_resource_projects.sql | 12 + .../database/project_migration_test.go | 2 +- .../resource_project_migration_test.go | 119 +++++++++ .../database/resource_project_mysql_test.go | 92 +++++++ .../database/sqlite_resource_constraints.go | 85 +++++++ backend/internal/deployment/model.go | 4 + backend/internal/domain/domain.go | 2 + backend/internal/domain/resource_scope.go | 16 ++ backend/internal/httpapi/resource_scope.go | 25 ++ .../internal/httpapi/resource_scope_test.go | 78 ++++++ backend/internal/httpapi/server.go | 4 + backend/internal/httpapi/server_test.go | 6 + .../repository/resource_project_test.go | 225 ++++++++++++++++++ .../internal/repository/server_repository.go | 36 ++- .../repository/server_repository_test.go | 17 ++ .../repository/sql_domain_repository.go | 27 ++- 22 files changed, 815 insertions(+), 27 deletions(-) create mode 100644 backend/internal/database/migrations/mysql/0011_resource_projects.sql create mode 100644 backend/internal/database/migrations/postgres/0011_resource_projects.sql create mode 100644 backend/internal/database/migrations/sqlite/0011_resource_projects.sql create mode 100644 backend/internal/database/resource_project_migration_test.go create mode 100644 backend/internal/database/resource_project_mysql_test.go create mode 100644 backend/internal/database/sqlite_resource_constraints.go create mode 100644 backend/internal/domain/resource_scope.go create mode 100644 backend/internal/httpapi/resource_scope.go create mode 100644 backend/internal/httpapi/resource_scope_test.go create mode 100644 backend/internal/repository/resource_project_test.go diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index e757224..736e22f 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -864,6 +864,8 @@ func newDomainEditCommand() *cobra.Command { return err } item.ID = existing.ID + item.ResourceScope = existing.ResourceScope + item.UpdatedBy = "" item.CreatedAt = existing.CreatedAt item.Status = "pending" item.ExpiresAt = nil diff --git a/backend/internal/cli/server.go b/backend/internal/cli/server.go index 2e14c41..78f4897 100644 --- a/backend/internal/cli/server.go +++ b/backend/internal/cli/server.go @@ -92,6 +92,8 @@ func newServerEditCommand() *cobra.Command { } item := value.server() item.ID = existing.ID + item.ResourceScope = existing.ResourceScope + item.UpdatedBy = "" item.CreatedAt = existing.CreatedAt item.UpdatedAt = time.Now().UTC() if _, err := service.SSHKeys.ValidateReference(cmd.Context(), item.PrivateKeyRef); err != nil { diff --git a/backend/internal/cli/sqlite_upgrade_cli_test.go b/backend/internal/cli/sqlite_upgrade_cli_test.go index d62f1af..d64b8e2 100644 --- a/backend/internal/cli/sqlite_upgrade_cli_test.go +++ b/backend/internal/cli/sqlite_upgrade_cli_test.go @@ -147,6 +147,29 @@ func createSQLiteProjectForBackup(t *testing.T, fixture sqliteBusinessFixture) { if err := repository.NewSQLProjectRepository(db.DB).CreateProject(t.Context(), item); err != nil { t.Fatal(err) } + domains := repository.NewSQLDomainRepository(db.DB) + resource, err := domains.GetByID(t.Context(), "domain") + if err != nil { + t.Fatal(err) + } + resource.ID, resource.Name = "project-domain", "project.example.test" + resource.ResourceScope = domain.ResourceScope{ProjectID: item.ID, OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "editor"} + if err := domains.Create(t.Context(), resource); err != nil { + t.Fatal(err) + } + servers := repository.NewSQLServerRepository(db.DB) + server, err := servers.GetServerByID(t.Context(), "server") + if err != nil { + t.Fatal(err) + } + server.ID = "project-server" + server.ResourceScope = resource.ResourceScope + if err := servers.CreateServer(t.Context(), server); err != nil { + t.Fatal(err) + } + if err := servers.ReplaceDomainServers(t.Context(), resource.ID, []string{server.ID}); err != nil { + t.Fatal(err) + } } func runSQLiteProductCLI(t *testing.T, binary string, fixture sqliteBusinessFixture, args ...string) { @@ -218,7 +241,7 @@ 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 != 10 { + if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 11 { t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err) } project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID) @@ -232,6 +255,15 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) { if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM domain_servers WHERE domain_id = 'domain' AND server_id = 'server'").Scan(&bindings); err != nil || bindings != 1 { t.Fatalf("历史部署绑定丢失:%v", err) } + for _, statement := range []string{ + `DELETE FROM projects WHERE id = 'default'`, + `UPDATE domains SET project_id = 'missing' WHERE id = 'domain'`, + `UPDATE domain_servers SET server_id = 'missing' WHERE domain_id = 'domain'`, + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err == nil { + t.Fatalf("恢复后的项目引用保护失效:%s", statement) + } + } user, err := repository.NewSQLUserRepository(db.DB).FindByIdentifier(t.Context(), "upgrade-owner") if err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(fixture.password)) != nil { t.Fatalf("升级后用户密码不可用:%v", err) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 6e9f8e9..b7a276f 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -12,6 +12,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 +//go:embed migrations/sqlite/0011_resource_projects.sql migrations/mysql/0011_resource_projects.sql migrations/postgres/0011_resource_projects.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -36,7 +37,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 10 +const latestMigrationVersion = 11 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -103,6 +104,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return fmt.Errorf("执行数据库迁移失败:%w", err) } } + if driver == "sqlite" && version == 11 { + if err := installSQLiteResourceConstraints(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" @@ -118,14 +125,21 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) } var ( - mysqlAddColumnPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+COLUMN\s+` + "`?([a-zA-Z0-9_]+)`?") - mysqlCreateIndexPattern = regexp.MustCompile(`(?is)^CREATE\s+INDEX\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ON\s+` + "`?([a-zA-Z0-9_]+)`?") + mysqlAddColumnPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+COLUMN\s+` + "`?([a-zA-Z0-9_]+)`?") + mysqlCreateIndexPattern = regexp.MustCompile(`(?is)^CREATE\s+(?:UNIQUE\s+)?INDEX\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ON\s+` + "`?([a-zA-Z0-9_]+)`?") + mysqlAddConstraintPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+CONSTRAINT\s+` + "`?([a-zA-Z0-9_]+)`?") ) // shouldSkipMySQLStatement 检查可恢复迁移中的字段和索引是否已经存在。 // MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS,因此需要在执行前查询元数据。 func shouldSkipMySQLStatement(ctx context.Context, db *sql.DB, statement string) (bool, error) { statement = strings.TrimSpace(statement) + if matches := mysqlAddConstraintPattern.FindStringSubmatch(statement); len(matches) == 3 { + var count int + err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() AND table_name = ? AND constraint_name = ?`, matches[1], matches[2]).Scan(&count) + return count > 0, err + } if matches := mysqlAddColumnPattern.FindStringSubmatch(statement); len(matches) == 3 { var count int err := db.QueryRowContext(ctx, ` @@ -167,6 +181,8 @@ func migrationName(version int) string { return "server_ssh_key_status" case 10: return "projects" + case 11: + return "resource_projects" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0011_resource_projects.sql b/backend/internal/database/migrations/mysql/0011_resource_projects.sql new file mode 100644 index 0000000..b204962 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0011_resource_projects.sql @@ -0,0 +1,16 @@ +ALTER TABLE domains ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default'; +ALTER TABLE domains ADD COLUMN owner_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN created_by VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN updated_by VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default'; +ALTER TABLE servers ADD COLUMN owner_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN created_by VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN updated_by VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE domain_servers ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default'; +CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id); +CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id); +CREATE INDEX idx_domain_servers_project ON domain_servers(project_id); +ALTER TABLE domains ADD CONSTRAINT fk_domains_project FOREIGN KEY (project_id) REFERENCES projects(id); +ALTER TABLE servers ADD CONSTRAINT fk_servers_project FOREIGN KEY (project_id) REFERENCES projects(id); +ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_domain_project FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id); +ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_server_project FOREIGN KEY (project_id, server_id) REFERENCES servers(project_id, id); diff --git a/backend/internal/database/migrations/postgres/0011_resource_projects.sql b/backend/internal/database/migrations/postgres/0011_resource_projects.sql new file mode 100644 index 0000000..ea5bafc --- /dev/null +++ b/backend/internal/database/migrations/postgres/0011_resource_projects.sql @@ -0,0 +1,16 @@ +ALTER TABLE domains ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE domains ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN created_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE servers ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN created_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE domain_servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id); +CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id); +CREATE INDEX idx_domain_servers_project ON domain_servers(project_id); +ALTER TABLE domains ADD CONSTRAINT fk_domains_project FOREIGN KEY (project_id) REFERENCES projects(id); +ALTER TABLE servers ADD CONSTRAINT fk_servers_project FOREIGN KEY (project_id) REFERENCES projects(id); +ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_domain_project FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id); +ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_server_project FOREIGN KEY (project_id, server_id) REFERENCES servers(project_id, id); diff --git a/backend/internal/database/migrations/sqlite/0011_resource_projects.sql b/backend/internal/database/migrations/sqlite/0011_resource_projects.sql new file mode 100644 index 0000000..e1dfa6b --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0011_resource_projects.sql @@ -0,0 +1,12 @@ +ALTER TABLE domains ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE domains ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN created_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE domains ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE servers ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN created_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE servers ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE domain_servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id); +CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id); +CREATE INDEX idx_domain_servers_project ON domain_servers(project_id); diff --git a/backend/internal/database/project_migration_test.go b/backend/internal/database/project_migration_test.go index 187e975..af13507 100644 --- a/backend/internal/database/project_migration_test.go +++ b/backend/internal/database/project_migration_test.go @@ -12,7 +12,7 @@ func TestSQLiteProjectMigrationPreservesBusiness(t *testing.T) { defer db.Close() createSQLiteHistoricalBusiness(t, db.DB, 9) before := snapshotSQLiteHistoricalBusiness(t, db.DB) - if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + if err := applyMigration(t.Context(), db.DB, "sqlite", 10); err != nil { t.Fatal(err) } assertSQLiteHistoricalBusiness(t, db.DB, before) diff --git a/backend/internal/database/resource_project_migration_test.go b/backend/internal/database/resource_project_migration_test.go new file mode 100644 index 0000000..9dab2f8 --- /dev/null +++ b/backend/internal/database/resource_project_migration_test.go @@ -0,0 +1,119 @@ +package database + +import ( + "fmt" + "testing" +) + +func TestSQLiteResourceProjectMigration(t *testing.T) { + for _, foreignKeys := range []int{0, 1} { + t.Run(fmt.Sprintf("foreign_keys_%d", foreignKeys), func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + createSQLiteHistoricalBusiness(t, db.DB, 10) + for _, query := range []string{ + fmt.Sprintf("PRAGMA foreign_keys = %d", foreignKeys), + `CREATE VIEW resource_names AS SELECT id, name FROM domains`, + `CREATE TABLE resource_events (name TEXT)`, + `CREATE TRIGGER resource_name_event AFTER UPDATE OF name ON domains BEGIN INSERT INTO resource_events VALUES (new.name); END`, + `CREATE INDEX custom_resource_name ON domains(name, id)`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + before := snapshotSQLiteHistoricalBusiness(t, db.DB) + objectsQuery := `SELECT type, name, sql FROM sqlite_master WHERE name IN ('resource_names', 'resource_events', 'resource_name_event', 'custom_resource_name') ORDER BY name` + objects := snapshotSQLiteUpgradeQuery(t, db.DB, objectsQuery) + for round := 0; round < 2; round++ { + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + if actual := snapshotSQLiteUpgradeQuery(t, db.DB, objectsQuery); actual != objects { + t.Fatal("资源升级改写已有视图、索引或触发器") + } + for _, table := range []string{"domains", "servers"} { + var project, owner, creator, updater string + if err := db.DB.QueryRowContext(t.Context(), "SELECT project_id, owner_id, created_by, updated_by FROM "+table).Scan(&project, &owner, &creator, &updater); err != nil { + t.Fatal(err) + } + if project != "default" || owner != "" || creator != "" || updater != "" { + t.Fatal("历史归属回填不正确,不能编造历史操作者") + } + } + for _, statement := range []string{ + `UPDATE domains SET project_id = 'missing'`, + `DELETE FROM projects WHERE id = 'default'`, + `UPDATE domain_servers SET server_id = 'missing'`, + `INSERT OR REPLACE INTO domains (id, name, certificate_type, sans_json, status) SELECT 'replacement', name, certificate_type, sans_json, status FROM domains WHERE id = 'domain'`, + `INSERT OR REPLACE INTO domains (id, name, certificate_type, sans_json, status) SELECT id, name, certificate_type, sans_json, status FROM domains WHERE id = 'domain'`, + `INSERT OR REPLACE INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) SELECT id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json FROM servers`, + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err == nil { + t.Errorf("资源约束被绕过:%s", statement) + } + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + var enabled int + if err := db.DB.QueryRowContext(t.Context(), "PRAGMA foreign_keys").Scan(&enabled); err != nil || enabled != foreignKeys { + t.Fatalf("迁移改变原连接外键配置:%d,%v", enabled, err) + } + if _, err := db.DB.ExecContext(t.Context(), `UPDATE domains SET name = 'after.example.test' WHERE id = 'domain'`); err != nil { + t.Fatal(err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM resource_events").Scan(&count); err != nil || count != 1 { + t.Fatalf("已有触发器升级后未执行:%d,%v", count, err) + } + }) + } +} + +func TestSQLiteResourceProjectMigrationRollback(t *testing.T) { + for _, failure := range []struct{ name, setup, repair string }{ + {"version", `CREATE TRIGGER resource_version_failure BEFORE INSERT ON schema_migrations WHEN new.version = 11 BEGIN SELECT RAISE(ABORT, 'test-only'); END`, `DROP TRIGGER resource_version_failure`}, + {"orphan_domain", `INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', 'server')`, `DELETE FROM domain_servers WHERE domain_id = 'missing'`}, + {"orphan_server", `INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'missing')`, `DELETE FROM domain_servers WHERE server_id = 'missing'`}, + {"missing_project", `DELETE FROM projects WHERE id = 'default'`, `INSERT INTO projects (id, name, description) VALUES ('default', '默认项目', '')`}, + } { + t.Run(failure.name, func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + createSQLiteHistoricalBusiness(t, db.DB, 10) + if _, err := db.DB.ExecContext(t.Context(), failure.setup); err != nil { + t.Fatal(err) + } + before := snapshotSQLiteHistoricalBusiness(t, db.DB) + schemaQuery := `SELECT type, name, sql FROM sqlite_master ORDER BY name` + schema := snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery) + if err := Migrate(t.Context(), db.DB, "sqlite"); err == nil { + t.Fatal("故障未阻止资源迁移") + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + if actual := snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery); actual != schema { + t.Fatal("资源迁移失败后未完整回滚模式") + } + version, err := currentMigrationVersion(t.Context(), db.DB) + if err != nil || version != 10 { + t.Fatalf("资源迁移失败仍登记版本:%d,%v", version, err) + } + if _, err := db.DB.ExecContext(t.Context(), failure.repair); err != nil { + t.Fatal(err) + } + for round := 0; round < 2; round++ { + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/backend/internal/database/resource_project_mysql_test.go b/backend/internal/database/resource_project_mysql_test.go new file mode 100644 index 0000000..3b3b894 --- /dev/null +++ b/backend/internal/database/resource_project_mysql_test.go @@ -0,0 +1,92 @@ +package database + +import ( + "strings" + "testing" +) + +func TestMySQLRealResourceProjectMigrationRetry(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 <= 10; version++ { + if err := applyMigration(ctx, db.DB, "mysql", version); err != nil { + t.Fatal(err) + } + } + for _, statement := range []string{ + `ALTER TABLE domains CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`, + `ALTER TABLE servers CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`, + `ALTER TABLE domain_servers CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`, + `INSERT INTO domains (id, name, certificate_type, sans_json, status) VALUES ('domain', 'example.test', 'single', '[]', 'pending')`, + `INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json, validation_command_json, ssh_key_error) VALUES ('server', 'server', '192.0.2.10', 22, 'deploy', 'secret://test', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '[]', '[]', '')`, + `INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`, + `INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', 'server')`, + } { + if _, err := db.DB.ExecContext(ctx, statement); err != nil { + t.Fatal(err) + } + } + if err := Migrate(ctx, db.DB, "mysql"); err == nil { + t.Fatal("历史悬空关联未阻断迁移") + } + var count int + if err := db.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM domain_servers").Scan(&count); err != nil || count != 2 { + t.Fatalf("迁移失败擅自删除历史关联:%d,%v", count, err) + } + if _, err := db.DB.ExecContext(ctx, "DELETE FROM domain_servers WHERE domain_id = 'missing'"); err != nil { + t.Fatal(err) + } + 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_resource_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("版本登记故障未阻断迁移") + } + version, err := currentMigrationVersion(ctx, db.DB) + if err != nil || version != 10 { + t.Fatalf("部分执行的迁移错误登记版本:%d,%v", version, err) + } + if _, err := db.DB.ExecContext(ctx, `UPDATE domains SET owner_id = 'after-partial', created_by = 'retained', updated_by = 'retained' WHERE id = 'domain'`); err != nil { + t.Fatal(err) + } + if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_resource_version"); err != nil { + t.Fatal(err) + } + for round := 0; round < 2; round++ { + if err := Migrate(ctx, db.DB, "mysql"); err != nil { + t.Fatal(err) + } + } + var project, owner, creator, updater string + if err := db.DB.QueryRowContext(ctx, "SELECT project_id, owner_id, created_by, updated_by FROM domains WHERE id = 'domain'").Scan(&project, &owner, &creator, &updater); err != nil { + t.Fatal(err) + } + if project != "default" || owner != "after-partial" || creator != "retained" || updater != "retained" { + t.Fatal("重试改写已保存的资源归属") + } + if err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_domains_project', 'fk_servers_project', 'fk_binding_domain_project', 'fk_binding_server_project')`).Scan(&count); err != nil || count != 4 { + t.Fatalf("项目关联外键未完整建立:%d,%v", count, err) + } + for _, statement := range []string{ + `DELETE FROM projects WHERE id = 'default'`, + `UPDATE domains SET project_id = 'DEFAULT' WHERE id = 'domain'`, + `UPDATE domain_servers SET server_id = 'missing'`, + } { + if _, err := db.DB.ExecContext(ctx, statement); err == nil { + t.Errorf("约束未生效:%s", statement) + } + } + if err := db.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM domain_servers WHERE domain_id = 'domain' AND server_id = 'server' AND project_id = 'default'").Scan(&count); err != nil || count != 1 { + t.Fatalf("历史有效绑定丢失:%d,%v", count, err) + } +} diff --git a/backend/internal/database/sqlite_resource_constraints.go b/backend/internal/database/sqlite_resource_constraints.go new file mode 100644 index 0000000..3b3b802 --- /dev/null +++ b/backend/internal/database/sqlite_resource_constraints.go @@ -0,0 +1,85 @@ +package database + +import ( + "context" + "database/sql" + "fmt" +) + +// SQLite 不重建业务表,以触发器实现引用约束;即使连接关闭外键开关也不能绕过。 +// 触发器作为完整语句执行,不能用迁移文件的分号拆分器切开触发器主体。 +func installSQLiteResourceConstraints(ctx context.Context, tx *sql.Tx) error { + for _, resource := range []struct{ table, column string }{{"domains", "domain_id"}, {"servers", "server_id"}} { + var invalid int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s r LEFT JOIN projects p ON p.id = r.project_id WHERE p.id IS NULL`, resource.table)).Scan(&invalid); err != nil { + return err + } + if invalid != 0 { + return fmt.Errorf("%s 引用了不存在的项目,拒绝迁移", resource.table) + } + query := fmt.Sprintf(`SELECT COUNT(*) FROM domain_servers b LEFT JOIN %s r + ON r.id = b.%s AND r.project_id = b.project_id WHERE r.id IS NULL`, resource.table, resource.column) + if err := tx.QueryRowContext(ctx, query).Scan(&invalid); err != nil { + return err + } + if invalid != 0 { + return fmt.Errorf("历史域名服务器关联存在无效的 %s 引用,拒绝迁移,请先核查原数据", resource.table) + } + statements := []string{ + fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_replace_guard BEFORE INSERT ON %[1]s + WHEN EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = NEW.id) + BEGIN SELECT RAISE(ABORT, '资源仍有部署关联,不能替换'); END`, resource.table, resource.column), + fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_project_insert BEFORE INSERT ON %[1]s + WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id) + BEGIN SELECT RAISE(ABORT, '资源项目不存在'); END`, resource.table), + fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_project_update BEFORE UPDATE OF project_id, id ON %[1]s + WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id) + OR EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = OLD.id + AND (project_id != NEW.project_id OR %[2]s != NEW.id)) + BEGIN SELECT RAISE(ABORT, '资源项目不存在或仍有部署关联'); END`, resource.table, resource.column), + fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_binding_delete BEFORE DELETE ON %[1]s + WHEN EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = OLD.id) + BEGIN SELECT RAISE(ABORT, '资源仍有部署关联'); END`, resource.table, resource.column), + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立资源项目约束失败:%w", err) + } + } + } + // REPLACE 的隐式删除在关闭 recursive_triggers 时不触发删除保护,需保护唯一域名冲突。 + for _, event := range []string{"INSERT", "UPDATE"} { + statement := fmt.Sprintf(`CREATE TRIGGER scert_domain_name_replace_%s BEFORE %s ON domains + WHEN EXISTS (SELECT 1 FROM domains d JOIN domain_servers b ON b.domain_id = d.id + WHERE d.name = NEW.name AND d.id != NEW.id) + BEGIN SELECT RAISE(ABORT, '同名域名仍有部署关联,不能替换'); END`, event, event) + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立域名替换保护失败:%w", err) + } + } + for _, event := range []string{"INSERT", "UPDATE"} { + statement := fmt.Sprintf(`CREATE TRIGGER scert_binding_project_%s BEFORE %s ON domain_servers + WHEN NOT EXISTS (SELECT 1 FROM domains WHERE id = NEW.domain_id AND project_id = NEW.project_id) + OR NOT EXISTS (SELECT 1 FROM servers WHERE id = NEW.server_id AND project_id = NEW.project_id) + BEGIN SELECT RAISE(ABORT, '域名与服务器必须存在且属于同一项目'); END`, event, event) + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立部署关联约束失败:%w", err) + } + } + for _, event := range []string{"DELETE", "UPDATE OF id"} { + name := "delete" + condition := "" + if event != "DELETE" { + name = "update" + condition = "OLD.id != NEW.id AND " + } + statement := fmt.Sprintf(`CREATE TRIGGER scert_project_resource_%s BEFORE %s ON projects + WHEN %s(EXISTS (SELECT 1 FROM domains WHERE project_id = OLD.id) + OR EXISTS (SELECT 1 FROM servers WHERE project_id = OLD.id)) + BEGIN SELECT RAISE(ABORT, '项目仍被资源引用'); END`, name, event, condition) + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立项目引用保护失败:%w", err) + } + } + return nil +} diff --git a/backend/internal/deployment/model.go b/backend/internal/deployment/model.go index cc3b985..8b8c0cc 100644 --- a/backend/internal/deployment/model.go +++ b/backend/internal/deployment/model.go @@ -8,6 +8,8 @@ import ( "regexp" "strings" "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" ) type WebServer string @@ -45,6 +47,7 @@ const ( var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`) type Server struct { + domain.ResourceScope ID string `json:"id"` Name string `json:"name"` Address string `json:"address"` @@ -71,6 +74,7 @@ type Server struct { } func (s Server) Normalize() Server { + s.ResourceScope = s.ResourceScope.Normalize() s.ID = strings.TrimSpace(s.ID) s.Name = strings.TrimSpace(s.Name) s.Address = strings.TrimSpace(s.Address) diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 8710a89..4394978 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -24,6 +24,7 @@ const ( ) type Domain struct { + ResourceScope ID string `json:"id"` Name string `json:"name"` CertificateType CertificateType `json:"certificate_type"` @@ -78,6 +79,7 @@ func NewDomainWithOptions(name string, certificateType CertificateType, sans []s } now := time.Now().UTC() return Domain{ + ResourceScope: ResourceScope{ProjectID: DefaultProjectID}, ID: NewID(), Name: name, CertificateType: certificateType, diff --git a/backend/internal/domain/resource_scope.go b/backend/internal/domain/resource_scope.go new file mode 100644 index 0000000..74591ce --- /dev/null +++ b/backend/internal/domain/resource_scope.go @@ -0,0 +1,16 @@ +package domain + +// ResourceScope 记录资源归属,不代替项目成员授权。空操作者表示历史数据或本地系统操作。 +type ResourceScope struct { + ProjectID string `json:"project_id"` + OwnerID string `json:"owner_id"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` +} + +func (s ResourceScope) Normalize() ResourceScope { + if s.ProjectID == "" { + s.ProjectID = DefaultProjectID + } + return s +} diff --git a/backend/internal/httpapi/resource_scope.go b/backend/internal/httpapi/resource_scope.go new file mode 100644 index 0000000..b10d98b --- /dev/null +++ b/backend/internal/httpapi/resource_scope.go @@ -0,0 +1,25 @@ +package httpapi + +import ( + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "github.com/gin-gonic/gin" +) + +// 归属由服务端会话填写,不能从请求体接受操作者或项目授权声明。 +func (s *Server) newResourceScope(c *gin.Context) domain.ResourceScope { + scope := domain.ResourceScope{ProjectID: domain.DefaultProjectID} + if session, ok := s.currentAuthSession(c); ok { + scope.OwnerID = session.UserID + scope.CreatedBy = session.UserID + scope.UpdatedBy = session.UserID + } + return scope +} + +func (s *Server) updatedResourceScope(c *gin.Context, existing domain.ResourceScope) domain.ResourceScope { + existing.UpdatedBy = "" + if session, ok := s.currentAuthSession(c); ok { + existing.UpdatedBy = session.UserID + } + return existing.Normalize() +} diff --git a/backend/internal/httpapi/resource_scope_test.go b/backend/internal/httpapi/resource_scope_test.go new file mode 100644 index 0000000..48ad179 --- /dev/null +++ b/backend/internal/httpapi/resource_scope_test.go @@ -0,0 +1,78 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +func TestDomainResourceScopeAPI(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + db, err := database.Open(t.Context(), cfg.Database) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + repo := repository.NewSQLDomainRepository(db.DB) + server := New(Dependencies{Config: cfg, Repository: repo}) + request := func(method, path, body, user string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(httpUserSessionCookie(server, user, repository.UserRoleAdmin)) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + return response + } + for _, field := range []string{"project_id", "owner_id", "created_by", "updated_by"} { + body := fmt.Sprintf(`{"name":"forged.example.test","certificate_type":"single","%s":"forged"}`, field) + response := request(http.MethodPost, "/api/v1/domains", body, "creator") + if response.Code != http.StatusBadRequest { + t.Fatalf("客户端不应提供 %s:%d", field, response.Code) + } + } + response := request(http.MethodPost, "/api/v1/domains", `{"name":"scope.example.test","certificate_type":"single"}`, "creator") + if response.Code != http.StatusCreated { + t.Fatalf("创建失败:%d,%s", response.Code, response.Body.String()) + } + var created domain.Domain + if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + want := domain.ResourceScope{ProjectID: "default", OwnerID: "creator", CreatedBy: "creator", UpdatedBy: "creator"} + if created.ResourceScope != want { + t.Fatalf("创建响应归属错误:%+v", created.ResourceScope) + } + response = request(http.MethodPut, "/api/v1/domains/"+created.ID, `{"name":"edited.example.test","certificate_type":"single"}`, "editor") + if response.Code != http.StatusOK { + t.Fatalf("更新失败:%d,%s", response.Code, response.Body.String()) + } + loaded, err := repo.GetByID(t.Context(), created.ID) + want.UpdatedBy = "editor" + if err != nil || loaded.ResourceScope != want { + t.Fatalf("更新后归属错误:%+v,%v", loaded.ResourceScope, err) + } +} + +func TestResourceScopePreservesProjectAndCreator(t *testing.T) { + server := New(Dependencies{Config: readyTestConfig(t.TempDir())}) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPut, "/", nil) + ctx.Request.AddCookie(httpUserSessionCookie(server, "editor", repository.UserRoleAdmin)) + existing := domain.ResourceScope{ProjectID: "project-a", OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "previous"} + updated := server.updatedResourceScope(ctx, existing) + if updated.ProjectID != existing.ProjectID || updated.OwnerID != existing.OwnerID || updated.CreatedBy != existing.CreatedBy || updated.UpdatedBy != "editor" { + t.Fatalf("更新操作者时丢失原资源归属:%+v", updated) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index cc1360f..d909bc1 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -546,6 +546,7 @@ func (s *Server) createDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, err.Error(), err) return } + item.ResourceScope = s.newResourceScope(c) if err := s.repository.Create(c.Request.Context(), item); err != nil { if repository.IsConflict(err) { s.writeError(c, http.StatusConflict, "域名已经存在", err) @@ -585,6 +586,7 @@ func (s *Server) updateDomain(c *gin.Context) { return } item.ID = existing.ID + item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope) item.CreatedAt = existing.CreatedAt item.Status = "pending" item.ExpiresAt = nil @@ -702,6 +704,7 @@ func (s *Server) createServer(c *gin.Context) { return } item := serverFromRequest(request) + item.ResourceScope = s.newResourceScope(c) item = item.Normalize() now := time.Now().UTC() item.CreatedAt = now @@ -754,6 +757,7 @@ func (s *Server) updateServer(c *gin.Context) { return } item.CreatedAt = existing.CreatedAt + item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope) item.UpdatedAt = time.Now().UTC() item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, &existing) if err != nil { diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index 9741d26..094cadb 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -342,6 +342,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) { t.Fatalf("服务器 API 未使用默认远程证书路径模板:%+v", created) } + if created.ProjectID != "default" || created.OwnerID != "test-admin" || created.CreatedBy != "test-admin" || created.UpdatedBy != "test-admin" { + t.Fatalf("服务器未记录服务端会话归属:%+v", created.ResourceScope) + } updateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/servers/"+created.ID, strings.NewReader(`{"name":"updated-server","address":"192.0.2.50","port":22,"username":"deploy","private_key_ref":"`+key.Reference+`","host_key_fingerprint":"SHA256:test","web_server":"nginx"}`)) updateRequest.Header.Set("Content-Type", "application/json") updateResponse := httptest.NewRecorder() @@ -356,6 +359,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) { if updated.Name != "updated-server" || updated.CreatedAt.IsZero() || updated.UpdatedAt.IsZero() { t.Fatalf("updated server response is incomplete: %+v", updated) } + if updated.ResourceScope != created.ResourceScope { + t.Fatalf("服务器更新丢失归属:%+v", updated.ResourceScope) + } associationRequest := httptest.NewRequest(http.MethodPut, "/api/v1/domains/"+item.ID+"/servers", strings.NewReader(`{"server_ids":["`+created.ID+`"]}`)) associationRequest.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/repository/resource_project_test.go b/backend/internal/repository/resource_project_test.go new file mode 100644 index 0000000..f0c1ad5 --- /dev/null +++ b/backend/internal/repository/resource_project_test.go @@ -0,0 +1,225 @@ +package repository + +import ( + "database/sql" + "errors" + "path/filepath" + "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 TestResourceProjectSQLite(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "resource-project.db") + checkResourceProjects(t, cfg) +} + +func TestResourceProjectMySQLReal(t *testing.T) { + server := mysqltest.New(t) + checkResourceProjects(t, server.Database(t)) +} + +func checkResourceProjects(t *testing.T, cfg config.DatabaseConfig) { + t.Helper() + ctx := t.Context() + db, err := database.Open(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(ctx, db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + projects := NewSQLProjectRepository(db.DB, cfg.Driver) + domains := NewSQLDomainRepository(db.DB, cfg.Driver) + servers := NewSQLServerRepository(db.DB, cfg.Driver) + for _, id := range []string{"project-a", "project-b"} { + if err := projects.CreateProject(ctx, domain.Project{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + item, err := domain.NewDomain("resource.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + scope := domain.ResourceScope{ProjectID: "project-a", OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "creator"} + item.ResourceScope = scope + if err := domains.Create(ctx, item); err != nil { + t.Fatal(err) + } + server := deployment.Server{ + ResourceScope: scope, + 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, + } + if err := servers.CreateServer(ctx, server); err != nil { + t.Fatal(err) + } + other := server + other.ID = "server-b" + other.ProjectID = "project-b" + if err := servers.CreateServer(ctx, other); err != nil { + t.Fatal(err) + } + t.Run("persisted_scope", func(t *testing.T) { + loaded, err := domains.GetByID(ctx, item.ID) + if err != nil || loaded.ResourceScope != scope { + t.Fatalf("域名归属未持久化:%+v,%v", loaded.ResourceScope, err) + } + loadedServer, err := servers.GetServerByID(ctx, server.ID) + if err != nil || loadedServer.ResourceScope != scope { + t.Fatalf("服务器归属未持久化:%+v,%v", loadedServer.ResourceScope, err) + } + items, err := domains.List(ctx) + if err != nil || len(items) != 1 || items[0].ResourceScope != scope { + t.Fatalf("域名列表丢失归属:%v", err) + } + serverItems, err := servers.ListServers(ctx) + if err != nil || len(serverItems) != 2 { + t.Fatalf("服务器列表错误:%v", err) + } + for _, value := range serverItems { + if value.OwnerID != scope.OwnerID || value.CreatedBy != scope.CreatedBy || value.ProjectID == "" { + t.Fatal("服务器列表丢失归属") + } + } + }) + t.Run("update_preserves_identity", func(t *testing.T) { + changed := item + changed.OwnerID, changed.CreatedBy, changed.UpdatedBy = "forged-owner", "forged-creator", "editor" + changed.Name = "updated.example.test" + if err := domains.Update(ctx, changed); err != nil { + t.Fatal(err) + } + changedServer := server + changedServer.OwnerID, changedServer.CreatedBy, changedServer.UpdatedBy = "forged-owner", "forged-creator", "editor" + if err := servers.UpdateServer(ctx, changedServer); err != nil { + t.Fatal(err) + } + // 包含 MySQL 实际没有字段变化时的重复更新。 + if err := domains.Update(ctx, changed); err != nil { + t.Fatal(err) + } + loaded, err := domains.GetByID(ctx, item.ID) + if err != nil || loaded.OwnerID != "owner" || loaded.CreatedBy != "creator" || loaded.UpdatedBy != "editor" { + t.Fatalf("域名更新错误覆盖归属:%+v,%v", loaded.ResourceScope, err) + } + loadedServer, err := servers.GetServerByID(ctx, server.ID) + if err != nil || loadedServer.OwnerID != "owner" || loadedServer.CreatedBy != "creator" || loadedServer.UpdatedBy != "editor" { + t.Fatalf("服务器更新错误覆盖归属:%+v,%v", loadedServer.ResourceScope, err) + } + changed.ProjectID = "project-b" + changedServer.ProjectID = "project-b" + if err := domains.Update(ctx, changed); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("普通更新不应迁移域名项目:%v", err) + } + if err := servers.UpdateServer(ctx, changedServer); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("普通更新不应迁移服务器项目:%v", err) + } + }) + t.Run("project_delete_protected_without_binding", func(t *testing.T) { + if err := projects.DeleteProject(ctx, "project-a"); err == nil { + t.Fatal("仍被资源引用的项目被删除") + } + if err := projects.DeleteProject(ctx, "project-b"); err == nil { + t.Fatal("仅被服务器引用的项目被删除") + } + }) + if err := servers.ReplaceDomainServers(ctx, item.ID, []string{server.ID, server.ID}); err != nil { + t.Fatal(err) + } + assertBinding := func(t *testing.T) { + t.Helper() + items, err := servers.ListServersByDomainID(ctx, item.ID) + if err != nil || len(items) != 1 || items[0].ID != server.ID || items[0].ProjectID != item.ProjectID { + t.Fatalf("原部署绑定未保留:%+v,%v", items, err) + } + } + for _, ids := range [][]string{{server.ID, other.ID}, {server.ID, "missing"}, {""}} { + t.Run("binding_failure_keeps_previous", func(t *testing.T) { + if err := servers.ReplaceDomainServers(ctx, item.ID, ids); err == nil { + t.Fatal("无效或跨项目绑定未被拒绝") + } + assertBinding(t) + }) + } + t.Run("missing_domain_even_when_clearing", func(t *testing.T) { + if err := servers.ReplaceDomainServers(ctx, "missing", nil); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("不存在域名的空绑定应被拒绝:%v", err) + } + }) + t.Run("database_constraints", func(t *testing.T) { + statements := []struct { + query string + args []any + }{ + {"UPDATE domains SET project_id = 'missing' WHERE id = ?", []any{item.ID}}, + {"UPDATE servers SET project_id = 'missing' WHERE id = ?", []any{other.ID}}, + {"UPDATE domains SET project_id = 'project-b' WHERE id = ?", []any{item.ID}}, + {"UPDATE servers SET project_id = 'project-b' WHERE id = ?", []any{server.ID}}, + {"UPDATE domains SET id = 'renamed' WHERE id = ?", []any{item.ID}}, + {"UPDATE servers SET id = 'renamed' WHERE id = ?", []any{server.ID}}, + {"UPDATE projects SET id = 'renamed' WHERE id = 'project-a'", nil}, + {"DELETE FROM projects WHERE id = 'project-a'", nil}, + {"DELETE FROM domains WHERE id = ?", []any{item.ID}}, + {"DELETE FROM servers WHERE id = ?", []any{server.ID}}, + {"INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, 'project-a')", []any{item.ID, other.ID}}, + {"UPDATE domain_servers SET server_id = ? WHERE domain_id = ?", []any{other.ID, item.ID}}, + {"UPDATE domain_servers SET project_id = 'project-b' WHERE domain_id = ?", []any{item.ID}}, + {"INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', ?)", []any{server.ID}}, + } + for _, statement := range statements { + if _, err := db.DB.ExecContext(ctx, Rebind(cfg.Driver, statement.query), statement.args...); err == nil { + t.Errorf("直接 SQL 绕过约束:%s", statement.query) + } + } + assertBinding(t) + }) + t.Run("missing_project_create", func(t *testing.T) { + invalid := item + invalid.ID, invalid.Name, invalid.ProjectID = "invalid-domain", "invalid.example.test", "missing" + if err := domains.Create(ctx, invalid); err == nil { + t.Fatal("域名引用不存在项目") + } + invalidServer := server + invalidServer.ID, invalidServer.ProjectID = "invalid-server", "missing" + if err := servers.CreateServer(ctx, invalidServer); err == nil { + t.Fatal("服务器引用不存在项目") + } + }) + t.Run("legacy_default", func(t *testing.T) { + legacy, err := domain.NewDomain("legacy.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + legacy.ResourceScope = domain.ResourceScope{} + if err := domains.Create(ctx, legacy); err != nil { + t.Fatal(err) + } + loaded, err := domains.GetByID(ctx, legacy.ID) + if err != nil || loaded.ProjectID != domain.DefaultProjectID || loaded.OwnerID != "" { + t.Fatalf("兼容调用的默认项目错误:%+v,%v", loaded.ResourceScope, err) + } + }) + t.Run("delete_resources_then_project", func(t *testing.T) { + if err := domains.Delete(ctx, item.ID); err != nil { + t.Fatal(err) + } + if err := projects.DeleteProject(ctx, "project-a"); err == nil { + t.Fatal("服务器仍引用项目时不应允许删除") + } + if err := servers.DeleteServer(ctx, server.ID); err != nil { + t.Fatal(err) + } + if err := projects.DeleteProject(ctx, "project-a"); err != nil { + t.Fatal(err) + } + }) +} diff --git a/backend/internal/repository/server_repository.go b/backend/internal/repository/server_repository.go index 8e390cf..8c210e7 100644 --- a/backend/internal/repository/server_repository.go +++ b/backend/internal/repository/server_repository.go @@ -58,9 +58,10 @@ func (r *SQLServerRepository) CreateServer(ctx context.Context, server deploymen updatedAt = createdAt } _, err = r.db.ExecContext(ctx, r.query(`INSERT INTO servers - (id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, - server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt) + (id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at, project_id, owner_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, + server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt, + server.ProjectID, server.OwnerID, server.CreatedBy, server.UpdatedBy) if err != nil { return fmt.Errorf("保存服务器失败:%w", err) } @@ -84,8 +85,8 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen if updatedAt.IsZero() { updatedAt = time.Now().UTC() } - result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`), - server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID) + result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ?, updated_by = ? WHERE id = ? AND project_id = ?`), + server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.UpdatedBy, server.ID, server.ProjectID) if err != nil { return fmt.Errorf("更新服务器失败:%w", err) } @@ -94,7 +95,13 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen return fmt.Errorf("读取服务器更新结果失败:%w", err) } if count == 0 { - return sql.ErrNoRows + stored, err := r.GetServerByID(ctx, server.ID) + if err != nil { + return err + } + if stored.ProjectID != server.ProjectID { + return sql.ErrNoRows + } } return nil } @@ -190,6 +197,7 @@ func (r *SQLServerRepository) ListServersByDomainID(ctx context.Context, domainI } func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID string, serverIDs []string) error { + domainID = strings.TrimSpace(domainID) if strings.TrimSpace(domainID) == "" { return errors.New("域名 ID 不能为空") } @@ -214,18 +222,22 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID _ = tx.Rollback() return cause } + var projectID string + if err := tx.QueryRowContext(ctx, r.query("SELECT project_id FROM domains WHERE id = ?"), domainID).Scan(&projectID); err != nil { + return rollback(fmt.Errorf("读取绑定域名项目失败:%w", err)) + } if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), domainID); err != nil { return rollback(fmt.Errorf("清理域名服务器关联失败:%w", err)) } for _, serverID := range cleaned { var exists int - if err := tx.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE id = ?"), serverID).Scan(&exists); err != nil { + if err := tx.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE id = ? AND project_id = ?"), serverID, projectID).Scan(&exists); err != nil { return rollback(fmt.Errorf("校验部署服务器失败:%w", err)) } if exists == 0 { - return rollback(fmt.Errorf("部署服务器不存在:%s", serverID)) + return rollback(fmt.Errorf("部署服务器不存在或不属于域名项目:%s", serverID)) } - if _, err := tx.ExecContext(ctx, r.query("INSERT INTO domain_servers (domain_id, server_id) VALUES (?, ?)"), domainID, serverID); err != nil { + if _, err := tx.ExecContext(ctx, r.query("INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, ?)"), domainID, serverID, projectID); err != nil { return rollback(fmt.Errorf("保存域名服务器关联失败:%w", err)) } } @@ -235,8 +247,8 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID return nil } -const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers` -const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.ssh_key_status, servers.ssh_key_fingerprint, servers.ssh_key_deployed_at, servers.ssh_key_tested_at, servers.ssh_key_error, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers` +const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at, project_id, owner_id, created_by, updated_by FROM servers` +const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.ssh_key_status, servers.ssh_key_fingerprint, servers.ssh_key_deployed_at, servers.ssh_key_tested_at, servers.ssh_key_error, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at, servers.project_id, servers.owner_id, servers.created_by, servers.updated_by FROM servers` type rowScanner interface { Scan(dest ...any) error @@ -248,7 +260,7 @@ func scanServer(row rowScanner) (deployment.Server, error) { var authMethod string var validationCommandJSON string var commandJSON string - if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil { + if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt, &server.ProjectID, &server.OwnerID, &server.CreatedBy, &server.UpdatedBy); err != nil { if errors.Is(err, sql.ErrNoRows) { return deployment.Server{}, sql.ErrNoRows } diff --git a/backend/internal/repository/server_repository_test.go b/backend/internal/repository/server_repository_test.go index b6a3e6a..375ff6a 100644 --- a/backend/internal/repository/server_repository_test.go +++ b/backend/internal/repository/server_repository_test.go @@ -8,6 +8,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/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" ) func TestServerRepositoryCRUDAndDomainAssociations(t *testing.T) { @@ -52,6 +53,14 @@ func TestServerRepositoryCRUDAndDomainAssociations(t *testing.T) { if updated.CreatedAt.IsZero() || updated.UpdatedAt.IsZero() || updated.UpdatedAt.Before(updated.CreatedAt) { t.Fatalf("updated server timestamps are invalid: %+v", updated) } + item, err := domain.NewDomain("example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ID = "domain-1" + if err := NewSQLDomainRepository(db.DB).Create(t.Context(), item); err != nil { + t.Fatal(err) + } if err := repo.ReplaceDomainServers(context.Background(), "domain-1", []string{server.ID, server.ID}); err != nil { t.Fatalf("保存域名服务器关联失败:%v", err) } @@ -128,6 +137,14 @@ func TestReplaceDomainServersRejectsUnknownServerWithoutChangingAssociations(t * if err := repo.CreateServer(context.Background(), server); err != nil { t.Fatal(err) } + item, err := domain.NewDomain("association.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ID = "domain-1" + if err := NewSQLDomainRepository(db.DB).Create(t.Context(), item); err != nil { + t.Fatal(err) + } if err := repo.ReplaceDomainServers(context.Background(), "domain-1", []string{server.ID}); err != nil { t.Fatal(err) } diff --git a/backend/internal/repository/sql_domain_repository.go b/backend/internal/repository/sql_domain_repository.go index f7f7eee..4be88fc 100644 --- a/backend/internal/repository/sql_domain_repository.go +++ b/backend/internal/repository/sql_domain_repository.go @@ -34,9 +34,10 @@ func (r *SQLDomainRepository) Create(ctx context.Context, item domain.Domain) er return fmt.Errorf("编码 SAN 域名失败:%w", err) } _, err = r.db.ExecContext(ctx, r.query(`INSERT INTO domains - (id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew, - item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt) + (id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew, + item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt, + item.ResourceScope.Normalize().ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy) if err != nil { return fmt.Errorf("保存域名失败:%w", err) } @@ -52,9 +53,9 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er return fmt.Errorf("编码 SAN 域名失败:%w", err) } result, err := r.db.ExecContext(ctx, r.query(`UPDATE domains SET - name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ? - WHERE id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment, - item.DNSProvider, item.UpdatedAt, item.ID) + name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ? + WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment, + item.DNSProvider, item.UpdatedAt, item.UpdatedBy, item.ID, item.ResourceScope.Normalize().ProjectID) if err != nil { return fmt.Errorf("更新域名失败:%w", err) } @@ -63,7 +64,13 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er return fmt.Errorf("读取更新结果失败:%w", err) } if count == 0 { - return sql.ErrNoRows + stored, err := r.GetByID(ctx, item.ID) + if err != nil { + return err + } + if stored.ProjectID != item.ResourceScope.Normalize().ProjectID { + return sql.ErrNoRows + } } return nil } @@ -72,7 +79,7 @@ func (r *SQLDomainRepository) GetByID(ctx context.Context, id string) (domain.Do if r == nil || r.db == nil { return domain.Domain{}, errors.New("数据库连接不能为空") } - row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at + row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by FROM domains WHERE id = ?`), id) return scanDomain(row) } @@ -81,7 +88,7 @@ func (r *SQLDomainRepository) List(ctx context.Context) ([]domain.Domain, error) if r == nil || r.db == nil { return nil, errors.New("数据库连接不能为空") } - rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at + rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by FROM domains ORDER BY name`)) if err != nil { return nil, fmt.Errorf("查询域名失败:%w", err) @@ -168,7 +175,7 @@ func scanDomain(row scanner) (domain.Domain, error) { var acmeEnvironment string var dnsProvider string var expiresAt sql.NullTime - if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt); err != nil { + if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy); err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.Domain{}, sql.ErrNoRows }