feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
376 lines
13 KiB
Python
376 lines
13 KiB
Python
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()
|