F01:捕获、写前镜像、指纹和恢复后内容校验共用按目标精确绑定的资源闭包: 从已限定范围的 resource_bindings 与正文 handle 一次提取,再按索引取资源, 不再对每个租户资源扫描全部正文表。无绑定正文引用仍保留,缺失资源仍失败。 F02/F03:恢复验证使用 capture/verify/canary/restore/finalize 自己的阶段,登记 证据不再叫新版本生效;顶部优先在飞作业;已验证且 API reason 为空时不再同屏 显示未读到状态,报告可展开。 F04-F11:显示实际目标实例/项目/库;运行中主按钮查看当前任务并禁止重复启动; 快照历史默认已登记、可筛选分页并定位所选;无真实下次计划时显示未读取;各 scope 独立 overview;新操作清旧通知;存储行按文档/分块/资源/绑定及 Wiki 页面/目录/日志分项。高影响恢复验证/恢复增加一次影响确认。 验证:隔离 PG 20k 无关资源夹具旧扫描 683ms→新导出 41ms,资源 ID 闭包相等; Go/前端全量及 make verify 17 项通过。未部署、未做 8088 UI 验收。
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 开发快照 0.1.359(发布管理 F01–F11:资源闭包、状态真源与易用性)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **F01 小目标大租户捕获/校验慢**:资源闭包不再按租户扫描 `resources` 并对每个资源反复
|
||||
扫描所有正文表。先从已限定范围的 `resource_bindings` 与正文句柄集合提取精确
|
||||
`resource_id` / `handle`,再按索引取资源;捕获、预备份指纹、写前镜像、恢复后内容校验
|
||||
共用同一条 `scopedResourceRows` 路径。绑定资源与无绑定的正文引用都保留,缺资源仍失败。
|
||||
隔离 PG 夹具(20,000 条无关资源)旧扫描约 683ms、新导出约 41ms,闭包逐 ID 相等;
|
||||
真实 168k 规模与端到端维护耗时须父在目标环境复测。
|
||||
- **F02/F03 阶段与验证证据**:恢复验证使用自己的 capture → 基线校验 → canary → 恢复 →
|
||||
登记证据阶段,不再把登记证据叫“新版本生效”;顶部优先显示当前在飞作业。已验证且
|
||||
API reason 为空时不再同屏显示“还没有读到验证状态”;pending/loading/error/passed
|
||||
分开,最近一次通过可展开本次报告。
|
||||
- **F04/F06/F07/F08/F09/F10/F11**:发布页显示实际目标实例/项目/知识库;运行中主按钮改为
|
||||
“查看当前任务”并禁止重复启动;快照抽屉默认只列已登记版本,提供类型筛选、分页和
|
||||
选中项滚动定位;下次计划缺失时显示未读取而不沿用陈旧时间;每个 scope 使用自己的
|
||||
overview(不同 scope/错配响应不借观测);新操作/切 scope 清除旧通知;数据卡明确
|
||||
“存储行”口径并在无总量时不伪造“未上报”。
|
||||
- **F05 高影响确认**:恢复验证与按版本恢复先显示目标、影响和恢复方式的一次确认,确认后
|
||||
才投递;不新增审批门槛。
|
||||
|
||||
### Tests
|
||||
|
||||
- 新增 F01 隔离 PG 资源闭包对拍与耗时;前端恢复验证通过/报告/确认、验证阶段、快照筛选、
|
||||
运行中主按钮等挂载/静态回归。
|
||||
|
||||
## 开发快照 0.1.358(业务 HTTP 超时不再被探针上限截断)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -342,7 +342,11 @@ type libraryOverview struct {
|
||||
CaptureAvailable bool `json:"capture_available"`
|
||||
CaptureReason string `json:"capture_reason,omitempty"`
|
||||
Executor libraryExecutorView `json:"executor"`
|
||||
Now time.Time `json:"now"`
|
||||
// 实际目标身份:页面不能只显示"weknora-test"或旧文档级绑定名称。
|
||||
TargetInstance string `json:"target_instance,omitempty"`
|
||||
TargetProject string `json:"target_project,omitempty"`
|
||||
TargetKnowledgeBaseID string `json:"target_knowledge_base_id,omitempty"`
|
||||
Now time.Time `json:"now"`
|
||||
}
|
||||
|
||||
// libraryVersionRef 只回答"这个快照被登记成哪一类版本、由哪个作业登记"。
|
||||
@@ -686,10 +690,16 @@ func libraryOverviewHandler(w http.ResponseWriter, r *http.Request, config Confi
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
overview := libraryOverview{Scope: scope, Jobs: []governance.LibraryJob{}, Now: now,
|
||||
TargetInstance: config.LibraryTargetInstance, TargetProject: config.LibraryTargetProject,
|
||||
Gate: libraryGateView{Reasons: []string{}},
|
||||
Diff: libraryDiffView{State: "unknown", Reason: "生产与测试的文档级差异读取未接线", Added: []any{}, Updated: []any{}, Removed: []any{}}}
|
||||
// 可发起封存 = worker 真实探测到执行器可用且指向本实例;原因逐条给全。
|
||||
overview.CaptureAvailable, overview.CaptureReason, overview.Executor = libraryExecutorAvailability(r.Context(), config)
|
||||
if scope == "internal" {
|
||||
overview.TargetKnowledgeBaseID = config.LibraryProductionInternalKB
|
||||
} else {
|
||||
overview.TargetKnowledgeBaseID = config.LibraryProductionExternalKB
|
||||
}
|
||||
|
||||
// 测试封存来自归档 + 版本登记:归档里**不止**测试封存——生产预备份与目标基线
|
||||
// 快照同样带 scope,如果只按"这个 scope 最新一份"挑,页面就会把目标自己的内容
|
||||
|
||||
@@ -73,32 +73,56 @@ type tableFingerprint struct {
|
||||
// fingerprints 读取当前生产指纹;Prepare 在导出前后各取一次做一致性判断。
|
||||
func (s *ProductionBackupStore) fingerprints(ctx context.Context) (map[string]tableFingerprint, error) {
|
||||
result := map[string]tableFingerprint{}
|
||||
exporter := PostgresExporter{db: s.pool}
|
||||
var bodies, bindings []json.RawMessage
|
||||
for _, table := range productionLibraryTables {
|
||||
rows, err := s.pool.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`,
|
||||
if table == "resources" {
|
||||
continue
|
||||
}
|
||||
rows, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`,
|
||||
table, table, scopeWhere(table)), s.identity.TenantID, s.identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取生产 %s 指纹失败", table)
|
||||
}
|
||||
digests := make([]string, 0, 64)
|
||||
for rows.Next() {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
if table != "resource_bindings" && table != "knowledge_tag_relations" {
|
||||
bodies = append(bodies, rows...)
|
||||
}
|
||||
if table == "resource_bindings" {
|
||||
bindings = rows
|
||||
}
|
||||
digests := make([]string, 0, len(rows))
|
||||
for _, raw := range rows {
|
||||
var value map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest, err := canonicalRowDigest(raw)
|
||||
digest, err := canonicalRowDigest(value)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
digests = append(digests, digest)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[table] = tableFingerprint{Count: int64(len(digests)), Digest: combineRowDigests(digests)}
|
||||
}
|
||||
// F01: resolve the resources once from exact binding ids and body handles
|
||||
// instead of scanning every tenant resource with a correlated body filter.
|
||||
rows, err := scopedResourceRows(ctx, &exporter, s.identity, true, bodies, bindings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取生产 resources 指纹失败: %w", err)
|
||||
}
|
||||
digests := make([]string, 0, len(rows))
|
||||
for _, raw := range rows {
|
||||
var value map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest, err := canonicalRowDigest(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digests = append(digests, digest)
|
||||
}
|
||||
result["resources"] = tableFingerprint{Count: int64(len(digests)), Digest: combineRowDigests(digests)}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,34 @@ func exportLibraryBeforeImage(ctx context.Context, db librarySnapshotDB, identit
|
||||
return PostgresSnapshot{}, errors.New("before-image schema contract mismatch")
|
||||
}
|
||||
exporter := PostgresExporter{db: tx}
|
||||
snapshot := PostgresSnapshot{}
|
||||
rowsByTable := map[string][]json.RawMessage{}
|
||||
var bodies, bindings []json.RawMessage
|
||||
for _, table := range productionLibraryTables {
|
||||
if table == "resources" {
|
||||
continue
|
||||
}
|
||||
rows, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s ORDER BY (%s)::text`, table, table, scopeWhere(table), identityExpr(table)), identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return PostgresSnapshot{}, err
|
||||
}
|
||||
snapshot.Tables = append(snapshot.Tables, newTableSnapshot(table, rows))
|
||||
rowsByTable[table] = rows
|
||||
if table != "resource_bindings" && table != "knowledge_tag_relations" {
|
||||
bodies = append(bodies, rows...)
|
||||
}
|
||||
if table == "resource_bindings" {
|
||||
bindings = rows
|
||||
}
|
||||
}
|
||||
var bodies []json.RawMessage
|
||||
for _, table := range snapshot.Tables {
|
||||
bodies = append(bodies, table.Rows...)
|
||||
// F01: the before-image needs exact rollback rows, including deleted
|
||||
// resources, but must not rescan every tenant resource per body table.
|
||||
resources, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings)
|
||||
if err != nil {
|
||||
return PostgresSnapshot{}, err
|
||||
}
|
||||
rowsByTable["resources"] = resources
|
||||
snapshot := PostgresSnapshot{}
|
||||
for _, table := range productionLibraryTables {
|
||||
snapshot.Tables = append(snapshot.Tables, newTableSnapshot(table, rowsByTable[table]))
|
||||
}
|
||||
handles, err := libraryResourceHandles(bodies)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,13 +81,17 @@ func ExportLibraryWithReleases(ctx context.Context, db librarySnapshotDB, identi
|
||||
// The latter includes Wiki manual pages, which document bundles used to miss.
|
||||
var bodies []json.RawMessage
|
||||
for _, table := range snapshot.Tables {
|
||||
if table.Name == "resource_bindings" || table.Name == "knowledge_tag_relations" {
|
||||
continue
|
||||
}
|
||||
bodies = append(bodies, table.Rows...)
|
||||
}
|
||||
handles, err := libraryResourceHandles(bodies)
|
||||
if err != nil {
|
||||
return PostgresSnapshot{}, err
|
||||
}
|
||||
resources, err := exporter.queryRows(ctx, libraryResourcesSQL, identity.TenantID, identity.KnowledgeBaseID, handles)
|
||||
resources, err := scopedResourceRows(ctx, &exporter, identity, false, bodies,
|
||||
snapshot.Table("resource_bindings").Rows)
|
||||
if err != nil {
|
||||
return PostgresSnapshot{}, err
|
||||
}
|
||||
@@ -151,4 +155,48 @@ var libraryContentQueries = []struct{ name, sql string }{
|
||||
{"wiki_log_entries", `SELECT to_jsonb(w) FROM wiki_log_entries w WHERE w.tenant_id=$1 AND w.knowledge_base_id=$2 ORDER BY w.id`},
|
||||
}
|
||||
|
||||
const libraryResourcesSQL = `SELECT to_jsonb(r) FROM resources r WHERE r.tenant_id=$1 AND r.deleted_at IS NULL AND (r.handle=ANY($3::text[]) OR EXISTS (SELECT 1 FROM resource_bindings b WHERE b.resource_id=r.id AND b.tenant_id=$1 AND ((b.owner_type='knowledge' AND EXISTS (SELECT 1 FROM knowledges k WHERE k.id=b.owner_id AND k.tenant_id=$1 AND k.knowledge_base_id=$2 AND k.deleted_at IS NULL)) OR (b.owner_type='chunk' AND EXISTS (SELECT 1 FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id WHERE c.id=b.owner_id AND c.tenant_id=$1 AND c.knowledge_base_id=$2 AND c.deleted_at IS NULL AND k.deleted_at IS NULL))))) ORDER BY r.id`
|
||||
// F01: resources were previously selected by an OR over a per-row EXISTS that
|
||||
// re-scanned every body table, so a small KB in a large tenant paid for all
|
||||
// unrelated resources. The caller now extracts the exact binding ids and body
|
||||
// handles first; both are indexed identities, never filename guesses.
|
||||
const libraryResourcesSQL = `SELECT to_jsonb(r) FROM resources r
|
||||
WHERE r.tenant_id=$1 AND ($4::boolean OR r.deleted_at IS NULL)
|
||||
AND (r.id=ANY($2::text[]) OR r.handle=ANY($3::text[]))
|
||||
ORDER BY r.id`
|
||||
|
||||
// scopedResourceRows is the single F01 read path: binding ids and body handles
|
||||
// are extracted once from already scoped rows, then both are resolved through
|
||||
// indexed resource identities. The old per-resource EXISTS/position filter
|
||||
// rescanned every body table for every tenant resource.
|
||||
func scopedResourceRows(ctx context.Context, exporter *PostgresExporter, identity PostgresIdentity,
|
||||
includeDeleted bool, bodies, bindings []json.RawMessage) ([]json.RawMessage, error) {
|
||||
handles, err := libraryResourceHandles(bodies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := libraryBindingResourceIDs(bindings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exporter.queryRows(ctx, libraryResourcesSQL, identity.TenantID, ids, handles, includeDeleted)
|
||||
}
|
||||
|
||||
// libraryBindingResourceIDs reads the exact resource ids owned by the already
|
||||
// scoped resource_bindings rows. It does not infer ownership from names.
|
||||
func libraryBindingResourceIDs(rows []json.RawMessage) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, raw := range rows {
|
||||
var row struct {
|
||||
ResourceID string `json:"resource_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &row); err != nil || row.ResourceID == "" {
|
||||
return nil, errors.New("invalid scoped resource binding")
|
||||
}
|
||||
if !seen[row.ResourceID] {
|
||||
seen[row.ResourceID] = true
|
||||
ids = append(ids, row.ResourceID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -301,23 +301,61 @@ func identityExpr(table string) string {
|
||||
}
|
||||
|
||||
func (t *ProductionLibraryTarget) tableState(ctx context.Context, table string) (int64, string, error) {
|
||||
rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
|
||||
"("+identityExpr(table)+")::text", table, scopeWhere(table)), t.scope.TenantID, t.scope.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 失败", table)
|
||||
identity := PostgresIdentity{TenantID: t.scope.TenantID, KnowledgeBaseID: t.scope.KnowledgeBaseID}
|
||||
var rows []json.RawMessage
|
||||
if table == "resources" {
|
||||
// F01: derive exact references once; do not run the per-resource body
|
||||
// scan used by the legacy scope predicate.
|
||||
exporter := PostgresExporter{db: t.pool}
|
||||
var bodies, bindings []json.RawMessage
|
||||
for _, name := range productionLibraryTables {
|
||||
if name == "resources" {
|
||||
continue
|
||||
}
|
||||
items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`,
|
||||
name, name, scopeWhere(name)), identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 失败", table)
|
||||
}
|
||||
if name != "resource_bindings" && name != "knowledge_tag_relations" {
|
||||
bodies = append(bodies, items...)
|
||||
}
|
||||
if name == "resource_bindings" {
|
||||
bindings = items
|
||||
}
|
||||
}
|
||||
items, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 失败: %w", table, err)
|
||||
}
|
||||
rows = items
|
||||
} else {
|
||||
exporter := PostgresExporter{db: t.pool}
|
||||
items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
|
||||
"("+identityExpr(table)+")::text", table, scopeWhere(table)), identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 失败", table)
|
||||
}
|
||||
rows = items
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0, 64)
|
||||
for rows.Next() {
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, raw := range rows {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return 0, "", err
|
||||
if table == "resources" {
|
||||
var value struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &value); err != nil || value.ID == "" {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 身份失败", table)
|
||||
}
|
||||
id = value.ID
|
||||
} else {
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return int64(len(ids)), digestIDs(ids), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package v071
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// F01 regression: a small target library inside a large tenant must resolve
|
||||
// resources from exact binding ids + body handles, not by scanning every
|
||||
// tenant resource with a per-row body predicate. The old predicate is kept
|
||||
// here only as an oracle for equality and timing; never as the product path.
|
||||
func TestLibraryResourceClosureIsBoundedByTargetReferences(t *testing.T) {
|
||||
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires explicitly isolated kg_library_test_ PostgreSQL database")
|
||||
}
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "kg_library_test_") || os.Getenv("KG_LIBRARY_TEST_ISOLATED") != "1" {
|
||||
t.Fatal("isolated database required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err = db.Exec(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(ctx, libraryTestSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(ctx, `CREATE INDEX resources_handle_f01 ON resources(handle); CREATE INDEX resources_pkey_f01 ON resources(id);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const unrelated = 20000
|
||||
if _, err = db.Exec(ctx, `INSERT INTO resources(id,handle,tenant_id,physical_path,deleted_at)
|
||||
SELECT 'unrelated-'||i, lpad(i::text,22,'0'), 10000, 'unrelated/'||i, NULL FROM generate_series(1,$1) i`, unrelated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A body-only reference must still be found without a binding row.
|
||||
if _, err = db.Exec(ctx, `INSERT INTO resources(id,handle,tenant_id,physical_path,deleted_at)
|
||||
VALUES ('res-body','BBBBBBBBBBBBBBBBBBBBBB',10000,'body.txt',NULL);
|
||||
UPDATE chunks SET content='see resource://BBBBBBBBBBBBBBBBBBBBBB' WHERE id='chunk-safe'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := PostgresIdentity{TenantID: 10000, KnowledgeBaseID: "kb-safe"}
|
||||
|
||||
start := time.Now()
|
||||
oldRows, err := db.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(resources)::text FROM resources WHERE %s`, scopeWhere("resources")),
|
||||
identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldIDs := []string{}
|
||||
for oldRows.Next() {
|
||||
var raw string
|
||||
if err := oldRows.Scan(&raw); err != nil {
|
||||
oldRows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &row); err != nil {
|
||||
oldRows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldIDs = append(oldIDs, row.ID)
|
||||
}
|
||||
oldRows.Close()
|
||||
oldElapsed := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
snapshot, err := ExportLibrary(ctx, db, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newElapsed := time.Since(start)
|
||||
newIDs := []string{}
|
||||
for _, raw := range snapshot.Table("resources").Rows {
|
||||
var row struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newIDs = append(newIDs, row.ID)
|
||||
}
|
||||
if fmt.Sprint(oldIDs) != fmt.Sprint(newIDs) {
|
||||
t.Fatalf("resource closure changed: old=%v new=%v", oldIDs, newIDs)
|
||||
}
|
||||
var bodies []json.RawMessage
|
||||
for _, table := range snapshot.Tables {
|
||||
bodies = append(bodies, table.Rows...)
|
||||
}
|
||||
handles, handleErr := libraryResourceHandles(bodies)
|
||||
t.Logf("old_ids=%v new_ids=%v handles=%v handle_err=%v", oldIDs, newIDs, handles, handleErr)
|
||||
if len(newIDs) != 2 {
|
||||
t.Fatalf("expected bound + body-only resources, got %v", newIDs)
|
||||
}
|
||||
t.Logf("unrelated_resources=%d old_scan=%s new_export=%s resources=%v", unrelated, oldElapsed, newElapsed, newIDs)
|
||||
if newElapsed >= oldElapsed {
|
||||
t.Fatalf("F01 path did not improve: old=%s new=%s", oldElapsed, newElapsed)
|
||||
}
|
||||
}
|
||||
@@ -68,22 +68,52 @@ func joinLines(items []string) string {
|
||||
// 归档没有的列(生产默认值/后续新增列)不参与比对,避免"我们没管的列"造成假失败,
|
||||
// 但归档里有的每一列都必须逐字一致。
|
||||
func (t *ProductionLibraryTarget) productionTableContent(ctx context.Context, table string, columns map[string]bool) (int64, string, error) {
|
||||
// 不加表别名:scopeWhere(resources) 里引用了 resources.id,别名会让它失效。
|
||||
rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, table, table, scopeWhere(table)),
|
||||
t.scope.TenantID, t.scope.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err)
|
||||
identity := PostgresIdentity{TenantID: t.scope.TenantID, KnowledgeBaseID: t.scope.KnowledgeBaseID}
|
||||
exporter := PostgresExporter{db: t.pool}
|
||||
var rows []json.RawMessage
|
||||
if table == "resources" {
|
||||
// F01: verification must use the same bounded reference set as capture;
|
||||
// the legacy per-resource body scan made post-sync verification minutes
|
||||
// long on a small target inside a large tenant.
|
||||
var bodies, bindings []json.RawMessage
|
||||
for _, name := range productionLibraryTables {
|
||||
if name == "resources" {
|
||||
continue
|
||||
}
|
||||
items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`,
|
||||
name, name, scopeWhere(name)), identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err)
|
||||
}
|
||||
if name != "resource_bindings" && name != "knowledge_tag_relations" {
|
||||
bodies = append(bodies, items...)
|
||||
}
|
||||
if name == "resource_bindings" {
|
||||
bindings = items
|
||||
}
|
||||
}
|
||||
items, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err)
|
||||
}
|
||||
rows = items
|
||||
} else {
|
||||
items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, table, table, scopeWhere(table)),
|
||||
identity.TenantID, identity.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err)
|
||||
}
|
||||
rows = items
|
||||
}
|
||||
defer rows.Close()
|
||||
digests := make([]string, 0, 64)
|
||||
for rows.Next() {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
digests := make([]string, 0, len(rows))
|
||||
for _, raw := range rows {
|
||||
var value map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
projected := make(map[string]json.RawMessage, len(columns))
|
||||
for key := range columns {
|
||||
if value, ok := raw[key]; ok {
|
||||
if value, ok := value[key]; ok {
|
||||
projected[key] = value
|
||||
}
|
||||
}
|
||||
@@ -93,9 +123,6 @@ func (t *ProductionLibraryTarget) productionTableContent(ctx context.Context, ta
|
||||
}
|
||||
digests = append(digests, digest)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return int64(len(digests)), combineRowDigests(digests), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +127,17 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
|
||||
<dd>{scriptLine}</dd>
|
||||
<dt>主机 cron</dt>
|
||||
<dd>{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}</dd>
|
||||
<dt>最近一次备份</dt>
|
||||
<dt>下次计划(主机读回)</dt>
|
||||
<dd>{schedule.host_next_run || '未读取(不以历史下发时间推算)'}</dd>
|
||||
<dt>最近一次运行</dt>
|
||||
<dd>
|
||||
{schedule.last_run_stamp || '—'}
|
||||
{runMoment(schedule) ? ` · ${runMoment(schedule)}` : ''}
|
||||
{schedule.last_run_stamp
|
||||
? schedule.last_run_failed_databases.length === 0 && !schedule.last_run_error
|
||||
? ' · 成功'
|
||||
: ' · 有失败/读取错误'
|
||||
: ''}
|
||||
</dd>
|
||||
<dt>备份耗时 / 体积</dt>
|
||||
<dd>
|
||||
|
||||
@@ -99,6 +99,26 @@ const overview: LibraryOverview = {
|
||||
],
|
||||
},
|
||||
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
|
||||
// 测试夹具把两份归档登记为测试封存:默认筛选「已登记版本」会隐藏未登记文件,
|
||||
// 但仍可由用户切到「全部归档」显式查看。
|
||||
versions: [
|
||||
{
|
||||
id: 'ver-internal',
|
||||
kind: 'test_sealed',
|
||||
snapshot_id: internalSnapshot.snapshot_id,
|
||||
version_label: '封存 f992d64ea45b',
|
||||
source_job_id: '',
|
||||
created_at: internalSnapshot.created_at,
|
||||
},
|
||||
{
|
||||
id: 'ver-external',
|
||||
kind: 'test_sealed',
|
||||
snapshot_id: externalSnapshot.snapshot_id,
|
||||
version_label: '封存 01343b0b310b',
|
||||
source_job_id: '',
|
||||
created_at: externalSnapshot.created_at,
|
||||
},
|
||||
],
|
||||
capture_available: true,
|
||||
now: '2026-10-28T03:40:00Z',
|
||||
}
|
||||
@@ -309,9 +329,17 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
})
|
||||
|
||||
it('抽屉只展示当前选中库的历史', () => {
|
||||
const html = render({ drawerOpen: true, scope: 'external' })
|
||||
expect(html).toContain('快照 01343b0b310b')
|
||||
expect(html).not.toContain('快照 f992d64ea45b')
|
||||
const html = render({
|
||||
drawerOpen: true,
|
||||
scope: 'external',
|
||||
overviews: {
|
||||
internal: overview,
|
||||
external: { ...overview, scope: 'external', versions: [overview.versions![1]] },
|
||||
},
|
||||
})
|
||||
const drawer = html.slice(html.indexOf('aria-label="版本与快照"'))
|
||||
expect(drawer).toContain('快照 01343b0b310b')
|
||||
expect(drawer).not.toContain('快照 f992d64ea45b')
|
||||
})
|
||||
|
||||
// E3:归档里同时躺着测试封存、目标基线与每次预备份。旧页面把每一行都写"测试封存",
|
||||
@@ -570,7 +598,86 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
expect(html).toContain('按版本恢复')
|
||||
expect(html).toContain('按版本恢复已完成')
|
||||
const stageBar = html.slice(html.indexOf('aria-label="作业阶段"'), html.indexOf('rel-steps-note'))
|
||||
expect(stageBar).toContain('生产已回到该快照')
|
||||
expect(stageBar).toContain('目标已回到该快照')
|
||||
})
|
||||
|
||||
it('恢复验证使用自己的阶段,不把登记证据说成新版本生效', () => {
|
||||
const verifyJob: LibraryJobRecord = {
|
||||
...persistedSeal,
|
||||
id: 'libverify-internal-1',
|
||||
kind: 'verify',
|
||||
state: 'running',
|
||||
stage: 'restore',
|
||||
stages: [
|
||||
{
|
||||
stage: 'capture',
|
||||
state: 'succeeded',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: '已捕获',
|
||||
updated_at: '2026-11-05T01:00:00Z',
|
||||
},
|
||||
{
|
||||
stage: 'verify',
|
||||
state: 'succeeded',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: '基线校验通过',
|
||||
updated_at: '2026-11-05T01:01:00Z',
|
||||
},
|
||||
{
|
||||
stage: 'canary',
|
||||
state: 'succeeded',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: 'canary 通过',
|
||||
updated_at: '2026-11-05T01:02:00Z',
|
||||
},
|
||||
{
|
||||
stage: 'restore',
|
||||
state: 'running',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: '正在恢复',
|
||||
updated_at: '2026-11-05T01:03:00Z',
|
||||
},
|
||||
{
|
||||
stage: 'finalize',
|
||||
state: 'pending',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: '',
|
||||
updated_at: '2026-11-05T01:04:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
const html = render({ overview: { ...overview, job: verifyJob, jobs: [verifyJob] } })
|
||||
expect(html).toContain('恢复验证阶段')
|
||||
expect(html).toContain('捕获目标')
|
||||
expect(html).toContain('登记验证证据')
|
||||
expect(html).not.toContain('新版本生效')
|
||||
})
|
||||
|
||||
it('快照抽屉默认隐藏未登记归档,并给出全部归档入口', () => {
|
||||
const html = render({
|
||||
drawerOpen: true,
|
||||
overview: { ...overview, versions: [] },
|
||||
})
|
||||
const drawer = html.slice(html.indexOf('aria-label="版本与快照"'))
|
||||
expect(drawer).toContain('已登记版本')
|
||||
expect(drawer).toContain('全部归档(含未登记)')
|
||||
expect(drawer).not.toContain('快照 f992d64ea45b')
|
||||
expect(drawer).toContain('当前筛选下没有已登记版本')
|
||||
})
|
||||
|
||||
it('同步中布局显示四类数据卡与明细,且不编造百分比', () => {
|
||||
@@ -579,10 +686,13 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
overview: { ...overview, job: persistedSeal, jobs: [persistedSeal] },
|
||||
})
|
||||
expect(html).toContain('测试库封存作业')
|
||||
expect(html).toContain('数据库 · 文档与分块')
|
||||
// F06: 运行中主按钮只能查看当前任务,不能再次启动。
|
||||
expect(html).toContain('查看当前任务')
|
||||
expect(html).not.toContain('▶ 启动同步')
|
||||
expect(html).toContain('数据库 · 存储行')
|
||||
expect(html).toContain('原文件 · 图片 · 附件')
|
||||
expect(html).toContain('检索索引 · 向量')
|
||||
expect(html).toContain('Wiki · 完整快照')
|
||||
expect(html).toContain('Wiki · 存储行')
|
||||
expect(html).toContain('同步明细')
|
||||
// 唯一允许出现的百分比是免责声明里的「100% ≠ 作业完成」,没有任何进度主张
|
||||
expect(html.match(/[1-9]\d*%/g) ?? []).toEqual(['100%'])
|
||||
|
||||
+208
-29
@@ -135,6 +135,8 @@ interface ViewProps {
|
||||
onGrantRestore: () => void
|
||||
onRestore: () => void
|
||||
overview: LibraryOverview | null
|
||||
/** F09: 每个 scope 的独立观测;不能拿当前 scope 的 overview 重算另一个页签。 */
|
||||
overviews?: Record<Scope, LibraryOverview | null>
|
||||
maintenance: LibraryMaintenanceView | null
|
||||
// 维护记录里 owner_job_id 对应的作业详情;缺失就是“未读取”,不能用 overview.job 顶替。
|
||||
maintenanceOwnerJob?: LibraryJobLookup
|
||||
@@ -153,8 +155,10 @@ function scopeView(
|
||||
const history = (page?.snapshots ?? []).filter((item) => item.scope === scope)
|
||||
const scoped = jobs.filter((job) => job.scope === scope)
|
||||
const persisted = overview?.jobs ?? []
|
||||
// F02/F06: 顶部阶段必须跟当前在飞作业;只有没有在飞作业时才显示最近终态。
|
||||
// 同一个 overview 里可能有刚完成的封存和正在跑的同步,不能用"最近一条"覆盖当前阶段。
|
||||
const job =
|
||||
overview?.job ?? persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? persisted[0]
|
||||
persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? overview?.job ?? persisted[0]
|
||||
return {
|
||||
scope,
|
||||
// 候选只认接口给的 test_sealed(来自版本登记):归档里还有生产预备份与
|
||||
@@ -598,6 +602,39 @@ export function wikiContributionLines(diff: LibraryOverview['diff'] | undefined)
|
||||
|
||||
function StageBar({ view }: { view: ScopeView }) {
|
||||
const job = view.job
|
||||
if (job?.kind === 'verify') {
|
||||
const labels: Record<string, string> = {
|
||||
capture: '捕获目标',
|
||||
verify: '基线校验',
|
||||
canary: '写入并核对 canary',
|
||||
restore: '按预备份恢复',
|
||||
finalize: '登记验证证据',
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ol className="rel-steps" aria-label="恢复验证阶段">
|
||||
{Object.entries(labels).map(([name, label], index) => {
|
||||
const row = job.stages.find((item) => item.stage === name)
|
||||
const state = row ? toState(row.state as 'succeeded' | 'running' | 'failed' | 'pending') : 'idle'
|
||||
return (
|
||||
<li key={name} className={`rel-step ${state}`}>
|
||||
<span className="rel-step-mark">
|
||||
{state === 'done' ? '✓' : state === 'live' ? '●' : state === 'fail' ? '!' : '○'}
|
||||
</span>
|
||||
<strong>{label}</strong>
|
||||
<small>{row?.detail || (state === 'idle' ? '未开始' : '')}</small>
|
||||
{index < 4 ? <span className="rel-step-arrow">→</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
<p className="rel-steps-note">
|
||||
作业 {job.id} · 恢复验证 · {jobStateText(job)}
|
||||
{job.error ? ` · ${job.error}` : ''};完成只表示本次恢复验证证据已登记,不会登记新版本。
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const sealJob = job?.kind === 'seal' ? job : undefined
|
||||
// 同步、恢复、建立基线共用同一串阶段(预备份 → 数据四类 → 校验 → 登记版本)。
|
||||
// 只按"sync"找阶段会让刚跑完的恢复/基线在页面上显示成"本次作业尚未开始"。
|
||||
@@ -662,16 +699,16 @@ function StageBar({ view }: { view: ScopeView }) {
|
||||
hint: verifyStage ? (verifyStage.state === 'succeeded' ? '校验通过' : '未完成') : '未接入',
|
||||
},
|
||||
{
|
||||
label: '新版本生效',
|
||||
label: writeJob?.kind === 'restore' ? '目标恢复生效' : '生产生效',
|
||||
state: writeJob && writeJob.state === 'succeeded' ? 'done' : 'idle',
|
||||
hint:
|
||||
writeJob && writeJob.state === 'succeeded'
|
||||
? writeJob.kind === 'restore'
|
||||
? '生产已回到该快照'
|
||||
? '目标已回到该快照,恢复后复核通过'
|
||||
: writeJob.kind === 'baseline'
|
||||
? '目标基线已登记(目标内容未改)'
|
||||
: '生产已生效'
|
||||
: '未接入',
|
||||
: '未开始',
|
||||
},
|
||||
]
|
||||
return (
|
||||
@@ -1027,11 +1064,34 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
const running = job?.state === 'running' || job?.state === 'pending'
|
||||
const stage = (name: string) => dataJob?.stages.find((item) => item.stage === name)
|
||||
|
||||
const sealed = view.overview?.test_sealed
|
||||
const breakdown = (keys: [string, string][]) =>
|
||||
sealed ? keys.map(([key, label]) => `${label} ${sealed.totals[key] ?? 0}`).join(' · ') : ''
|
||||
const cards = [
|
||||
{ title: '数据库 · 文档与分块', row: stage('database'), axis: '文档 / 分块' },
|
||||
{ title: '原文件 · 图片 · 附件', row: stage('resources'), axis: '对象 / 字节' },
|
||||
{ title: '检索索引 · 向量', row: stage('index'), axis: '索引文档' },
|
||||
{ title: 'Wiki · 完整快照', row: stage('wiki'), axis: 'Wiki 页' },
|
||||
// F11: these totals are storage rows, not business document/page counts.
|
||||
{
|
||||
title: '数据库 · 存储行',
|
||||
row: stage('database'),
|
||||
axis: '行(文档/分块/标签/关系)',
|
||||
counts: breakdown([
|
||||
['documents', '文档'],
|
||||
['chunks', '分块'],
|
||||
['resources', '资源'],
|
||||
['bindings', '绑定'],
|
||||
]),
|
||||
},
|
||||
{ title: '原文件 · 图片 · 附件', row: stage('resources'), axis: '对象 / 字节', counts: '' },
|
||||
{ title: '检索索引 · 向量', row: stage('index'), axis: '索引文档', counts: '' },
|
||||
{
|
||||
title: 'Wiki · 存储行',
|
||||
row: stage('wiki'),
|
||||
axis: '页面 / 目录 / 日志',
|
||||
counts: breakdown([
|
||||
['wiki_pages', '页面'],
|
||||
['wiki_folders', '目录'],
|
||||
['wiki_log_entries', '日志'],
|
||||
]),
|
||||
},
|
||||
].map((card) => ({
|
||||
...card,
|
||||
// 只有真实的 total 才画进度;没有 total 就是"未知总量",绝不按 0 或 100% 画。
|
||||
@@ -1044,7 +1104,7 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rel-panel rel-progress-head">
|
||||
<div className="rel-panel rel-progress-head" id="rel-progress">
|
||||
<div className="rel-between">
|
||||
<h2>
|
||||
{sealJob ? '测试库封存作业' : dataJob ? `${jobKindText(dataJob.kind)}作业` : '本次作业'}
|
||||
@@ -1054,7 +1114,7 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
: dataJob?.kind === 'baseline'
|
||||
? `目标基线 ${dataJob.target_snapshot_id.slice(0, 12) || '(本次捕获后登记)'}`
|
||||
: `测试封存 ${view.overview?.test_sealed?.snapshot_id.slice(0, 12) ?? '未接入'}`}{' '}
|
||||
→ 生产当前{' '}
|
||||
→ 目标当前{' '}
|
||||
<b>
|
||||
{view.overview?.production.state === 'known' && view.overview.production.version
|
||||
? view.overview.production.version.version_label ||
|
||||
@@ -1117,15 +1177,20 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
<p className="rel-datacount">
|
||||
<strong>{card.count}</strong> · {card.axis}
|
||||
</p>
|
||||
{card.counts ? <p className="rel-datadetail">封存快照分项:{card.counts}</p> : null}
|
||||
<div className="rel-progress" aria-hidden={card.percent === null ? 'true' : undefined}>
|
||||
<span style={{ width: `${card.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<p className="rel-datadetail">
|
||||
{card.row?.detail
|
||||
? card.row.detail
|
||||
: dataJob
|
||||
? '该分项还没有上报数据'
|
||||
: '生产同步执行链未接通,分项不会产生数据'}
|
||||
: card.row
|
||||
? card.row.total != null
|
||||
? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
|
||||
: '该分项已完成,执行器未提供可核对总量'
|
||||
: dataJob
|
||||
? '本次作业没有产生该分项阶段记录'
|
||||
: '目标同步执行链未接通,分项不会产生数据'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
@@ -1242,9 +1307,34 @@ function Drawer({
|
||||
onGrantRestore,
|
||||
onRestore,
|
||||
}: DrawerProps) {
|
||||
if (!open) return null
|
||||
const listing = view.history
|
||||
const [kindFilter, setKindFilter] = useState<'registered' | 'all' | 'test' | 'production'>('registered')
|
||||
const [historyPage, setHistoryPage] = useState(0)
|
||||
const [confirmRestore, setConfirmRestore] = useState(false)
|
||||
const selectedRef = useRef<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
setKindFilter('registered')
|
||||
setHistoryPage(0)
|
||||
setConfirmRestore(false)
|
||||
}, [view.scope, open])
|
||||
useEffect(() => {
|
||||
if (expanded) selectedRef.current?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [expanded])
|
||||
const versionLabelOf = (snapshot: LibrarySnapshot) =>
|
||||
snapshotVersionLabel(view.overview?.versions, snapshot.snapshot_id)
|
||||
const filtered = listing.filter((snapshot) => {
|
||||
const label = versionLabelOf(snapshot)
|
||||
if (kindFilter === 'all') return true
|
||||
if (kindFilter === 'test') return label.includes('测试封存')
|
||||
if (kindFilter === 'production') return label.includes('生产生效')
|
||||
return !label.startsWith('未登记')
|
||||
})
|
||||
const pageSize = 8
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const currentPage = Math.min(historyPage, pageCount - 1)
|
||||
const pageItems = filtered.slice(currentPage * pageSize, currentPage * pageSize + pageSize)
|
||||
const selected = listing.find((item) => item.snapshot_id === expanded)
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="rel-drawer-backdrop" role="presentation" onClick={onClose}>
|
||||
<aside className="rel-drawer" role="dialog" aria-label="版本与快照" onClick={(event) => event.stopPropagation()}>
|
||||
@@ -1259,7 +1349,7 @@ function Drawer({
|
||||
</header>
|
||||
<dl className="rel-drawer-kinds">
|
||||
<div>
|
||||
<dt>生产当前生效</dt>
|
||||
<dt>目标当前生效</dt>
|
||||
<dd>
|
||||
{view.overview?.production.state === 'known' && view.overview.production.version
|
||||
? `${view.overview.production.version.version_label || view.overview.production.version.snapshot_id.slice(0, 12)} · ${stamp(view.overview.production.version.created_at)}`
|
||||
@@ -1288,6 +1378,29 @@ function Drawer({
|
||||
</div>
|
||||
</dl>
|
||||
<p className="rel-notice">三类版本分别记录,不能互相顶替:测试封存不等于生产生效,预备份不等于已验证恢复。</p>
|
||||
<nav className="rel-history-filters" aria-label="快照类型筛选">
|
||||
{(
|
||||
[
|
||||
['registered', '已登记版本'],
|
||||
['test', '测试封存'],
|
||||
['production', '生产生效'],
|
||||
['all', '全部归档(含未登记)'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={kindFilter === value ? 'active' : ''}
|
||||
aria-pressed={kindFilter === value}
|
||||
onClick={() => {
|
||||
setKindFilter(value)
|
||||
setHistoryPage(0)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<table className="rel-drawer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -1298,14 +1411,16 @@ function Drawer({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listing.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="rel-table-empty">
|
||||
尚无封存记录。封存测试版本后,这里会按时间列出每一份完整版本。
|
||||
{kindFilter === 'all'
|
||||
? '尚无封存记录。封存测试版本后,这里会按时间列出每一份完整版本。'
|
||||
: '当前筛选下没有已登记版本;切到「全部归档」可查看未登记文件。'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
listing.map((snapshot) => {
|
||||
pageItems.map((snapshot) => {
|
||||
const isSelected = expanded === snapshot.snapshot_id
|
||||
// 类型只能来自版本登记:归档里的目标基线与预备份也在同一个根下。
|
||||
const versionLabel = snapshotVersionLabel(view.overview?.versions, snapshot.snapshot_id)
|
||||
@@ -1344,9 +1459,20 @@ function Drawer({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rel-history-pager" aria-label="快照分页">
|
||||
<button type="button" disabled={currentPage <= 0} onClick={() => setHistoryPage(currentPage - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {currentPage + 1} / {pageCount} 页 · 共 {filtered.length} 份
|
||||
</span>
|
||||
<button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setHistoryPage(currentPage + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<section className="rel-snapshot">
|
||||
<section className="rel-snapshot" ref={selectedRef}>
|
||||
<header>
|
||||
<h3>封存 {selected.snapshot_id.slice(0, 12)} · 恢复资料</h3>
|
||||
<span className="rel-pill green">
|
||||
@@ -1420,7 +1546,7 @@ function Drawer({
|
||||
<button
|
||||
type="button"
|
||||
className="rel-danger"
|
||||
onClick={onRestore}
|
||||
onClick={() => setConfirmRestore(true)}
|
||||
disabled={pendingRestore || restore?.allowed !== true}
|
||||
title={
|
||||
restore?.allowed
|
||||
@@ -1441,6 +1567,33 @@ function Drawer({
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{confirmRestore && selected ? (
|
||||
<div className="rel-blocked" aria-label="按版本恢复影响确认">
|
||||
<strong>确认按该快照恢复目标?</strong>
|
||||
<p>
|
||||
目标:{view.overview?.target_instance || '目标实例未读取'} · 知识库{' '}
|
||||
{view.overview?.target_knowledge_base_id || '未读取'} · 快照 {selected.snapshot_id.slice(0, 12)}
|
||||
</p>
|
||||
<p>影响:停止目标写入者并关闭入口,按该快照回放数据库、索引、对象与 Wiki;恢复后复核通过才开入口。</p>
|
||||
<p>恢复方式:本快照的完整写前镜像与预备份;不是同步,也不会登记新版本。</p>
|
||||
<div className="rel-drawer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rel-danger"
|
||||
onClick={() => {
|
||||
setConfirmRestore(false)
|
||||
onRestore()
|
||||
}}
|
||||
disabled={pendingRestore}
|
||||
>
|
||||
确认并恢复
|
||||
</button>
|
||||
<button type="button" className="rel-secondary" onClick={() => setConfirmRestore(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{restore?.allowed ? (
|
||||
<p className="rel-hint">
|
||||
条件已满足:生产写入闸口打开、这份快照有恢复授权
|
||||
@@ -1487,6 +1640,7 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
page,
|
||||
jobs,
|
||||
overview,
|
||||
overviews,
|
||||
loading,
|
||||
error,
|
||||
notice,
|
||||
@@ -1516,10 +1670,17 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
onCapture,
|
||||
onRecover,
|
||||
} = props
|
||||
const internal = scopeView(page, jobs, 'internal', overview?.scope === 'internal' ? overview : null)
|
||||
const external = scopeView(page, jobs, 'external', overview?.scope === 'external' ? overview : null)
|
||||
// A response with a different scope is not an observation for this tab.
|
||||
const scopedOverview = (target: Scope): LibraryOverview | null => {
|
||||
const candidate = overviews?.[target] ?? overview
|
||||
return candidate?.scope === target ? candidate : null
|
||||
}
|
||||
const internal = scopeView(page, jobs, 'internal', scopedOverview('internal'))
|
||||
const external = scopeView(page, jobs, 'external', scopedOverview('external'))
|
||||
const view = scope === 'internal' ? internal : external
|
||||
const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending'
|
||||
// F06: 运行中禁止再次启动,也禁止把主按钮继续显示成可启动的绿色动作。
|
||||
const runningJob = active && view.job ? view.job : undefined
|
||||
// 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。
|
||||
const target = view.overview?.test_sealed
|
||||
// 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空":
|
||||
@@ -1608,20 +1769,33 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="rel-primary"
|
||||
onClick={sync.onStart}
|
||||
disabled={!sync.canStart || sync.pending}
|
||||
onClick={() => {
|
||||
if (runningJob) {
|
||||
document.getElementById('rel-progress')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
sync.onStart()
|
||||
}}
|
||||
disabled={Boolean(runningJob) ? false : !sync.canStart || sync.pending}
|
||||
title={
|
||||
sync.canStart
|
||||
? '按已签发的同步授权启动:先固定测试版本,再备份目标并写入'
|
||||
: `暂不能启动同步:${gate.reasons.join(';')}`
|
||||
runningJob
|
||||
? `${jobKindText(runningJob.kind)}正在执行;请查看当前任务,不要重复启动`
|
||||
: sync.canStart
|
||||
? '按已签发的同步授权启动:先固定测试版本,再备份目标并写入'
|
||||
: `暂不能启动同步:${gate.reasons.join(';')}`
|
||||
}
|
||||
>
|
||||
{sync.pending ? '投递中…' : '▶ 启动同步'}
|
||||
{runningJob ? '查看当前任务' : sync.pending ? '投递中…' : '▶ 启动同步'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="rel-target-identity" aria-label="实际目标实例">
|
||||
目标实例 {view.overview?.target_instance || view.overview?.executor?.instance || '未读取'} · 项目{' '}
|
||||
{view.overview?.target_project || view.overview?.executor?.project || '未读取'} · 知识库{' '}
|
||||
{view.overview?.target_knowledge_base_id || target?.knowledge_base_id || '未读取'}({scopeName(scope)})
|
||||
</p>
|
||||
<p className="rel-versionline">
|
||||
生产当前{' '}
|
||||
目标当前{' '}
|
||||
{productionClass === 'known' && production?.version ? (
|
||||
<b>
|
||||
{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}
|
||||
@@ -2055,6 +2229,7 @@ export function ReleaseTargets() {
|
||||
page={page}
|
||||
jobs={jobs}
|
||||
overview={overviews[scope]}
|
||||
overviews={overviews}
|
||||
loading={loading}
|
||||
error={error}
|
||||
notice={notice}
|
||||
@@ -2062,6 +2237,8 @@ export function ReleaseTargets() {
|
||||
onScope={(next) => {
|
||||
setScope(next)
|
||||
setExpandedSnapshot(null)
|
||||
// F10: 旧操作通知属于旧作业/旧对象,切范围后不得继续挂在页顶。
|
||||
setNotice(null)
|
||||
}}
|
||||
drawerOpen={drawerOpen}
|
||||
onDrawer={setDrawerOpen}
|
||||
@@ -2085,6 +2262,8 @@ export function ReleaseTargets() {
|
||||
}}
|
||||
verification={{
|
||||
onAccepted: (jobId) => {
|
||||
// F10: 新验证作业开始时清掉上一次封存/同步的旧通知。
|
||||
setNotice(null)
|
||||
setActiveVerifyJob(jobId)
|
||||
void load()
|
||||
},
|
||||
|
||||
@@ -152,6 +152,8 @@ describe('RestoreVerificationSection mounted (offline fixtures)', () => {
|
||||
)
|
||||
})
|
||||
await press('跑一次恢复验证(维护隔离)')
|
||||
expect(onAccepted).not.toHaveBeenCalled()
|
||||
await press('确认并投递')
|
||||
expect(onAccepted).toHaveBeenCalledWith('libverify-internal-1')
|
||||
expect(container.textContent).toContain('验证执行中')
|
||||
await press('刷新验证状态')
|
||||
@@ -179,6 +181,9 @@ describe('RestoreVerificationSection mounted (offline fixtures)', () => {
|
||||
await act(async () => {
|
||||
;[...container.querySelectorAll('button')].find((item) => item.textContent?.includes('跑一次恢复验证'))!.click()
|
||||
})
|
||||
await act(async () => {
|
||||
;[...container.querySelectorAll('button')].find((item) => item.textContent === '确认并投递')!.click()
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RestoreVerificationSection scope="external" isAdmin onAccepted={onAccepted} onComplete={onComplete} />
|
||||
|
||||
@@ -72,17 +72,46 @@ describe('RestoreVerificationView', () => {
|
||||
status: 'passed',
|
||||
snapshot_id: '0f9b39a16738',
|
||||
job_id: 'libverify-internal-1',
|
||||
report: { status: 'passed', checks: ['database', 'index', 'objects', 'wiki'] },
|
||||
started_at: '2026-11-03T01:00:00Z',
|
||||
finished_at: '2026-11-03T01:02:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(html).toContain('已验证(当前目标)')
|
||||
// F03: a passed record with empty API reason must not print "还没有读到验证状态".
|
||||
expect(html).toContain('当前目标的最近一次真实恢复验证已通过')
|
||||
expect(html).not.toContain('还没有读到验证状态')
|
||||
expect(html).toContain('查看本次恢复验证报告')
|
||||
expect(html).toContain('0f9b39a16738')
|
||||
expect(html).toContain('a8e9c8ad-411c-4c53-8421-599d29e9b179')
|
||||
expect(html).toContain('weknora_test_chunks')
|
||||
})
|
||||
|
||||
it('高影响恢复验证先给出目标/影响/恢复方式的一次确认', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RestoreVerificationView
|
||||
view={view()}
|
||||
scope="internal"
|
||||
isAdmin
|
||||
pending={false}
|
||||
error={null}
|
||||
notice=""
|
||||
stale={false}
|
||||
confirming
|
||||
onRun={() => undefined}
|
||||
onConfirm={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
onReload={() => undefined}
|
||||
/>
|
||||
)
|
||||
expect(html).toContain('恢复验证影响确认')
|
||||
expect(html).toContain('目标:对内知识库 · weknora-test')
|
||||
expect(html).toContain('短暂停止目标写入者并关闭入口')
|
||||
expect(html).toContain('确认并投递')
|
||||
expect(html).toContain('取消')
|
||||
})
|
||||
|
||||
it('失败时把失败原因写出来,按钮仍可重试', () => {
|
||||
const html = render(
|
||||
view({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
enqueueLibraryVerification,
|
||||
fetchLibraryVerification,
|
||||
verificationReasonText,
|
||||
verificationStatusText,
|
||||
verificationTargetLines,
|
||||
} from './restoreVerification'
|
||||
@@ -26,6 +27,9 @@ export interface RestoreVerificationViewProps {
|
||||
stale: boolean
|
||||
onRun: () => void
|
||||
onReload: () => void
|
||||
confirming?: boolean
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const stampText = (value: string): string => {
|
||||
@@ -35,7 +39,20 @@ const stampText = (value: string): string => {
|
||||
}
|
||||
|
||||
export function RestoreVerificationView(props: RestoreVerificationViewProps) {
|
||||
const { view, scope, isAdmin, pending, error, notice, stale, onRun, onReload } = props
|
||||
const {
|
||||
view,
|
||||
scope,
|
||||
isAdmin,
|
||||
pending,
|
||||
error,
|
||||
notice,
|
||||
stale,
|
||||
onRun,
|
||||
onReload,
|
||||
confirming = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
} = props
|
||||
const status = verificationStatusText(view, stale)
|
||||
const latest = view?.latest ?? null
|
||||
const running = Boolean(view?.running)
|
||||
@@ -58,7 +75,7 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) {
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="rel-hint">{view?.reason || '还没有读到验证状态。'}</p>
|
||||
<p className="rel-hint">{verificationReasonText(view, stale)}</p>
|
||||
{latest ? (
|
||||
<p className="rel-hint">
|
||||
最近一次:{latest.status === 'passed' ? '通过' : '未通过'} · 快照 {latest.snapshot_id.slice(0, 12) || '—'} ·{' '}
|
||||
@@ -66,11 +83,37 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) {
|
||||
</p>
|
||||
) : null}
|
||||
{latest?.failure ? <p className="rel-warn">未通过原因:{latest.failure}</p> : null}
|
||||
{latest?.report ? (
|
||||
<details className="rel-report" aria-label="本次恢复验证报告">
|
||||
<summary>查看本次恢复验证报告</summary>
|
||||
<pre>{JSON.stringify(latest.report, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{error ? <p className="rel-warn">{error}</p> : null}
|
||||
{notice ? <p className="rel-hint">{notice}</p> : null}
|
||||
{confirming ? (
|
||||
<div className="rel-blocked" aria-label="恢复验证影响确认">
|
||||
<strong>确认停写并执行恢复验证?</strong>
|
||||
<p>
|
||||
目标:{scope === 'internal' ? '对内知识库' : '对外知识库'} · {view?.target?.instance || '目标实例未配置'}
|
||||
</p>
|
||||
<p>
|
||||
影响:短暂停止目标写入者并关闭入口,在目标写入 canary,再按预备份恢复并逐类复核;完成后按原运行状态收尾。
|
||||
</p>
|
||||
<p>恢复方式:本次作业自己的预备份与写前镜像;不是测试封存,也不会登记新版本。</p>
|
||||
<div className="rel-drawer-actions">
|
||||
<button type="button" className="rel-danger" onClick={onConfirm} disabled={pending || running}>
|
||||
确认并投递
|
||||
</button>
|
||||
<button type="button" className="rel-secondary" onClick={onCancel} disabled={pending}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rel-drawer-actions">
|
||||
{isAdmin ? (
|
||||
<button type="button" className="rel-secondary" onClick={onRun} disabled={pending || running}>
|
||||
<button type="button" className="rel-secondary" onClick={onRun} disabled={pending || running || confirming}>
|
||||
{pending ? '投递中…' : running ? '验证执行中…' : '跑一次恢复验证(维护隔离)'}
|
||||
</button>
|
||||
) : (
|
||||
@@ -109,6 +152,7 @@ export function RestoreVerificationSection({
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const mounted = useRef(true)
|
||||
const generation = useRef(0)
|
||||
const viewScopeRef = useRef('')
|
||||
@@ -170,6 +214,7 @@ export function RestoreVerificationSection({
|
||||
setStale(false)
|
||||
setError(null)
|
||||
setNotice('')
|
||||
setConfirming(false)
|
||||
acceptedJob.current = ''
|
||||
sawRunning.current = false
|
||||
const controller = new AbortController()
|
||||
@@ -226,7 +271,13 @@ export function RestoreVerificationSection({
|
||||
error={error}
|
||||
notice={notice}
|
||||
stale={stale}
|
||||
onRun={onRun}
|
||||
confirming={confirming}
|
||||
onRun={() => setConfirming(true)}
|
||||
onConfirm={() => {
|
||||
setConfirming(false)
|
||||
onRun()
|
||||
}}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onReload={() => void reload(scope)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface BackupSchedule {
|
||||
host_error: string
|
||||
host_schedule_enabled: boolean
|
||||
host_schedule_time: string
|
||||
// 主机若没有返回真实 next-run,保持空串;页面不得用历史下发时间推算。
|
||||
host_next_run?: string
|
||||
host_keep_days: number
|
||||
host_keep_months: number
|
||||
host_cron_lines: string[]
|
||||
@@ -77,6 +79,7 @@ function scheduleProjection(item: Record<string, unknown>): BackupSchedule {
|
||||
host_error: asString(item.host_error),
|
||||
host_schedule_enabled: item.host_schedule_enabled === true,
|
||||
host_schedule_time: asString(item.host_schedule_time),
|
||||
host_next_run: asString(item.host_next_run),
|
||||
host_keep_days: asNumber(item.host_keep_days),
|
||||
host_keep_months: asNumber(item.host_keep_months),
|
||||
host_cron_lines: asStringList(item.host_cron_lines),
|
||||
|
||||
@@ -689,6 +689,9 @@ export interface LibraryOverview {
|
||||
capture_reason?: string
|
||||
// 执行器是 worker 的真实观测:指向哪个实例、何时观测、是否降级/在跑。
|
||||
executor?: LibraryExecutorView
|
||||
target_instance?: string
|
||||
target_project?: string
|
||||
target_knowledge_base_id?: string
|
||||
now: string
|
||||
}
|
||||
|
||||
|
||||
@@ -672,3 +672,46 @@
|
||||
.rel-wiki-pages li {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* F04/F07/F08: 目标身份、历史筛选与分页、恢复验证报告。 */
|
||||
.rel-target-identity {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--rel-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.rel-history-filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.rel-history-filters button {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.rel-history-filters button.active {
|
||||
color: #168b73;
|
||||
background: #ebf8f3;
|
||||
border-color: #a6d7c8;
|
||||
}
|
||||
.rel-history-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--rel-muted);
|
||||
}
|
||||
.rel-report pre {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 11px;
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e6edf2;
|
||||
border-radius: 7px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface LibraryVerificationRecord {
|
||||
snapshot_id: string
|
||||
job_id: string
|
||||
failure?: string
|
||||
report?: Record<string, unknown>
|
||||
started_at: string
|
||||
finished_at: string
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export function parseVerificationRecord(value: unknown): LibraryVerificationReco
|
||||
snapshot_id: text(value.snapshot_id),
|
||||
job_id: text(value.job_id),
|
||||
failure: text(value.failure) || undefined,
|
||||
report: record(value.report) ? value.report : undefined,
|
||||
started_at: text(value.started_at),
|
||||
finished_at: text(value.finished_at),
|
||||
}
|
||||
@@ -172,3 +174,16 @@ export function verificationStatusText(view: LibraryVerificationView | null, sta
|
||||
if (view.latest) return view.latest.status === 'failed' ? '最近一次未通过' : '未通过'
|
||||
return '还没有验证记录'
|
||||
}
|
||||
|
||||
// F03: when the API has a passed record, its reason is empty by design. The
|
||||
// panel must not fall back to "还没有读到验证状态" and contradict the green
|
||||
// status. This also keeps loading/error/never-verified distinct.
|
||||
export function verificationReasonText(view: LibraryVerificationView | null, stale = false): string {
|
||||
if (!view) return '还没有读到验证状态'
|
||||
if (stale) return '刷新失败:这是上次成功读取的证据,当前目标是否仍可恢复未知'
|
||||
if (view.running) return view.reason || `验证作业 ${view.job_id || ''} 正在执行(${view.stage || '执行中'})`
|
||||
if (view.verified)
|
||||
return `当前目标的最近一次真实恢复验证已通过${view.latest ? `(快照 ${view.latest.snapshot_id.slice(0, 12)})` : ''}`
|
||||
if (view.latest) return view.reason || view.latest.failure || '最近一次恢复验证未通过'
|
||||
return view.reason || '还没有验证记录'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user