feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View File

@@ -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)