```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -11,22 +8,6 @@ from sqlalchemy import select
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import BusinessResponseKey, StatusValue
|
||||
|
||||
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
_db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
||||
os.environ["API_KEY"] = "test-key"
|
||||
os.environ["AUDIT_API_KEY"] = "audit-key"
|
||||
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
|
||||
os.environ["FEISHU_APP_ID"] = ""
|
||||
os.environ["FEISHU_APP_SECRET"] = ""
|
||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
@@ -153,13 +134,6 @@ def create_business_record(domain: str, data: dict, actor: str = "pytest") -> Se
|
||||
db.close()
|
||||
|
||||
|
||||
def teardown_module() -> None:
|
||||
engine.dispose()
|
||||
path = Path(_db.name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def test_project_report_and_feishu_command_preview() -> None:
|
||||
response = create_business_record(
|
||||
"projects",
|
||||
@@ -449,7 +423,7 @@ def test_v3_event_retry_and_dispatch_pending_route() -> None:
|
||||
assert dispatched[0]["status"] == EventStatus.PROCESSED
|
||||
|
||||
|
||||
def test_v3_ai_memory_recall_and_auto_write() -> None:
|
||||
def test_v3_ai_noop_does_not_auto_write_memory() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/ai/ask",
|
||||
headers=headers,
|
||||
@@ -464,45 +438,33 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||
assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == (
|
||||
AIMemoryStatus.ACTIVE
|
||||
)
|
||||
assert data[AIResponseKey.OK] is False
|
||||
assert AIResponseKey.MEMORY_WRITE not in data[AIResponseKey.RAW]
|
||||
|
||||
list_response = client.get(
|
||||
"/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE",
|
||||
headers=headers,
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[AIMemoryResponseKey.ITEMS]
|
||||
|
||||
recall_response = client.post(
|
||||
"/api/v1/ai/memory/recall",
|
||||
headers=headers,
|
||||
json={
|
||||
"query": "concise bullet summaries",
|
||||
"scope": "project",
|
||||
"subject": "P-MEM-SMOKE",
|
||||
},
|
||||
)
|
||||
assert recall_response.status_code == 200
|
||||
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
|
||||
assert list_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": {
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memory = AIMemoryService(db).auto_write(
|
||||
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
|
||||
answer="Retain the confidential cash flow and budget details.",
|
||||
actor="pytest",
|
||||
)
|
||||
assert memory is not None
|
||||
assert memory.status == AIMemoryStatus.REJECTED
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
rejected = client.get(
|
||||
"/api/v1/ai/memory",
|
||||
@@ -531,7 +493,9 @@ def test_default_json_response_masks_sensitive_fields() -> None:
|
||||
assert payload["nested"]["status"] == "ok"
|
||||
|
||||
|
||||
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
def test_v3_risk_action_routes_write_local_state_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -549,10 +513,14 @@ def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "route to owner"},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["risk_event"]["assigned_to"] == "risk-owner"
|
||||
|
||||
|
||||
def test_writeback_and_approval_routes_are_removed() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_writeback_and_approval_routes_are_removed(monkeypatch, read_only_mode: str) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
response = client.post(
|
||||
"/api/v1/writebacks",
|
||||
headers=headers,
|
||||
@@ -577,6 +545,7 @@ def test_writeback_and_approval_routes_are_removed() -> None:
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
|
||||
@@ -687,6 +656,7 @@ def test_dashboard_and_response_masking() -> None:
|
||||
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
|
||||
assert dashboard_response.status_code == 200
|
||||
assert "metrics" in dashboard_response.json()
|
||||
assert "latest_audit_logs" not in dashboard_response.json()
|
||||
|
||||
|
||||
def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
@@ -714,7 +684,10 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_business_write_routes_are_permanently_disabled(monkeypatch, read_only_mode: str) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
create_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
@@ -737,9 +710,15 @@ def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
|
||||
},
|
||||
)
|
||||
assert update_response.status_code == 405
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_approval_and_feishu_approval_card_routes_are_removed(
|
||||
monkeypatch, read_only_mode: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
@@ -768,9 +747,30 @@ def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 404
|
||||
openclaw_response = client.post(
|
||||
"/api/v1/ai/openclaw/tools/invoke",
|
||||
headers=headers,
|
||||
json={
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {},
|
||||
"session_key": "main",
|
||||
},
|
||||
)
|
||||
assert openclaw_response.status_code == 404
|
||||
assert "/api/v1/approvals" not in app.openapi()["paths"]
|
||||
assert "/api/v1/integrations/feishu/approval-card-action" not in app.openapi()["paths"]
|
||||
assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"]
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
def test_new_ledgers_reports_and_local_risk_state(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.risk.routes.enqueue_risk_event_generation",
|
||||
lambda actor: {"queued": True, "actor": actor},
|
||||
)
|
||||
domains_response = client.get("/api/v1/business/domains", headers=headers)
|
||||
assert domains_response.status_code == 200
|
||||
domains = domains_response.json()["domains"]
|
||||
@@ -810,20 +810,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
report_response = client.post(
|
||||
"/api/v1/reports/work-reports/generate",
|
||||
headers=headers,
|
||||
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
|
||||
json={
|
||||
"report_type": ReportType.DAILY,
|
||||
"reporter": "pytest",
|
||||
"actor": "pytest",
|
||||
"persist": True,
|
||||
},
|
||||
)
|
||||
assert report_response.status_code == 200
|
||||
assert report_response.json()["data"] is None
|
||||
assert report_response.json()["data"]["reporter"] == "pytest"
|
||||
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
|
||||
|
||||
risk_response = client.post(
|
||||
"/api/v1/risks/events/generate?actor=pytest",
|
||||
headers=headers,
|
||||
)
|
||||
assert risk_response.status_code == 405
|
||||
assert risk_response.status_code == 200
|
||||
|
||||
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
|
||||
assert enqueue_response.status_code == 405
|
||||
assert enqueue_response.status_code == 200
|
||||
assert enqueue_response.json()["queued"] is True
|
||||
|
||||
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
|
||||
assert overdue_response.status_code == 200
|
||||
@@ -1004,8 +1010,9 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
||||
)
|
||||
assert ai_response.status_code == 200
|
||||
ai_data = ai_response.json()
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is False
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||
assert "不可用" in ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.ANSWER]
|
||||
|
||||
|
||||
def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
|
||||
@@ -1101,7 +1108,9 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||
assert metrics["expenses_pending"] == 1
|
||||
|
||||
|
||||
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
|
||||
def test_legacy_read_query_and_local_import_allowed_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
rows = [
|
||||
{
|
||||
"id": 9001,
|
||||
@@ -1138,10 +1147,15 @@ def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
|
||||
"field_map": {"title": "task_name"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dry_run"] is False
|
||||
assert response.json()["items"][0]["task"]["source_system"] == "legacy_mysql"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
def test_risk_event_actions_write_local_state_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
create_response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -1162,21 +1176,21 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "please handle"},
|
||||
)
|
||||
assert assign_response.status_code == 405
|
||||
assert assign_response.status_code == 200
|
||||
|
||||
comment_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/comment",
|
||||
headers=headers,
|
||||
json={"comment": "working on it", "payload": {"step": 1}},
|
||||
)
|
||||
assert comment_response.status_code == 405
|
||||
assert comment_response.status_code == 200
|
||||
|
||||
resolve_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/resolve",
|
||||
headers=headers,
|
||||
json={"comment": "resolved"},
|
||||
)
|
||||
assert resolve_response.status_code == 405
|
||||
assert resolve_response.status_code == 200
|
||||
|
||||
close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
@@ -1186,21 +1200,22 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
"review_summary": "handled",
|
||||
},
|
||||
)
|
||||
assert close_response.status_code == 405
|
||||
assert close_response.status_code == 200
|
||||
|
||||
reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck"},
|
||||
)
|
||||
assert reopen_response.status_code == 405
|
||||
assert reopen_response.status_code == 200
|
||||
|
||||
actions_response = client.get(
|
||||
f"/api/v1/risks/events/{event_id}/actions",
|
||||
headers=headers,
|
||||
)
|
||||
assert actions_response.status_code == 200
|
||||
assert actions_response.json()["items"] == []
|
||||
assert len(actions_response.json()["items"]) == 5
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_report_push_failure_is_recorded() -> None:
|
||||
@@ -1278,7 +1293,8 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
|
||||
def mappings(self) -> "FakeResult":
|
||||
return self
|
||||
|
||||
def all(self) -> list:
|
||||
def fetchmany(self, size: int) -> list:
|
||||
_ = size
|
||||
return []
|
||||
|
||||
class FakeConnection:
|
||||
@@ -1486,7 +1502,7 @@ def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() ->
|
||||
|
||||
|
||||
def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
IntasectSyncService,
|
||||
@@ -1524,16 +1540,21 @@ def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None:
|
||||
def test_lifecycle_enqueue_allows_local_work_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.reports.routes.enqueue_lifecycle_report",
|
||||
lambda **kwargs: {"status": "queued", "report_type": kwargs["report_type"]},
|
||||
)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/reports/lifecycle/enqueue",
|
||||
headers=headers,
|
||||
json={"report_type": "daily"},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "queued"
|
||||
finally:
|
||||
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
@@ -1685,7 +1706,7 @@ def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) ->
|
||||
|
||||
|
||||
def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
created = client.post(
|
||||
@@ -2260,7 +2281,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")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
scheduler = create_scheduler()
|
||||
@@ -2399,7 +2420,7 @@ def test_market_pipeline_is_idempotent_and_requires_complete_ai_chart_delivery(
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "test-app")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret")
|
||||
monkeypatch.setenv("FEISHU_DEFAULT_CHAT_ID", "oc_market")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.feishu.service.FeishuService.upload_image",
|
||||
@@ -2518,31 +2539,25 @@ def test_feishu_market_rules_are_scoped_to_market(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_read_only_mode_blocks_feishu_mutations_and_market_pipeline(monkeypatch) -> None:
|
||||
class MustNotRunMarket:
|
||||
def sync_daily(self, target):
|
||||
pytest.fail("read-only mode must block market synchronization")
|
||||
|
||||
def test_read_only_mode_allows_local_rules_and_watchlist(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rules = FeishuCommandService(db).handle_text(
|
||||
"学习市场规则:这条规则不应写入", actor="ou_read_only", auto_reply=False
|
||||
"学习市场规则:这条规则应写入本地状态",
|
||||
actor="ou_read_only",
|
||||
auto_reply=False,
|
||||
)
|
||||
watchlist = FeishuCommandService(db).handle_text(
|
||||
"加入自选 600105", actor="ou_read_only", auto_reply=False
|
||||
)
|
||||
pipeline = MarketPipelineService(db, MustNotRunMarket()).run(
|
||||
"close", date(2032, 7, 12), actor="pytest"
|
||||
)
|
||||
assert "只读模式" in rules["content"]
|
||||
assert "只读模式" in watchlist["content"]
|
||||
assert pipeline["status"] == "operations_disabled"
|
||||
assert "规则已学习" in rules["content"]
|
||||
assert "已加入自选:600105.SH" in watchlist["content"]
|
||||
assert db.execute(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则不应写入")
|
||||
).scalar_one_or_none() is None
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则应写入本地状态")
|
||||
).scalar_one_or_none() is not None
|
||||
finally:
|
||||
db.close()
|
||||
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
|
||||
|
||||
Reference in New Issue
Block a user