chore(security): 摘除 knowledge_service 硬编码默认 DeepSeek Key(缺 env 抛明确错误/503)

- knowledge_service: 移除 os.getenv 默认值占位,改 _require_deepseek_key() 必填,缺 env 抛 DeepSeekKeyMissing
- knowledge API: generate/{daily,weekly,monthly} 缺 Key 返回 503(可观测,不再静默走模板兜底)
- finance_bot / verify_zxt_vouchers_json / bot_bridge_push: 凭据改 env 优先(CMA_BOT_KEY_* / CMA_BOT_API_KEYS / CMA_BRIDGE_TOKEN),历史共享键仅作兼容期兜底并告警
- tests: 新增 5 例回归(缺 Key 服务层抛错、API 503;有 Key 正常 200)

(cherry picked from commit 859bd8276d7c5aad4bf10ee3399b6cccc188f064)
This commit is contained in:
wecom-fullstack
2026-10-18 16:31:52 +08:00
committed by Hermes CI Fix
parent 885cfddbff
commit a9dcbf22bc
6 changed files with 170 additions and 4 deletions
+10
View File
@@ -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)}")
+15 -1
View File
@@ -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(
+8 -1
View File
@@ -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 = {
+23 -1
View File
@@ -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
+22 -1
View File
@@ -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 ""))
@@ -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