真实 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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user