diff --git a/CHANGELOG.md b/CHANGELOG.md index 76096c8..1c77d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,13 @@ 治理侧同步才补齐;只按 `term tenant_id` 过滤会把整库正文漏掉(真跑:索引有 2 条、 导出 0 条、候选校验报 legacy coverage)。改为"本租户 或 未写租户",写了别的租户仍排除。 +- **空目标索引不能当成"索引被改坏"**:全新 WeKnora 实例先用空映射建索引(`{}`), + 字段等第一次写入才动态生成;首次同步因此在读取目标状态时就失败。现在"没有范围字段 + + 确实 0 篇文档"按空基线处理,有文档却看不出归属仍然报错。 + ### Tests +- `internal/releaseadapter/v071/search_empty_index_test.go`:空索引=空基线、有文档无范围字段必须报错。 - `internal/httpapi/library_capture_test.go`:队列作业 id 必须放得进 `varchar(36)` 且形状固定。 - `internal/releaseadapter/v071/search_tenant_filter_test.go`:接受未写租户、排除他租户、 无租户字段时不造条件、整库过滤仍带知识库身份。 diff --git a/internal/releaseadapter/v071/library_search.go b/internal/releaseadapter/v071/library_search.go index 6407ad3..161c31e 100644 --- a/internal/releaseadapter/v071/library_search.go +++ b/internal/releaseadapter/v071/library_search.go @@ -26,6 +26,22 @@ func (e *SearchExporter) ExportLibrary(ctx context.Context, identity SearchIdent } return SearchSnapshot{Documents: []SearchDocument{}, MappingSHA256: "", DocumentsSHA256: emptyDigest}, nil } + if errors.Is(err, errScopeFieldsMissing) { + // 全新实例先用空映射建索引:没有范围字段、又确实 0 篇文档时就是空基线; + // 有文档却看不出归属必须报错(不能当成空库覆盖)。 + empty, probeErr := e.emptyScopeIndex(ctx) + if probeErr != nil { + return SearchSnapshot{}, probeErr + } + if empty { + emptyDigest, digestErr := searchDocumentsDigest([]SearchDocument{}) + if digestErr != nil { + return SearchSnapshot{}, digestErr + } + return SearchSnapshot{Documents: []SearchDocument{}, MappingSHA256: "", DocumentsSHA256: emptyDigest}, nil + } + return SearchSnapshot{}, err + } if err != nil { return SearchSnapshot{}, err } diff --git a/internal/releaseadapter/v071/search.go b/internal/releaseadapter/v071/search.go index 1556b41..a9a908b 100644 --- a/internal/releaseadapter/v071/search.go +++ b/internal/releaseadapter/v071/search.go @@ -67,6 +67,23 @@ type searchFieldNames struct { // errIndexMissing 表示目标索引根本不存在(空生产基线)。 var errIndexMissing = errors.New("Elasticsearch index missing") +// errScopeFieldsMissing 表示索引存在但还没有发布范围字段: +// 全新的 WeKnora 实例会先用空映射建好索引(`{}`),字段要等第一次写入才动态生成。 +// 这跟"索引被改坏了"必须分开:只有在索引里确实一篇文档都没有时才能当成空基线。 +var errScopeFieldsMissing = errors.New("Elasticsearch index mapping is missing release scope fields") + +// emptyScopeIndex 判断"没有范围字段"的索引是不是真的空。 +// 有文档却没有范围字段 = 无法判断这批文档属于谁,必须报错,不能当成 0。 +func (exporter *SearchExporter) emptyScopeIndex(ctx context.Context) (bool, error) { + var response struct { + Count int64 `json:"count"` + } + if err := exporter.requestJSON(ctx, http.MethodGet, "/"+exporter.index+"/_count", nil, &response); err != nil { + return false, err + } + return response.Count == 0, nil +} + type mappingInspection struct { digest string fields searchFieldNames @@ -127,7 +144,7 @@ func (exporter *SearchExporter) inspectMapping(ctx context.Context) (mappingInsp base: resolve("knowledge_base_id"), } if fields.knowledge == "" || fields.base == "" { - return mappingInspection{}, errors.New("Elasticsearch index mapping is missing release scope fields") + return mappingInspection{}, errScopeFieldsMissing } return mappingInspection{digest: hex.EncodeToString(digest[:]), fields: fields}, nil } diff --git a/internal/releaseadapter/v071/search_empty_index_test.go b/internal/releaseadapter/v071/search_empty_index_test.go new file mode 100644 index 0000000..0ccb7e6 --- /dev/null +++ b/internal/releaseadapter/v071/search_empty_index_test.go @@ -0,0 +1,59 @@ +package v071 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// 全新的 WeKnora 实例会先用空映射建索引(`{}`),字段等第一次写入才动态生成。 +// 这种索引不能被当成"索引被改坏了":只有确实 0 篇文档时才是空基线; +// 有文档却没有范围字段 = 不知道这批文档属于谁,必须报错。 +func TestEmptyScopeIndexCountsAsEmptyBaselineOnlyWhenTrulyEmpty(t *testing.T) { + for _, testCase := range []struct { + name string + docCount int + wantErr bool + }{ + {"空索引当空基线", 0, false}, + {"有文档但没有范围字段要报错", 7, true}, + } { + t.Run(testCase.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/chunks/_mapping": + _, _ = w.Write([]byte(`{"chunks":{"mappings":{}}}`)) + case "/chunks/_count": + _ = json.NewEncoder(w).Encode(map[string]any{"count": testCase.docCount}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + exporter, err := NewSearchExporter(SearchExporterConfig{BaseURL: server.URL, Index: "chunks", PageSize: 100}) + if err != nil { + t.Fatalf("exporter: %v", err) + } + identity := SearchIdentity{TenantID: 10001, KnowledgeBaseID: "kb-1"} + count, countErr := exporter.Count(context.Background(), identity) + if testCase.wantErr { + if countErr == nil { + t.Fatalf("有文档却当成空基线:count=%d", count) + } + } else { + if countErr != nil || count != 0 { + t.Fatalf("空索引没有被当成空基线:count=%d err=%v", count, countErr) + } + } + _, exportErr := exporter.ExportLibrary(context.Background(), identity, 100) + if testCase.wantErr && exportErr == nil { + t.Fatal("导出把有文档的空映射索引当成空库") + } + if !testCase.wantErr && exportErr != nil { + t.Fatalf("空索引导出失败:%v", exportErr) + } + }) + } +} diff --git a/internal/releaseadapter/v071/search_write.go b/internal/releaseadapter/v071/search_write.go index 5b3a0e9..430e7dc 100644 --- a/internal/releaseadapter/v071/search_write.go +++ b/internal/releaseadapter/v071/search_write.go @@ -19,6 +19,17 @@ func (exporter *SearchExporter) Count(ctx context.Context, identity SearchIdenti return 0, errors.New("invalid search identity") } inspection, err := exporter.inspectMapping(ctx) + if errors.Is(err, errScopeFieldsMissing) { + // 新建索引还没有范围字段:只有确实为空时才等于"这个范围 0 条"。 + empty, probeErr := exporter.emptyScopeIndex(ctx) + if probeErr != nil { + return 0, probeErr + } + if empty { + return 0, nil + } + return 0, err + } if err != nil { return 0, err }