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