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,375 @@
from datetime import datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.application.delivery.subscriptions import FeishuSubscriptionSender
from app.core.database import Base
from app.modules.feishu.errors import FeishuAPIError
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import FeishuUser
from app.modules.subscriptions.constants import (
MAX_DAILY_DELIVERIES,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
DeliveryGenerationRequest,
DeliverySendRequest,
DeliveryService,
)
class StubGenerator:
def __init__(self, content: str = "生成后的提醒") -> None:
self.content = content
self.requests: list[DeliveryGenerationRequest] = []
def generate(self, request: DeliveryGenerationRequest) -> str:
self.requests.append(request)
return self.content
class StubSender:
def __init__(self, outcomes: list[dict[str, Any] | Exception]) -> None:
self.outcomes = outcomes
self.requests: list[DeliverySendRequest] = []
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
self.requests.append(request)
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class StubFeishuService:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def send_text(self, text: str, **kwargs: Any) -> dict[str, Any]:
self.calls.append({"text": text, **kwargs})
return {"code": 0}
def _delivery_fixture(
db: Session,
*,
suffix: str,
current: datetime,
target_type: str = SubscriptionTargetType.USER,
) -> tuple[FeishuUser, PushSubscription, PushDelivery]:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=(
FeishuUserRole.ADMIN
if target_type == SubscriptionTargetType.CHAT
else FeishuUserRole.USER
),
timezone="Asia/Shanghai",
)
db.add(owner)
db.flush()
subscription = PushSubscription(
code=f"SUB-{suffix}",
owner_id=owner.id,
target_type=target_type,
target_id=(
f"chat-{suffix}"
if target_type == SubscriptionTargetType.CHAT
else owner.open_id
),
prompt="按我的偏好生成提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone=owner.timezone,
next_run_at=current + timedelta(days=1),
status=PushSubscriptionStatus.ACTIVE,
consented_at=current - timedelta(days=1),
)
db.add(subscription)
db.flush()
delivery = PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=current,
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=PushDeliveryStatus.PENDING,
next_attempt_at=current,
created_at=current,
updated_at=current,
)
db.add(delivery)
db.commit()
db.refresh(owner)
db.refresh(subscription)
db.refresh(delivery)
return owner, subscription, delivery
def test_private_delivery_uses_personal_context_open_id_and_is_idempotent() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
owner, _, delivery = _delivery_fixture(
db,
suffix="private",
current=current,
)
generator = StubGenerator()
sender = StubSender(
[{"code": 0, "data": {"message_id": "provider-message"}}]
)
service = DeliveryService(db, generator=generator, sender=sender)
sent = service.process(delivery.code, now=current)
duplicate = service.process(delivery.code, now=current)
assert sent.status == PushDeliveryStatus.SENT
assert sent.attempt_count == 1
assert sent.provider_message_id == "provider-message"
assert duplicate.status == PushDeliveryStatus.SENT
assert len(generator.requests) == 1
assert generator.requests[0] == DeliveryGenerationRequest(
prompt="按我的偏好生成提醒",
owner_id=owner.id,
use_personal_context=True,
use_company_rules=False,
)
assert len(sender.requests) == 1
assert sender.requests[0].receive_id == owner.open_id
assert sender.requests[0].receive_id_type == "open_id"
assert sender.requests[0].tenant_key == owner.tenant_key
assert sender.requests[0].uuid == delivery.message_uuid
finally:
engine.dispose()
def test_feishu_sender_forwards_delivery_tenant_key() -> None:
feishu = StubFeishuService()
sender = FeishuSubscriptionSender(feishu) # type: ignore[arg-type]
request = DeliverySendRequest(
receive_id="open-user",
receive_id_type="open_id",
tenant_key="tenant-a",
text="提醒内容",
uuid="stable-delivery-uuid",
)
result = sender.send(request)
assert result == {"code": 0}
assert feishu.calls == [
{
"text": "提醒内容",
"receive_id": "open-user",
"receive_id_type": "open_id",
"actor": "scheduler",
"uuid": "stable-delivery-uuid",
"tenant_key": "tenant-a",
}
]
def test_group_delivery_never_exposes_creator_personal_context() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
owner, subscription, delivery = _delivery_fixture(
db,
suffix="group",
current=current,
target_type=SubscriptionTargetType.CHAT,
)
generator = StubGenerator()
sender = StubSender([{"code": 0, "data": {"message_id": "group-message"}}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SENT
assert generator.requests[0].owner_id is None
assert generator.requests[0].use_personal_context is False
assert generator.requests[0].use_company_rules is True
assert generator.requests[0].allow_tools is False
assert generator.requests[0].record_history is False
assert sender.requests[0].receive_id == subscription.target_id
assert sender.requests[0].receive_id_type == "chat_id"
assert sender.requests[0].tenant_key == owner.tenant_key
finally:
engine.dispose()
def test_business_failure_retries_after_one_five_and_fifteen_minutes() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
first_at = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, _, delivery = _delivery_fixture(
db,
suffix="retry",
current=first_at,
)
generator = StubGenerator()
sender = StubSender([{"code": 999}] * 4)
service = DeliveryService(db, generator=generator, sender=sender)
first = service.process(delivery.code, now=first_at)
assert first.status == PushDeliveryStatus.RETRY
assert first.attempt_count == 1
assert first.next_attempt_at == first_at + timedelta(minutes=1)
too_early = service.process(
delivery.code,
now=first_at + timedelta(seconds=30),
)
assert too_early.attempt_count == 1
assert len(sender.requests) == 1
second_at = first_at + timedelta(minutes=1)
second = service.process(delivery.code, now=second_at)
assert second.status == PushDeliveryStatus.RETRY
assert second.attempt_count == 2
assert second.next_attempt_at == second_at + timedelta(minutes=5)
third_at = second_at + timedelta(minutes=5)
third = service.process(delivery.code, now=third_at)
assert third.status == PushDeliveryStatus.RETRY
assert third.attempt_count == 3
assert third.next_attempt_at == third_at + timedelta(minutes=15)
fourth_at = third_at + timedelta(minutes=15)
fourth = service.process(delivery.code, now=fourth_at)
assert fourth.status == PushDeliveryStatus.FAILED
assert fourth.attempt_count == 4
assert fourth.next_attempt_at is None
assert len(generator.requests) == 1
assert len({request.uuid for request in sender.requests}) == 1
finally:
engine.dispose()
def test_paused_subscription_is_skipped_without_generation_or_send() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, subscription, delivery = _delivery_fixture(
db,
suffix="paused",
current=current,
)
subscription.status = PushSubscriptionStatus.PAUSED
db.commit()
generator = StubGenerator()
sender = StubSender([{"code": 0}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SKIPPED
assert result.attempt_count == 0
assert generator.requests == []
assert sender.requests == []
finally:
engine.dispose()
def test_non_retryable_feishu_client_error_fails_without_retry() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, _, delivery = _delivery_fixture(
db,
suffix="permanent-feishu",
current=current,
)
generator = StubGenerator()
sender = StubSender(
[
FeishuAPIError(
"invalid receive target",
retryable=False,
http_status=400,
)
]
)
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.FAILED
assert result.attempt_count == 1
assert result.next_attempt_at is None
assert len(sender.requests) == 1
finally:
engine.dispose()
def test_send_time_daily_limit_counts_deferred_deliveries_by_sent_at() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, subscription, delivery = _delivery_fixture(
db,
suffix="send-limit",
current=current,
)
for index in range(MAX_DAILY_DELIVERIES):
db.add(
PushDelivery(
code=f"DEL-SENT-{index}",
subscription_id=subscription.id,
scheduled_for=current - timedelta(days=1, minutes=index + 1),
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=PushDeliveryStatus.SENT,
attempt_count=1,
sent_at=current - timedelta(seconds=index + 1),
created_at=current - timedelta(days=1),
updated_at=current,
)
)
db.commit()
generator = StubGenerator()
sender = StubSender([{"code": 0}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SKIPPED
assert result.attempt_count == 1
assert "Daily delivery limit" in str(result.last_error)
assert sender.requests == []
finally:
engine.dispose()