完善域名服务器项目归属与数据库关联约束

保存资源项目、所有者和操作人,接口从会话记录归属,普通更新保留创建信息。
新增三库第十一版迁移、同项目绑定约束和 SQLite 触发器保护,补充迁移回滚重试、仓储、接口及备份恢复测试。
本次整理验证:后端构建、静态检查、关联改造针对性测试和 CLI 包测试通过。
范围说明:项目成员权限及其他资源归属尚未完成;本次未重新执行外部数据库实库、独立程序及生产部署验收。
This commit is contained in:
xuans 2026-09-28 10:45:00 +08:00
parent 059e6d7b67
commit 7284958819
22 changed files with 815 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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