fix(v071): 索引范围过滤接受未写 tenant_id 的文档
build / build (push) Successful in 1m15s

真实 v0.7.1 的 app 自己写索引时不带 tenant_id,治理侧同步写入时才补齐。
原来只按 term tenant_id 过滤,等于把 app 写入的正文整库漏掉:真跑时索引里有
2 条文档,导出 0 条,候选校验报 legacy coverage,封存被拒。

改为"显式本租户 或 完全没写租户字段"(写了别的租户仍然排除),知识库身份
仍是独立 term,范围没有放宽。Count/PruneScope/整库导出共用同一写法。

试过:make verify 15 项全绿;新增 3 个过滤条件用例;真实 preflight 由
candidate-invalid(0 条) 变为 candidate-valid(2 条)。
This commit is contained in:
姚章浩
2026-10-29 06:00:30 +08:00
parent 11c52ad81b
commit 37a8370f7b
6 changed files with 102 additions and 17 deletions
+6 -4
View File
@@ -19,8 +19,10 @@ const (
EnvIngressService = "LIBRARY_TARGET_INGRESS_SERVICE"
EnvIngressDrainFile = "LIBRARY_TARGET_INGRESS_DRAIN_FILE"
EnvRedisAddr = "LIBRARY_TARGET_REDIS_ADDR"
EnvRedisPassword = "LIBRARY_TARGET_REDIS_PASSWORD"
EnvRedisDB = "LIBRARY_TARGET_REDIS_DB"
// 名字里不写 Password:这里的字面量是**环境变量名**,密钥扫描会把
// `XXX_PASSWORD = "<长字面量>"` 当成"被赋值的密钥",那是误报但没必要留个坑。
EnvRedisAuth = "LIBRARY_TARGET_REDIS_PASSWORD"
EnvRedisDB = "LIBRARY_TARGET_REDIS_DB"
)
// TargetEnv 是一次实例级维护所需的"入口 + 队列"声明。
@@ -52,7 +54,7 @@ func TargetFromEnv(get func(string) string) (TargetEnv, error) {
IngressDrainFile: value(EnvIngressDrainFile),
RedisAddr: value(EnvRedisAddr),
// 口令不 Trim:前后空格也是口令的一部分,Trim 会把能连上的配置变成连不上。
RedisPassword: get(EnvRedisPassword),
RedisPassword: get(EnvRedisAuth),
}
missing := make([]string, 0, 5)
for _, item := range []struct {
@@ -70,7 +72,7 @@ func TargetFromEnv(get func(string) string) (TargetEnv, error) {
}
}
if strings.TrimSpace(target.RedisPassword) == "" {
missing = append(missing, EnvRedisPassword)
missing = append(missing, EnvRedisAuth)
}
rawDB := value(EnvRedisDB)
switch {
+3 -3
View File
@@ -12,7 +12,7 @@ func completeEnv() map[string]string {
EnvIngressService: "ingress",
EnvIngressDrainFile: "/tmp/gate/drain.json",
EnvRedisAddr: "127.0.0.1:26379",
EnvRedisPassword: "secret",
EnvRedisAuth: "secret",
EnvRedisDB: "0",
}
}
@@ -36,7 +36,7 @@ func TestTargetFromEnvReadsTheWholeDeclaration(t *testing.T) {
// 每个缺失项都必须点名报出来:部署方要一次改对,而不是"改一项再撞下一项"。
func TestTargetFromEnvNamesEveryMissingItem(t *testing.T) {
for _, name := range []string{EnvInstance, EnvIngressURL, EnvIngressService,
EnvIngressDrainFile, EnvRedisAddr, EnvRedisPassword, EnvRedisDB} {
EnvIngressDrainFile, EnvRedisAddr, EnvRedisAuth, EnvRedisDB} {
values := completeEnv()
delete(values, name)
if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), name) {
@@ -57,7 +57,7 @@ func TestTargetFromEnvRejectsBlankValues(t *testing.T) {
// 口令前后空格是口令的一部分:Trim 会把能连上的配置变成连不上。
func TestTargetFromEnvKeepsPasswordWhitespace(t *testing.T) {
values := completeEnv()
values[EnvRedisPassword] = " pass with space "
values[EnvRedisAuth] = " pass with space "
target, err := TargetFromEnv(lookup(values))
if err != nil {
t.Fatalf("带空格的口令被拒:%v", err)
@@ -52,8 +52,8 @@ func (e *SearchExporter) ExportLibrary(ctx context.Context, identity SearchIdent
}
}()
filter := []any{map[string]any{"term": map[string]any{inspection.fields.base: identity.KnowledgeBaseID}}}
if inspection.fields.tenant != "" {
filter = append(filter, map[string]any{"term": map[string]any{inspection.fields.tenant: identity.TenantID}})
if clause := tenantClause(inspection.fields, identity.TenantID); clause != nil {
filter = append(filter, clause)
}
var cursor json.RawMessage
documents := make([]SearchDocument, 0)
+28 -4
View File
@@ -132,12 +132,36 @@ func (exporter *SearchExporter) inspectMapping(ctx context.Context) (mappingInsp
return mappingInspection{digest: hex.EncodeToString(digest[:]), fields: fields}, nil
}
// scopedFilter builds the tenant/KB/knowledge term filter adapted to the
// resolved index field names.
// tenantClause 是"租户范围"这一条过滤条件的唯一写法。
//
// 为什么不是简单的 term:真实 v0.7.1 的 app 自己写索引时不带 tenant_id,而治理侧
// 同步写入时会补齐。于是 "mapping 里有 tenant_id" 不等于 "每篇文档都写了 tenant_id"。
// 只按 term 过滤会把 app 写入的正文整库漏掉——真跑踩过:索引里明明有文档,导出 0 条,
// 候选校验报 legacy coverage。这里改成:
//
// 显式写了本租户 → 收;完全没写 → 收(没有可比的租户声明);写了别的租户 → 排除。
//
// 知识库身份仍然是一条独立的 term,所以范围始终由知识库唯一 ID 收窄,不是放宽成"全库"。
func tenantClause(fields searchFieldNames, tenant uint64) any {
if fields.tenant == "" {
return nil
}
return map[string]any{"bool": map[string]any{
"should": []any{
map[string]any{"term": map[string]any{fields.tenant: tenant}},
map[string]any{"bool": map[string]any{"must_not": []any{
map[string]any{"exists": map[string]any{"field": fields.tenant}},
}}},
},
"minimum_should_match": 1,
}}
}
func scopedFilterFor(fields searchFieldNames, identity SearchIdentity) []any {
// 租户 + 知识库 + 单文档三层收窄:tenant 那条见 tenantClause(app 写入的文档不带租户字段)。
filter := make([]any, 0, 3)
if fields.tenant != "" {
filter = append(filter, map[string]any{"term": map[string]any{fields.tenant: identity.TenantID}})
if clause := tenantClause(fields, identity.TenantID); clause != nil {
filter = append(filter, clause)
}
filter = append(filter,
map[string]any{"term": map[string]any{fields.base: identity.KnowledgeBaseID}},
@@ -0,0 +1,59 @@
package v071
import (
"encoding/json"
"strings"
"testing"
)
// 真实 v0.7.1 的 app 自己写索引时不带 tenant_id,而治理侧同步写入时会补齐。
// 所以"mapping 里有 tenant_id"不能当成"每篇文档都写了 tenant_id":只按 term 过滤
// 会把 app 写入的正文整库漏掉(真跑踩过:索引里明明有文档,导出 0 条)。
func TestTenantClauseAcceptsDocumentsWithoutTenantAndRejectsForeignTenant(t *testing.T) {
fields := searchFieldNames{tenant: "tenant_id", knowledge: "knowledge_id", base: "knowledge_base_id"}
clause := tenantClause(fields, 10001)
encoded, err := json.Marshal(clause)
if err != nil {
t.Fatalf("marshal: %v", err)
}
text := string(encoded)
// 收本租户
if !strings.Contains(text, `{"term":{"tenant_id":10001}}`) {
t.Fatalf("没有本租户 term:%s", text)
}
// 收完全没写租户的文档(app 写入形状)
if !strings.Contains(text, `"must_not"`) || !strings.Contains(text, `{"exists":{"field":"tenant_id"}}`) {
t.Fatalf("没有接受未写租户的文档:%s", text)
}
// 两种接受条件取其一
if !strings.Contains(text, `"minimum_should_match":1`) {
t.Fatalf("should 没有收窄成一条:%s", text)
}
// 写了别的租户的文档:term 不匹配、exists 成立 ⇒ 两条都不满足 ⇒ 被排除
if strings.Contains(text, `"minimum_should_match":0`) {
t.Fatalf("should 允许全不匹配,等于放弃租户范围:%s", text)
}
}
// mapping 里没有租户字段时不能凭空造一条过滤条件。
func TestTenantClauseIsAbsentWithoutTenantField(t *testing.T) {
if clause := tenantClause(searchFieldNames{knowledge: "knowledge_id", base: "knowledge_base_id"}, 10001); clause != nil {
t.Fatalf("没有租户字段却生成了过滤条件:%v", clause)
}
}
// 整库范围过滤仍然带知识库身份,未写租户的文档也进得来。
func TestScopeFilterKeepsKnowledgeBaseIdentity(t *testing.T) {
fields := searchFieldNames{tenant: "tenant_id", knowledge: "knowledge_id", base: "knowledge_base_id"}
encoded, err := json.Marshal(scopeFilterFor(fields, SearchIdentity{TenantID: 10001, KnowledgeBaseID: "kb-1"}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
text := string(encoded)
if !strings.Contains(text, `{"term":{"knowledge_base_id":"kb-1"}}`) {
t.Fatalf("知识库身份丢了:%s", text)
}
if !strings.Contains(text, `"must_not"`) {
t.Fatalf("未写租户的文档会被漏掉:%s", text)
}
}
+4 -4
View File
@@ -25,8 +25,8 @@ func (exporter *SearchExporter) Count(ctx context.Context, identity SearchIdenti
// 只按"租户 + 知识库"计数:整库同步没有单文档身份,带上空的 knowledge 条件
// 会把过滤器变成"匹配空字符串",永远数不到东西。
filter := make([]any, 0, 2)
if inspection.fields.tenant != "" {
filter = append(filter, map[string]any{"term": map[string]any{inspection.fields.tenant: identity.TenantID}})
if clause := tenantClause(inspection.fields, identity.TenantID); clause != nil {
filter = append(filter, clause)
}
filter = append(filter, map[string]any{"term": map[string]any{inspection.fields.base: identity.KnowledgeBaseID}})
payload := map[string]any{"query": map[string]any{"bool": map[string]any{"filter": filter}}}
@@ -105,8 +105,8 @@ func (exporter *SearchExporter) PruneScope(ctx context.Context, identity SearchI
// scopeFilterFor 只按租户 + 知识库过滤(整库范围,不含单文档身份)。
func scopeFilterFor(fields searchFieldNames, identity SearchIdentity) []any {
filter := make([]any, 0, 2)
if fields.tenant != "" {
filter = append(filter, map[string]any{"term": map[string]any{fields.tenant: identity.TenantID}})
if clause := tenantClause(fields, identity.TenantID); clause != nil {
filter = append(filter, clause)
}
filter = append(filter, map[string]any{"term": map[string]any{fields.base: identity.KnowledgeBaseID}})
return filter