From 49e2b476f58bdcdc4f18ca9d8f0856e7efe744a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 12 Oct 2026 21:57:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=85=8D=E7=BD=AE=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88push=E6=95=B0?= =?UTF-8?q?=E5=80=BC=E5=93=A8=E5=85=B5=E5=8C=96/=E6=81=92=E7=9C=9F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=AE=E6=AD=A3/=E5=93=A8=E5=85=B5?= =?UTF-8?q?=E9=98=B2=E6=BC=82=E7=A7=BB/=E7=A7=8D=E5=AD=90=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E6=B6=88=E5=8F=8C=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important-1: push 数值字段(timeout/max_attempts/retry_backoff_base) 出厂默认改 None 哨兵(与 ai.model 同款),resolve_push_settings 对 None 回退 env——根治 truthy 出厂默认永久遮住 env 覆盖;恒真测试修正(env 数值 patch 成 7.5/5/2.0 与出厂区分)+租户显式数值优先断言;ai.provider 遮 env 的边界记入 docstring。Minor-2: 出厂 model 哨兵与 DEFAULT_CONFIG 一致性测试 + defaults.py 双源注释(model/rate_limit_per_hour)。 Minor-4: allowed_seed_keys → context_seed_map(返回映射,单一取值路径)。 Minor-6/7/3/8: WS 配置冻结时机注释/name_to_region 查表序注释改准确/ chat_rest limit 0 语义注释/resolver None 路径仅供测试注释。404 全绿。 Co-Authored-By: Claude Code --- app/engine/routes/chat_rest.py | 1 + app/engine/services/ai_service.py | 3 ++ app/engine/services/cc_completion.py | 4 ++- app/engine/services/conversation.py | 19 +++++++----- app/engine/services/lead_push_service.py | 21 +++++++++---- app/platform/defaults.py | 17 ++++++++--- tests/test_tenant_config_wiring.py | 38 +++++++++++++++++------- 7 files changed, 74 insertions(+), 29 deletions(-) diff --git a/app/engine/routes/chat_rest.py b/app/engine/routes/chat_rest.py index 2b797cd..d0482c7 100644 --- a/app/engine/routes/chat_rest.py +++ b/app/engine/routes/chat_rest.py @@ -122,6 +122,7 @@ async def chat_message(req: MessageRequest, request: Request): ip = request.client.host if request.client else "" dev = request.headers.get("X-Device-Id", "") engine_cfg = runtime.config.data.get("engine") or {} + # 类型与 0 语义由 Phase 2 配置校验统一(当前: 空/0 回退 DEFAULT_MAX) limit = engine_cfg.get("rate_limit_per_hour") or DEFAULT_MAX if not rate_check(runtime.config.slug, ip, dev, max_requests=limit): raise HTTPException(status_code=429, detail="rate limit exceeded") diff --git a/app/engine/services/ai_service.py b/app/engine/services/ai_service.py index b76f80d..2e73c54 100644 --- a/app/engine/services/ai_service.py +++ b/app/engine/services/ai_service.py @@ -114,6 +114,9 @@ def resolve_ai_settings(config_data: dict | None) -> dict: api_key/base_url 空槽(None/空串)→ env 智谱凭据(平台默认集团 key, spec §5.5 "用平台默认"状态下租户侧不存密钥);model 空或等于出厂默认 → env AI_MODEL。 + 已知边界: provider 出厂 "glm" 同样遮 env AI_PROVIDER——当前单 provider + 无实害,多 provider 接入时再改哨兵结构(与 push 数值同款纪律)。 + config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。 """ ai = ((config_data or {}).get("ai")) or {} model = ai.get("model") diff --git a/app/engine/services/cc_completion.py b/app/engine/services/cc_completion.py index 484c189..68c8033 100644 --- a/app/engine/services/cc_completion.py +++ b/app/engine/services/cc_completion.py @@ -298,7 +298,9 @@ def name_to_region(name: str | None, 容错:去首尾空白、大小写不敏感、弯撇号(U+2019)归一为 ASCII。 Task 9: extras = misc.name_to_region_extras(租户扩表),可加键也可 - 覆盖基础表同名键(租户纠偏)——查表序 extras 优先。 + 覆盖基础表同名键(租户纠偏)。查表分两轮:先精确匹配(extras→基础 + 表)、后 casefold 折叠(extras→基础表)——基础表的精确命中优先于 + extras 的大小写折叠命中(精确 > 折叠,非"extras 整体优先")。 """ if not name: return None diff --git a/app/engine/services/conversation.py b/app/engine/services/conversation.py index d364e8e..c9ea3bf 100644 --- a/app/engine/services/conversation.py +++ b/app/engine/services/conversation.py @@ -50,14 +50,16 @@ def resolve_contact_flags(config_data: dict | None) -> dict: } -def allowed_seed_keys(config_data: dict | None) -> set[str]: - """context 种子白名单 = config.engine.context_seed_keys 的键集合。 +def context_seed_map(config_data: dict | None) -> dict: + """context 种子映射 = config.engine.context_seed_keys(ctx_key → raw_key)。 Task 9 前白名单(product_name/product_id)硬编码在本模块——两租户差异 - (auto 是 vehicle_*)由此配置化;create_conversation 按映射值写 raw_info。 + (auto 是 vehicle_*)由此配置化;create_conversation 是唯一消费口(按 + 映射值写 raw_info,白名单 = 本映射的键集合——审查 Minor-4: 单一取值 + 路径,不留第二条内联读法)。 """ engine = ((config_data or {}).get("engine")) or {} - return {str(k) for k in (engine.get("context_seed_keys") or {})} + return dict(engine.get("context_seed_keys") or {}) # 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明: # - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接, @@ -372,7 +374,9 @@ class ConversationManager: wall=runtime.compiled_wall, config_data=runtime.config.data) # Task 9: contact 行为开关(cc 补全/邮箱轻补)与国名映射扩表—— - # manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线) + # manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线; + # 生效时机: WS 长连接内冻结至断连,REST 路径下一请求生效——Phase 2 + # 联调改配置后按此预期,勿误判"没生效") self._contact_flags = resolve_contact_flags(runtime.config.data) self._region_extras = (((runtime.config.data.get("misc")) or {}) .get("name_to_region_extras") or None) @@ -408,9 +412,8 @@ class ConversationManager: # WS path passes no context -> raw_info stays {}. raw_info: dict = {} if context: - engine_cfg = self.runtime.config.data.get("engine") or {} - seed_map = engine_cfg.get("context_seed_keys") or {} - for ctx_key, raw_key in seed_map.items(): + for ctx_key, raw_key in context_seed_map( + self.runtime.config.data).items(): if context.get(ctx_key): raw_info[raw_key] = context[ctx_key] profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info) diff --git a/app/engine/services/lead_push_service.py b/app/engine/services/lead_push_service.py index 9d1dbab..ef3a107 100644 --- a/app/engine/services/lead_push_service.py +++ b/app/engine/services/lead_push_service.py @@ -37,8 +37,14 @@ logger = logging.getLogger(__name__) def resolve_push_settings(config_data: dict | None) -> dict: """租户 config.push → 推送参数(Task 9 接线; env settings 回退)。 - 出厂默认的空槽(False/空串)视同"未设置"→ 回退 env PUSH_*——部署级 - .env 开关是平台默认集的一部分,租户未配置时继承,不静默关停。 + 空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律): + - 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 env + PUSH_*——部署级 .env 开关是平台默认集的一部分,租户未配置时继承, + 不静默关停; + - 数值字段(timeout/max_attempts/retry_backoff_base): 出厂 None = + "未设置"→ 回退 env(settings 自带默认 10.0/3/1.0,最终行为与出厂 + truthy 默认时一致),租户显式数值(含显式 0)优先于 env。 + config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。 已知边界: env 开 + 租户显式 false 无法区分于未配置(布尔空槽只有 False),Phase 2 admin 落地时以"平台清 env、配置显式写 false"解决。 """ @@ -48,10 +54,15 @@ def resolve_push_settings(config_data: dict | None) -> dict: "url": p.get("url") or settings.PUSH_API_URL, "api_key": p.get("api_key") or settings.PUSH_API_KEY, "prefix": p.get("prefix") or settings.PUSH_EXTERNAL_KEY_PREFIX, - "timeout": p.get("timeout") or settings.PUSH_TIMEOUT_SECONDS, - "max_attempts": p.get("max_attempts") or settings.PUSH_MAX_ATTEMPTS, + # 数值哨兵: None(出厂/显式清空)→ env;显式数值优先 + "timeout": (p.get("timeout") if p.get("timeout") is not None + else settings.PUSH_TIMEOUT_SECONDS), + "max_attempts": (p.get("max_attempts") + if p.get("max_attempts") is not None + else settings.PUSH_MAX_ATTEMPTS), "retry_backoff_base": (p.get("retry_backoff_base") - or settings.PUSH_RETRY_BACKOFF_BASE), + if p.get("retry_backoff_base") is not None + else settings.PUSH_RETRY_BACKOFF_BASE), } # 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效 diff --git a/app/platform/defaults.py b/app/platform/defaults.py index 982fa53..11cd244 100644 --- a/app/platform/defaults.py +++ b/app/platform/defaults.py @@ -13,10 +13,19 @@ DEFAULT_CONFIG: dict = { "wall": {}, # wall 由租户 seed 的 wall.json 提供,出厂留空 "contact": {"cc_completion": True, "email_light_ask": True, "template_languages": ["en", "zh", "es"]}, "email": {"smtp": None, "sender_name": "", "notify_emails": []}, - "push": {"enabled": False, "url": "", "api_key": "", "prefix": "", "timeout": 10, - "max_attempts": 3, "retry_backoff_base": 1.0}, - "ai": {"provider": "glm", "api_key": None, "base_url": None, "model": "glm-4-flash"}, - "engine": {"context_seed_keys": {}, "rate_limit_per_hour": 30, + # 数值字段出厂哨兵 None = "未设置"(与 ai.model 同款纪律): + # resolve_push_settings 对 None 回退 env PUSH_*(settings 自带部署级默认, + # 最终行为不变)——truthy 出厂默认(10/3/1.0)会永久遮住 env 同名覆盖 + "push": {"enabled": False, "url": "", "api_key": "", "prefix": "", + "timeout": None, "max_attempts": None, "retry_backoff_base": None}, + "ai": {"provider": "glm", "api_key": None, "base_url": None, + # 双源防漂移: 出厂 model 须与 ai_service._FACTORY_DEFAULT_MODEL 一致 + # (resolve_ai_settings 的"未设置"哨兵;测试强制同步) + "model": "glm-4-flash"}, + "engine": {"context_seed_keys": {}, + # 双源防漂移: 默认值须与 rate_limit.DEFAULT_MAX 一致 + # (chat_rest 空槽兜底读它) + "rate_limit_per_hour": 30, # 双源防漂移: 默认值须与 conn_limiter.DEFAULT_WS_CONN_LIMIT 一致 "ws_conn_limit": 10}, "misc": {"name_to_region_extras": {}}, diff --git a/tests/test_tenant_config_wiring.py b/tests/test_tenant_config_wiring.py index 998ffa8..8bec4be 100644 --- a/tests/test_tenant_config_wiring.py +++ b/tests/test_tenant_config_wiring.py @@ -22,7 +22,7 @@ from app.engine.services import email_service, lead_push_service from app.engine.services.ai_service import resolve_ai_settings from app.engine.services.cc_completion import name_to_region from app.engine.services.conversation import ( - ConversationManager, allowed_seed_keys, resolve_contact_flags, + ConversationManager, context_seed_map, resolve_contact_flags, ) from app.engine.services.rate_limit import _reset_for_test, check from app.platform.tenant_config import TenantConfig @@ -106,17 +106,21 @@ def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch): # ---- push ---------------------------------------------------------------- def _patch_push_env(monkeypatch): + """env PUSH_* 全量 patch——数值刻意取与出厂不同的值(7.5/5/2.0): + 出厂哨兵 None 不参与取值,回退是否真实生效由此可判(审查 Important-1, + 修复前 env patch 成出厂同值 10/3/1.0 时数值断言恒真)。""" monkeypatch.setattr(settings, "PUSH_ENABLED", True) monkeypatch.setattr(settings, "PUSH_API_URL", "env-url") monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key") monkeypatch.setattr(settings, "PUSH_EXTERNAL_KEY_PREFIX", "env-pfx") - monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 10.0) - monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 3) - monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 1.0) + monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 7.5) + monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 5) + monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 2.0) def test_push_reads_tenant_section(monkeypatch): - """PUSH 组全部字段: 租户段给了就用租户的。""" + """PUSH 组全部字段: 租户段给了就用租户的(显式数值优先于 env—— + env 已 patch 成 7.5/5/2.0,断言 5/4/0.5 才证明读的是租户槽)。""" _patch_push_env(monkeypatch) r = lead_push_service.resolve_push_settings(_cfg({"push": { "enabled": True, "url": "https://t.example/push", "api_key": "t-key", @@ -128,16 +132,19 @@ def test_push_reads_tenant_section(monkeypatch): "prefix": "t-pfx", "timeout": 5, "max_attempts": 4, "retry_backoff_base": 0.5, } + # 租户显式数值优先于 env(哨兵化后 falsy-truthy 争议不再依赖巧合) + assert (r["timeout"], r["max_attempts"], r["retry_backoff_base"]) == ( + 5, 4, 0.5) def test_push_empty_section_falls_back_to_env(monkeypatch): - """空槽(默认 False/空串)视同未设置 → 回退 env PUSH_*。""" + """空槽(默认 False/空串/数值 None 哨兵)视同未设置 → 回退 env PUSH_*。""" _patch_push_env(monkeypatch) r = lead_push_service.resolve_push_settings(_cfg({}).data) assert r == { "enabled": True, "url": "env-url", "api_key": "env-key", - "prefix": "env-pfx", "timeout": 10.0, "max_attempts": 3, - "retry_backoff_base": 1.0, + "prefix": "env-pfx", "timeout": 7.5, "max_attempts": 5, + "retry_backoff_base": 2.0, } @@ -269,15 +276,24 @@ def test_name_to_region_extras_merged(): def test_chat_rest_seed_keys_from_config(): - """种子键白名单 = config.engine.context_seed_keys 的键集合。""" + """种子键白名单 = context_seed_map 映射的键集合。""" cfg = _cfg({"engine": {"context_seed_keys": { "vehicle_name": "app_vehicle_name", "vehicle_id": "app_vehicle_id"}}}) - assert allowed_seed_keys(cfg.data) == {"vehicle_name", "vehicle_id"} + assert set(context_seed_map(cfg.data)) == {"vehicle_name", "vehicle_id"} # jiancai 出厂 = 旧硬编码白名单的配置化形态 - assert allowed_seed_keys(build_jiancai_runtime().config.data) == { + assert set(context_seed_map(build_jiancai_runtime().config.data)) == { "product_name", "product_id"} +def test_factory_model_sentinel_in_sync(): + """哨兵防漂移(审查 Minor-2): resolve_ai_settings 的"未设置"哨兵必须 + 与 DEFAULT_CONFIG 出厂 model 一致——默认值一旦漂移,"回退 env AI_MODEL" + 永不触发且无测试报错。""" + from app.platform.defaults import DEFAULT_CONFIG + from app.engine.services.ai_service import _FACTORY_DEFAULT_MODEL + assert _FACTORY_DEFAULT_MODEL == DEFAULT_CONFIG["ai"]["model"] + + def test_seed_keys_drive_create_conversation(async_db): """create_conversation 按映射种子化: 白名单外的 context 键被丢弃。"""