新增统一项目根实体及三库迁移并验证项目备份恢复

This commit is contained in:
xuans 2026-09-28 10:15:08 +08:00
parent adb7d7f536
commit 6b72ac2e20
20 changed files with 846 additions and 4 deletions

View File

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

View File

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

View File

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

View 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');

View 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 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');

View File

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

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

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

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

View 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("构造函数未拒绝空项目名称")
}
}

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

View 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("删除没有拒绝空数据库")
}
}
}

View File

@ -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 项范围不变。

View File

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

View File

@ -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 服务端

View File

@ -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 原生运行或生产验收。

View File

@ -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 明确产品范围

View File

@ -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 项继续执行。本节不提供生产放行结论。

View File

@ -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 已验收。

View 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 号迁移不是完整统一领域模型交付。