补充重新核查实库持久化测试并同步整改进度

This commit is contained in:
xuans 2026-10-04 00:01:25 +08:00
parent f9cd19f631
commit c1faebf9ef
12 changed files with 311 additions and 60 deletions

View File

@ -23,10 +23,16 @@ func (c *reconciliationConsoleClient) PrepareRecoveryReconciliation(sourceID str
}
func (c *reconciliationConsoleClient) ExecuteRecovery(id, token string) (deployment.RecoveryDispositionSummary, error) {
value, err := c.dispositionConsoleClient.ExecuteRecovery(id, token)
if err != nil { return value, err }
if err != nil {
return value, err
}
value.SourceDispositionID = c.sourceID
if value.Status == "succeeded" { value.ResultCode = "committed" }
if c.mismatch { value.SourceDispositionID = strings.Repeat("d", 64) }
if value.Status == "succeeded" {
value.ResultCode = "committed"
}
if c.mismatch {
value.SourceDispositionID = strings.Repeat("d", 64)
}
return value, nil
}
@ -37,26 +43,45 @@ func TestRecoveryReconciliationConsoleConfirmation(t *testing.T) {
client := &reconciliationConsoleClient{}
answer := "确认重新核查 " + sourceID + " " + dispositionActionLabel(deployment.RecoveryActionVerifyCommit)
switch mode {
case "rejected": client.status = "rejected"
case "cancelled": answer = "确认"
case "expired": client.expired = true
case "disconnect": client.executeError = true
case "mismatch": client.mismatch = true
case "rejected":
client.status = "rejected"
case "cancelled":
answer = "确认"
case "expired":
client.expired = true
case "disconnect":
client.executeError = true
case "mismatch":
client.mismatch = true
}
screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{sourceID, answer}}}
err := recoveryReconciliationFlow(t.Context(), client, screen)
output := strings.Join(screen.lines, "|")
if client.prepares != 1 || client.queries != 0 || strings.Contains(output, strings.Repeat("c", 64)) { t.Fatal("终端重复调用或泄露令牌") }
if client.prepares != 1 || client.queries != 0 || strings.Contains(output, strings.Repeat("c", 64)) {
t.Fatal("终端重复调用或泄露令牌")
}
if mode == "cancelled" || mode == "expired" {
if client.executes != 0 { t.Fatal("取消或过期仍执行核查") }
if client.executes != 0 {
t.Fatal("取消或过期仍执行核查")
}
return
}
if client.executes != 1 || !strings.Contains(output, "原不确定记录永久保留") { t.Fatal("缺少明确警告或重复执行") }
if client.executes != 1 || !strings.Contains(output, "原不确定记录永久保留") {
t.Fatal("缺少明确警告或重复执行")
}
if mode == "disconnect" || mode == "mismatch" {
if err == nil || !strings.Contains(output, "新核查编号查询") { t.Fatal("失去可靠响应后误报成功") }
} else if err != nil { t.Fatal(err) }
if mode == "success" && !strings.Contains(output, "占用已安全释放") { t.Fatal("缺少完成结果") }
if mode == "rejected" && !strings.Contains(output, "原占用继续保留") { t.Fatal("错误显示拒绝结果") }
if err == nil || !strings.Contains(output, "新核查编号查询") {
t.Fatal("失去可靠响应后误报成功")
}
} else if err != nil {
t.Fatal(err)
}
if mode == "success" && !strings.Contains(output, "占用已安全释放") {
t.Fatal("缺少完成结果")
}
if mode == "rejected" && !strings.Contains(output, "原占用继续保留") {
t.Fatal("错误显示拒绝结果")
}
})
}
}

View File

@ -15,7 +15,9 @@ type localReconciliationJournal struct {
}
func (j *localReconciliationJournal) GetRecoveryDisposition(ctx context.Context, id string) (deployment.RecoveryDisposition, error) {
if id == j.source.Intent.ID { return j.source, nil }
if id == j.source.Intent.ID {
return j.source, nil
}
return j.localDispositionJournal.GetRecoveryDisposition(ctx, id)
}
@ -31,16 +33,20 @@ func localReconciliationFixture(t *testing.T) (*Server, *localResetSession, *loc
Files: map[string]string{}, TemporaryFiles: map[string]string{}, Baseline: deployment.RecoveryBaseline{Endpoint: "https://example.test", Domains: []string{"example.test"}, ExpectedFingerprint: "new"}}
for _, path := range journal.record.Paths {
journal.record.LockPaths = append(journal.record.LockPaths, path+".scert-lock")
journal.record.Backups = append(journal.record.Backups, deployment.TransactionBackup{Original: path, Saved: path+".scert-backup-"+journal.record.ID})
journal.record.Backups = append(journal.record.Backups, deployment.TransactionBackup{Original: path, Saved: path + ".scert-backup-" + journal.record.ID})
journal.record.Evidence.Files[path] = strings.Repeat("e", 64)
}
service := deployment.NewServiceWithJournal(localDispositionFactory{}, journal)
service.VerifyRecoveryTarget = func(context.Context, deployment.Server, deployment.RecoveryBaseline, bool) error { return nil }
resolve := func(context.Context, string) (deployment.Server, error) { return target, nil }
review, err := service.PrepareRecoveryReview(t.Context(), journal.record.ID, "uid:0", deployment.RecoveryActionCommit, resolve)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
intent, err := review.DispositionIntent(strings.Repeat("a", 64), strings.Repeat("b", 64))
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
journal.source = deployment.RecoveryDisposition{Intent: intent, Status: "uncertain", ResultCode: "uncertain", CreatedAt: now, UpdatedAt: now}
server.recoveryInspection = service
@ -52,25 +58,44 @@ func TestLocalReconciliationRejectsInvalidSession(t *testing.T) {
t.Run(mode, func(t *testing.T) {
server, session, journal := localReconciliationFixture(t)
prepared, err := session.PrepareRecoveryReconciliation(t.Context(), journal.source.Intent.ID)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
switch mode {
case "expired": session.expires = time.Time{}
case "cancelled": cancel()
case "changed-admin": server.config.Admin.Email = "changed@example.test"
case "closed": session.Close()
case "reset-stage": session.stage = 1
case "wrong-id": prepared.DispositionID = journal.source.Intent.ID
case "wrong-token": prepared.Token = strings.Repeat("a", 64)
case "changed-peer": session.peer.Identity = "uid:123"
case "foreign-session": session = localDispositionNewSession(t, server)
case "expired":
session.expires = time.Time{}
case "cancelled":
cancel()
case "changed-admin":
server.config.Admin.Email = "changed@example.test"
case "closed":
session.Close()
case "reset-stage":
session.stage = 1
case "wrong-id":
prepared.DispositionID = journal.source.Intent.ID
case "wrong-token":
prepared.Token = strings.Repeat("a", 64)
case "changed-peer":
session.peer.Identity = "uid:123"
case "foreign-session":
session = localDispositionNewSession(t, server)
case "replaced-confirmation":
if _, err := session.PrepareRecoveryReconciliation(ctx, journal.source.Intent.ID); err != nil { t.Fatal(err) }
if _, err := session.PrepareRecoveryReconciliation(ctx, journal.source.Intent.ID); err != nil {
t.Fatal(err)
}
}
if _, err := session.ExecuteRecovery(ctx, prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("无效会话执行了重新核查")
}
if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("失败确认被重放")
}
if journal.begins != 0 || journal.finishes != 0 || session.recoveryConfirmation.DispositionID() != "" {
t.Fatal("无效会话写入了审计或保留了确认")
}
if _, err := session.ExecuteRecovery(ctx, prepared.DispositionID, prepared.Token); err == nil { t.Fatal("无效会话执行了重新核查") }
if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil { t.Fatal("失败确认被重放") }
if journal.begins != 0 || journal.finishes != 0 || session.recoveryConfirmation.DispositionID() != "" { t.Fatal("无效会话写入了审计或保留了确认") }
})
}
}
@ -78,11 +103,19 @@ func TestLocalReconciliationRejectsInvalidSession(t *testing.T) {
func TestLocalReconciliationRejectsMissingFilesAndQueriesNewAudit(t *testing.T) {
server, session, journal := localReconciliationFixture(t)
prepared, err := session.PrepareRecoveryReconciliation(t.Context(), journal.source.Intent.ID)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
result, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token)
if err != nil || result.Status != "rejected" || result.SourceDispositionID != journal.source.Intent.ID { t.Fatalf("缺失文件仍被放行:%+v %v", result, err) }
if journal.item.Intent.Operator != "uid:0" || journal.begins != 1 || journal.finishes != 1 || journal.source.Status != "uncertain" { t.Fatal("身份或历史记录被改变") }
if err != nil || result.Status != "rejected" || result.SourceDispositionID != journal.source.Intent.ID {
t.Fatalf("缺失文件仍被放行:%+v %v", result, err)
}
if journal.item.Intent.Operator != "uid:0" || journal.begins != 1 || journal.finishes != 1 || journal.source.Status != "uncertain" {
t.Fatal("身份或历史记录被改变")
}
next := localDispositionNewSession(t, server)
queried, err := next.GetRecoveryDisposition(t.Context(), result.ID)
if err != nil || queried != result { t.Fatal("重连未查到关联审计", err) }
if err != nil || queried != result {
t.Fatal("重连未查到关联审计", err)
}
}

View File

@ -9,7 +9,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
type reconciliationSession struct { dispositionTestSession }
type reconciliationSession struct{ dispositionTestSession }
func (s *reconciliationSession) PrepareRecoveryReconciliation(ctx context.Context, sourceID string) (RecoveryPreparation, error) {
value, err := s.PrepareRecovery(ctx, "tx-1", deployment.RecoveryActionVerifyCommit)
@ -22,7 +22,9 @@ func (s *reconciliationSession) PrepareRecoveryReconciliation(ctx context.Contex
func (s *reconciliationSession) ExecuteRecovery(ctx context.Context, id, token string) (deployment.RecoveryDispositionSummary, error) {
sourceID := s.prepared.Summary.SourceDispositionID
value, err := s.dispositionTestSession.ExecuteRecovery(ctx, id, token)
if err != nil { return value, err }
if err != nil {
return value, err
}
value.SourceDispositionID, value.ResultCode = sourceID, deployment.RecoveryResultCommitted
s.result = value
return value, nil
@ -32,51 +34,81 @@ func TestRecoveryReconciliationProtocolAndQuery(t *testing.T) {
session := &reconciliationSession{}
_, conn, done := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
sourceID := strings.Repeat("a", 64)
prepared, err := client.PrepareRecoveryReconciliation(sourceID)
if err != nil || prepared.Summary.SourceDispositionID != sourceID { t.Fatal("原处置绑定丢失", err) }
if err != nil || prepared.Summary.SourceDispositionID != sourceID {
t.Fatal("原处置绑定丢失", err)
}
result, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token)
if err != nil || result.SourceDispositionID != sourceID || result.Status != "succeeded" { t.Fatal("重新核查结果无效", err) }
if err != nil || result.SourceDispositionID != sourceID || result.Status != "succeeded" {
t.Fatal("重新核查结果无效", err)
}
queried, err := client.GetRecoveryDisposition(result.ID)
if err != nil || queried != result { t.Fatal("查询改变了关联结果", err) }
if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err == nil { t.Fatal("旧令牌被重放") }
if err != nil || queried != result {
t.Fatal("查询改变了关联结果", err)
}
if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("旧令牌被重放")
}
_ = client.Close()
<-done
if session.prepares.Load() != 1 || session.executes.Load() != 2 || session.queries.Load() != 1 || session.resets.Load() != 0 { t.Fatal("调用了错误业务入口") }
if session.prepares.Load() != 1 || session.executes.Load() != 2 || session.queries.Load() != 1 || session.resets.Load() != 0 {
t.Fatal("调用了错误业务入口")
}
}
func TestRecoveryReconciliationRejectsUnboundResponse(t *testing.T) {
sourceID := strings.Repeat("a", 64)
session := &reconciliationSession{}
prepared, err := session.PrepareRecoveryReconciliation(t.Context(), sourceID)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
request := Request{Action: "recovery_reconcile_prepare", DispositionID: sourceID}
for _, mode := range []string{"source", "action", "same-id", "transaction", "mixed", "expired-range"} {
t.Run(mode, func(t *testing.T) {
value := prepared
response := Response{Preparation: &value}
switch mode {
case "source": value.Summary.SourceDispositionID = strings.Repeat("d", 64)
case "action": value.Summary.Action = deployment.RecoveryActionCommit
case "same-id": value.DispositionID = sourceID
case "transaction": value.Summary.TransactionID = "../bad"
case "mixed": response.Disposition = &deployment.RecoveryDispositionSummary{}
case "expired-range": value.Summary.ExpiresAt = value.Summary.ObservedAt.Add(2*time.Minute)
case "source":
value.Summary.SourceDispositionID = strings.Repeat("d", 64)
case "action":
value.Summary.Action = deployment.RecoveryActionCommit
case "same-id":
value.DispositionID = sourceID
case "transaction":
value.Summary.TransactionID = "../bad"
case "mixed":
response.Disposition = &deployment.RecoveryDispositionSummary{}
case "expired-range":
value.Summary.ExpiresAt = value.Summary.ObservedAt.Add(2 * time.Minute)
}
if validDispositionResponse(request, response) {
t.Fatal("接受了混合或无绑定准备响应")
}
if validDispositionResponse(request, response) { t.Fatal("接受了混合或无绑定准备响应") }
})
}
if validRecoveryAction(deployment.RecoveryActionVerifyCommit) || validRecoveryAction(deployment.RecoveryActionVerifyRollback) { t.Fatal("普通准备允许绕过原处置绑定") }
if validRecoveryAction(deployment.RecoveryActionVerifyCommit) || validRecoveryAction(deployment.RecoveryActionVerifyRollback) {
t.Fatal("普通准备允许绕过原处置绑定")
}
}
func TestRecoveryReconciliationOldServerFailsClosed(t *testing.T) {
session := &dispositionTestSession{}
_, conn, done := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil { t.Fatal(err) }
if _, err := client.PrepareRecoveryReconciliation(strings.Repeat("a", 64)); err == nil { t.Fatal("旧服务错误地支持了重新核查") }
if err != nil {
t.Fatal(err)
}
if _, err := client.PrepareRecoveryReconciliation(strings.Repeat("a", 64)); err == nil {
t.Fatal("旧服务错误地支持了重新核查")
}
_ = client.Close()
<-done
if session.prepares.Load() != 0 || session.executes.Load() != 0 { t.Fatal("旧服务错误地执行了原动作") }
if session.prepares.Load() != 0 || session.executes.Load() != 0 {
t.Fatal("旧服务错误地执行了原动作")
}
}

View File

@ -76,7 +76,23 @@ func runExternalRecovery(t *testing.T, fixture recoveryExternalFixture) {
t.Fatal(err)
}
runReconciliationSuccess(t, NewSQLDeploymentTransactionRepository(db, cfg.Driver), db, action)
if err := db.Close(); err != nil {
t.Fatal(err)
}
db = openRecoveryExternal(t, cfg)
recoveryCount(t, db, "recovery_dispositions", 2)
recoveryCount(t, db, "recovery_events", 4)
recoveryCount(t, db, "recovery_claims", 0)
pending, err := NewSQLDeploymentTransactionRepository(db, cfg.Driver).ListPendingDeploymentTransactions(t.Context())
if err != nil || len(pending) != 0 {
t.Fatal("重新打开实库后成功收尾丢失", err)
}
})
for _, stage := range []string{"started", "finished"} {
t.Run("reconciliation_audit/"+string(action)+"/"+stage, func(t *testing.T) {
runExternalReconciliationAuditFailure(t, fixture, action, stage)
})
}
}
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionRecord, deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
for _, stage := range []string{"none", "started", "finished"} {

View File

@ -0,0 +1,73 @@
package repository
import (
"context"
"errors"
"maps"
"reflect"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
// 重开真实隔离数据库,验证审计失败不丢失占用,也不将只读核查变成远端重放。
func runExternalReconciliationAuditFailure(t *testing.T, fixture recoveryExternalFixture, action deployment.RecoveryAction, stage string) {
t.Helper()
cfg := fixture.database(t)
db := openRecoveryExternal(t, cfg)
if err := database.Migrate(t.Context(), db, cfg.Driver); err != nil {
t.Fatal(err)
}
repo := NewSQLDeploymentTransactionRepository(db, cfg.Driver)
service, client, server, record, source := seedUncertainReconciliation(t, repo, db, action)
files := maps.Clone(client.files)
resolve := func(context.Context, string) (deployment.Server, error) { return server, nil }
confirmation, token, err := service.PrepareRecoveryReconciliation(t.Context(), source.Intent.ID, "unix:0", resolve)
if err != nil {
t.Fatal(err)
}
fixture.inject(t, db, cfg, stage)
result, err := service.ExecuteRecoveryDisposition(t.Context(), confirmation, "unix:0", token, resolve)
if !errors.Is(err, deployment.ErrRecoveryDispositionUnavailable) || result.Status == "succeeded" {
t.Fatal("重新核查审计失败发布成功结果", err)
}
if client.writes != 0 || !reflect.DeepEqual(files, client.files) {
t.Fatal("审计失败核查修改远端文件")
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
db = openRecoveryExternal(t, cfg)
repo = NewSQLDeploymentTransactionRepository(db, cfg.Driver)
assertReconciliationSource(t, repo, source)
recoveryCount(t, db, "recovery_claims", 1)
wantOwner := source.Intent.ID
if stage == "finished" {
wantOwner = confirmation.DispositionID()
recoveryCount(t, db, "recovery_dispositions", 2)
recoveryCount(t, db, "recovery_events", 3)
stored, err := repo.GetRecoveryDisposition(t.Context(), wantOwner)
if err != nil || stored.Status != "running" || stored.ResultCode != "" || stored.Intent.SourceDispositionID != source.Intent.ID {
t.Fatal("结束审计失败未持久保留运行中处置", err)
}
} else {
recoveryCount(t, db, "recovery_dispositions", 1)
recoveryCount(t, db, "recovery_events", 2)
}
var owner string
if err := db.QueryRowContext(t.Context(), repo.query("SELECT disposition_id FROM recovery_claims WHERE transaction_id = ?"), record.ID).Scan(&owner); err != nil || owner != wantOwner {
t.Fatal("重开后占用归属错误", err)
}
pending, err := repo.ListPendingDeploymentTransactions(t.Context())
if err != nil || len(pending) != 1 || pending[0].Status != deployment.TransactionStatusRecoveryRequired {
t.Fatal("审计失败改写了原事务终态", err)
}
restarted := deployment.NewServiceWithJournal(nil, repo)
if err := restarted.RecoverPending(t.Context(), func(context.Context, string) (deployment.Server, error) {
t.Error("重开后自动恢复试图重放已占用事务")
return deployment.Server{}, errors.New("禁止连接远端")
}); !errors.Is(err, deployment.ErrRecoveryDispositionBusy) {
t.Fatal("重开后自动恢复未遵守持久占用", err)
}
}

View File

@ -1,5 +1,18 @@
# Linux 生产流程测试记录
## 2026年10月3日23:58 重新核查源码验证
第一台隔离源码目录:`/var/tmp/scert-local-recovery-20261003-1828/backend`;使用 nobody 和原生 Go 1.26.6,不替换运行中的正式服务。
- [✓] `reconciliation-latest.log`:五包专项退出码零,筛选包含 Recovery、Disposition、Reconciliation,覆盖新本机会话测试。
- [✓] `reconciliation-race.log`:同五包竞态退出码零。
- [~] `reconciliation-external.log`:显式启用隔离 MySQL 8.0 与 PostgreSQL 14,验证迁移二十五、中断重入、两个收尾方向、审计失败及连接池重开;尚未取得最终结果。
- [~] `reconciliation-full.log`、`reconciliation-vet.log`:最新后端全量和静态检查正在顺序运行。
上批 `STRICT_UMASK_FULL_EXIT=0` 仅覆盖关闭修复及备份测试权限基线;不能替代本批结果。五包竞态结束后只补充了外部实库测试,产品实现未再改变。所有日志位于上述源码目录的父目录。
当前线上仍为 `a5dfd42`、schema24;真实处置 `bc4099fd53c10ce0ae643ae2a89c2597fa48ed7ea6d6661fe4126f7f310b7007` 及占用未手动修改。正式新包、schema25、真实本机菜单五收尾均待验。
## 2026年10月3日23:14 真实中断恢复与关闭误判
### 重载阶段中断与自动恢复

View File

@ -1,5 +1,12 @@
# Scert 开发任务清单
## 2026年10月3日23:58 最新开发状态
- [x] 不确定处置重新核查已接通执行器、数据库、本机协议及管理终端;仅允许已结束 uncertain,保留原审计,成功才原子结束原事务并释放占用。
- [✓] 最新五包专项及竞态通过;旧确认重放、身份变化、残留文件、HTTPS 失败、最终关闭失败及会话混淆均有回归覆盖。
- [~] 新增隔离实库审计失败与重开验证,正在运行 MySQL/PostgreSQL、最新全量和静态检查。
- [ ] 正式 Linux 发布及实机收尾尚未完成;当前线上仍为迁移二十四,不宣称已部署新能力。
## 2026年10月3日23:14 真实故障整改进度
- [✓] 正式包真实进程中断后双机自动回滚,任务保留重启失败历史,第二台网站继续通过严格 HTTPS。

View File

@ -1,5 +1,13 @@
# Scert 整改任务执行清单
## 2026年10月3日23:58 当前执行状态
- [x] 独立重新核查实现、本机菜单五及迁移二十五已完成编码,原 uncertain 永久保留,只核验原方向,不执行远端写操作。
- [✓] 五包专项及竞态通过;覆盖最新本机会话、协议和终端用例。
- [~] 后端全量、静态检查、隔离 MySQL/PostgreSQL 迁移与执行器回归正在运行,结果以测试记录后续更新为准。
- [ ] 正式包升级、实机重新核查释放占用、人工回滚与断连验证仍待执行。
- [!] 公网、系统通知授权及首次初始化等生产门禁未关闭;不将源码测试代替上线验收。
## 2026年10月3日23:14 当前整改任务
- [✓] 真实部署重载阶段 SIGKILL 后,systemd 自动重启及双机回滚通过;第二台 HTTPS 正常。

View File

@ -1,5 +1,18 @@
# Scert 整改、合并与优化清单
## 2026年10月3日23:58 独立重新核查实现与回归
本节优先于下方历史进度,仅处理 Linux;生产验收仍未完成。
- [x] 不确定处置独立重新核查已实现:受保护本机菜单五,方向绑定原处置,只读远端核验,以新审计收尾,原不确定记录保留;不接管运行中处置。
- [✓] 最新五包专项及竞态通过,包含部署、仓储、本机协议、管理终端和 HTTP 会话的 Recovery、Disposition、Reconciliation 用例;不代表真实远端收尾已通过。
- [✓] 上批关闭修复及备份权限夹具在严格 umask 下后端全量通过,静态检查通过;此结论不包含后续重新核查代码。
- [~] 最新代码后端全量、静态检查、隔离 MySQL/PostgreSQL 迁移二十五及执行器验证正在运行。已补充审计失败、连接池重开和持久占用验证。
- [ ] 测试通过后构建干净 Linux 候选包,通过正式安装器升级并以正式本机入口收尾实机 uncertain;禁止手动改终态、删占用或重放原动作。
- [!] 第一台公网备案拦截、Smail 系统通知授权,以及三数据库首次 Web、人工回滚和断连、DNS/ACME 完整验收仍未关闭。
修改前检查点 `f9cd19f` 已中文提交并推送。第二台空间已核实充足,不再作为整改阻塞。
## 2026年10月3日23:14 崩溃恢复实测与关闭误判修复
- [✓] 正式 API 任务 `c68e3956-1b6d-42c7-ba9a-753ef2d1a44b` 在第二目标重载阶段被真实 SIGKILL 中断;systemd 自动重启,两条事务均 rolled_back,任务保留 service_restart 失败记录。

View File

@ -2,7 +2,7 @@
## 当前发布状态
2026年10月3日:功能正在原生 Linux 验证,尚未随正式候选包部署。以下说明适用于包含该功能的新版本,不能据此认定线上已经提供此菜单。
2026年10月3日23:58:只读核查和人工处置已在迁移二十四候选包提供;新增“重新核查不确定处置”正在原生 Linux 回归,尚未部署,需包含迁移二十五的新版本。各项实机验收状态以生产测试记录为准。
## 入口与权限
@ -33,6 +33,14 @@ systemctl start Scert-admin
- 输入异常、过期、读取失败、响应超限或管理员配置发生变化时,终止会话,不将部分观察作为成功结果。
- 核查与密码重置不能在同一会话互相切换;需要切换时退出后重新启动。
## 尚未提供的能力
## 重新核查不确定处置
独立人工处置、审计和基于可信证据的提交或回滚仍在开发。不要手工修改数据库事务终态,不要删锁后宣称历史部署成功。完整状态以整改清单和生产测试记录为准。
以下流程仅适用于包含迁移二十五的新版本:
1. 启动本机管理员工具,选择菜单 `5`,输入已持久化为 uncertain 的原处置编号。
2. 核对原编号、新核查编号、事务、服务器与原动作方向。保留新核查编号,用于断连后查询。
3. 完整输入终端显示的中文确认文字。取消或过期不会提交,不能切换原提交或回滚方向。
4. 产品只重新核验,不重放远端文件修改或重载。全部文件、残留、锁、严格 HTTPS 及关闭检查通过后,以新审计结束原事务并释放占用,原不确定记录保留。
5. 断连或没有可靠结果时,重启工具选择菜单 `4`,按新核查编号查询;不要再次提交原动作或手动删锁。
运行中处置、证据不足、身份变化及现场不满足收尾条件均拒绝,不因等待超时接管。人工处置成功场景、不同版本证书回滚和完整首次生产验收仍须实测;不能把新增菜单视作这些验收已通过。

View File

@ -1,5 +1,17 @@
# 部署事务人工处置设计
## 2026年10月3日23:58 独立重新核查设计已实现
原不确定处置不改写。通过受保护本机独立入口指定原处置编号,重新绑定身份、事务摘要和现场摘要,自动沿用原提交或回滚方向;普通准备入口不接受重新核查动作。
- 只接受已有完整开始、结束审计的 uncertain;不接管 running,不以等待时间代替执行者停止证据。
- 原事务必须处于对应 verified 阶段,文件符合原方向摘要,临时文件、备份和锁均不存在;严格 HTTPS 后再次核验现场,连接正常关闭才允许成功。
- 核查不执行远端写文件、重命名、删除、解锁、命令或服务重载。
- schema25 保存原处置关联;开始审计与占用交接原子提交,成功审计、原事务终态及占用释放原子提交。
- 核查拒绝或不确定时归还原占用;结束审计失败保留新 running 占用,不能自动重试或改为成功。
五包专项与竞态已通过,实库重开验证正在执行;本节说明实现边界,不代表新包已经部署或所有历史恢复问题已经解决。
## 2026年10月3日23:14 实机暴露的关闭与重新核查边界
真实人工提交已完成文件核验、备份删除、commit_verified 持久化和远端解锁,但 SSH 先主动关闭、SFTP 后关闭返回预期连接已关闭错误,导致执行器报告 uncertain。新增 Linux 真实协议回归已复现,修复限制为正常主动关闭后的 EOF 或连接已关闭,不吞掉既有断连、其他关闭错误或动作错误。重复和并发 Close 共享首次结果。

View File

@ -1,5 +1,16 @@
# 部署事务人工核查任务清单
## 2026年10月3日23:58 重新核查任务进度
- [x] R6-A:绑定原处置、方向、服务器和现场摘要,拒绝运行中或缺失可信证据的来源。
- [x] R6-B:迁移二十五、原子占用交接及新审计已实现,保留原 uncertain;正在补充实库重开验证。
- [x] R6-C:只读文件及残留核验、严格 HTTPS 后复核、关闭检查完成;专项负向和竞态通过,不执行远端写操作。
- [x] R6-D:本机菜单五、完整确认、单次令牌、会话绑定、断连查询已实现,五包专项和竞态通过;三数据库门禁仍待本批结果。
- [~] 最新后端全量、静态检查和隔离 MySQL/PostgreSQL 正在运行。上批严格 umask 全量已通过,不再沿用旧的“等待权限回归”状态。
- [ ] R6-E:正式 Linux 包升级和实机 uncertain 收尾尚未执行,其他无可信基线历史事务继续保留。
本节优先于下方历史清单。未清理生产数据库、未创建辅助验收脚本、未开放新网络端口。
## 2026年10月3日23:30 不确定处置重新核查任务
沿用已确认的 Linux 范围,不增加网络入口,不直接修改现场事务或删除占用。