refactor(Dockerfile): 使用requirements.txt替代硬编码依赖

将Dockerfile中的硬编码pip包列表替换为通过requirements.txt文件安装,
提高依赖管理的灵活性和可维护性。

feat(scheduling): 移除内置APScheduler,采用独立调度系统

移除app/core/background/scheduler.py中原来的APScheduler实现,
改为使用新的应用级调度系统app.application.scheduling。

refactor(task_queue): 调整任务队列模块结构和导入路径

将任务队列相关常量从app.core.background.task_queue.constants迁移至
app.tasks.constants,并更新所有相关导入路径和引用。

refactor(events): 将事件服务重构为独立的应用层组件

将事件分发逻辑从核心层迁移到应用层,使用app.application.events.EventDispatchService
替代原有的app.modules.events.services.EventService。

feat(ai_memory): 增强AI记忆自动写入的安全策略

新增ai_memory_blocked_content_terms配置项用于阻止敏感内容,
添加TTL过期机制控制自动写入条目的生命周期。

fix(security): 强化生产环境安全验证机制

增加model_validator确保生产环境中数据库连接、API密钥、CORS设置等
关键安全配置符合要求。

feat(risks): 优化风险事件操作动作的外键约束

为RiskEventAction模型的风险事件ID字段添加外键约束,
防止孤立记录并增强数据完整性。

refactor(audit): 优化审计服务方法命名和事务处理

将AuditService的log方法重命名为record以反映其阶段行为,
并调整事务提交时机以提高性能。

feat(events): 增强领域事件并发处理和响应模型

添加事件锁定机制防止重复处理,更新API响应模型以提供
更准确的数据类型定义。
```
This commit is contained in:
2026-07-15 16:36:42 +08:00
parent 267b01b9f4
commit db751f03b4
73 changed files with 1615 additions and 933 deletions

View File

@@ -30,12 +30,12 @@ os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.core.background.scheduler import create_scheduler
from app.application.scheduling import create_scheduler
from app.core.database import Base, SessionLocal, engine
from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.http.responses import MaskedJSONResponse
from app.core.security import require_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.ai_memory.constants import (
AIMemoryPayloadKey,
AIMemoryResponseKey,
@@ -48,7 +48,9 @@ from app.modules.events.constants import (
EventStatus,
EventType,
)
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
from app.application.events import EventDispatchService
from app.modules.business.registry import get_domain_model
from app.modules.business.models import (
Employee,
@@ -101,17 +103,18 @@ from app.modules.reports.constants import (
ReportTitle,
ReportType,
)
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService
from app.application.pipelines import LifecyclePipelineService
from app.application.delivery import ReportDeliveryService
from app.modules.reports.chart import render_lifecycle_chart
from app.modules.reports.services import ReportService
from app.modules.feishu.service import FeishuService
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.events import FeishuEventService
from app.application.feishu import FeishuCommandService
from app.application.feishu.events import FeishuEventService, _audit_event_metadata
from app.modules.feishu.constants import FeishuEventSource
from app.modules.risk.constants import RiskEventActionValue
from app.modules.market.chart import render_market_chart
from app.modules.market.service import MarketService, TushareClient, normalize_symbol
from app.modules.market.pipeline import MarketPipelineService
from app.application.pipelines import MarketPipelineService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance
@@ -208,6 +211,9 @@ def test_feishu_webhook_routes_message_event() -> None:
data = response.json()
assert data["handled"] is True
assert data["result"]["command"] == "risk_summary"
audit_metadata = _audit_event_metadata(payload)
assert "content" not in json.dumps(audit_metadata)
assert "test-feishu-token" not in json.dumps(audit_metadata)
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert duplicate_response.status_code == 200
@@ -220,7 +226,7 @@ def test_feishu_webhook_routes_message_event() -> None:
assert logs_response.status_code == 200
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
assert "test-feishu-token" not in audit_payload
assert AUDIT_REDACTED_VALUE in audit_payload
assert "evt-smoke-risk-001" in audit_payload
def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None:
@@ -371,7 +377,10 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
event = EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-idempotency",
)
duplicate = service.emit(
event_type=EventType.RISK_ACTION_RECORDED,
@@ -384,7 +393,10 @@ def test_v3_event_idempotency_and_workflow_dispatch() -> None:
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
duplicate = EventDispatchService(db).dispatch_event(
duplicate.event_id,
worker_id="pytest-idempotency-duplicate",
)
assert duplicate.event_id == event.event_id
assert event.status == EventStatus.PROCESSED
@@ -442,7 +454,7 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "Summarize quarterly cash planning for project memory smoke",
"prompt": "Remember that project updates should use concise bullet summaries",
"context": {
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE",
@@ -467,7 +479,7 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
"/api/v1/ai/memory/recall",
headers=headers,
json={
"query": "quarterly cash planning",
"query": "concise bullet summaries",
"scope": "project",
"subject": "P-MEM-SMOKE",
},
@@ -476,6 +488,49 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
def test_ai_memory_rejects_financial_facts_and_applies_retention() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "Remember the project cash flow and budget details for next quarter",
"context": {
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-FINANCIAL",
},
},
)
assert response.status_code == 200
memory_write = response.json()[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE]
assert memory_write[AIMemoryPayloadKey.STATUS] == AIMemoryStatus.REJECTED
rejected = client.get(
"/api/v1/ai/memory",
headers=headers,
params={
"scope": "project",
"subject": "P-MEM-FINANCIAL",
"status": AIMemoryStatus.REJECTED,
},
).json()[AIMemoryResponseKey.ITEMS]
assert rejected
assert rejected[0]["expires_at"] is not None
assert "cash flow" not in rejected[0][AIMemoryPayloadKey.CONTENT].lower()
def test_default_json_response_masks_sensitive_fields() -> None:
response = MaskedJSONResponse(
{
"token": "provider-token",
"nested": {"password": "provider-password", "status": "ok"},
}
)
payload = json.loads(response.body)
assert payload["token"] == "[MASKED]"
assert payload["nested"]["password"] == "[MASKED]"
assert payload["nested"]["status"] == "ok"
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
response = create_business_record(
"risk-events",
@@ -776,8 +831,8 @@ def test_new_ledgers_reports_and_risk_events() -> None:
def test_independent_feishu_report_schedules_and_tasks_are_registered() -> None:
from app.core.background.scheduler import create_scheduler
from app.core.background.task_queue.constants import (
from app.application.scheduling import create_scheduler
from app.tasks.constants import (
TASK_PUSH_ATTENDANCE_SUMMARY,
TASK_PUSH_DAILY_BRIEF,
TASK_PUSH_PROJECT_WEEKLY,
@@ -979,6 +1034,17 @@ def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
db = SessionLocal()
try:
event = db.execute(
select(DomainEvent).where(
DomainEvent.idempotency_key
== f"enterprise-analytics:{data[EnterpriseAnalyticsKey.CODE]}"
)
).scalar_one()
assert event.status == EventStatus.PENDING
EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-enterprise",
)
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS,
@@ -1438,7 +1504,11 @@ def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
"ai_analysis": {"ok": True, "answer": "analysis"},
},
)
monkeypatch.setattr(ReportService, "push_report", lambda self, *args, **kwargs: {"ok": True})
monkeypatch.setattr(
ReportDeliveryService,
"push_report",
lambda self, *args, **kwargs: {"ok": True},
)
db = SessionLocal()
try:
@@ -1506,7 +1576,7 @@ def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> Non
},
)
monkeypatch.setattr(
ReportService,
ReportDeliveryService,
"push_report",
lambda self, *args, **kwargs: pytest.fail("business report must not be sent"),
)
@@ -1596,7 +1666,7 @@ def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) ->
monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
db = SessionLocal()
try:
ReportService(db).push_report(
ReportDeliveryService(db).push_report(
{
"title": "Lifecycle",
"report_type": "daily",
@@ -2190,6 +2260,7 @@ def test_market_closed_day_skips_quote_collection() -> None:
def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
monkeypatch.setenv("READ_ONLY_MODE", "false")
get_settings.cache_clear()
try:
scheduler = create_scheduler()
@@ -2199,6 +2270,7 @@ def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
assert "market_weekly_analysis" in job_ids
finally:
monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False)
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()