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

@@ -0,0 +1,360 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.core.background.task_queue.subscriptions as subscription_queue
from app.application.scheduling import create_scheduler
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.modules.feishu_users.models import FeishuUser
from app.modules.observability.constants import (
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.routes import router as observability_router
from app.modules.subscriptions.constants import (
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.routes import router as subscriptions_router
@pytest.fixture(autouse=True)
def _reset_settings() -> Iterator[None]:
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("API_KEY", "subscription-service-key")
monkeypatch.setenv("API_KEYS", "[]")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _seed_subscription(
db: Session,
*,
suffix: str,
status_value: str = PushSubscriptionStatus.ACTIVE,
) -> PushSubscription:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
)
db.add(owner)
db.flush()
subscription = PushSubscription(
code=f"SUB-{suffix}",
owner_id=owner.id,
target_type=SubscriptionTargetType.USER,
target_id=owner.open_id,
prompt="生成一条个人提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone="Asia/Shanghai",
next_run_at=(
datetime(2030, 1, 1, 1, 0)
if status_value == PushSubscriptionStatus.ACTIVE
else None
),
status=status_value,
consented_at=datetime(2026, 7, 26, 1, 0),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
def _seed_delivery(
db: Session,
subscription: PushSubscription,
*,
suffix: str,
status_value: str,
) -> PushDelivery:
delivery = PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=datetime(2026, 7, 27, 1, 0),
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=status_value,
attempt_count=2,
next_attempt_at=datetime(2030, 1, 1, 1, 0),
last_error="temporary provider failure",
)
db.add(delivery)
db.commit()
db.refresh(delivery)
return delivery
def _test_app(
session_factory: sessionmaker[Session],
*,
include_subscriptions: bool = False,
include_observability: bool = False,
) -> FastAPI:
app = FastAPI()
if include_subscriptions:
app.include_router(
subscriptions_router,
prefix="/api/v1/subscriptions",
)
if include_observability:
app.include_router(observability_router, prefix="/api/v1")
def override_db() -> Iterator[Session]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return app
def test_subscription_internal_apis_require_key_and_serialize_records(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
subscription = _seed_subscription(db, suffix="api")
delivery = _seed_delivery(
db,
subscription,
suffix="api",
status_value=PushDeliveryStatus.RETRY,
)
client = TestClient(
_test_app(session_factory, include_subscriptions=True)
)
assert client.get("/api/v1/subscriptions").status_code == 401
assert client.get("/api/v1/subscriptions/deliveries").status_code == 401
assert (
client.get(
"/api/v1/subscriptions",
headers={"X-API-Key": "invalid-key"},
).status_code
== 401
)
headers = {"X-API-Key": "subscription-service-key"}
subscriptions_response = client.get(
"/api/v1/subscriptions",
headers=headers,
)
deliveries_response = client.get(
"/api/v1/subscriptions/deliveries",
headers=headers,
)
assert subscriptions_response.status_code == 200
subscriptions_body = subscriptions_response.json()
assert subscriptions_body["total"] == 1
subscription_item = subscriptions_body["items"][0]
assert set(subscription_item) == {
"code",
"owner_id",
"target_type",
"target_id",
"prompt",
"schedule_type",
"schedule_config",
"timezone",
"next_run_at",
"status",
"consented_at",
"last_run_at",
"created_at",
"updated_at",
}
assert subscription_item["code"] == subscription.code
assert subscription_item["schedule_config"] == {"hour": 9, "minute": 0}
assert subscription_item["next_run_at"] is not None
assert deliveries_response.status_code == 200
deliveries_body = deliveries_response.json()
assert deliveries_body["total"] == 1
delivery_item = deliveries_body["items"][0]
assert set(delivery_item) == {
"code",
"subscription_id",
"scheduled_for",
"idempotency_key",
"message_uuid",
"status",
"attempt_count",
"next_attempt_at",
"provider_message_id",
"last_error",
"sent_at",
"created_at",
"updated_at",
}
assert delivery_item["code"] == delivery.code
assert delivery_item["message_uuid"] == delivery.message_uuid
assert delivery_item["attempt_count"] == 2
assert "rendered_content" not in delivery_item
@pytest.mark.parametrize(
("features_enabled", "job_expected"),
[(False, False), (True, True)],
)
def test_subscription_scheduler_job_follows_feature_flag(
monkeypatch: pytest.MonkeyPatch,
features_enabled: bool,
job_expected: bool,
) -> None:
monkeypatch.setenv(
"FEISHU_USER_FEATURES_ENABLED",
str(features_enabled).lower(),
)
get_settings.cache_clear()
scheduler = create_scheduler()
job = scheduler.get_job("subscription_delivery_cycle")
assert scheduler.running is False
assert (job is not None) is job_expected
if job is not None:
assert job.trigger.interval == timedelta(minutes=1)
assert scheduler._job_defaults["coalesce"] is True
assert scheduler._job_defaults["max_instances"] == 1
def test_subscription_queue_runs_inline_without_celery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
get_settings.cache_clear()
calls: list[str] = []
def fake_cycle(*, actor: str) -> dict[str, list[str]]:
calls.append(actor)
return {"created": [], "processed": []}
monkeypatch.setattr(
subscription_queue,
"run_subscription_cycle",
fake_cycle,
)
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
assert result == {
"queued": False,
"mode": "inline",
"task_name": "subscriptions.run_cycle",
"result": {"created": [], "processed": []},
}
assert calls == ["runtime-test"]
def test_subscription_queue_uses_celery_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.tasks import celery_app
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
get_settings.cache_clear()
calls: dict[str, object] = {}
class FakeSignature:
def apply_async(self) -> SimpleNamespace:
calls["applied"] = True
return SimpleNamespace(id="queued-task-id")
def fake_signature(
task_name: str,
*,
kwargs: dict[str, str],
) -> FakeSignature:
calls["task_name"] = task_name
calls["kwargs"] = kwargs
return FakeSignature()
monkeypatch.setattr(celery_app, "signature", fake_signature)
monkeypatch.setattr(
subscription_queue,
"run_subscription_cycle",
lambda **_: pytest.fail("Celery dispatch must not run inline"),
)
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
assert result == {
"queued": True,
"mode": "celery",
"task_name": "subscriptions.run_cycle",
"task_id": "queued-task-id",
}
assert calls == {
"task_name": "subscriptions.run_cycle",
"kwargs": {"actor": "runtime-test"},
"applied": True,
}
def test_ready_route_returns_503_for_processable_delivery_without_credentials(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
get_settings.cache_clear()
with session_factory() as db:
subscription = _seed_subscription(
db,
suffix="ready",
status_value=PushSubscriptionStatus.COMPLETED,
)
_seed_delivery(
db,
subscription,
suffix="ready",
status_value=PushDeliveryStatus.PENDING,
)
client = TestClient(
_test_app(session_factory, include_observability=True)
)
response = client.get("/api/v1/health/ready")
assert response.status_code == 503
body = response.json()
assert body[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
check = body[ObservabilityKey.CHECKS]["feishu_subscriptions"]
assert check[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert check["active"] == 0
assert check["processable_deliveries"] == 1
assert check["reasons"] == ["credentials_missing"]
assert "生成一条个人提醒" not in response.text