diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index c936ea9c..16fc4c5c 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -18,6 +18,7 @@ from app.auth_middleware import require_role from app.deps import get_entity_id from app.models import KnowledgeEvent, KnowledgeSummary from app.services.knowledge_service import ( + DeepSeekKeyMissing, generate_summary_sync, generate_daily_sync, generate_weekly_sync, @@ -150,6 +151,9 @@ def trigger_daily_summary( try: result = generate_daily_sync(db, entity_id=entity_id) return {"message": "每日摘要已生成", "summary": result} + except DeepSeekKeyMissing as e: + logger.error("每日摘要生成失败(缺少 DeepSeek Key): %s", e) + raise HTTPException(status_code=503, detail=str(e)) except Exception as e: logger.exception("每日摘要生成失败") raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") @@ -164,6 +168,9 @@ def trigger_weekly_summary( try: result = generate_weekly_sync(db, entity_id=entity_id) return {"message": "周度摘要已生成", "summary": result} + except DeepSeekKeyMissing as e: + logger.error("周度摘要生成失败(缺少 DeepSeek Key): %s", e) + raise HTTPException(status_code=503, detail=str(e)) except Exception as e: logger.exception("周度摘要生成失败") raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") @@ -178,6 +185,9 @@ def trigger_monthly_summary( try: result = generate_monthly_sync(db, entity_id=entity_id) return {"message": "月度摘要已生成", "summary": result} + except DeepSeekKeyMissing as e: + logger.error("月度摘要生成失败(缺少 DeepSeek Key): %s", e) + raise HTTPException(status_code=503, detail=str(e)) except Exception as e: logger.exception("月度摘要生成失败") raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py index ba690372..77d06b5a 100644 --- a/backend/app/services/knowledge_service.py +++ b/backend/app/services/knowledge_service.py @@ -39,9 +39,23 @@ KPI变化:列出核心指标变化(名称、方向、幅度) 注意:如果事件列表为空或没有有价值的信息,直接输出"本期无重要变化"。""" +class DeepSeekKeyMissing(RuntimeError): + """DEEPSEEK_API_KEY 未配置 —— 禁止用硬编码默认 Key 兜底(2026-10-18 代码卫生)。""" + + +def _require_deepseek_key() -> str: + """强制从环境变量读取 DeepSeek Key;缺失即抛明确错误,绝不静默使用假 Key。""" + api_key = (os.getenv("DEEPSEEK_API_KEY") or "").strip() + if not api_key: + raise DeepSeekKeyMissing( + "DEEPSEEK_API_KEY 未配置(knowledge_service 禁止硬编码默认 key)" + ) + return api_key + + async def _call_deepseek(prompt: str, timeout: int = 30) -> str: """调用DeepSeek API生成摘要""" - api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e2...c2e8") + api_key = _require_deepseek_key() # 缺 env 直接抛,不拿假 Key 发请求 try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post( diff --git a/backend/scripts/bot_bridge_push.py b/backend/scripts/bot_bridge_push.py index c07d269c..030a588e 100644 --- a/backend/scripts/bot_bridge_push.py +++ b/backend/scripts/bot_bridge_push.py @@ -33,7 +33,14 @@ from typing import Optional import httpx CMA_API = os.getenv("CMA_API", "http://127.0.0.1:8010/api/cma/bot-bridge") -BRIDGE_TOKEN = os.getenv("CMA_BRIDGE_TOKEN", "cma-bot-bridge-2026") + +# 凭据只应从环境变量取;兼容期兜底历史共享 Token(P3 待轮换,轮换后删除兜底) +_LEGACY_BRIDGE_TOKEN = "cma-bot-bridge-2026" +BRIDGE_TOKEN = (os.getenv("CMA_BRIDGE_TOKEN") or "").strip() +if not BRIDGE_TOKEN: + print("[WARN] CMA_BRIDGE_TOKEN 未配置,回退历史共享 Token(待轮换)", file=sys.stderr) + BRIDGE_TOKEN = _LEGACY_BRIDGE_TOKEN + # MPM字段 → KPI编码映射(与 bot_bridge_v2.MPM_TO_KPI_MAP 保持一致) MPM_TO_KPI_MAP = { diff --git a/backend/scripts/finance_bot.py b/backend/scripts/finance_bot.py index 428278bd..65a68e68 100644 --- a/backend/scripts/finance_bot.py +++ b/backend/scripts/finance_bot.py @@ -7,7 +7,29 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # 直接调用BOT API CMA_BOT_API = "http://127.0.0.1:8010/api/cma/bot" -API_KEY = "cma-bot-finance-2026" + +# 凭据来源优先级:CMA_BOT_KEY_FINANCE(单键)→ CMA_BOT_API_KEYS(JSON 注册表,取 role=finance) +# 兼容期兜底:历史共享键(P3 待轮换,轮换后删除本兜底) +_LEGACY_SHARED_KEY = "cma-bot-finance-2026" + + +def _resolve_api_key() -> str: + key = (os.getenv("CMA_BOT_KEY_FINANCE") or "").strip() + if key: + return key + raw = (os.getenv("CMA_BOT_API_KEYS") or "").strip() + if raw: + try: + for k, v in json.loads(raw).items(): + if isinstance(v, dict) and v.get("role") == "finance": + return k + except Exception: + pass + print("[WARN] 未配置 CMA_BOT_KEY_FINANCE / CMA_BOT_API_KEYS,回退历史共享键(待轮换)", file=sys.stderr) + return _LEGACY_SHARED_KEY + + +API_KEY = _resolve_api_key() import httpx from datetime import datetime diff --git a/backend/scripts/verify_zxt_vouchers_json.py b/backend/scripts/verify_zxt_vouchers_json.py index 2f73f9b6..8d206ee0 100644 --- a/backend/scripts/verify_zxt_vouchers_json.py +++ b/backend/scripts/verify_zxt_vouchers_json.py @@ -47,7 +47,28 @@ def override_get_db(): app.dependency_overrides[get_db] = override_get_db client = TestClient(app) -HDR = {"X-BOT-KEY": "cma-bot-admin-2026"} +# 凭据来源:CMA_BOT_KEY_ADMIN → CMA_BOT_API_KEYS(role=ceo/admin) → 兼容期历史共享键(P3 待轮换) +_LEGACY_ADMIN_KEY = "cma-bot-admin-2026" + + +def _resolve_admin_key() -> str: + key = (os.getenv("CMA_BOT_KEY_ADMIN") or "").strip() + if key: + return key + raw = (os.getenv("CMA_BOT_API_KEYS") or "").strip() + if raw: + try: + import json as _json + for k, v in _json.loads(raw).items(): + if isinstance(v, dict) and v.get("role") in ("ceo", "admin"): + return k + except Exception: + pass + return _LEGACY_ADMIN_KEY + + +HDR = {"X-BOT-KEY": _resolve_admin_key()} + def check(name, cond, detail=""): print(("PASS " if cond else "FAIL ") + name + (" | " + detail if detail else "")) diff --git a/backend/tests/test_knowledge_deepseek_key_required.py b/backend/tests/test_knowledge_deepseek_key_required.py new file mode 100644 index 00000000..8f1fd8e7 --- /dev/null +++ b/backend/tests/test_knowledge_deepseek_key_required.py @@ -0,0 +1,92 @@ +"""knowledge_service 硬编码默认 DeepSeek Key 清理回归(2026-10-18 P2 代码卫生) + +覆盖: +1. 缺 DEEPSEEK_API_KEY → 抛 DeepSeekKeyMissing(不再拿假 Key 发请求) +2. 有 DEEPSEEK_API_KEY → 正常走请求链路并返回内容 +3. API 层:缺 Key 时 /knowledge/generate/daily 返回 503 且错误可观测(非静默模板兜底) +4. API 层:有 Key 时行为不变(200 + 写入摘要) +""" +import asyncio +import types + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.services import knowledge_service +from app.services.knowledge_service import DeepSeekKeyMissing +from tests.conftest import create_test_user, get_token_for_user, auth_header + + +class _FakeResp: + status_code = 200 + + def json(self): + return {"choices": [{"message": {"content": "测试摘要:本期无重大变化。"}}]} + + +class _FakeAsyncClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **kw): + return _FakeResp() + + +def _patch_httpx(monkeypatch): + """只替换 knowledge_service 模块内的 httpx 引用,不动全局模块。""" + monkeypatch.setattr( + knowledge_service, "httpx", types.SimpleNamespace(AsyncClient=_FakeAsyncClient) + ) + + +# ── 服务层 ── + +def test_service_raises_when_key_missing(monkeypatch): + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + with pytest.raises(DeepSeekKeyMissing) as ei: + asyncio.run(knowledge_service._call_deepseek("hello")) + assert "DEEPSEEK_API_KEY" in str(ei.value) + + +def test_service_works_when_key_present(monkeypatch): + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test-not-a-real-key") + _patch_httpx(monkeypatch) + out = asyncio.run(knowledge_service._call_deepseek("hello")) + assert out == "测试摘要:本期无重大变化。" + + +def test_require_key_strips_blank(monkeypatch): + monkeypatch.setenv("DEEPSEEK_API_KEY", " ") + with pytest.raises(DeepSeekKeyMissing): + knowledge_service._require_deepseek_key() + + +# ── API 层 ── + +def test_api_generate_daily_returns_503_without_key(client: TestClient, db: Session, monkeypatch): + create_test_user(db) + token = get_token_for_user(client, username="testadmin") + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + + r = client.post("/api/cma/knowledge/generate/daily", headers=auth_header(token)) + assert r.status_code == 503, r.text + detail = r.json()["detail"] + assert "DEEPSEEK_API_KEY" in detail, detail + + +def test_api_generate_daily_ok_with_key(client: TestClient, db: Session, monkeypatch): + create_test_user(db) + token = get_token_for_user(client, username="testadmin") + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test-not-a-real-key") + _patch_httpx(monkeypatch) + + r = client.post("/api/cma/knowledge/generate/daily", headers=auth_header(token)) + assert r.status_code == 200, r.text + assert r.json()["summary"]["entity_id"] == 1