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,327 @@
import json
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import Base
from app.modules.feishu.app_tickets import FeishuAppTicketService
from app.modules.feishu_users.models import FeishuUser
from app.modules.observability.constants import (
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.service import ObservabilityService
from app.modules.subscriptions.constants import (
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
@pytest.fixture(autouse=True)
def _reset_settings() -> None:
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _add_active_subscription(
db: Session,
*,
tenant_key: str,
suffix: str,
) -> PushSubscription:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=tenant_key,
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(2026, 7, 27, 1, 0),
status=PushSubscriptionStatus.ACTIVE,
consented_at=datetime(2026, 7, 26, 1, 0),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
def _add_delivery(
db: Session,
subscription: PushSubscription,
*,
suffix: str,
status_value: str,
scheduled_for: datetime,
) -> None:
future = datetime(2030, 1, 1, 0, 0)
db.add(
PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=scheduled_for,
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=status_value,
next_attempt_at=(
future
if status_value
in {
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
}
else None
),
locked_by=(
"readiness-worker"
if status_value == PushDeliveryStatus.PROCESSING
else None
),
locked_until=(
future
if status_value == PushDeliveryStatus.PROCESSING
else None
),
)
)
db.commit()
def test_subscription_readiness_requires_basic_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="missing")
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["credentials_configured"] is False
assert result["reasons"] == ["credentials_missing"]
finally:
engine.dispose()
def test_self_app_readiness_rejects_multiple_active_tenants(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="self-a")
single = ObservabilityService(db)._feishu_subscriptions_check()
assert single[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert single["active_tenant_count"] == 1
_add_active_subscription(db, tenant_key="tenant-b", suffix="self-b")
multiple = ObservabilityService(db)._feishu_subscriptions_check()
assert multiple[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert multiple["active_tenant_count"] == 2
assert multiple["reasons"] == ["self_app_multiple_tenants"]
finally:
engine.dispose()
def test_store_app_readiness_accepts_persisted_ticket_without_exposing_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ticket = "readiness-ticket-secret"
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
monkeypatch.setenv("FEISHU_APP_TICKET", "")
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-a")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="store")
missing = ObservabilityService(db)._feishu_subscriptions_check()
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert missing["reasons"] == ["app_ticket_missing"]
FeishuAppTicketService(db).store_verified("cli-store", ticket)
configured = ObservabilityService(db)._feishu_subscriptions_check()
assert configured[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert configured["ticket_configured"] is True
assert configured["default_tenant_configured"] is True
assert configured["reasons"] == []
assert ticket not in json.dumps(configured)
finally:
engine.dispose()
def test_store_app_readiness_requires_default_tenant(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
monkeypatch.setenv("FEISHU_APP_TICKET", "environment-ticket")
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="default")
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["ticket_configured"] is True
assert result["default_tenant_configured"] is False
assert result["reasons"] == ["default_tenant_missing"]
finally:
engine.dispose()
def test_readiness_checks_credentials_for_processable_deliveries_without_active_plan(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
subscription = _add_active_subscription(
db,
tenant_key="tenant-a",
suffix="durable",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, status_value in enumerate(
[
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
PushDeliveryStatus.PROCESSING,
]
):
_add_delivery(
db,
subscription,
suffix=f"durable-{index}",
status_value=status_value,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["active"] == 0
assert result["processable_deliveries"] == 3
assert result["active_tenant_count"] == 1
assert result["reasons"] == ["credentials_missing"]
finally:
engine.dispose()
def test_readiness_ignores_terminal_deliveries_without_active_plan() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
subscription = _add_active_subscription(
db,
tenant_key="tenant-a",
suffix="terminal",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, status_value in enumerate(
[
PushDeliveryStatus.SENT,
PushDeliveryStatus.FAILED,
PushDeliveryStatus.SKIPPED,
]
):
_add_delivery(
db,
subscription,
suffix=f"terminal-{index}",
status_value=status_value,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result == {
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
"active": 0,
"processable_deliveries": 0,
}
finally:
engine.dispose()
def test_self_app_counts_tenants_from_processable_deliveries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, tenant_key in enumerate(["tenant-a", "tenant-b"]):
subscription = _add_active_subscription(
db,
tenant_key=tenant_key,
suffix=f"delivery-tenant-{index}",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
_add_delivery(
db,
subscription,
suffix=f"delivery-tenant-{index}",
status_value=PushDeliveryStatus.PENDING,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["active"] == 0
assert result["processable_deliveries"] == 2
assert result["active_tenant_count"] == 2
assert result["reasons"] == ["self_app_multiple_tenants"]
finally:
engine.dispose()